Always signal stack

Paths 2 and 3 — transcript to spoken answer

The turn

A turn crosses three processes and two entirely different socket disciplines: a fresh connection per turn from the gateway to the worker, and a pooled, deliberately persistent connection from the worker to the agent. The asymmetry is not accidental — it is the fix for a real bug in which conversation memory silently stopped accumulating.

turn id token_hex(6) worker pool 8 conns · 1,800 s TTL agent timeout 180 s speech chunk sentence or 260 chars forwarded events 12 types

01 — Lifecycle

One turn, four processes

A turn begins when the browser sends stop_listening and ends when the gateway emits response_complete. Between those two events the gateway allocates a turn identity, transcribes, opens a socket, streams, chunks, synthesises and cleans up — and at four separate points re-checks whether the turn it is working on is still the current one.

Foreground turn lifecycle across four processes, with measured spans Four horizontal process lanes — browser, gateway, voice worker and jcode agent. A single amber line steps between lanes through nine ordered events: stop listening, begin foreground turn, transcription, opening a per-turn worker socket, the pooled agent connection, the message reaching the model, streamed text deltas, chunked speech synthesis, and playback. Below the lanes, four measurement bars show which spans the TurnResult dataclass records: audio statistics at the start, asr seconds over transcription, worker seconds over the whole agent round trip, and total seconds over everything. browser gateway worker jcode serve stop_listening 1 _begin_foreground_turn 2 · turn_id = token_hex(6), VoiceRun persisted transcribe_audio_qwen3 3 · asyncio.to_thread, then Figure 5 fresh AF_UNIX socket 4 · one per turn, op = chat, stream = true pooled _PooledConn 5 · keyed by session_id, reused across turns type = message 6 · after subscribe + resume_session text_delta × N 7 · 12 event types forwarded, rest dropped chunk → Kokoro → audio_chunk 8 · speech begins before the answer ends playback 9 · response_complete what TurnResult actually records audio_seconds · audio_rms · audio_peak asr_seconds worker_seconds — the whole agent round trip total_seconds tts_seconds exists on the same dataclass but is zero on this path: the voice endpoints call run_text_turn with include_tts = False and let the streaming chunker do the synthesis instead.
Figure 6. The turn as a signal stepping between processes. The measurement bars are the whole of the system's instrumentation: seven floats on TurnResult.metrics(), rounded to three decimals (five for the amplitudes) and attached to the transcript and response_complete frames. Nothing aggregates them. There is no percentile, no histogram, and no exporter anywhere in the repository.

Four liveness checks, not one

_send_turn_result() defines a local turn_still_current() closure and calls it before the transcript frame, before the response frame, before binding the session and before delivering audio. The predicate is state.transport_alive and not state.turn_cancelled and (not turn_id or state.turn_id == turn_id). Anything a user does mid-turn — interrupt, reset, disconnect — changes one of those three, and the in-flight coroutine notices at its next checkpoint rather than at the end.

02 — Socket discipline

Fresh per turn, then pooled forever

The gateway opens a new unix socket for every single turn (always_assistant/worker_client.py): connect, write one JSON line, read newline-delimited events until {"event": "final"}, close. That is simple and cheap and has no state to corrupt.

The worker cannot do the same thing, and the docstring of voice-daemon/jcode_server_client.py explains exactly why:

“Previous version opened a fresh socket per chat() call. jcode-server treats closed-mid-edit connections as discard, so each turn's messages didn't get appended to the session file → no conversation memory across turns.”
voice-daemon/jcode_server_client.py, lines 3–6 — verbatim

So the worker keeps an OrderedDict pool keyed by session id, with LRU eviction at eight entries and a 1,800-second idle TTL. A connection that has never been used is stored under the literal key "__new__" until the agent reveals which session it was actually given, at which point _remember_under_id() re-keys it. Session discovery is opportunistic: any event carrying a session_id field wins, and failing that, a swarm_status event with exactly one member is treated as authoritative.

