research live sender - sinrimin/harness-nexus GitHub Wiki

Research: live Sender — mid-turn config writes & queued prompts (#10)

Question (from the user, 2026-09-22): while a turn is RUNNING, the chat Sender should not be read-only — model / permission mode / thinking level should stay writable, and sending new input mid-turn should QUEUE it instead of being impossible. What do our supported targets actually support, and what are the exclusion rules? Investigated by reading the installed adapter sources (rig machine container, /root/.hnx/acp-adapters/), the ACP spec, upstream docs, and our own daemon/pi code.

1. Where WE gate today (our stack)

Layer Prompt while busy Config write while busy
Web (composer.tsx) composer stays editable, but canSend requires !turnActive; the button swaps to Stop, so sending is impossible controlsDisabled = disabled || turnActive — all three selects disabled mid-turn
Server relay (realtime/chat.ts) onMessageSend → SESSION_BUSY when session.busy (+ MACHINE_BUSY per-machine active budget for cross-session fairness) onConfigSet deliberately NOT busy-gated
Daemon (daemon/chat.ts) chat:message.send → {error:'session-busy'} when session.busy chat:config.set forwards session/set_mode / session/set_config_option, no busy gate, optimistic merge

So the config half of the idea is blocked ONLY by the web UI. The prompt half is blocked at three layers (button, server gate, daemon gate).

2. Per-target ground truth (installed versions)

Prompt-while-busy = what the AGENT/ADAPTER does with a second session/prompt while a turn streams. Mid-turn config = what a session/set_mode / set_config_option during a turn does.

claude-code — @agentclientprotocol/claude-agent-acp 0.79.0 (VERIFIED, JS source)

  • Prompt-while-busy: QUEUED natively. prompt() pushes onto session.turnQueue; a single long-lived consumer runs exactly one turn at a time; queued-not-started turns settle cancelled if the client cancels (dist/acp-agent.js:1458, 1801-1819, cancel ~4508). There is also a NON-STANDARD _session/steering extension for mid-turn injection (opt-in; without a live turn it can start a new one).
  • Mid-turn config: pushed to the LIVE query immediately — query.setModel() / setPermissionMode() / applyFlagSettings({effortLevel}); the only error case is a CLOSED session (acp-agent.js:4811-4824). Upstream docs: permission mode applies "at any time"; /model and /effort "apply to the next request it makes in that turn"; /fast applies from the next turn.
  • Surface: set_mode (default/acceptEdits/plan/auto/bypassPermissions)
    • config options mode, model, effort, fast.
  • TUI precedent: Claude Code's own terminal queues typed messages (sent after the turn, or Ctrl+Enter interrupts-and-flushes); the Agent SDK streaming mode documents "Queued messages … with ability to interrupt".

codex — @zed-industries/codex-acp 0.16.0 (Rust binary; strings/symbols evidence)

  • Prompt-while-busy: queued — INFERRED. No busy-error string exists ("prompt in progress" absent); codex-core runs a serialized submission_loop over a Submission channel ("Submitted prompt with submission_id", "queued input submission"). Not provable from the binary whether the OLD adapter surfaces a second concurrent prompt at all, but nothing rejects it. (The NEW agentclientprotocol/codex-acp adapter — not installed here — SUPPLANTED semantics: a newer prompt stops the older one, plus a _session/steering extension.)
  • Mid-turn config: stored, applied at next sendPrompt — the new adapter's source reads mode/model/effort only when starting a turn; the old one submits Op::ThreadSettings. Treat as NEXT-TURN.
  • Surface: set_mode (read-only / default / full-access — approval presets) + config options model, effort, approval preset.
  • TUI precedent: Enter while running INJECTS into the current turn (steer); Tab QUEUES for the next turn; /model and /permissions work mid-session.

opencode — native opencode acp 1.18.31 (embedded minified JS, VERIFIED)

  • Prompt-while-busy: neither rejected nor client-queued — the prompt is inserted and JOINS the running loop (steer-like): the ACP handler wraps session.prompt in runUntilIdle; the server stores the user message first, and the run loop's exit condition (lastAssistant.parentID === lastUser.id) keeps running into the new message. Shell (!) commands DO error with SessionBusyError (409). Effectively: queue/steer hybrid.
  • Mid-turn config: accepted, pinned at prompt start → NEXT TURN (set_config_option = plain in-memory state; the prompt handler reads model/variant/modeId when creating the user message).
  • Surface: config options model, effort (variant), mode (agent); set_mode; unstable set_model.

dsh (deepseek) — @deepseek-ai/dsh-acp 0.1.2-rc.1 (VERIFIED, JS source)

  • Prompt-while-busy: REJECTED — JSON-RPC -32602 "a prompt is already in flight for this session" (lib/index.js:798-800). README: "one prompt at a time per session".
  • Mid-turn config: accepted, PINNED PER PROMPT — next turn. prompt() snapshots the selection at admission and pinTurns it for every model step of that turn; set() only mutates the selection used when no turn is pinned. README states it verbatim.
  • Surface: config options ONLY — model, reasoning_effort. NO set_mode (permission modes not supported).

pi — our in-daemon PiRpcConnection façade over pi --mode rpc (VERIFIED, our code)

  • Prompt-while-busy: REJECTED BY OUR FAÇADE — throw new Error('pi turn already in flight') (pi-connection.ts sendPrompt). pi's own RPC emits queue_* notifications (pi queues internally in its TUI), so the façade's guard is the blocker, not necessarily pi — relaxing it is testable but UNVERIFIED.
  • Mid-turn config: forwarded, no busy gate — set_model / set_thinking_level commands; application timing UNKNOWN (untested).
  • Surface: config options model, thought_level. NO modes (modes: [] always; set_mode unreachable; no permission surface at all).

hermes — internal test fixture (python3 -m acp_adapter)

Not a shipped target; excluded from the matrix.

3. ACP spec & client precedents

  • The spec is deliberately silent. v1 says nothing about a second session/prompt during a turn. v2 (draft) says "Acceptance means INSERTION … MUST respond successfully once it has inserted the user message", and its RFD explicitly defers "queueing, steering, or whether agents insert new prompts while busy". There is no busy error code.
  • set_mode / set_config_option may be called at any point during a session (v1 session-modes / session-config-options wording: "whether the Agent is idle or generating a response"). Effect timing is implementation-defined.
  • Every mainstream client queues client-side: Zed queues by default and sends after the turn (steering only for its own agent — "Zed can't detect turn boundaries for external agents"); Claude Code TUI queues; Codex TUI Tab-queues / Enter-steers. None show an error.

4. Matrix (the one-glance answer)

Target Prompt mid-turn Mid-turn config write Effect timing Permission-mode control
claude-code adapter QUEUES natively (turnQueue) accepted, pushed to live query perm mode live; model/effort next request in-turn YES (5 modes)
codex 0.16.0 queued (inferred; no rejection path found) accepted next turn YES (3 approval presets)
opencode inserted, joins the run (steer-like) accepted next turn YES (agents as modes)
dsh REJECTS (-32602) accepted next turn (per-prompt pin) NO set_mode
pi OUR façade rejects; pi may queue internally forwarded unknown (test) NO modes at all

5. Implications for the design (issue #10)

  1. Config selects can be unlocked mid-turn NOW — every target accepts the write; server and daemon already pass it through. The honest UI contract: "applies from the next turn" (claude-code may apply sooner — permission mode live, model/effort next in-turn request).
  2. Queueing must live in OUR stack, not the agents — behavior is heterogeneous (queue / steer / reject / supersedes). The daemon is the right owner: it knows turn boundaries (turn_result), it already serializes prompts, and a daemon queue gives uniform semantics across all five targets and survives a browser reload. Drop the same-session busy gate in the daemon AND the server's SESSION_BUSY (keep MACHINE_BUSY for cross-session fairness); queue per session (bounded, e.g. ≤5), flush on turn end, clear on cancel.
  3. claude-code could later expose native queue/steering (_session/steering, adapter turnQueue) as an enhancement; the uniform daemon queue is the baseline that works for everyone.
  4. Exclusion rules the UI must respect (already data-driven): dsh and pi render no permission selector (no set_mode / no modes); pi exposes thought_level instead of effort; codex modes ARE approval presets. The selects already render only what the adapter advertised — keep that.
  5. Stop control: with queueing, Stop and Send must coexist while running (draft non-empty → Send queues; Stop cancels the turn — and must decide: cancel also drops the queue, mirroring Claude Code TUI where interrupt flushes/clears queued input).

Open questions for the issue: queue depth cap; queue UI (queued rows in the transcript vs. chips above the composer); cancel semantics (flush queue like Ctrl+Enter, or drop it); whether to relax the pi façade's in-flight guard to test pi's native queue; steering (mid-turn injection) explicitly out of scope for v1.