14. Mid-Turn Messages, Interruption, and the Command Queue
How can the user send a message while the agent is mid-turn? The short answer: there is an always-open command queue, a synchronous query guard that prevents races, and a per-tool opt-in interrupt policy that controls whether the current turn can be cancelled or must be allowed to finish first.
14.1 The command queue — /workspaces/src/utils/messageQueueManager.ts
A module-level, non-React queue. All reads/writes mutate commandQueue[]; React components observe via a useSyncExternalStore snapshot.
Three priority tiers
'now'(priority 0) — highest. Used by UDS clients (VS Code chat, browser remote) that must interrupt regardless of tool policy.'next'(priority 1) — default user input.'later'(priority 2) — system task notifications (background agent done, teammate messages that waited).
Selection
dequeue() (lines 167-193) picks the highest-priority non-empty tier, then FIFO within that tier. Commands come out one at a time unless the processor opts to batch (slash commands are always individual; non-slash can batch if they share mode).
Enqueue
enqueueCommand() (line 129) is called from the text input handler when the user submits. It also pops the editable preview (popAllEditable at line 428) so the input buffer is cleared but the user can still see their queued message while the query runs.
Non-React readers
getCommandQueue() and getCommandQueueLength() are called from outside React (print.ts headless loop, SDK streaming callers). The queue is the single source of truth across UI and SDK paths.
14.2 The query guard — /workspaces/src/utils/QueryGuard.ts
A synchronous state machine with three states: 'idle' | 'dispatching' | 'running'.
reserve()— idle → dispatching (the queue processor reserves a slot).tryStart()— dispatching or idle → running (the query actually begins).end(generation)— running → idle (normal completion).forceEnd()— running → idle (cancellation).
The generation counter (line 103) makes stale cleanup safe:
end(generation: number): boolean {
if (this._generation !== generation) return false // stale
if (this._status !== 'running') return false
this._status = 'idle'
this._notify()
return true
}If a cancellation increments the generation and then a finally block from the cancelled run tries to end(), it's a no-op — it can't accidentally re-enable the loop.
14.3 The queue processor — /workspaces/src/hooks/useQueueProcessor.ts
A React hook that subscribes to both the queue and the guard via useSyncExternalStore. The condition for dispatch (lines 28-68):
No query active AND queue has items AND no blocking local JSX UI.
When it fires, it calls processQueueIfReady() which:
- Dequeues a command (or a mode-compatible batch).
- Feeds it to
executeQueuedInput()→handlePromptSubmit()→executeUserInput(). - Before the first
awaitof that chain,queryGuard.reserve()runs synchronously, blocking re-entry.
That synchronous-before-await gap is the critical race-preventer: once the processor has picked an item, the guard is committed before React gets a chance to re-observe the queue.
14.4 How mid-turn submission actually works
Scenario: the user types a message while the agent is in a long Bash call.
- Text input submits →
enqueueCommand({ priority: 'next', ... })(messageQueueManager.ts:129). - The message appears as a pill preview above the input so the user sees what will run next.
useQueueProcessorwakes because the queue changed, butisQueryActiveis true → it does nothing and waits for the guard to flip to idle.handlePromptSubmitinspects the running state. At/workspaces/src/utils/handlePromptSubmit.ts:321, it checks whether all executing tools declareinterruptBehavior() === 'cancel':
// handlePromptSubmit.ts:321, approx
if (allExecutingToolsCancelOnInterrupt) {
params.abortController?.abort('interrupt')
}- Because Bash is
'block'(default), nothing is aborted. The current turn finishes. - When the query loop's
finallyblock runsqueryGuard.end(...), the guard flips idle. useQueueProcessorre-fires → dequeues the user's message → new turn begins.
14.5 Interrupt behavior per tool — Tool.ts:411-416
interruptBehavior?(): 'cancel' | 'block''cancel'— stop the tool and discard results. Used by Sleep, Monitor, long-watch tools: there's nothing productive to finish; cancel immediately so the user's new message runs.'block'— keep running; the user's message waits. The default, used by Bash, Read, Edit, Write. A half-completed file edit or shell command should not be killed.
Enforcement in StreamingToolExecutor.ts:219-226
if (this.toolUseContext.abortController.signal.aborted) {
if (this.toolUseContext.abortController.signal.reason === 'interrupt') {
return this.getToolInterruptBehavior(tool) === 'cancel'
? 'user_interrupted'
: null // 'block' tools: don't abort
}
}The executor inspects the signal's reason — if it's the literal string 'interrupt', the tool's declared policy decides the outcome. If the abort came from somewhere else (API error, parent cancel), all tools abort regardless.
In the query loop — query.ts:1046
if (toolUseContext.abortController.signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: false })
}Non-interrupt aborts emit an explicit "interrupted by user" message. Interrupt aborts don't — because the queued user message is about to run and provides its own context.
14.6 'now'-priority bypass — /workspaces/src/screens/REPL.tsx:4100-4103
useEffect(() => {
if (queuedCommands.some(cmd => cmd.priority === 'now')) {
abortControllerRef.current?.abort('interrupt')
}
}, [queuedCommands])When a 'now'-priority message is enqueued (typically from a UDS / browser-remote client), the current turn is aborted regardless of interruptBehavior. This ensures responsive UI for external clients that have different latency contracts than a local terminal user.
14.7 Cancellation (Escape / Ctrl+C) — /workspaces/src/hooks/useCancelRequest.ts
Priority order:
- Running task exists → call
onCancel()which fires the abort controller (line 100). This invokesqueryGuard.forceEnd(). - Queue has items (no running task) → pop the topmost queued command (line 107).
- Fallback → call
onCancel()anyway (line 115).
Escape is not a hard kill — it is an abort signal that the loop cooperatively reacts to, with per-tool policy deciding whether mid-flight work is preserved.
14.8 Remote-session message path — /workspaces/src/remote/
When a message originates in the browser at claude.ai/code/session_…:
- The browser HTTP POSTs the message to
/v1/sessions/submit. The WebSocket atwss://api.anthropic.com/v1/sessions/ws/{sessionId}/subscribeis not the inbound channel — it's outbound (events, progress, permission requests). - The local CCR session receives the HTTP submission, formats it as a
QueuedCommandwith mode'prompt', and enqueues. - From there, the same
handlePromptSubmitflow runs: query guard check, interrupt-policy check, abort or enqueue.
Why HTTP for inbound, WebSocket for outbound? The WebSocket must be always-listening so the session can deliver model tokens, permission prompts, and progress. HTTP POST for inbound is stateless and supports retries cleanly. The asymmetry also means an expensive WebSocket reconnect doesn't affect the user's ability to submit.
Connection maintenance: ping every 30s (SessionsWebSocket.ts:19), reconnect up to 5 times (MAX_RECONNECT_ATTEMPTS, line 18).
14.9 Mailbox — async teammate-to-parent delivery — /workspaces/src/utils/mailbox.ts
In-process, non-persistent queue for async agents:
send(msg: Message): void
poll(fn?: filter): Message | undefined
receive(fn?: filter): Promise<Message>useInboxPoller (hooks/useInboxPoller.ts:126-969) polls teammate inboxes every 1 second:
- Main session idle → submit immediately as a new turn (line 843).
- Main session busy → stash in
AppState.inboxfor later (line 854). When the session goes idle, the effect at line 876 delivers pending messages as a batched turn (lines 918-924).
Mailbox messages do not bypass the query guard. They follow the same enqueue → dequeue → reserve path. A teammate can't interrupt a 'block' tool; it waits for a turn boundary.
This is how background agents' completion notifications reach the parent without disrupting mid-flight work.
14.10 Summary table — what actually happens
| User action | Turn running? | Current tool | Result |
|---|---|---|---|
| Type message + Enter | No | — | New turn starts immediately. |
| Type message + Enter | Yes | Bash (block) | Message queues. When turn ends, it runs. User sees a pill preview. |
| Type message + Enter | Yes | Sleep (cancel) | abort('interrupt'). Sleep cancels with synthetic result. Queued turn runs. |
| Type message + Enter | Yes | Agent (block) | Message queues. Agent completes, then new turn. |
| Press Escape | Yes | Any | forceEnd() + abort(). Turn cancels; no "interrupted" message if reason is 'interrupt'. |
| Press Escape | No, queue has items | — | Pops top of queue (removes pending message). |
Browser POST (UDS 'now') |
Yes | Any (block or cancel) | Priority-'now' triggers unconditional abort. |
| Background agent finishes | Any | Any | enqueueAgentNotification with 'later' priority. Delivered on next idle. |
| Teammate sends via SendMessage | Any | Any | Queued in LocalAgentTask.pendingMessages or mailbox. Delivered on recipient's next turn boundary. |
14.11 The core design principle
The UI enqueues immediately; the loop drains on boundaries; tool-level policy decides whether boundaries can be forced.
- Immediate enqueue = users never feel like their input is lost.
- Drain on boundaries = tool atomicity is preserved by default (no half-written files).
- Per-tool policy = the rare tools that are safe to cancel (sleep, watch) opt in.
- Synchronous guard = no races between UI event handlers and the async query loop.
- Priority tiers = UI clients with different latency contracts can still interrupt when they must.