Two connections, two disciplines
gateway → workerworker → jcode serve
Lifetimeone turnuntil idle 1,800 s or LRU-evicted
Keyed bynothing — always newsession_id, or "__new__"
Concurrencyone thread per connection in the workera single threading.Lock around chat()
Framingone request line, then NDJSON eventsNDJSON both ways, correlated by integer id
Termination{"event": "final", …}type: done or type: error with the matching id
On failureraises; the turn errorsdrop from pool, reopen, retry once
Timeoutworker_timeout, default 60 s180 s default; 15 s for subscribe, 60 s for resume

Two ordering details in chat() are worth reading, because both are comments recording a bug that was actually hit:

voice-daemon/jcode_server_client.pylines 339–343, 360–362
# resume_session emits an ack/error and possibly history.
# Wait for it to finish before sending the message.
# Otherwise a fresh jcode-server can route the message
# into its default session before the requested session
# is attached, silently forking browser chat context.

# jcode-server applies set_model in-order but does not
# emit a done frame for it; waiting here forces a
# timeout and falls back to the slow subprocess path.
The first is a race that produces two divergent conversations with no error. The second is the inverse: waiting for an acknowledgement that the protocol never sends, which degrades the fast socket path into a subprocess spawn. Both are load-bearing.
03 — What the model receives

The five-part prompt

The user's words are not sent alone. chat_with_worker_stream() assembles between three and five blocks, joined by blank lines, and the transcript arrives last:

  1. The persona and format prefix. Configurable via ALWAYS_PROMPT_PREFIX; the default is quoted below.
  2. A runtime-context directive naming the harness and the active model alias, ending with instructions on how to answer “what model are you” — the model is told to give the alias and to mention that llama.cpp is only the backend runtime.
  3. A visible-reasoning directive, only when the browser has thinking mode on. It demands exactly two XML-ish sections, <always_reasoning> and <always_final>, which the gateway then splits apart with two compiled regexes.
  4. A web-search directive, only when search is enabled, whose last clause is “Do not say you lack internet before trying the available … web tools.”
  5. User request: <transcript>.
“You are a local coding agent running behind a voice stack. Answer in English in 1 to 3 short spoken sentences unless the user explicitly asks for detail. Use tools when useful. For ordinary browser tasks, use the browser tool first. Do not include XML, markdown tables, or thinking tags.”
always_assistant/config.py, default prompt_prefix — verbatim

“1 to 3 short spoken sentences” is the single most consequential line in the configuration. Everything downstream — the sentence chunker, the 220-character speech split, the 320-character ambient buffer cap — is sized on the assumption that it is obeyed.

04 — Speaking early

The chunker starts talking before the answer exists

Waiting for a complete answer before synthesising it adds the whole generation latency to time-to-first-audio. So the gateway maintains a growing foreground_tts_buffer and, on every text_delta, asks _pop_streamable_spoken_chunk() whether enough has arrived to be worth speaking. It is a four-rule decision:

