Always signal stack

Path 4 — the chain with no request at its head

Ambient

Every other path in this system is a response. This one is not. A subagent dispatched minutes ago finishes, the Rust harness wakes the host model with a synthetic user message, the host narrates the result, and a socket nobody is transmitting on carries that narration to a speaker. Two systems had to be modified to make it work, and the second attempt at the Python half threw away the first.

inline answer cap 256,000 bytes fallback preview 500 chars forwarded events 11 types ambient buffer cap 320 chars reconnect backoff 1 s → 30 s

01 — The Rust half

A subagent that returns before it answers

The harness already had a background-task manager and a soft-interrupt mechanism; both were used by background shell commands. The Always patch adds a fourth variant to the subagent tool's output mode and wires it into the existing machinery. The doc comment on the new variant is the whole design in four lines:

“Spawn the subagent on the global BackgroundTaskManager. Returns a task_id immediately; the parent's turn ends without waiting. Completion is auto-injected into the parent's next turn via the BackgroundTask soft interrupt path (Bus → dispatch_background_task_completion → Injection D).”
jcode/src/tool/task.rs, lines 75–78 — verbatim
Background subagent lifecycle in the jcode harness Four bands. The first shows the parent thread: a tool call with output mode background short-circuits in SubagentTool execute to execute_background, which strips five recursive tools and calls spawn_with_notify with notify and wake both true, after which the parent turn ends immediately with a task id. The second band shows the spawned tokio task running the child agent, writing the full answer to a temporary output file, writing a status file, and publishing a bus event whose preview is capped at five hundred bytes. The third band shows the server dispatching that event, formatting a notification that inlines the whole answer when the tool name is subagent and the file is under 256,000 bytes, and choosing between running a turn immediately in an idle live session or queueing a soft interrupt. The fourth band shows injection at point D as a user-role message, the host narrating, and text deltas reaching every subscribed connection. band 1 — the parent turn, which does not wait tool call output_mode = "background" 4th of 4 modes, task.rs:79 SubagentTool::execute short-circuits at task.rs:165 after the child session is on disk execute_background task.rs:315 · provider.fork() removes subagent, task, todo×3 spawn_with_notify background.rs:283 notify = true   wake = true parent turn ends returns task_id + 2 paths nothing blocks band 2 — the spawned tokio task, minutes later child Agent runs run_once_capture(&prompt) its own model, its own session write <task_id>.output temp_dir()/jcode-bg-tasks/ answer + <subagent_metadata> write .status.json completed | failed | superseded errors are results, never Err Bus::publish BackgroundTaskCompleted output_preview capped at 500 B tokio::spawn — the parent is long gone band 3 — server dispatch and the delivery ladder dispatch_background_ task_completion server/background_tasks.rs:77 format notification tool_name == "subagent"? inline whole file if ≤ 256,000 B is the session live and idle? agent.try_lock() must succeed yes — run a turn now system reminder attached no queue involved no — queue it SoftInterruptSource:: BackgroundTask fallback band 4 — injection, narration, and the handoff to path 4 ServerEvent::Notification scope = "background_task" the notify branch — UI only inject_soft_interrupts agent/interrupts.rs:296 grouped by source, joined "\n\n" INJECTION POINT D turn_streaming_mpsc.rs:1045 added as Role::User host model narrates an ordinary turn nobody typed anything text_delta fan-out to every subscriber path 4 begins here Amber = the four places the Always patch touches. Teal = code paths that already existed for background shell commands and are simply reused. wake implies notify: normalize_delivery(notify, wake) returns (notify || wake, wake) — background/model.rs:79. jcode/src/tool/task.rs · src/background.rs · src/server/background_tasks.rs · src/message/notifications.rs
Figure 8. The full background-subagent lifecycle. The patch itself is small — a new enum variant, a dispatch arm, one execute_background function and one branch in the notification formatter. Everything else is machinery that already existed to make bash commands run in the background, reused unchanged.
The notify/wake delivery matrix, from normalize_delivery and the dispatch ladder
notifywakeEffectiveWhat happens
falsefalse(false, false)Task runs, files are written, nothing is delivered. This is spawn()'s caller's choice, not a default.
truefalse(true, false)Attached clients get a notification card. The model never learns the task finished.
falsetrue(true, true)Wake silently implies notify. Requesting a wake without a notification is not expressible.
truetrue(true, true)What background subagents use. Notification fans out and the host is woken.

