design live sender - sinrimin/harness-nexus GitHub Wiki

Design: live Sender β€” writable mid-turn selects + queued follow-ups (#10)

The user's ask (2026-09-22): while a turn runs, the Sender should not be read-only β€” model / permission mode / thinking level stay writable, and sending new input mid-turn QUEUES it for the next turn. Ground truth that gated this design: research-live-sender.md β€” every target ACCEPTS mid-turn config writes (server+daemon already forwarded them; only the web locked the selects), while prompt-while-busy is heterogeneous (claude queues natively, opencode joins the run, dsh rejects, our pi faΓ§ade rejects) β€” so the queue lives in OUR stack.

Decisions (recorded on the issue)

  • Queue UI: a CHIP floating ABOVE the composer card β€” outside it, right- aligned (2026-09-23 refinement: inside the card it read as an attachment preview; the user asked for it to hover above the Sender instead), never a transcript row (it is a sender affordance, not history).
  • Depth 1 β€” a second parked send bounces QUEUE_FULL.
  • The parked entry is cancellable (chip Γ—) and editable β€” edit is TAKE-BACK-TO-DRAFT: click returns the content to the composer and drops the slot; re-send re-queues. One daemon-free op for both.
  • Turn cancel DROPS the parked entry (does not auto-send).
  • The queue lives on the SERVER, not the daemon (refined from the initial proposal while sizing): zero daemon changes, queue.cancel is atomic at the server, the slot rides the live-session record (channel close drops it for free), and the flush hook is the busyβ†’idle transition the server already observes.

Wire

  • shared/realtime.ts: ChatStreamEvent gains {kind:'queue_state', prompt: PromptBlock[] | null, flushed: boolean} β€” SERVER-emitted only (never daemon); prompt: null clears the slot and flushed distinguishes clear-because-it-RUNS from clear-because-cancelled. New browserβ†’server op chat:queue.cancel {sessionId} (ack {accepted:true}, idempotent). chat:message.send's ack gains queued: boolean.
  • Server realtime/chat.ts: LiveSession.queuedPrompt (the slot). onMessageSend: busy β†’ park (emit queue_state, ack queued:true), second park β†’ QUEUE_FULL; the per-machine ACTIVE budget still guards IDLE sends (a queued send serializes behind the running turn, adds no concurrency). onStream's busyβ†’idle transition is the flush hook: emit queue_state(null, flushed:true) then forward the parked chat:message.send to the daemon. onTurnCancel and onQueueCancel drop the slot (queue_state(null, flushed:false)). reattach replays a non-empty slot so a re-joining viewer restores the chip.
  • Daemon: UNCHANGED β€” its session-busy reject stays as the race backstop it always was; the parked entry only reaches the daemon (and its history ring) when it actually runs, so the transcript stays honest.

Web

  • fold.ts: queued slice; a queue_state event sets/clears it. The FLUSH conversion lives in the reducer: queue_state(null, flushed:true) while a chip exists folds the parked blocks into a USER row first β€” every viewer saw the chip (live or via reattach replay), so every viewer can materialize the row; late joiners get it from the daemon ring.
  • composer.tsx: controlsDisabled = disabled (selects + attach stay writable mid-turn; the trigger tooltip shows "applies from the next turn" while a turn runs). While turnActive with a non-empty draft, the primary Send (title "Queue message") renders ALONGSIDE Stop; empty draft is Stop-only. The chip (rendered by AgentSession.tsx, floating above the card): clock glyph + label + mono preview + take-back (pencil) + cancel (Γ—).
  • AgentSession.tsx: send() no longer bounces on turnActive β€” mid-turn it skips the optimistic user_message dispatch (the chip is the projection; the fold adds the row at flush), acks queued:true as a toast, QUEUE_FULL as an error (draft restored). queueEdit prefills the draft from the parked blocks (text β†’ value, resource_link β†’ file refs, image β†’ attachments) then queue.cancels.

Semantics summary

Moment Behavior
Turn running, change a select forwarded immediately; the running turn keeps its pinned config β€” applies from the next turn (claude-code may apply sooner; dsh/pi have no permission modes at all)
Turn running, send parks (chip + toast); QUEUE_FULL if the slot is taken
Turn running, chip Γ— / pencil slot dropped atomically server-side (pencil also refills the draft)
Turn ends (idle) queue_state(null, flushed:true) β†’ user_message echo β†’ forwarded prompt runs
Stop pressed turn cancelled AND the parked entry dropped (flushed:false)
Page refresh / second tab queue_state replayed on reattach β€” the chip restores

Multi-viewer (#11, 2026-09-23)

Two gaps surfaced when two windows of the same account watched one session:

  • Prompt echo. The daemon's live stream never carries the user's own message (ACP session/update is agent-side), so the user row existed only on the sending tab's optimistic dispatch β€” every other viewer saw just the agent's rows. The SERVER now emits a user_message stream event ({kind:'user_message', blocks}) to the room on BOTH send paths (direct and queue flush); the web dropped the optimistic dispatch and the fold's flush-row workaround, so the echo is the single source of truth. No adapter can double-render it: no live user-item kind exists.
  • Turn state after refresh. The fold's history ingestion rebuilds from scratch and force-closes running rows, so a viewer (re)joining mid-turn landed with turnActive=false (Send where Stop belongs) even though LiveSession.busy was true. When the server relays a history batch for a busy session it follows it with a synthetic session_status {state:'active'} β€” same socket, emitted after the history event, so the rebuild-then-set order is guaranteed.

Verified with two simultaneous rig viewers: live echo on the passive tab (no refresh), mid-turn join AND mid-turn refresh both restore the user row and the Stop button.

Test & verification

  • server/test/chat.test.ts: the busy-gate case became the queue case (ack queued:true, chip event, chat:queue.cancel drop); a new lifecycle test covers park β†’ QUEUE_FULL β†’ rejoin replay β†’ idle flush (forwarded prompt + flushed:true) β†’ turn-cancel drop with a no-flush counter assertion.
  • apps/web fold.test.ts: queue slice set/clear, flush clear adds no row (the user_message echo paints it), echo starts the turn for a non-sending viewer, cancel-clear adds no row.
  • Rig E2E (agent-browser, live claude-code session): selects enabled mid-turn; ζŽ’ι˜Ÿε‘ι€+εœζ­’η”Ÿζˆ coexist; chip + toast on queue; QUEUE_FULL on a second; the sleep-45 turn's end auto-flushed ζŽ’ι˜ŸζΆˆζ―A (fold made the user row, the turn ran and answered); a cancelled entry produced NO flush after the turn ended. (Take-back was exercised only partially in the UI β€” the flush raced the click β€” but it is the cancel op plus local draft prefill.)