Decision machine for the foreground speech chunker A decision flow. Each incoming text delta is normalised and appended to a buffer, then four rules are tested in order: if splitting the buffer yields more than one sentence, emit the first; else if the buffer ends in terminal punctuation and is at least four characters, emit all of it; else if it is at least 260 characters, force a split at 220 characters; otherwise hold. Emitted text passes through a turn-identity guard and a lock before being synthesised and sent as an audio chunk frame. A separate end-of-turn path computes the remaining unspoken text by prefix comparison. text_delta appended to buffer rule 1 — more than one sentence? split_spoken_reply() returns > 1 rule 2 — ends in . ! ? and length ≥ 4 rule 3 — length ≥ 260 force split at 220 chars rule 4 — otherwise hold, emit nothing no no no turn-identity guard turn_cancelled? turn_id match? checked before and inside the lock yes foreground_tts_lock serialises chunk order Kokoro synthesis to_thread, timed audio_chunk frame index++ · foreground_streaming the frame the browser receives type: "audio_chunk"   format: "wav" data: base64(WAV, 24 kHz mono) index, is_final_chunk, turn_id, session_id foreground_streaming: true chunk_seconds: synthesis time, 3 dp index 0 tells the client to clear its queue first end of turn — _remaining_tts_text() normalise whitespace on both strings final startswith spoken → speak only the tail spoken startswith final → speak nothing neither → speak the whole final answer prefix comparison, not diffing — a rewrite re-speaks everything The buffer holds when no rule fires, so a long clause with no punctuation is silent until it crosses 260 characters — the same tension the ambient path resolves differently, with a 320-character cap and no idle timer. gateway/server.py 2242–2358 · always_assistant/tts.py 14–39
Figure 7. The foreground chunker. Rule 3 is the interesting one: at 260 characters it re-splits the buffer with max_chars=220, which walks word by word rather than by sentence. The effect is that an unpunctuated monologue is spoken in ~220-character mouthfuls with breaks at word boundaries instead of stalling.

Reconciliation at the end of the turn is deliberately blunt. _remaining_tts_text() compares normalised strings by prefix only — no diffing, no alignment. If the streamed prefix matches, only the tail is spoken; if the final answer is shorter than what was already said, nothing is spoken; and if the model rewrote its answer mid-stream so that neither is a prefix of the other, the entire final answer is spoken again. That last case is a real duplicate-audio path, accepted in exchange for a function that is four lines long and cannot itself fail.

05 — The worker protocol

Five verbs

The voice worker is a newline-delimited JSON server on an AF_UNIX stream socket, chmod 0600, listen(8), one daemon thread per connection. Its entire vocabulary is five operations. Anything else gets a single, very specific error string:

poking the voice worker — voice-daemon/jcode_voice_worker.py:840
$ SOCK=~/.hermes/voice-daemon/jcode-worker.sock

# op 1 of 5 — liveness. also lazily ensures the agent process is up.
$ printf '{"op":"ping"}\n' | socat - UNIX-CONNECT:$SOCK
{"ok": true, "pid": 3184177, "rpc_pid": 3184240}

# the op the README and ARCHITECTURE.md both still document:
$ printf '{"op":"subscribe_ambient","session_id":"…","model":""}\n' | socat - UNIX-CONNECT:$SOCK
{"ok": false, "error": "unknown op: subscribe_ambient"}

# the streaming path the gateway actually uses:
$ printf '{"op":"chat","prompt":"say hi","session_id":"","stream":true}\n' | socat - UNIX-CONNECT:$SOCK
{"event": "connection_phase", …}
{"event": "text_delta", "text": "Hi"}
{"event": "text_delta", "text": " there."}
{"event": "message_end", …}
{"event": "tokens", "input": …, "output": …}
{"event": "final", "ok": true, "answer": "Hi there.", "session_id": "…"}
Process ids and token counts differ per run. The two strings that matter are exact: {"ok": false, "error": "unknown op: subscribe_ambient"} is generated verbatim by the else branch at line 932, and {"event": "final", …} is the sentinel the gateway's reader loop breaks on. The stream forwards only 12 event types; anything else the agent emits is dropped at the worker boundary.
The complete worker vocabulary — jcode_voice_worker.py
opLineReturnsUsed by
ping841ok, pid, rpc_pid/health, /ready
shutdown851ok, then exits the accept loopoperator
ensure_session854session_idchat-session binding in the gateway
history869entriestranscript replay into the browser
chat881one final, or a stream then finalevery turn

A dropped connection does not cancel the turn

The streaming branch catches BrokenPipeError and ConnectionResetError around the whole chat() call and logs “gateway closed mid-stream; chat continues in background.” The agent keeps generating and the session file keeps growing; only the delivery stops. That is what makes a browser reload mid-answer recoverable — and it is also why the ambient path, which reads the same session from a different socket, can pick up narration the foreground reader never saw.