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 ontosession.turnQueue; a single long-lived consumer runs exactly one turn at a time; queued-not-started turns settlecancelledif the client cancels (dist/acp-agent.js:1458,1801-1819, cancel ~4508). There is also a NON-STANDARD_session/steeringextension 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";/modeland/effort"apply to the next request it makes in that turn";/fastapplies from the next turn. - Surface:
set_mode(default/acceptEdits/plan/auto/bypassPermissions)- config options
mode,model,effort,fast.
- config options
- 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_loopover 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 NEWagentclientprotocol/codex-acpadapter — not installed here — SUPPLANTED semantics: a newer prompt stops the older one, plus a_session/steeringextension.) - 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 submitsOp::ThreadSettings. Treat as NEXT-TURN. - Surface:
set_mode(read-only / default / full-access — approval presets) + config optionsmodel,effort, approval preset. - TUI precedent: Enter while running INJECTS into the current turn (steer);
Tab QUEUES for the next turn;
/modeland/permissionswork 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.promptinrunUntilIdle; 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 withSessionBusyError(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; unstableset_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 andpinTurns 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. NOset_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.tssendPrompt). pi's own RPC emitsqueue_*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_levelcommands; application timing UNKNOWN (untested). - Surface: config options
model,thought_level. NO modes (modes: []always;set_modeunreachable; 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/promptduring 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_optionmay 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)
- 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).
- 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(keepMACHINE_BUSYfor cross-session fairness); queue per session (bounded, e.g. ≤5), flush on turn end, clear on cancel. - 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. - Exclusion rules the UI must respect (already data-driven): dsh and
pi render no permission selector (no set_mode / no modes); pi exposes
thought_levelinstead ofeffort; codex modes ARE approval presets. The selects already render only what the adapter advertised — keep that. - 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.