The 256,000-byte branch is the whole point, and it is untested

Ordinary background tasks deliver a 500-character preview, which is useless for a subagent whose entire value is its answer. So format_background_task_notification_markdown special-cases tool_name == "subagent", reads the output file synchronously with std::fs::read_to_string, and inlines the whole thing if it is non-empty and s.len() <= 256_000. The literal appears exactly once in the entire Rust tree. src/message/tests.rs has five tests for this function and grep -n subagent against it returns nothing, so neither the branch nor the cap has coverage. Neither does execute_background itself — no test in the tree constructs SubagentOutputMode::Background.

jcode/src/message/notifications.rslines 190–206 the inline branch
// Subagent background completions need the FULL final answer inlined
// (not the 500-char preview) — the host LLM must see the complete result
// to integrate it on its next turn. Falls back to preview if the file
// is missing, empty, or exceeds the cap.
let full_subagent_output = if task.tool_name == "subagent" {
    std::fs::read_to_string(&task.output_file)
        .ok()
        .filter(|s| !s.is_empty() && s.len() <= 256_000)
} else {
    None
};
Three things a reviewer would flag: the read is blocking inside an async dispatch path; the cap is bytes rather than characters, so a multi-byte answer clips earlier than it looks; and an oversized answer degrades silently to the 500-character preview with no signal to the model that it is reading a truncation.
02 — The Python half

The design that was thrown away

By the time those text_delta events reach the voice stack, the hard problem is no longer producing them. It is reading them. The chat client only reads its socket from inside a chat() call, so events emitted between turns queue in the kernel buffer and are then misattributed to whatever turn reads next.

The first attempt merged ambient reading into the chat client's connection pool: one socket per session, a dedicated reader thread, and demultiplexing by message id. The documentation still describes that design. The module that replaced it opens with a post-mortem of why it did not work:

The abandoned pooled-reader design compared with the shipped independent listener Two side-by-side designs. On the left, the abandoned approach: one pooled socket per session shared by the chat client and ambient consumers, with a reader thread demultiplexing events by message id into pending turns and ambient queues. Three annotated failures are marked: most streaming events arrive with a null id and had to be broadcast to everyone, filling queues; a reader-thread spawn race let a caller block forever on subscribe; and one socket serving two consumer types made demultiplexing unsolvable. On the right, the shipped approach: the chat client keeps its pooled socket untouched while a separate AmbientListener opens its own second socket per WebSocket session, subscribes, resumes the session and reads in a straight line with no locks and no shared state. abandoned — one socket, demultiplexed chat() caller wants one turn ambient consumer wants everything else 2nd chat() concurrent reader thread — demux by msg_id _route_event() into pending[] and subscribers[] one pooled socket shared by all three 1 — most events arrive with id = None, so they had to be broadcast; queues filled 2 — a reader-thread spawn race let a caller block on subscribe forever 3 — two consumer types on one socket is an impossible demux problem shipped — two sockets, no demux at all chat() caller unchanged, still pooled AmbientListener one asyncio task per WS session pooled socket reads only inside chat() its own socket subscribe + resume_session, then read jcode serve — one session, many subscribers fans text_delta to every subscribed connection no pool, no shared socket, no thread spawning a thread if it crashes or hangs, voice chat is unaffected The listener writes exactly two lines on connect and then never writes again. Its own subscribe and resume acknowledgements are filtered out by id, and only 11 event types are forwarded to the callback; the socket is otherwise a pure sink. voice-daemon/ambient_listener.py — the failure list is lines 29–39, verbatim
Figure 9. Two solutions to the same problem. The left design is still what README.md and ARCHITECTURE.md describe, in ten places between them. The right one is what runs. The decisive observation is on the right: the agent already fans events out to all subscribed connections on a session, so a second connection needs no cooperation from the first.
“Verified empirically (2026-05-16) that jcode-server fans out text_delta events from background-subagent completions to ALL subscribed connections on the session, not only to the connection that originally sent the /subagent message.”
voice-daemon/ambient_listener.py, lines 22–26 — verbatim. The script it cites as evidence, scripts/test_passive_subscriber.py, is not in the tree.
03 — Narration control

