Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
710fbeaaeThis file is pinned to the selected release. Download the original to open it in your design tool.
/**
* CommanderMirror — derives the paired hardware device's `commander_event` stream from the same LiveEvent
* stream the web receives, and produces the per-turn RECAP the way the hosted runtime does:
*
* turn_started → {kind:'processing'} (tile busy)
* tool_start → {kind:'tool', text, recap, color, detail}
* tool_start TodoWrite → + {kind:'processing', todos:[{c,s}]} (checklist)
* tool_start Task/Agent → + {kind:'agents', agents:[{text,color}]} (live sub-agent list)
* subagent_finished → + {kind:'agents', …} with that row ticked off
* turn_ended (device on) → {kind:'processing', text:'Summarizing…'} then, once the LLM one-shot
* returns, {kind:'summary', text:body, recap} (persisted per session,
* and handed to the NEXT turn's summariser as its previous recap)
* turn_ended (no device) → nothing (device-gated; the summary map is left untouched)
* turn_ended (empty text) → {kind:'done'} (clear busy)
*
* Every frame carries top-level `agentId` + `dbSessionId` (= the tmux session id). The recap
* (`summarizeTurnText`) is injected so it's unit-testable with a stub. Mirrors the hosted runtime's
* device-gated recap (recap.ts / manager.ts triggerTurnRecap / websocket.ts handleBrainSummaryEvent).
*/
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { LastTurnText, LiveEvent } from './normalize.js'
export type CommanderFrame = {
type: 'commander_event'
agentId: string
dbSessionId: string
payload: Record<string, unknown>
}
export interface CommanderMirrorOpts {
/** Device-audience frame (commander_event cards). */
send: (frame: CommanderFrame) => void
/** Web-audience frame (turn_summary_pending / turn_summary — the "Summarizing…" indicator). */
sendWeb: (frame: Record<string, unknown>) => void
hasDevice: () => boolean
/** True while a device is ACTIVELY rendering this machine (vs attached-but-background under multi-attach).
* Gates the live turn-card STREAM; the turn-done `summary` card ignores it so a background machine still
* badges. Omitted → defaults to hasDevice (single-machine firmware: attached == active, streams as before). */
active?: () => boolean
/** `previousRecap` is the stored `recap\n\nbody` of this session's last summarised turn, when there is
* one — the continuity a recap of THIS turn alone cannot carry ("same fix, other file" is a fragment
* without it). Undefined on a session's first turn. */
summarize: (
text: string,
signal?: AbortSignal,
userMessage?: string,
sessionId?: string,
previousRecap?: string,
) => Promise<string | null>
/** True when `summarize` is a local derivation rather than a model call — see startSummary. */
summarizeIsLocal?: boolean
/** Agent display name for a sessionId — rides the summary's outer frame so a BACKGROUND machine's device
* notification shows the agent name on line 2 (its tile isn't loaded). */
nameFor?: (sessionId: string) => string | undefined
readLastTurn?: (sessionId: string) => Promise<LastTurnText | null>
/** Engine session id → the AGENT that owns it. Frames are addressed to the agent (stable across a
* `/clear` rotation); `dbSessionId` stays the engine session, which is what the device echoes back to
* cancel a turn and what the backend keys its voice queue on. */
agentIdFor?: (sessionId: string) => string | undefined
/**
* True for a session whose turn end is nobody's news: an Orchestrator specialist, or its Director while
* specialists are still out. The terminal cards (`done`, `summary`, `error`) then carry `subagent: true`
* and the dial redraws the tile without the beep or a drawer row (cable_client.c). The person asked for
* one notification per project — the main agent's — not one per sub-agent (owner, 2026-09-21).
*/
isSubagent?: (sessionId: string) => boolean
/**
* Whether a sub-agent of this session is still at work — its transcript
* (`<session>/subagents/agent-<id>.jsonl`) written recently. Consulted while a turn end is held, so a
* long sub-agent keeps the hold and only a silent one is given up on. Absent → the hold is bounded by
* time alone, as before.
*/
subagentActive?: (sessionId: string, agentId: string) => boolean
dataDir: string
recapForce?: boolean
alwaysGenerate?: boolean
}
interface SessionState {
lastAssistantText: string
/** The user's prompt for the current turn (from turn_started) — fed to the recap so it leads with
* the direct answer to what was asked. */
lastUserMessage: string
turnOpen: boolean
/** Has a turn_started ever been seen for this session? Separates the normal duplicate-close
* (watcher and Stop hook race, every turn) from a close whose turn was never opened at all. */
everOpened: boolean
summarizing: boolean
lastTool: Record<string, unknown> | null
lastTodos: Array<{ c: string; s: string }> | null
/** Sub-agents this turn spawned, in the order they started — the device's list above "Working…". */
agents: Array<{ id: string; desc: string; startedAt: number; doneMs: number | null; carried?: true }>
/** A turn_ended is being HELD because sub-agents this turn spawned are still running (see below). */
endPending: boolean
/** The hold was given up on (a sub-agent went quiet), not completed: the recap goes out silent. */
abandoned: boolean
/** Fires the held turn-end once the last sub-agent has finished AND the wrap-up text has stopped. */
endSettle: NodeJS.Timeout | null
/** Backstop: a sub-agent that never reports back must not cost the turn its recap. */
endDeadline: NodeJS.Timeout | null
abort: AbortController | null
}
interface TodoInput {
todos?: Array<{ content?: string; subject?: string; status?: string }>
}
const MAX_PART = 2000
// Stable per-tool colors (device renders the tool name in this color); hash fallback for others.
const TOOL_COLORS: Record<string, string> = {
Bash: '#e5c07b', Read: '#61afef', Write: '#98c379', Edit: '#98c379', Glob: '#c678dd',
Grep: '#c678dd', WebFetch: '#56b6c2', WebSearch: '#56b6c2', Task: '#d19a66', Agent: '#d19a66',
TodoWrite: '#5b8def',
}
const PALETTE = ['#e06c75', '#98c379', '#e5c07b', '#61afef', '#c678dd', '#56b6c2', '#d19a66']
export function toolColor(name: string): string {
const hit = TOOL_COLORS[name]
if (hit) return hit
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return PALETTE[h % PALETTE.length]
}
const PRIMARY_KEYS = ['file_path', 'path', 'command', 'pattern', 'query', 'url', 'description', 'prompt', 'content']
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + '…' : s
}
function primaryArg(input: unknown): string {
if (!input || typeof input !== 'object') return ''
const obj = input as Record<string, unknown>
for (const key of PRIMARY_KEYS) {
const v = obj[key]
if (typeof v === 'string' && v.trim()) return truncate(v.replace(/\s+/g, ' ').trim(), 60)
}
for (const v of Object.values(obj)) {
if (typeof v === 'string' && v.trim()) return truncate(v.replace(/\s+/g, ' ').trim(), 60)
}
return ''
}
function compactArgs(input: unknown): string {
if (!input || typeof input !== 'object') return ''
const obj = input as Record<string, unknown>
const parts: string[] = []
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string' && v.trim()) parts.push(`${k}: ${v.replace(/\s+/g, ' ').trim()}`)
if (parts.join(' · ').length > 200) break
}
return truncate(parts.join(' · '), 200)
}
/**
* What counts as a sub-agent.
*
* Every engine's own name is mapped to `Task` by its normalizer — EXCEPT claude, whose own tool is called
* `Agent` and passes through unmapped. Watching only for `Task` therefore missed the one engine this
* feature is modelled on; measured on a live claude turn, whose transcript names the tool `Agent`.
*/
const SUBAGENT_TOOLS = new Set(['Task', 'Agent'])
/** Running = orange, finished = green. The device just paints the colour it is handed. */
const AGENT_RUNNING_COLOR = '#ff9d00'
const AGENT_DONE_COLOR = '#3ddc84'
/**
* Claude's async `Agent` tool answers its own tool_use in ~4ms with this line — a LAUNCH ACK, not a
* result (measured: `dur= undefined out= Async agent launched successfully.`). Ticking the row off on it
* showed every sub-agent as finished the instant it started. The real finish is `subagent_finished`.
*/
const ASYNC_LAUNCH_ACK = /Async agent launched successfully/i
/**
* After the last sub-agent reports in, the engine still writes its wrap-up ("Cả 2 sub-agent đã hoàn thành:
* …"). How long that takes is a model round-trip, not a constant — measured at 3s, 7s and 10.8s on three
* consecutive runs, so any fixed window is a coin flip (the 10s one lost by 0.85s).
*
* So the timer is only a backstop for "nothing more is coming". The two real signals are the Stop hook
* (`noteEngineStopped`, exact) and the wrap-up text itself landing — after which a short debounce is
* enough, because the text we were waiting for is already in hand.
*/
const SUBAGENT_SETTLE_MS = 12_000
const SUBAGENT_TEXT_SETTLE_MS = 2_000
/**
* A sub-agent that never reports back must not cost the turn its recap forever — but a sub-agent that IS
* still writing is not "never": a fixed ten minutes released the hold under long sub-agents, which rang
* the dial for a turn that was not over and then rang it again when it was. So the deadline is checked
* every minute against the sub-agents' own transcripts (`subagentActive`): held while any of them wrote
* in the last SUBAGENT_IDLE_MS, given up only once all of them have gone quiet for that long. Without
* the probe, the old ten minutes stand.
*/
const SUBAGENT_MAX_WAIT_MS = 10 * 60_000
export const SUBAGENT_IDLE_MS = 2 * 60_000
const SUBAGENT_CHECK_MS = 60_000
/**
* The device renders ONE line per sub-agent and does no formatting of its own: `› desc` while it runs,
* `✓ desc · 12s` once it finishes, in the colour we pick (`ui_project_set_agents`). It also scrolls the
* `›` row into view, so that prefix is load-bearing, not decoration.
*/
function agentRows(agents: SessionState['agents']): Array<{ text: string; color: string }> {
return agents.map((a) => (a.doneMs === null
? { text: `› ${a.desc}`, color: AGENT_RUNNING_COLOR }
: { text: `✓ ${a.desc} · ${Math.max(1, Math.round(a.doneMs / 1000))}s`, color: AGENT_DONE_COLOR }))
}
/**
* Does this row's sub-agent gate THIS turn's recap? Only one that started during it.
*
* Rows survive a turn boundary while they run (cursor announces a Task before its turn opens; claude's
* async agents outlive the turn that spawned them), so they stay on screen — but an agent spawned two
* prompts ago must not hold the answer to the question just asked: that could park a recap for the full
* 10-minute backstop with nothing on the device to explain the wait.
*/
function holdsTurn(a: SessionState['agents'][number]): boolean {
return a.doneMs === null && !a.carried
}
/** Split a persisted "recap\n\nbody" into its parts (mirror websocket.ts:540 / getRecentEvents). */
function splitSummary(summary: string): { recap: string; body: string } {
const nl = summary.indexOf('\n\n')
const recap = (nl >= 0 ? summary.slice(0, nl) : summary).replace(/\s+/g, ' ').trim().slice(0, MAX_PART)
const body = (nl >= 0 ? summary.slice(nl + 2) : summary).replace(/\s+/g, ' ').trim().slice(0, MAX_PART)
return { recap, body }
}
/** How many turn summaries are kept per session. Three is what the voice router reads: enough to tell one
* agent's subject from another's, few enough that one chatty agent cannot crowd the others out. */
const RECENT_TURNS = 3
/**
* The cap on ONE stored final answer, in bytes of UTF-8.
*
* Not a taste judgement — it is arithmetic against the narrowest transport that carries this. The
* Autonomous device link opens its socket with `maxPayload: 65536` (lib/autonomous-device/direct.ts),
* a `recap` may ask for five turns at once, and every payload is sealed and base64'd on the way out
* (wrapPayload → roughly +37%). Five of these is 40 KiB, which lands near 55 KiB on the wire and still
* fits. Raising it without redoing that multiplication is how frames start disappearing with no error
* anywhere: an oversized one is dropped by the socket, not rejected by anything that logs.
*/
const FULL_TEXT_MAX_BYTES = 8192
/**
* How much of the user's own words is kept per turn.
*
* Raised 200 → 1000 when the router stopped cutting these again on the way out. Two hundred was set
* against a worst case that does not happen — fifteen agents each carrying three questions — while a
* real machine has four to eight, and the cost of the old number was paid on every long question by
* losing its second half.
*
* Still bounded, and the bound is not about prompt size: somebody pasting a stack trace or a file at
* an agent must not put the whole thing on disk here and then into a routing prompt.
*
* Only affects questions recorded from now on — `summaries-asks.json` keeps what it already holds at
* the old length.
*/
const ASK_MAX_CHARS = 1000
/**
* Truncate to a byte budget without splitting a character.
*
* `slice()` counts UTF-16 units, so cutting Vietnamese or emoji by a byte figure lands mid-sequence and
* ships a broken string. Decoding the truncated buffer non-fatally turns whatever was severed into
* U+FFFD, which is then dropped along with a trailing lone surrogate.
*/
function clipBytes(text: string, max: number): string {
if (Buffer.byteLength(text, 'utf8') <= max) return text
const decoded = new TextDecoder('utf-8').decode(Buffer.from(text, 'utf8').subarray(0, max - 3))
return `${decoded.replace(/\uFFFD+$/, '').replace(/[\uD800-\uDBFF]$/, '')}\u2026`
}
export class CommanderMirror {
private states = new Map<string, SessionState>()
private summaries = new Map<string, string>() // sessionId → "recap\n\nbody" (the LATEST turn)
/**
* The last few turns, newest first — what a router reads to tell one agent from another.
*
* A SECOND file rather than widening the first, and the reason is rollback: this daemon self-updates,
* and a published build that predates this change reads summaries.json with `typeof v === 'string'`.
* Handed an array it keeps nothing and then rewrites the file — every stored recap gone, silently, on a
* downgrade nobody asked for. The old file therefore keeps its exact old shape and meaning, and the
* history lives beside it where an older reader simply never looks.
*/
private history = new Map<string, string[]>()
/**
* The same turns' COMPLETE final answers, newest first and index-aligned with `history`.
*
* A third file for the reason the second one exists (see above): the two beside it keep their exact
* shape, so a rollback reads them unchanged and simply never looks here.
*
* It is kept at all because `history` cannot answer for it. What is stored there has already been
* flattened to one line and clipped to 250 characters by deriveTurnSummary — enough for a dial that
* is cabled to the screen already showing the answer, and not enough for anything reading the answer
* aloud in another room.
*/
private fullTexts = new Map<string, string[]>()
/**
* What the USER ASKED on each of those turns, newest first and index-aligned with `history`.
*
* A fourth file, for the same reason as the third: the others keep their shape and a rollback simply
* never looks here.
*
* It exists because a recap answers the wrong question for routing. Recaps summarise what the AGENT
* REPLIED — a turn that asked "which year did the second world war end" is recapped, correctly and
* uselessly, as "1945." Ask the same agent about the FIRST world war a minute later and nothing in
* that recap connects the two, so the router cannot see a conversation it is plainly in the middle
* of. The question carries the topic; the answer usually does not.
*/
private asks = new Map<string, string[]>()
private file: string
private historyFile: string
private fullTextFile: string
private askFile: string
private saveTimer: NodeJS.Timeout | null = null
constructor(private opts: CommanderMirrorOpts) {
this.file = join(opts.dataDir, 'summaries.json')
this.historyFile = join(opts.dataDir, 'summaries-history.json')
this.fullTextFile = join(opts.dataDir, 'summaries-fulltext.json')
this.askFile = join(opts.dataDir, 'summaries-asks.json')
this.load()
}
private stateFor(sessionId: string): SessionState {
let st = this.states.get(sessionId)
if (!st) {
st = { lastAssistantText: '', lastUserMessage: '', turnOpen: false, everOpened: false, summarizing: false, lastTool: null, lastTodos: null, agents: [], endPending: false, abandoned: false, endSettle: null, endDeadline: null, abort: null }
this.states.set(sessionId, st)
}
return st
}
private emit(sessionId: string, payload: Record<string, unknown>): void {
// Bandwidth gate (mirrors the hosted runtime’s emitCommanderEvent empty-pool early-return): with no device
// connected every card would ride adapter→backend just to be dropped by deliverUpLocal. Gate the
// SEND only — ingest() keeps folding state (lastTool/lastTodos/lastAssistantText) regardless, so a
// device joining mid-turn still gets a correct replay (onCommanderJoin) and turn-end recap.
if (!this.opts.hasDevice() && !this.opts.recapForce) return
// Multi-attach: the turn-done `summary` card reaches ANY connected commander (a BACKGROUND machine badges
// from it). Streaming cards (processing/tool/todos/done) reach only the ACTIVELY-rendered machine — a
// background machine gets just its summary, not the live stream. Single-machine firmware defaults active to
// hasDevice, so attached == active and everything streams exactly like before.
const terminal = payload.kind === 'summary'
const active = this.opts.active ? this.opts.active() : this.opts.hasDevice()
if (!terminal && !active && !this.opts.recapForce) return
// Agent name on the summary's outer frame → background-machine device notif line 2 (see nameFor).
const name = terminal && this.opts.nameFor ? this.opts.nameFor(sessionId) : undefined
// A sub-agent's turn end is not announced: the flag rides the terminal cards only, so the live stream
// (processing/tool/todos) is untouched and the tile still moves.
const ending = payload.kind === 'summary' || payload.kind === 'done' || payload.kind === 'error'
const subagent = ending && ((this.opts.isSubagent?.(sessionId) ?? false) || (this.states.get(sessionId)?.abandoned ?? false))
this.opts.send({ type: 'commander_event', agentId: this.opts.agentIdFor?.(sessionId) ?? sessionId, dbSessionId: sessionId, ...(name ? { name } : {}), payload: subagent ? { ...payload, subagent: true } : payload })
}
/** Recap diagnostics stay in the adapter log; the web only gets summary pending/done state. */
private trace(_sessionId: string, line: string, isError = false): void {
;(isError ? console.error : console.log)(`[recap] ${line}`)
}
/** Fold one session's LiveEvents into device commander_event frames (live cards + async recap). */
ingest(events: LiveEvent[], sessionId: string): void {
const st = this.stateFor(sessionId)
for (const e of events) {
switch (e.type) {
case 'turn_started':
st.abort?.abort() // supersede any in-flight recap from the previous turn
st.abort = null
// A new prompt supersedes a held turn-end outright: its sub-agents belong to the turn the user
// has just moved on from, and the recap they were holding is now stale.
this.clearEndTimers(st)
st.endPending = false
st.summarizing = false
st.lastAssistantText = ''
st.lastUserMessage = e.payload.userMessage || ''
// RECORDED HERE, WHEN IT IS ASKED — not when the answer is summarised.
//
// It used to be written in the summariser's success branch, index-aligned with the recaps,
// which meant a question only existed once its ANSWER had been summarised. Measured on this
// desk: three of eight agents had any questions on record at all, and the missing ones were
// the newest — a turn that produced no assistant text, or whose summariser returned null,
// left no trace of what the person had asked. The router then ranked those agents on their
// name alone, which is exactly the case it is worst at.
//
// The question is a fact about the TURN, not about the recap, so it is kept on its own list
// and no longer has to wait for, or stay in step with, anything else.
this.rememberAsk(sessionId, e.payload.userMessage || '')
st.turnOpen = true
st.everOpened = true
st.abandoned = false
st.lastTool = null
st.lastTodos = null
// Drop the previous turn's finished rows, KEEP the ones still running. Two reasons, both
// measured: cursor's tool-start HOOK beats its transcript-derived turn_started by ~60ms, so a
// blanket reset deleted the sub-agent that had just been announced (two Tasks collapsed into
// one row, live); and a claude async sub-agent genuinely outlives the turn that spawned it, so
// a new prompt does not make it stop running. Anything past the wait cap is abandoned.
const hadAgents = st.agents.length > 0
st.agents = st.agents
.filter((a) => a.doneMs === null && Date.now() - a.startedAt < SUBAGENT_MAX_WAIT_MS)
.map((a) => ({ ...a, carried: true as const }))
this.emit(sessionId, { kind: 'processing', text: 'Processing' })
// Push the new list only when there WAS one: an empty array CLEARS the device's list
// (ui_project_set_agents deletes the box when the array is empty), which is what stops last
// turn's rows hanging around — but a turn that never had sub-agents has nothing to clear.
if (hadAgents) this.emit(sessionId, { kind: 'agents', agents: agentRows(st.agents) })
break
case 'text_delta':
st.lastAssistantText += e.payload.content
// The wrap-up text landed while the turn-end was held: this IS what the hold was waiting for,
// so drop to a short debounce (more blocks of the same message may still follow).
if (st.endSettle) this.armSettle(sessionId, st, SUBAGENT_TEXT_SETTLE_MS)
break
case 'tool_start': {
const tool = e.payload.tool || 'Tool'
// AskUserQuestion is not a tool card — it's a question the user answers on the device's question
// screen (QuestionWatcher pushes it live off the pane). By the time this line reaches the
// transcript the CLI has already been answered, so there is nothing left to show: drop it.
if (tool === 'AskUserQuestion') break
const toolPayload = {
kind: 'tool',
text: tool,
recap: primaryArg(e.payload.input),
color: toolColor(tool),
detail: compactArgs(e.payload.input),
}
st.lastTool = toolPayload
this.emit(sessionId, toolPayload)
// A sub-agent is not just a card: the device keeps a live LIST of them above "Working…", which
// nothing ever fed — the firmware has been able to render it since it shipped, and never got data.
if (SUBAGENT_TOOLS.has(tool)) {
const id = String(e.payload.id ?? `task-${st.agents.length}`)
const desc = primaryArg(e.payload.input) || 'sub-agent'
if (!st.agents.some((a) => a.id === id)) st.agents.push({ id, desc, startedAt: Date.now(), doneMs: null })
this.emit(sessionId, { kind: 'agents', agents: agentRows(st.agents) })
const running = st.agents.filter((a) => a.doneMs === null).length
console.log(`[subagents] ${sessionId.slice(0, 8)} ${running}/${st.agents.length} running · "${desc.slice(0, 40)}"`)
}
if (tool === 'TodoWrite') {
const todos = (e.payload.input as TodoInput | undefined)?.todos
if (Array.isArray(todos)) {
const mapped = todos.map((t) => ({ c: t?.content ?? t?.subject ?? '', s: t?.status ?? 'pending' }))
st.lastTodos = mapped
this.emit(sessionId, { kind: 'processing', text: 'Processing', todos: mapped })
}
}
break
}
case 'turn_ended':
// A killed turn takes the cancel path, not the recap path: clear the tile and stay silent. There
// is nothing to summarize, and a beep for output the user just threw away is worse than nothing.
if (e.payload.aborted) {
this.clearEndTimers(st)
st.endPending = false
this.cancel(sessionId)
} else if (st.agents.some(holdsTurn)) {
// Async sub-agents outlive the turn that spawned them: claude says "đã spawn 3 sub-agent" and
// ENDS the turn while all three are still working. Recapping there summarized the launch
// message and dropped the tile out of busy — the answer the user was waiting for arrived
// afterwards with nothing left to show it. Hold the end until the last one reports in.
this.holdTurnEnd(sessionId, st)
} else this.onTurnEnded(sessionId, st)
break
case 'tool_end': {
// Only sub-agents are tracked here; every other tool_end stays web-only.
const done = st.agents.find((a) => a.id === String(e.payload.id))
if (!done || done.doneMs !== null) break
// An async launch ack is not a finish — that one arrives later as `subagent_finished`.
if (ASYNC_LAUNCH_ACK.test(e.payload.output || '')) break
done.doneMs = e.payload.durationSeconds ? e.payload.durationSeconds * 1000 : Date.now() - done.startedAt
this.emit(sessionId, { kind: 'agents', agents: agentRows(st.agents) })
this.onSubagentDone(sessionId, st, done.desc)
break
}
case 'subagent_finished': {
const done = st.agents.find((a) => a.id === e.payload.id)
if (!done || done.doneMs !== null) break
done.doneMs = Date.now() - done.startedAt
this.emit(sessionId, { kind: 'agents', agents: agentRows(st.agents) })
this.onSubagentDone(sessionId, st, done.desc)
break
}
default:
break // thinking/user_message — web-only
}
}
}
private clearEndTimers(st: SessionState): void {
if (st.endSettle) clearTimeout(st.endSettle)
if (st.endDeadline) clearTimeout(st.endDeadline)
st.endSettle = null
st.endDeadline = null
}
/**
* The turn closed with sub-agents still running. Keep `turnOpen` true (so the eventual recap takes the
* normal path) and the device tile busy — it stays on `Processing` with the live sub-agent list until a
* `done`/`summary` card arrives, which is exactly the state we want it in.
*/
private holdTurnEnd(sessionId: string, st: SessionState): void {
if (st.endPending) return
st.endPending = true
const running = st.agents.filter(holdsTurn).length
console.log(`[subagents] ${sessionId.slice(0, 8)} turn-end HELD · ${running} sub-agent(s) still running`)
this.clearEndTimers(st)
this.armDeadline(sessionId, st, Date.now())
}
/**
* The hold's backstop. With `subagentActive` the question is asked every minute — is any held sub-agent
* still writing? — and the hold outlives the old cap for as long as the answer is yes; a sub-agent quiet
* for SUBAGENT_IDLE_MS is abandoned and the hold released without one. Without the probe, one timer at
* the old cap, as before.
*/
private armDeadline(sessionId: string, st: SessionState, heldAt: number): void {
const probe = this.opts.subagentActive
const wait = probe ? SUBAGENT_CHECK_MS : SUBAGENT_MAX_WAIT_MS
st.endDeadline = setTimeout(() => {
st.endDeadline = null
if (!st.endPending) return
const held = st.agents.filter(holdsTurn)
const elapsed = Date.now() - heldAt
if (probe && elapsed < SUBAGENT_MAX_WAIT_MS) { this.armDeadline(sessionId, st, heldAt); return }
if (probe && held.some((a) => probe(sessionId, a.id))) {
console.log(`[subagents] ${sessionId.slice(0, 8)} turn-end still HELD after ${Math.round(elapsed / 60_000)}m · a sub-agent is still writing`)
this.armDeadline(sessionId, st, heldAt)
return
}
const stuck = held.map((a) => a.desc).join(', ')
console.log(`[subagents] ${sessionId.slice(0, 8)} turn-end RELEASED by timeout · still running: ${stuck}`)
// Given up on, not done: the person did not get their answer, and a beep for that is the very ring
// this exists to remove. The tile still gets the recap.
st.abandoned = true
this.releaseTurnEnd(sessionId, st)
}, wait)
st.endDeadline.unref?.()
}
/**
* The engine stopped writing (its Stop hook fired). If a turn-end is held and every sub-agent has
* reported in, the wrap-up is written — but not necessarily FLUSHED: measured, the hook beat the
* transcript line by ~200ms and an immediate release recapped the stale text (153 chars on disk, 1233
* a moment later). So this closes the window rather than releasing outright; the wrap-up landing
* re-arms it once more, and the recap reads a complete file either way.
*
* A stop while sub-agents are still running is just the launch ack's own stop: keep holding.
*/
noteEngineStopped(sessionId: string): void {
const st = this.states.get(sessionId)
if (!st?.endPending) return
if (st.agents.some(holdsTurn)) return
console.log(`[subagents] ${sessionId.slice(0, 8)} turn-end settling after Stop hook`)
this.armSettle(sessionId, st, SUBAGENT_TEXT_SETTLE_MS)
}
/** A sub-agent finished: log it, and if it was the last one, let the held turn-end go through. */
private onSubagentDone(sessionId: string, st: SessionState, desc: string): void {
const running = st.agents.filter(holdsTurn).length
console.log(`[subagents] ${sessionId.slice(0, 8)} ${running}/${st.agents.length} still running · done "${desc.slice(0, 40)}"`)
if (!st.endPending || running > 0) return
this.armSettle(sessionId, st)
}
private armSettle(sessionId: string, st: SessionState, ms = SUBAGENT_SETTLE_MS): void {
if (st.endSettle) clearTimeout(st.endSettle)
st.endSettle = setTimeout(() => this.releaseTurnEnd(sessionId, st), ms)
st.endSettle.unref?.()
}
private releaseTurnEnd(sessionId: string, st: SessionState): void {
if (!st.endPending) return
this.clearEndTimers(st)
st.endPending = false
this.onTurnEnded(sessionId, st)
}
private onTurnEnded(sessionId: string, st: SessionState): void {
// ONE recap per turn. The same turn can close from two independent sources that race — the watcher
// parsing the `end_turn` line, and the Claude Stop hook force-closing — each emitting `turn_ended`.
// A real close always follows a `turn_started` (which set turnOpen=true); a duplicate close finds
// turnOpen already false → skip, so the second source can't trigger (or supersede) a second recap.
if (!st.turnOpen) {
// A close for a session that never opened a turn is NOT the dedup case: the turn ran and its
// recap is being dropped. That used to return in silence, which is why a whole turn could go
// missing on the device with nothing in this log to explain it.
if (!st.everOpened) {
this.trace(
sessionId,
`${sessionId.slice(0, 8)} turn-end · DROPPED (no turn_started was ever seen for this session — ` +
`the turn opened before the session was attached), no recap for this turn`,
true,
)
}
return
}
const fallbackText = st.lastAssistantText.trim()
const fallbackUserMessage = st.lastUserMessage
st.turnOpen = false
st.lastTool = null
st.lastTodos = null
st.lastAssistantText = ''
const sid = sessionId.slice(0, 8)
const device = this.opts.hasDevice()
// Whether to GENERATE at all. Two independent reasons to proceed without a device:
// • recapForce — the test override, which ALSO un-gates emit() (streaming cards ride to a device
// that is not there). Kept as-is for the scripted device tests.
// • alwaysGenerate — persist a recap on EVERY turn so `agent.recap`/`agents.list` are populated
// for a programmatic client (a local-ws app), with no device ever paired. Unlike recapForce it
// does NOT touch emit(): the persistence below runs, `emit()` stays device-gated on its own, so
// nothing rides the wire to an absent device. This is the fix for the back-fill gap — a device
// that pairs LATER restores real tiles from what was persisted here, instead of blank ones,
// because replayAll() only re-emits stored recaps and never regenerates a past turn.
// Cost: with SUMMARY_MODE=model this is one engine one-shot per turn, per agent, forever — the very
// cost the device gate used to avoid. SUMMARY_MODE=local makes it free (no model, same-tick excerpt).
if (!device && !this.opts.recapForce && !this.opts.alwaysGenerate) {
// Console-only: no device and generation is off, so there is no recap flow to watch.
console.log(`[recap] ${sid} turn-end · SKIP (no device connected) · textLen=${fallbackText.length}`)
return
}
st.abort?.abort()
const ac = new AbortController()
st.abort = ac
st.summarizing = true
const t0 = Date.now()
void this.resolveTurnText(sessionId, fallbackText, fallbackUserMessage)
.then(({ text, userMessage, source }) => {
if (ac.signal.aborted) return
this.startSummary(sessionId, st, ac, t0, sid, text, userMessage, device, source)
})
.catch((err) => {
if (ac.signal.aborted) return
this.trace(sessionId, `${sid} JSON session read failed: ${err instanceof Error ? err.message : String(err)} — using live buffer`, true)
this.startSummary(sessionId, st, ac, t0, sid, fallbackText, fallbackUserMessage, device, 'live')
})
}
private async resolveTurnText(
sessionId: string,
fallbackText: string,
fallbackUserMessage: string,
): Promise<{ text: string; userMessage: string; source: 'session-json' | 'live' }> {
const turn = this.opts.readLastTurn ? await this.opts.readLastTurn(sessionId) : null
if (turn?.assistantText.trim()) {
return {
text: turn.assistantText.trim(),
userMessage: turn.userMessage || fallbackUserMessage,
source: 'session-json',
}
}
return { text: fallbackText, userMessage: fallbackUserMessage, source: 'live' }
}
private startSummary(
sessionId: string,
st: SessionState,
ac: AbortController,
t0: number,
sid: string,
text: string,
userMessage: string,
device: boolean,
source: 'session-json' | 'live',
): void {
if (!text) {
// No assistant TEXT block accumulated this turn (thinking/tools don't count) → nothing to
// summarize. Happens when: a new prompt closed the turn before any text arrived (back-to-back
// messages), or a tools/thinking-only turn ended with no final text. Graceful: clear busy, no recap.
const ask = userMessage.replace(/\s+/g, ' ').trim().slice(0, 60)
st.summarizing = false
this.trace(sessionId, `${sid} turn-end · empty assistant text → done · source=${source}${ask ? ` · ask="${ask}"` : ' · (no user prompt captured this turn)'}`)
this.emit(sessionId, { kind: 'done', text: 'done' })
return
}
const ask = userMessage.replace(/\s+/g, ' ').trim().slice(0, 60)
// Read BEFORE the new summary is stored below, or the "previous" recap is this turn's own.
const previousRecap = this.summaries.get(sessionId)
this.trace(sessionId, `${sid} summarizing · source=${source} · textLen=${text.length} · device=${device}${this.opts.recapForce ? ' · recapForce' : ''}${ask ? ` · ask="${ask}"` : ''} · prev=${previousRecap ? 'yes' : 'none'}`)
// Device: busy "Summarizing…" card. Web: the "Summarizing for device…" indicator (mirrors the
// node's handleBrainSummaryEvent — the web ignores the summary text, only toggles the flag).
//
// Both are skipped when the summary is derived locally: it lands in the same tick, so the card
// would be a flash of "Summarizing…" replaced before anyone could read it — a waiting state for a
// wait that no longer happens.
if (!this.opts.summarizeIsLocal) {
this.emit(sessionId, { kind: 'processing', text: 'Summarizing…' })
this.opts.sendWeb({ type: 'turn_summary_pending', dbSessionId: sessionId, payload: { sessionId } })
}
this.opts
.summarize(text, ac.signal, userMessage, sessionId, previousRecap)
.then((summary) => {
const ms = Date.now() - t0
if (ac.signal.aborted) { this.trace(sessionId, `${sid} superseded after ${ms}ms (newer turn) — dropping result`); return }
st.summarizing = false
if (summary) {
this.summaries.set(sessionId, summary)
this.remember(sessionId, summary)
// Recorded in the SAME branch as the summary, so the two arrays cannot drift out of step —
// a turn that produced no summary produces no row in either.
this.rememberFullText(sessionId, text)
this.saveSoon()
const { recap, body } = splitSummary(summary)
this.trace(sessionId, `${sid} done in ${ms}ms · recap="${recap}" · bodyLen=${body.length}`)
this.emit(sessionId, { kind: 'done', text: 'done' })
this.emit(sessionId, { kind: 'summary', text: body || recap, recap })
this.opts.sendWeb({ type: 'turn_summary', dbSessionId: sessionId, payload: { summary, sessionId } })
} else {
this.trace(sessionId, `${sid} summarizer returned NULL after ${ms}ms → done`)
this.emit(sessionId, { kind: 'done', text: 'done' })
if (!this.opts.summarizeIsLocal) {
this.opts.sendWeb({ type: 'turn_summary_pending', dbSessionId: sessionId, payload: { sessionId, done: true } })
}
}
})
.catch((err) => {
const ms = Date.now() - t0
if (ac.signal.aborted) return
st.summarizing = false
this.trace(sessionId, `${sid} FAILED after ${ms}ms: ${err instanceof Error ? err.message : String(err)}`, true)
this.emit(sessionId, { kind: 'done', text: 'done' })
this.opts.sendWeb({ type: 'turn_summary_pending', dbSessionId: sessionId, payload: { sessionId, done: true } })
})
}
/** Converge on commander (re)join — fires when a device joins OR when the adapter reconnects to the backend
* with a device still attached (the adapter has no periodic heartbeat). For an OPEN turn, re-assert its
* live processing/tool state. For an IDLE session, re-assert a terminal `done`: its turn-end summary may
* have been emitted into a WS that dropped right after (see the ETH case — `done` then `close 1006`), so a
* device that missed it is stuck "Working…" forever. A bare `done` clears that stale busy WITHOUT a beep or
* recap card, and is a harmless no-op on a tile that isn't busy; the device's recent-poll restores the
* stored summary text afterwards. */
replayAll(): void {
for (const sessionId of this.states.keys()) {
// Busy session → re-assert its live processing/summarizing state (shared with heartbeat()).
// Idle session → re-assert a terminal `done` (heartbeat() returns false and emits nothing).
if (!this.heartbeat(sessionId)) this.emit(sessionId, { kind: 'done', text: 'done' })
}
}
/** 5s heartbeat fan-out to the device (called from cli.ts's per-session turn timer). Re-emits the current
* BUSY state so the device's busy tile stays fresh through BOTH the turn (Processing) AND the summarize
* window (Summarizing…) — the device clears a busy tile only via a live terminal, and has a busy-timeout
* watchdog that fires if these stop arriving. Returns true while the session is busy (turnOpen ||
* summarizing) so the caller knows when to self-cancel the timer; false (emitting nothing) when idle. */
/** A turn is open on this session right now. Summarising after one does not count: the transcript is
* complete, which is what a fork needs. */
isBusy(sessionId: string): boolean {
return this.states.get(sessionId)?.turnOpen === true
}
heartbeat(sessionId: string): boolean {
const st = this.states.get(sessionId)
if (!st) return false
if (st.turnOpen) {
this.emit(sessionId, st.lastTodos
? { kind: 'processing', text: 'Processing', todos: st.lastTodos }
: { kind: 'processing', text: 'Processing' })
if (st.lastTool) this.emit(sessionId, st.lastTool)
return true
}
if (st.summarizing) {
this.emit(sessionId, { kind: 'processing', text: 'Summarizing…' })
return true
}
return false
}
/**
* The session's last `n` turn summaries, newest first — for a device restoring its tile at boot and for
* the voice router deciding which agent a spoken sentence belongs to.
*
* `n` is honoured now. It used to be ignored and this always returned one, which made every caller
* asking for more a promise nobody kept: the router in particular was told it had three turns of
* context and was given one.
*
* Falls back to the single latest summary when the history is empty, so the recaps already on disk from
* before this existed are usable on the first run rather than after three more turns.
*/
recent(sessionId: string, n = 2): Array<{ kind: string; text: string; recap?: string; fullText?: string }> {
const want = Math.max(1, n)
const stored = this.history.get(sessionId) ?? []
const latest = this.summaries.get(sessionId)
// The latest lives in both places once a turn has run under this build; dedupe so it is not read twice.
const usingHistory = stored.length > 0
const all = usingHistory ? stored : latest ? [latest] : []
const fulls = this.fullTexts.get(sessionId) ?? []
return all
.slice(0, want)
// PAIRED BEFORE FILTERING, not after. The filter below drops empty summaries, and dropping them
// from one array while reading the other by position is how a turn ends up carrying the previous
// turn's answer — wrong in the one way nobody would think to check.
.map((summary, at) => ({ summary, full: fulls[usingHistory ? at : 0] }))
.filter(({ summary }) => summary && summary.trim())
.map(({ summary, full }) => {
const { recap, body } = splitSummary(summary)
return { kind: 'summary', text: body || recap, recap, ...(full ? { fullText: full } : {}) }
})
}
/**
* The person's own last questions to this agent, newest first.
*
* Its OWN accessor rather than a field on [recent], because the two no longer describe the same
* thing: a recap exists once a turn has been answered and summarised, a question exists the moment
* it is asked. Pairing them cost the newest question — the one most likely to say where the next
* one belongs — every time a turn ended without a summary.
*/
recentAsks(sessionId: string, n = RECENT_TURNS): string[] {
return (this.asks.get(sessionId) ?? []).filter(Boolean).slice(0, Math.max(1, n))
}
/** The newest turn's complete final answer, for a consumer that reads rather than glances. */
lastFullText(sessionId: string): string | undefined {
return this.fullTexts.get(sessionId)?.[0]
}
/** The user's own words for a turn, clipped — the topic signal the recap cannot carry. */
private rememberAsk(sessionId: string, ask: string): void {
const kept = [(ask || '').replace(/\s+/g, ' ').trim().slice(0, ASK_MAX_CHARS), ...(this.asks.get(sessionId) ?? [])].slice(0, RECENT_TURNS)
this.asks.set(sessionId, kept)
}
/** Keep the last few turns for this session, newest first. */
private remember(sessionId: string, summary: string): void {
const kept = [summary, ...(this.history.get(sessionId) ?? [])].slice(0, RECENT_TURNS)
this.history.set(sessionId, kept)
}
/** The same, for the unclipped answer. Always called with `remember`, so the two stay aligned. */
private rememberFullText(sessionId: string, text: string): void {
const kept = [clipBytes(text.trim(), FULL_TEXT_MAX_BYTES), ...(this.fullTexts.get(sessionId) ?? [])].slice(0, RECENT_TURNS)
this.fullTexts.set(sessionId, kept)
}
/** Turn cancelled (web/device C-c). The claude turn is killed with no end_turn line, so no turn_ended
* fires and the recap flow never closes the device's busy tile — it would spin "Working…" forever. Abort
* any in-flight recap (so a killed turn can't emit a late summary + beep) and send a bare `done`, which
* the device treats as "clear the processing status, keep the last card" (no recap, no beep). The session
* stays alive; the next prompt reopens a fresh turn. Mirrors forget() but WITHOUT dropping the state. */
cancel(sessionId: string): void {
const st = this.states.get(sessionId)
if (!st) return
st.abort?.abort()
st.abort = null
st.summarizing = false
st.turnOpen = false
st.lastTool = null
st.lastTodos = null
// A cancel outranks a held turn-end: the user threw the turn away, sub-agents and all.
this.clearEndTimers(st)
st.endPending = false
this.emit(sessionId, { kind: 'done', text: 'done' })
}
/**
* Move the stored recap to a new session id, for a rotation (`/clear`) inside the same live process agent.
*
* The device tile shows the last recap; without this, clearing the context blanks the tile even though
* the agent, its pane and its engine process never went anywhere.
*/
inheritSummary(fromSessionId: string, toSessionId: string): void {
const summary = this.summaries.get(fromSessionId)
if (!summary || this.summaries.get(toSessionId)) return
this.summaries.set(toSessionId, summary)
// The history moves with it. A rotation is the same agent in the same pane — losing what it was doing
// is exactly what this method exists to prevent, and the router reads the history, not the latest.
const past = this.history.get(fromSessionId)
if (past?.length && !this.history.get(toSessionId)?.length) this.history.set(toSessionId, past)
// Moved with it, or the rotated session would answer `recap` with summaries whose full answers are
// missing — index-aligned arrays where only one side survived is worse than neither surviving.
const pastFull = this.fullTexts.get(fromSessionId)
if (pastFull?.length && !this.fullTexts.get(toSessionId)?.length) this.fullTexts.set(toSessionId, pastFull)
this.save()
}
/** Session metadata was unbound or its process agent was removed: abort any in-flight recap and drop
* the EPHEMERAL live state, but KEEP the persisted summary. A later resume of the same engine session
* can reuse it via recent()/project_recent instead of losing the last device recap. SessionEnd alone
* does not call this; process discovery owns lifetime. */
forget(sessionId: string): void {
const st = this.states.get(sessionId)
st?.abort?.abort()
// The state object is about to be dropped; a held turn-end timer would fire against a dead session.
if (st) this.clearEndTimers(st)
this.states.delete(sessionId)
// NB: intentionally do NOT delete this.summaries[sessionId] — reuse it on the next resume.
this.emit(sessionId, { kind: 'done', text: 'done' })
}
// ── persistence ──────────────────────────────────────────────────────────────────────────────
private load(): void {
try {
const obj = JSON.parse(readFileSync(this.file, 'utf-8')) as Record<string, string>
for (const [k, v] of Object.entries(obj)) if (typeof v === 'string') this.summaries.set(k, v)
} catch { /* no file yet */ }
try {
const obj = JSON.parse(readFileSync(this.historyFile, 'utf-8')) as Record<string, unknown>
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v)) this.history.set(k, v.filter((x): x is string => typeof x === 'string').slice(0, RECENT_TURNS))
}
} catch { /* no history yet — recent() falls back to the latest summary */ }
try {
const obj = JSON.parse(readFileSync(this.fullTextFile, 'utf-8')) as Record<string, unknown>
for (const [k, v] of Object.entries(obj)) {
// Re-clipped on the way in: the cap may have been lowered since this was written, and a stored
// row is not evidence that it still fits the wire.
if (Array.isArray(v)) this.fullTexts.set(k, v.filter((x): x is string => typeof x === 'string').slice(0, RECENT_TURNS).map(t => clipBytes(t, FULL_TEXT_MAX_BYTES)))
}
} catch { /* no full answers yet — recap simply omits the field */ }
try {
const obj = JSON.parse(readFileSync(this.askFile, 'utf-8')) as Record<string, unknown>
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v)) this.asks.set(k, v.filter((x): x is string => typeof x === 'string').slice(0, RECENT_TURNS).map((t) => t.slice(0, ASK_MAX_CHARS)))
}
} catch { /* no questions stored yet — the router falls back to recaps alone */ }
}
private saveSoon(): void {
if (this.saveTimer) clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => this.save(), 200)
}
private save(): void {
try {
mkdirSync(this.opts.dataDir, { recursive: true, mode: 0o700 })
writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.summaries), null, 2))
writeFileSync(this.historyFile, JSON.stringify(Object.fromEntries(this.history), null, 2))
writeFileSync(this.fullTextFile, JSON.stringify(Object.fromEntries(this.fullTexts), null, 2))
writeFileSync(this.askFile, JSON.stringify(Object.fromEntries(this.asks), null, 2))
} catch (err) {
console.error('[commander] save summaries failed:', err)
}
}
}
// Used by other modules only for potential reuse — kept exported.
export { splitSummary }