A live background agent needs a session-lifetime event bus
An async agent is muted only because the event bus is per-turn; lifting
the bus to session lifetime lets a background child stream a live tab exactly
like a sync child, and it is a pure presentation change because completion is
already a control-flow fact, not a bus state. The async/sync split is a real
dependency-vs-orchestration distinction in what the model sees
(async-agent-tool); the live-tab gap is
not part of that distinction — it is an artifact of the channel’s lifetime,
and it is the only asymmetry left once the settle breadcrumb is unified.
Context
A sub-agent reaches the frontend over two independent channels, and the two modes use different ones:
- The bus —
Kindevents through anEmitter, drained bySink::drive/drive_events. It is per-turn:pumpmints a freshchannel()inside itsthread::scopeand closes it when the turn’sdoneflag is set (exarch/src/bus.rs). It carriesBorn/Token/ToolCall/Diedand drives the live viewports and tabs. - The inbox —
InboxMsgthrough a session-livedArc<Mutex<…>>, drained at the turn boundary (scheduled-wakeups). It carries the settle digestInboxMsg::AgentResult.
A sync child rides the bus, so it gets a live tab. An async child is a
detached registry-owned worker that outlives the spawning turn
(concurrency-detached-vs-structured),
so it cannot hold the turn’s sender — the channel is gone before it finishes.
It is therefore muted to its own SessionLog and surfaces only through the
inbox, as the deferred Turn::Agent breadcrumb. This is the same denial
watch-repl-builtin makes: a detached
writer may hold only root- or handle-owned resources, never a foreground frame’s
capture buffer.
Two facts narrow the change:
- The settle breadcrumb is already one path. Both modes reduce a child’s
TurnOutcomethrough the singleAgentOutcome::breadcrumband draw it with the onepush_subagentviaKind::SubagentDone. So this decision is not about drawing results — that is done — it is only about live streaming while the child runs. - The frontend is already concurrency-ready. Every
EventisSessionId-stamped, and the TUI routes all of it by id into per-sessionViewports and tabs (Born/Token/Diedinexarch/src/tui.rs). The sink state that a tab needs —viewports,tabs,dispatch_order, thedying/LINGERage-out,AGENT_HUES— all lives inAppacross turns. Nothing there assumes a single active worker. The only turn-scoped thing is the channel.
So the live-tab gap reduces to one seam: a worker that outlives a turn has no worker→frontend channel that also outlives the turn.
Decision
-
Mint the bus once per session, not once per turn. The
(tx, rx)is created at REPL start and owned for the session’s life.pumpkeeps the per-turndoneflag and the foreground worker thread, but no longer owns the channel. Every worker — the foreground turn and each detached async child — receives anEmittercloned off the session sender, stamped with its own id. -
The async path swaps its muted emitter for a real one. It emits
Born/Token/ToolCall/Diedlike any child and so gets a tab through the existing draw path — no new rendering code. The muting indispatch_asyncis deleted; nothing else in the async dispatcher changes. -
Completion stays a control-flow fact, and the drain honours it independent of bus state. The foreground turn still ends on its explicit per-turn
done, never on the channel emptying or disconnecting — the run-turn-host-loop invariant that the bus is presentation, not liveness. The refinement:drain_pass/drive_eventsmust checkdoneeven while the channel is non-empty, because concurrent background producers keep it non-empty. Ondone, drain the buffered batch, render a final frame, and return; any in-flight background events flow to the idle drainer below. This upholds the invariant —doneremains the sole arbiter — and removes the latent conflation of “channel empty” with “turn over” that muting currently hides. -
The idle wait gains the bus as a third source. The select that scheduled-wakeups made
{input, inbox}(read_prompt,Idle::{Prompt, Inbox, Quit}) becomes{input, inbox, bus}: at rest, the frontend drains the session bus so background tabs advance and a child’sDiedages its tab out while the user sits at the prompt. -
Worker lifetime and the model-facing contract are untouched. The child still owns its
Arc<Provider>and cancelToken, still sits in the ephemeral registry with agenerationand theprocess::reaperceiling. Its reply still returns only through the inbox as a deferred, markedTurn::Agent— the orchestration edge is unchanged. This decision adds a live frontend view; it changes nothing the model sees. -
Tabs age out under
/clearby generation, not by muting. Today muting means a cleared worker leaves no live tab to retract. With live tabs,AgentRegistry::clear(which cancels workers and bumps the generation) must also drive the frontend to age out those tabs, so a worker cancelled across a context rebuild does not keep painting into a session that no longer expects it — the visual twin of the stale-generation rejection the inbox already does. -
This is a TUI property, like the
watchdurable-sink axis. Headless has no idle loop and no tabs; it keeps muting async children, with the stderrSubagentDonebreadcrumb as their only trace. Bus lifetime is a host capability (watch-repl-builtin’s axis), not a core obligation.
Why this shape
-
It removes an incidental asymmetry while preserving the essential one. The dependency-vs-orchestration edge — what the model receives — is untouched. Only the frontend’s view converges, and converging it costs almost nothing because the draw path is already shared and id-routed.
-
It rests on an invariant already in force. Once run-turn-host-loop made completion a control-flow fact and the bus presentation, the per-turn channel became the conservative default, not a load-bearing boundary. Session lifetime is its natural relaxation; the deferral in async-agent-tool (“lift the bus for live background output now — deferred”) is exactly the question this answers.
-
The risk is localised. Three sites move — channel lifetime, the completion-drain refinement, the idle select — and only the second carries subtlety. Everything visual (viewports, hues,
LINGER, the unified breadcrumb) is reused unchanged.
Alternatives considered
-
Stay muted (status quo). The
agentslisting plus the settle breadcrumb. Cheapest, and correct if watching background work is not wanted. Rejected only if live visibility is a goal; it loses nothing model-facing. -
Per-worker channels selected in the frontend. A dynamic set of receivers merged at the TUI.
mpschas no native select, so this converges to a single shared sender — which is the session bus — with strictly more plumbing. Rejected as the same design wearing more machinery. -
Tail each child’s
SessionLoginto a tab. Reuses the log the child already writes, sidestepping the channel entirely. Rejected: it creates a second rendering path (log-replay vs event-stream) and two sources of truth per tab. The bus is the honest live source; the log is the durable record, not a UI feed. -
Lift the bus but keep completion gated on an empty channel. Rejected: with concurrent background producers the channel is never momentarily empty, so the foreground turn would fail to terminate. Completion must remain the explicit
donefact, per run-turn-host-loop.
Open questions
-
Tab-bar pressure. Steady-state N concurrent background tabs is a new regime; sync tabs are short-lived within one turn. The tui-transcript-as-graphic agents×steps matrix is the likely home — background agents as a compact strip unless focused — but the policy is open.
-
/clearteardown affordance. Whether a live background tab retracts visibly on clear or simply ages out through the existingdying/LINGERpath. -
Headless visibility. Whether a headless background agent ever surfaces more than its settle breadcrumb (a periodic stderr heartbeat), or stays settle-only because the host has no live surface.
-
Surfacing from a background child. A live background worker that raises a
Cardthroughsurfacecompetes for the foreground; the interaction with terminal-lease / terminal-foreground ownership is unexplored, since muting currently forecloses it.
As built
Implemented as specified. The seam is a SessionBus value (bus.rs) owning the
channel and inbox, borrowed by pump/run_turn: SessionBus::session for the
TUI (emitters session-lived, async children stream a live tab),
SessionBus::per_turn for headless and tests (async children muted). An
Emitter::session_lived flag, inherited by child, is what dispatch_async
reads to choose a streaming vs muted child emitter, bracketing run_child with
Born/Died. drain_pass latches done at the top of each pass and stops on
it regardless of channel occupancy; the idle wait gained drain_bus_idle as a
third source; App::clear retires live tabs into the dying/LINGER path and an
App::handle dying-guard drops a cancelled worker’s late events. Headless and the
model-facing inbox contract are unchanged.
See also
async-agent-tool (the mode split this
completes; its deferred “lift the bus” open question),
run-turn-host-loop (completion as a
control-flow fact; the bus as presentation, not liveness — the invariant this
rests on),
host-seam-turn-observer (the
superseded attempt that motivated that invariant),
scheduled-wakeups (the inbox seam and the
{input, inbox} idle select this extends with the bus),
tool-boundary-steering (why the
inbox delivery is not steering),
watch-repl-builtin (the durable-sink host
axis bus lifetime mirrors),
concurrency-detached-vs-structured
(the detached-worker mechanics left untouched),
turn-ends-ready,
a-turn-end-to-end,
output-capture-and-detachment,
frontend, agent,
tools.