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.cancelis 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:ChatStreamEventgains{kind:'queue_state', prompt: PromptBlock[] | null, flushed: boolean}β SERVER-emitted only (never daemon);prompt: nullclears the slot andflusheddistinguishes clear-because-it-RUNS from clear-because-cancelled. New browserβserver opchat:queue.cancel {sessionId}(ack{accepted:true}, idempotent).chat:message.send's ack gainsqueued: boolean.- Server
realtime/chat.ts:LiveSession.queuedPrompt(the slot).onMessageSend: busy β park (emitqueue_state, ackqueued: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: emitqueue_state(null, flushed:true)then forward the parkedchat:message.sendto the daemon.onTurnCancelandonQueueCanceldrop the slot (queue_state(null, flushed:false)).reattachreplays a non-empty slot so a re-joining viewer restores the chip. - Daemon: UNCHANGED β its
session-busyreject 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:queuedslice; aqueue_stateevent 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). WhileturnActivewith a non-empty draft, the primary Send (title "Queue message") renders ALONGSIDE Stop; empty draft is Stop-only. The chip (rendered byAgentSession.tsx, floating above the card): clock glyph + label + mono preview + take-back (pencil) + cancel (Γ).AgentSession.tsx:send()no longer bounces onturnActiveβ mid-turn it skips the optimisticuser_messagedispatch (the chip is the projection; the fold adds the row at flush), acksqueued:trueas a toast,QUEUE_FULLas an error (draft restored).queueEditprefills the draft from the parked blocks (text β value,resource_linkβ file refs, image β attachments) thenqueue.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/updateis 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 auser_messagestream 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 thoughLiveSession.busywas true. When the server relays a history batch for a busy session it follows it with a syntheticsession_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 (ackqueued:true, chip event,chat:queue.canceldrop); 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/webfold.test.ts: queue slice set/clear, flush clear adds no row (theuser_messageecho 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_FULLon 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.)