Deciding what is worth saying out loud

Reading the events is not enough. A woken host model produces the same stream it produces for any turn — including planning chatter that reads badly when a speaker says it into an empty room. The gateway therefore applies three independent controls before anything is synthesised.

Suppression

The callback's first act, after the debug-only soft_interrupt_injected marker, is to check state.foreground_turn_active. If a user-initiated turn is in flight, the ambient buffer is cleared, the chunk index is reset to zero, and the event is dropped. The foreground path is already synthesising those same deltas via _send_turn_result; without this check the user would hear every sentence twice. Notably it is not gated on state.is_speaking, because ambient synthesis sets that flag itself and would suppress its own continuation.

The narratable filter

_ambient_text_is_narratable() lower-cases the normalised text and rejects it if it contains any of eleven substrings. The list is a direct transcript of what a model sounds like when it is thinking aloud:

gateway/server.pylines 2853–2865 _AMBIENT_INTERNAL_MARKERS
_AMBIENT_INTERNAL_MARKERS = (
    "<think", "analysis:", "reasoning:",
    "the user is asking", "the user asked",
    "i need to", "i should", "we need to",
    "let me inspect", "let me check", "tool call",
)
A substring match against the whole chunk, so one occurrence of "i should" anywhere silences the entire flush — including the useful sentence it was attached to. Crude, and cheap, and it is the only thing standing between the user and a speaker reading out a chain of thought.

Chunking, which is off by default

This is the detail most likely to surprise someone who has only read the architecture document. ALWAYS_AMBIENT_STREAM_TTS defaults to false. With that default, a text_delta is appended to the buffer and the callback returns immediately — no ambient_text_delta frame, no sentence-boundary flush, no size-based flush. The buffer is merely trimmed to its last 8,000 characters. Everything is spoken in a single chunk when message_end arrives.

Ambient behaviour by configuration
Default (ambient_stream_tts = false)Streaming enabled
Text to browserone frame at message_endone ambient_text_delta per token
First audioafter the whole narration completesat the first sentence boundary
Buffer cap8,000 chars, trimmed from the front320 chars, forces a flush
Chunk indexalways 0, then resetincrements per flush
Narratable filterapplied to the whole narrationapplied per chunk

One constant is defined and never read

_AMBIENT_IDLE_FLUSH_S = 1.2 sits at gateway/server.py:2847 under a five-line comment explaining that it catches short narrations without terminal punctuation. grep finds exactly one occurrence: the definition. There is no timer in the shipped listener — the idle-flush behaviour belonged to the abandoned pump, which polled with a 1.2-second readline timeout. The constant is a fossil of Figure 9's left-hand design, as is AMBIENT_FORWARDED in the worker, which is defined at lines 81–87 and referenced nowhere.

04 — Two queues

Why the browser keeps ambient audio separate

Ambient audio and foreground audio arrive on the same WebSocket as the same audio_chunk message type, distinguished only by an ambient: true flag the gateway sets. The client keeps two arrays and a strict priority:

  • foregroundAudioQueueRef — cleared whenever a chunk with index === 0 arrives, so a new answer always cancels the previous one.
  • ambientAudioQueueRef — appended to, never cleared by foreground activity.
  • playNextAudio() shifts from the foreground queue first and only falls through to the ambient queue when it is empty. On a playback failure the item is pushed back onto the queue it came from.

The invariant that falls out of this is the one the feature needs: a narration that begins while you are silent will not be destroyed by you then speaking — it is deprioritised, not discarded. Meanwhile the suppression check in the gateway ensures no ambient audio is generated during that foreground turn in the first place, so the two mechanisms bracket the same hazard from both ends.

Where the ambient listener is started

_start_ambient_pump() is called from inside _send_turn_result(), after the agent has revealed the session id. It cannot run earlier: resume_session needs an id that does not exist until the first turn completes. The function is idempotent, returns immediately if a listener is already bound to the same session, and tears down and replaces the listener when the session id changes. Its name is a leftover — the docstring says so — from the design in Figure 9's left panel.