Actor-model agentic UI
Actor-model agentic UI is DevCentr’s second cross-org harness UX contract (after grounded tokens).
In plain language: agent chat is a graph of communicating nodes on disk — not one drifting scroll that pretends to be one topic forever.
A node is an actor: a bounded conversation context with a type, an address, parent/child spawn links, and a mailbox summary coworkers read without loading its full transcript.
Why linear chat is wrong
Today’s chat models assume one thread drifts until the user starts over. Agent harnesses violate that assumption constantly:
-
A coordinator discussion spawns twelve task nodes across repos
-
A task node’s chat tangents into a CLA permissions digression — that should become a discussion child, not noise in the merge transcript
-
Parallel jobs need a grid view; tangents need a graph view — same node store, different projections
Serializing all of that into one scroll repeats aggregate headers and buries blockers. The fix is not "better summaries" alone — it is a persisted node graph the UI renders in parallel.
Node model
| Field | Meaning |
|---|---|
|
Stable node identifier (also actor address suffix) |
|
|
|
Disk path root for this node ( |
|
Parent node id — child always knows where it came from |
|
Child node ids — parent records every node it forked |
|
Edges that are not spawn (waits-on, references, soft dependency) |
|
Mailbox — latest state for subscribers |
|
Which layout snapshot last included this node in the grid (optional; see temporal layout) |
|
When true, temporal fade must not treat the node as idle garbage |
Structs mirror disk (1:1)
In-memory node records are not a shadow cache. meta.json and chat.jsonl are authoritative; runtime structs deserialize from them and serialize back on every mutation. Model invocations receive a slice of that struct (mailbox + recent lines + declared wait subscriptions), not a separate RAM-only transcript.
Spawn on drift
When a node’s chat drifts from its declared topic, the harness spawns a child node:
-
Parent appends child id to
spawned[]; child setsspawnedByto parent id -
Parent and child addresses are written to disk before either accepts new messages
-
Parent chat gets a one-line routing marker; new lines belong to the child
Primary trigger: model-proposed spawn. The model running in the parent should propose a spawn when the user mixes a continuation of the current subject with new ideas that deserve their own bounded context — a common pattern in long agent sessions.
Secondary triggers: user gesture (Spawn tangent), or policy (non-task utterance in a task node).
Boundary ambiguity
When the model cannot tell whether new lines continue the parent topic or fork it, spawn a disambiguation child instead of guessing:
-
Parent pauses routing until the disambiguation node resolves the boundary
-
Disambiguation links parent to one or more candidate children (
discussion,task, …) -
Resolution is recorded in the orchestrator metathread (refs below) and parent
spawned[]
Node types (initial set)
| Type | Role |
|---|---|
|
Root orchestrator; grid owner; continuous top-level voice with summarization; may invoke Mixr before spawning routed task nodes |
|
Exploratory context; may spawn task, discussion, or disambiguation children |
|
Bounded job (repo PR, merge, docs sync); publishes status to grid |
|
Short-lived fork resolver when parent/child boundary is unclear |
More types can extend the same struct — the harness treats type as routing hints, not a separate storage silo.
Disk layout (required)
All graph state is persisted — not RAM-only. I/O cost is acceptable; losing spawn lineage is not.
$CHAT_ROOT/
graph.json # index: node ids, types, spawn edges, related edges
orchestrator/
meta.jsonl # metathread: coordinator voice + refs into node logs
index.json # optional aggregate: nodeId → line ranges, summaries
nodes/
{nodeId}/
meta.json # full node struct (addresses, spawnedBy, spawned, status, …)
chat.jsonl # authoritative messages for this node only
summaries/
latest.json # mailbox
v{n}.json # compaction chain
views/
epoch-1.json # optional layout snapshot (which task nodes, positions)
epoch-2.json
thread.jsonl # optional export/replay merge — not source of truth
Every spawn is two writes minimum (parent meta.json, child meta.json + graph.json edge update) before the child accepts traffic. The harness batches where the filesystem allows; correctness beats micro-latency.
Metathread and audit
Each session has two conversational surfaces:
-
Interactive thread — whichever node the user is typing in (
nodes/{id}/chat.jsonl) -
Orchestrator metathread — cross-node narrative plus line refs into child logs
Ref example (one line in orchestrator/meta.jsonl):
{"kind":"ref","node":"task-bitwarden","line":42,"summary":"CLA blocker cleared"}
Audit = walk metathread refs; do not replay one serialized scroll. Subscribers read mailboxes and peek at referenced lines — not a filtered projection of a mega-log.
Wait graph
ACTOR_WAIT_GRAPH default is warn.
Subagents declare subscriptions ahead of time:
| Mode | Meaning |
|---|---|
|
Mailbox may be stale or partial; subagent may continue independent work |
|
Block until terminal status — merges, releases, irreversible actions |
Example subscription on a node struct: waitOn: [{ node: "task-cla", mode: "warn" }].
Git close-out (deferred push)
Parallel task nodes must not each inherit standing end-of-run push. High-volatility waves (2+ concurrent non-terminal tasks) default leaves to coordinator-batch / commit-only: commit locally; the coordinator pushes once after the wave.
The harness records gitCloseout on the node and injects a system addendum on spawn / provider session so copied always-on push rules cannot silently win (the Cursor Task failure mode).
| Value | Leaf behavior |
|---|---|
|
Commit OK; no push unless validation needs the remote now |
|
Single-agent / low volatility — commit + push on that node’s close-out |
|
No automatic close-out for this node |
Harness variable: GIT_CLOSEOUT (default coordinator-batch when ACTOR_AGENTIC_UI=graph-grid). Portable policy: general/parallel-git-closeout.md.
UI projections
One graph; multiple views over the same disk store.
Cadence separation (hard rule)
Two conversational cadences must never mix on the same surface:
-
Discussion surface — coordinator +
discussionnodes: human Q&A / exploratory cadence only. -
Workers surface —
tasknodes: tile/status mailbox (status+summary). Full worker transcript is fork-panel only. -
No cross-append — worker tool noise, progress scraps, and completion dumps must not append into the coordinator/
discussionchat.jsonl. Coordinator may get one-line routing markers / metathread refs only.
This is the architectural fix for Cursor multitask comingling — the parent chat misused as a delivery surface for worker progress.
Primary view modes
Same graph, different projections:
| Mode | Projection |
|---|---|
Linear |
Cursor-like: discussion chat list + focused transcript (discussion cadence). Compact worker status allowed; not mixed into transcript lines. |
Grid |
Spatial 2D task tiles (work cadence) + discussion main chat remains separate. |
Tree |
L→R spawn/dependency tree (root/coordinator at left). |
Shared chrome across modes:
-
Fork panel — selected node; load
nodes/{id}/chat.jsonl(full worker or discussion transcript) -
Graph strip — spawn lineage: parent → children (compact; complements Tree mode)
-
Coordinator / global thread — continuous top-level discussion surface with summarization; not every subagent line
-
Temporal layout (below) — cross-cutting timeline widget embeddable in any primary mode; not a fourth competing primary mode or storage model
Hierarchical coordinator mode and global personal-context mode are different views of the same graph, not different stores. Reset in global mode is a scope filter (archive nodes, trim personal context to a bookmark) — not delete disk.
Mixed project contexts
When topics do not share a parent (different workspace/project), prefer a new $CHAT_ROOT / session root (Cursor-workspace analog) rather than forcing one coordinator tree. Soft related / wait edges may cross-reference between roots when needed — but each root owns its own coordinator and node graph.
Temporal layout (planned view engine)
Parallel nodes do not naturally "chapter" together. Automatic epoch breaks by clock alone lie to the model when the user left a question unanswered.
Temporal layout is a cross-cutting feature/widget — global and per-subtree scopes, embeddable in Linear, Grid, or Tree — not a fourth primary mode or alternate storage model.
The widget accepts:
-
node activity timestamps (
lastEventAt,lastTouched) -
optional token-volume series
-
agent-generated timeline bookmarks (keywords, images) as chat progresses
Behaviors (phased):
-
Fade, not delete — idle nodes dim after inactivity unless
awaitingUser: trueor terminal status -
Horizontal scrubber — seek a time window; replay which nodes were visible then
-
Heatmap / token strip — faint overlay on the scrubber; resizable height
-
Bookmark panel — sidebar index; map-style detail as user zooms in
Scopes: a global timeline (all nodes under $CHAT_ROOT) and sub-timelines scoped to one node subtree. The widget mounts in the global shell and inside a node detail panel — same engine, different scope input.
Epoch layout snapshots (views/epoch-N.json) become optional bookmark-freeze artifacts when the coordinator explicitly reframes a batch — not the primary navigation model.
Harness variables
ACTOR_AGENTIC_UI = graph-grid | serialized | off
ACTOR_GRAPH_EPOCHS = on | off
ACTOR_WAIT_GRAPH = warn | enforce | off
ACTOR_NODE_STORE = jsonl-per-node | off
GIT_CLOSEOUT = coordinator-batch | per-node | off
Defaults when ACTOR_AGENTIC_UI=graph-grid: ACTOR_NODE_STORE=jsonl-per-node, ACTOR_WAIT_GRAPH=warn, GIT_CLOSEOUT=coordinator-batch.
| Variable | Behavior |
|---|---|
|
Persist node graph; emit grid updates for task nodes; route detail chat to node forks; coordinator summaries in main thread |
|
Legacy — all lines in one scroll |
|
Single-node mode |
|
Optional layout snapshots when coordinator explicitly reframes a batch (supplements temporal bookmarks) |
|
Emit maybe-stale warnings on sibling mailbox reads; subagent may continue parallel work |
|
Block model calls that depend on non-terminal sibling mailboxes |
|
Per-node |
|
Task leaves commit locally; coordinator batches push after the wave; spawn injects system addendum |
|
Each node may commit+push on its own close-out (serialized / single-agent) |
|
No automatic close-out policy on nodes |
Migration: PROJECT_INBOX* names are deprecated aliases for one release cycle; map grid-forks → graph-grid, PROJECT_INBOX_CHAPTERS → ACTOR_GRAPH_EPOCHS.
Reference implementation
DevCentr ships a reference runtime at dev-centr/harness (see Agent harness).
| Phase | Ships |
|---|---|
v0 |
Disk layout, spawn API, metathread refs, CLI |
v1 |
Mixr model router, wait-graph ( |
v2 |
Temporal layout engine — scrubber, fade, heatmap, scoped bookmarks |
HCI Nerdz demo remains a static teaching surface; the harness is the live $CHAT_ROOT backend.
Relationship to HCI Nerdz
HCI Nerdz owns the attention surface — grid, graph strip, spawn UX, demo, essay.
DevCentr owns harness routing, disk schema, and $CODE_ROOT/harness.md variables.