Watches every agent's context, hands over and respawns agents near the ceiling, restarts the main thread from a handover note, and charts it all in a pane.

This mod watches every agent loop in a session, hands it over before its context runs out, and charts what it is doing. It ships the orchestrated-run skill, rewritten so the mod handles context limits and the main session no longer polls transcripts.
Monitoring. Every model request, from the main thread and from subagents at any depth, reports its token usage through turn.step. The mod records each agent's context size (input + cache-write + cache-read) over time. No transcript files are polled.
Subagent handover. When a subagent crosses agentThreshold (default 450k):
HANDOVER with a note path under the run folder's handover/. While the agent works, the HANDOVER rides on its next tool result. A message would do no good there: a named agent (a teammate) reads its inbox only between turns. An idle agent, or one that ends its turn before another tool call, is sent the HANDOVER as a message, which wakes it.probe → probe-2); the mod reads the real name back. The engine suffixes any taken name the same way, at the first spawn too. The mod records the registered name, and check-ins and /orch handover accept it, the id, or the requested name when that picks one agent. It also queues the agent a message: [orchestrator mod] You are registered as \<name>\ (you asked for \<requested>\); tell your children to SendMessage \<name>\.[orchestrator mod] <old> was replaced by <new> (id <id>); this message went to <new>. Use that name from now on. A name a live agent holds is never redirected, and a replacement messaging its predecessor goes as addressed.If the agent ends again without writing its note, it is marked done and not respawned. If it hasn't handed over within 20 minutes, it is marked stalled and you get a notification (a toast in the desktop app); when a stalled agent's run does end, the mod settles it as any other: replaced if the note is there by then, done if not. If the replacement can't be started (the engine refuses the spawn, or no brief was recorded), the old agent is marked respawn-failed. Its parent is told that nothing is carrying the work, with the note path, the brief's name, type and model, and a file holding the full replacement prompt (handover/<…>.brief.md). An agent parent gets a message, and main gets a prompt that wakes it. You get a toast and a notification. When the parent spawns an agent whose prompt names that note, the mod links it as the replacement, moves the old agent's children under it and stops the old agent. A replacement whose first reading is already at half the threshold or more (its brief and note alone fill the window) is left to run and you get a warning, so handovers can't loop.
Resumed agents. A finished agent that is messaged again resumes: its row goes back to running and carries a dim ↻.
Check-ins. The mod registers a tool, mcp__orchestrator__checkin { agent, minutes }, listed in the prompt's tools rather than behind ToolSearch. A parent calls it after spawning a child: the main thread, or any subagent (a subagent orchestrator with the default tools can call it; one whose definition restricts tools needs mcp__orchestrator__checkin added to that list, which is untested). It calls it naming the child by id, by the name it is registered under (the engine's, suffixed when taken: orchestrator-2), or by the name it was spawned with when that picks one child, with its estimate of the child's task plus a buffer. It works only on the caller's own children, which include the children of an agent the caller replaced. Calling it again re-arms it; minutes of 0 or less cancels it. If the child hasn't reported by then, the check-in fires once: the mod wakes the parent with a message, [orchestrator mod] check-in: <name> (id …) has not reported after N min; status …, last active hh:mm. Check on it, or call checkin again to re-arm. A check-in set by the main thread arrives as a prompt that wakes it, plus a toast, and one set by an orchestrator that has since handed over goes to its replacement. It is disarmed when the child sends its parent a SendMessage or ends its run. A SubagentHandback doesn't count: it may reach main rather than the parent. A teammate's turn end doesn't disarm it: a teammate ends a turn whenever it waits. A teammate's check-in is disarmed by its SendMessage, or when it ends for good (the engine stops listing it). The deadline is kept in state.json, so it survives a reload.
Background commands. The engine sometimes never wakes an idle agent when its own background shell exits: a named agent-teams teammate in an interactive session is never woken, and headless the wake waits for main's turn end when main is busy (see docs/engine-bug-bg-shell-wake.md). So the mod tracks every Bash call that goes to the background: explicit run_in_background calls, and long commands the engine moves there. A command that dies with its caller's final response isn't tracked. A TaskStop drops it. Every shellPollSeconds the mod checks each open command's output file and, when it has grown, reads the [exited with code N] or [killed] line the engine appends at the end (a file over 64 KiB through tail, so only its last bytes), and logs the exit. The agent has picked a finished command up when it starts a new run after the command ended (the woken run takes in the held notice), or when it makes a tool call naming the command's id or output path (a Read, TaskOutput or cat). A step in the same busy run doesn't count, so the grace runs from that run's end. If an idle agent with none still running hasn't picked up its finished commands within shellGraceSeconds, the mod sends it one nudge: [orchestrator mod] your background command "<description>" (id …) exited with code N at hh:mm UTC and you haven't picked it up; output: <path> (with several, … your background commands finished and you haven't picked them up: and one line each). Neither nudge goes to an agent the engine lists as completed, failed or killed, or no longer lists: its commands are settled without one (if the engine's list can't be read, nudges go as before). A command still running after shellCeilingMinutes gets its agent one nudge to check it (a suspended process, say) or stop it. At each turn end, the mod also compares the open commands against the engine's list of background tasks: one the engine no longer lists is ended (the stopping agent's at once, another agent's only once it started at least 30 seconds before, since the list may predate it), with the code its file holds, or as unknown. That catches what the poll missed (polling off, a file whose tail couldn't be read, a crash with no marker). Main's commands are tracked and logged, but main is never prompted: the engine wakes main itself. Its finished commands are dropped at its next step, and past the ceiling it gets a warning toast. Nudges ride the outbox like check-ins, so they can arrive 30 seconds or more late (see "Some messages wait…" below). Teammates running in their own terminal pane make no tool calls in this process, so their commands aren't tracked.
Idle watchdog. An agent that sits idle with nothing to wait for gets one nudge after idleWatchdogMinutes: [orchestrator mod] you've been idle N min with no running children or background commands; if you're waiting on something, check it; otherwise carry on or report. It applies only when the agent isn't main, both the mod and the engine list it idle (not finished, and not held on its own work), it has no live children (running, idle or handing over), and it has no background commands, open or unread. In practice that means idle teammates and background agents the engine keeps idle. It nudges once per idle stretch: it nudges again only after another run besides the one its nudge started, or after the agent starts a new background command.
Auto-forwarded reports. A background subagent's final answer doesn't reach its parent agent on its own. At the end of each of a child's runs, the mod forwards its final answer to its parent agent ([orchestrator mod] final report of <name> (id …), auto-forwarded:), which wakes it; if the parent has handed over, the answer goes to its replacement. It skips the forward when the child's last act in that run was a delivered SendMessage report to its parent, or when a report it delivered says the same as the answer. Otherwise it forwards, because a lost report costs more than a duplicate; when the child did send something that run, the forward says (may duplicate a message it sent you). It never forwards to the main thread (the engine already tells main), for teammates (their answers arrive anyway), for an aborted or failed run or an empty answer, or for a HANDOVER. A foreground agent's answer is its parent's tool result, so it isn't forwarded either, except a mod-spawned replacement's, which is always forwarded under the same per-run check. So a grandchild's report goes to its orchestrator, not to main.
Some messages wait for an agent's turn to end. In this engine, a run that a mod starts from a timer loses the mod's hooks: the agent can't call checkin and the mod can't track it. A run woken from inside one of the mod's hooks runs without that hook (see docs/engine-bug-timer-hooks.md). So the mod queues its cross-agent sends and spawns (check-in wake-ups, HANDOVER messages to idle agents, forwarded reports, respawns, name notices, and background-command and idle nudges; state key outbox) and sends them from a turn.complete hook that does nothing else, at the next turn end of any agent. A run that hook wakes or spawns loses only that hook. So whatever that run itself queues waits for another agent's turn to end, or for the fallback: if the queue survives a sync while main is idle, and nothing has drained it since the last nudge, the mod submits one short [orchestrator mod] queued messages… prompt to main, whose turn end sends them. Queued messages can therefore arrive 30 seconds or more late, and main may see that short prompt. The pane's Hand over button and /orch handover send at once.
HANDOVER clause. Every Agent call has the cold-restart clause appended to its prompt (injectClause), so orchestrators no longer have to paste it into each brief.
Auto mode. The mod's own messages, its spawn of a replacement, and its stop of a replaced agent are tool calls no request of yours asked for, so auto mode's classifier would refuse them. The mod allows exactly those calls while they are in flight (10 seconds at most), and nothing else.
Main-thread restart. When the main thread crosses mainThreshold:
/clear.Running agents survive /clear. This prompt is how the new context learns about them. With mainHandover: ask (the default), the pane carries a Hand over main button at any time, on the terminal and in the desktop app; past mainThreshold, a Hand over & restart band also appears above the prompt. With auto the mod acts on its own and shows no button. With off it never acts. /orch handover main works in every mode. If you ask for a handover mid-turn (with either button or /orch handover), it waits and starts when the turn ends, even a stopped one; meanwhile the buttons read "Handover queued". After a restart, the mod doesn't offer again until the main context has read below the threshold once.
Visual. The Orchestrator pane opens on the first spawn, or with /orch. In the desktop app it also opens on the first spawn, but /orch may not be listed in the desktop prompt.
Terminal. The pane has:
Hand over main button in the header (with mainHandover: ask; hidden while main is handing over)/tasks is shown inverted● bright green, waiting ◐ teal, idle ○ faint orange, handing over ↻ blue, stalled ! red, done ✓ faint green, replaced ⤳ grey, respawn failed ✗ red. The colours are theme keys, so they follow your themeSonnet 5.5 med (the model alone when the engine reports no effort)…; a click shows its full text beneath it, pushing the rest down (as many lines as the pane has room for), and a second click hides it. Click the name again to close the box. The box takes its rows from the activity log▾ before a row with finished agents beneath it (main included) collapses them into +N done; the ▸ it becomes expands them again. With the pane focused, c collapses or expands the selected row (main when none is selected)…, and a click shows its full text beneath it (a second click hides it); the log gives up the rows it takesDesktop app. The pane has:
Sonnet 5.5 med), context, and a status emoji before the status word: running 🏃♂️ or 🏃♀️ (fixed per agent), waiting ⏳, idle 😴, handing over 📝, stalled 🛑, done ✅, replaced ♻️, respawn failed ⚠️… that a click expands in place. Click again to clear both (the agent in view stays highlighted)▾ before a row with finished agents beneath it collapses them into +N done, and their lines leave the chart; the ▸ it becomes expands them again. With the pane focused, c collapses or expands the selected row (main when none is selected)Hand over button beside each live subagent, and a Hand over main button in the header (hidden unless mainHandover is ask, and while main is handing over)…, and clicking it expands the full text beneath it⧉ beside each agent, each expanded activity or transcript card, and each prose section of a tool call's detail copies its text (a toast says whether it took). A code section, such as a command or its output, has the app's own copy button instead. Nothing else in a desktop pane can be selected. The terminal has no ⧉: select its text as usualVS Code and the mobile app get the desktop pane.
Clicking a subagent shows its transcript inside the pane, read through the host's own transcript API. Main has none there, since it is the main view already. The box says loading… until the first read comes back, and it updates as the agent works. A teammate running in another pane's process, or a finished agent whose transcript was not saved, can't be read; the box says "transcript not readable (teammate in another pane, or finished)". The host gives mods no way to switch the main view to an agent, so to read a whole transcript open it with /tasks (terminal) or the app's agent panel (desktop). Each entry is a message's text (» marks the agent's instructions) or one tool call, ⚙ <Tool> <command, path or pattern>, with a red ✗ when it failed and (running) while it runs. Times are UTC hh:mm, as in the activity log. The host stamps no times on messages, so the mod records them as it sees them happen: tool calls, the agent's answers, and its brief (its start time). Anything it didn't see, such as history from before the mod loaded or a reload, or a SendMessage to the agent, shows --:--.
Clicking a tool call opens its detail in a second pane: a Bash call's command and output, an Edit as a diff (numbered from the start of the edit, since Edit gives no line numbers), a Read's path and the lines it read, a Write's content, a Grep or Glob's pattern and results, an Agent call's prompt and result, and the input and result of any other tool. A subagent's transcript stores only what the model read, so there is no separate stderr or exit code. Escape, the pane's Close button, or clicking the tool call again closes it, and the Orchestrator pane shows again. A surface that places no second pane (such as an older desktop app) shows the detail in place, beneath the tool call.
A waiting agent is idle with live children: it ended its turn and is waiting on agents it spawned. A click on a row only opens or closes its transcript; collapsing is the ▾/▸ glyph's job, or c's. Main's row is pressable too: a click selects it (bolds it), so c acts on it, and opens no transcript. Collapsing hides the finished agents from the tree and, in the desktop app, from the chart, without changing the other agents' colours. It is display only: the header counts, the roster and handovers still see every agent, and a live agent is never hidden, even under a finished one.
On both the terminal and the desktop app, the status line shows live agents, the largest context and main's context; toasts show; and in ask mode the Hand over & restart band shows above the prompt past the threshold. The desktop app has no notifications; the stall toast still shows there.
Records. The run folder, <project>/.orchestrator/<run>/ by default, holds roster.md (one row per agent: name, id, type, status, context, peak, note, replaced by/replaces), state.json, handover/*.md, and activity.log: every activity line with its UTC time and level, plus every press on the mod's panes (element, surface, pane id) and every Hand over that could not start, with the reason. It keeps the last 2,000 lines and survives a reload. If a Hand over press seems lost, look there: no press handover:<id> line means the press never reached the mod. The run id is the first session id, so it stays the same across /clear. The folder sits inside the project so agents can write notes there under ordinary edit permissions (~/.claude is a protected path, so they can't write there). The mod adds .orchestrator/ to .git/info/exclude so it never shows in git status. Set runRoot to put run folders elsewhere.
skills/orchestrated-run is the run playbook: the main session settles the user's decisions and spawns one orchestrator, which drives TDD implementers, read-only reviewers and an adjudicator, and commits in waves on a branch. Its context-limit section defers to this mod: no poller, no hand-pasted HANDOVER clause, and orchestrators told to accept the mod's replacements rather than spawn their own. Orchestrators still end every spawn prompt with a SendMessage report-back clause, then call the check-in tool with an estimate plus a buffer; the mod auto-forwards any report a child didn't send, so reports reach the orchestrator and no longer land in main. It loads as orchestrator:orchestrated-run.
/orch | open the pane |
/orch close | close the pane |
/orch roster | print the roster |
/orch handover | hand the main thread over now |
/orch handover <name or id> | hand that agent over now: its id, its registered name, or the name it was spawned with when that picks one agent |
In the desktop app, the pane's Hand over and Hand over main buttons do what /orch handover does, so you don't need /orch handover there. A main handover asked for mid-turn starts when the turn ends.
Configure in /config (or pluginConfigs.orchestrator.options in settings):
| option | default | |
|---|---|---|
agentThreshold | 450000 | tokens at which a subagent is handed over |
mainThreshold | 450000 | tokens at which the main thread is offered (or given) a restart |
mainHandover | ask | ask, auto or off |
autoRespawn | true | spawn the replacement automatically; when false, the agent asks its parent to |
injectClause | true | append the HANDOVER clause to every Agent prompt |
runRoot | (empty) | where run folders go; empty means <project>/.orchestrator |
shellPollSeconds | 15 | how often background command output files are checked for their exit; 0: never |
shellGraceSeconds | 60 | an idle agent that hasn't picked up its finished background commands within this is nudged; 0: never |
idleWatchdogMinutes | 5 | an idle agent with no running children or background commands this long is nudged once; 0: never |
shellCeilingMinutes | 60 | a background command still running this long gets its agent one nudge; 0: never |
/plugin install orchestrator --marketplace victor-bajanov/cc-orchestrator-mod
Or run it from a checkout:
claude --plugin-dir /path/to/cc-orchestrator-mod
/plugin install isn't available in the desktop app. Install the plugin at user scope from a terminal with the command above; it then loads in the desktop app's local sessions too.
The desktop app starts sessions with no CLI flags, so name the folder in the env block of ~/.claude/settings.json:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/cc-orchestrator-mod",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
CLAUDE_CODE_PLUGIN_DIR_WATCH is optional; it reloads the mod when you edit it. Restart the app afterwards. The app has no /config menu, so set options in pluginConfigs.orchestrator.options.
claude plugin test . # unit and integration tests (hooks/*.test.ts)
claude plugin validate .
npx -p typescript@5.6.3 tsc -p . # needs .claude-plugin/types/, which the engine lays when it loads the mod
hooks/core.ts holds the pure logic (context math, timeline, roster, prompts). hooks/view.ts holds the pure drawing logic both surfaces share (pane header, series colours, the desktop chart's SVG, transcript items and times, model labels, status styles and emoji, collapsing, check-in and forwarding rules). hooks/detail.ts builds the tool-call detail sections per tool (diffs, excerpts, caps). hooks/register.tsx wires them to the engine and draws one pane tree for the terminal and another for the desktop
hooks/register.tsx 2623 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { AgentRow, Delivery, DisplayStatus, LogEntry, MainPhase, ShellExit, ToolView, Transcript } from '../types'
5import { bodyOf, toolDetail } from './detail'
6import type { ToolDetail } from './detail'
7import {
8 ACTIVITY_MAX,
9 HANDOVER_CLAUSE,
10 activityLine,
11 addShell,
12 adoptChildren,
13 backgroundedShell,
14 briefPathFor,
15 ceilingDue,
16 ceilingNudge,
17 contextOf,
18 dropShell,
19 endShell,
20 fmtTokens,
21 goneShells,
22 graceDue,
23 handoverMessage,
24 label,
25 level,
26 mainHandoverPrompt,
27 markCeiling,
28 nameNotice,
29 newRow,
30 notePathFor,
31 numberSetting,
32 openShells,
33 orderRows,
34 pickUpShells,
35 recordStep,
36 replacementPrompt,
37 respawnFailedNotice,
38 resumePrompt,
39 rosterMarkdown,
40 shellEndWords,
41 shellExit,
42 shellName,
43 shellNudge,
44 timeline,
45 watchdogNudge,
46 wrapFill,
47} from './core'
48import {
49 LIVE,
50 addressFor,
51 capLines,
52 ceilingOf,
53 chartWidthPx,
54 contextChart,
55 cut,
56 displayStatus,
57 expandCap,
58 hhmm,
59 logId,
60 mainButtonShown,
61 modelLabel,
62 paneHeader,
63 checkinDue,
64 collapseHidden,
65 forwardTarget,
66 hiddenCount,
67 canFold,
68 foldGlyph,
69 latest,
70 namesAgent,
71 redirectTarget,
72 resolveAgent,
73 seriesColors,
74 statusEmoji,
75 statusStyle,
76 terminalBase,
77 textKey,
78 transcriptItems,
79 transcriptView,
80 watchdogDue,
81 watchdogTurnEnded,
82} from './view'
83import type { TranscriptLine } from './view'
84
85type $ = EngineInterface
86
87const PANE = 'orchestrator'
88/** The tool-call detail dialog: a second pane, one at a time. */
89const TOOL_PANE = 'orchestrator-tool'
90const SYNC_MS = 30_000
91/** The check-in tool as the model calls it. */
92const CHECKIN_TOOL = 'mcp__orchestrator__checkin'
93/** A check-in cleared: no deadline, no length, no one who armed it. */
94const DISARMED = { checkinAt: undefined, checkinMins: undefined, checkinBy: undefined } as const
95const STALL_MS = 20 * 60_000
96
97const agentsAtom = atom({ plugin: 'orchestrator', key: 'agents' } as const, [] as AgentRow[])
98const logAtom = atom({ plugin: 'orchestrator', key: 'log' } as const, [] as LogEntry[])
99const phaseAtom = atom({ plugin: 'orchestrator', key: 'mainPhase' } as const, 'idle' as MainPhase)
100const runAtom = atom({ plugin: 'orchestrator', key: 'runId' } as const, '')
101/** The line (activity or transcript text) whose full text a press shows beneath it, in flow ('' for none). */
102const pinnedAtom = atom({ plugin: 'orchestrator', key: 'pinnedLog' } as const, '')
103/** The agent a click selected: its chart line is highlighted and its transcript shown beneath its row ('' for none). */
104const selectedAtom = atom({ plugin: 'orchestrator', key: 'selectedAgent' } as const, '')
105/** Messages timers queued for the next hook to deliver (a send from a timer wakes a run without hooks). */
106const outboxAtom = atom({ plugin: 'orchestrator', key: 'outbox' } as const, [] as Delivery[])
107/** Rows the person collapsed with a row's fold glyph or the `c` hotkey, their finished descendants hidden; display only. */
108const collapsedAtom = atom({ plugin: 'orchestrator', key: 'collapsed' } as const, [] as string[])
109/** The tool call open in the detail dialog, read off the render by refreshTool. */
110const toolViewAtom = atom({ plugin: 'orchestrator', key: 'toolView' } as const, null as ToolView | null)
111/** Each agent's last transcript items, read off the render by refreshTranscript. */
112const transcriptsAtom = atom({ plugin: 'orchestrator', key: 'transcripts' } as const, {} as Record<string, Transcript>)
113/** Mirrors `mainHandoverQueued` for drawing: the buttons say a handover waits on this turn. */
114const queuedAtom = atom({ plugin: 'orchestrator', key: 'mainQueued' } as const, false)
115
116const patch = (rows: AgentRow[], id: string, change: Partial<AgentRow>) =>
117 rows.map(r => (r.id === id ? { ...r, ...change } : r))
118
119const cfg = {
120 agentThreshold: 450_000,
121 mainThreshold: 450_000,
122 mainMode: 'ask',
123 autoRespawn: true,
124 injectClause: true,
125 runRoot: '',
126 /** How often open shells' output files are checked; 0: never. */
127 shellPollSeconds: 15,
128 /** How long an idle agent's finished shells may go unpicked before a nudge; 0: never. */
129 shellGraceSeconds: 60,
130 /** How long an idle agent with nothing running waits before one nudge; 0: never. */
131 idleWatchdogMinutes: 5,
132 /** How long a shell may run before one nudge; 0: never. */
133 shellCeilingMinutes: 60,
134}
135
136/** Running or idle: an agent loop that has not settled or started handing over. */
137const isActive = (r: AgentRow) => r.status === 'running' || r.status === 'idle'
138
139/** A subagent that can be handed over now. */
140const canHandOver = (r: AgentRow) => r.id !== 'main' && isActive(r)
141
142/** The selection, or '' when it names no row (so a stale one fades no line). */
143export const liveSelection = (rows: readonly AgentRow[], selected: string) =>
144 rows.some(r => r.id === selected) ? selected : ''
145
146/** A dispatch this plugin raised (`next.origin`, as plugin.json names it); anything malformed is not. */
147export const isOwnOrigin = (origin: unknown): boolean =>
148 typeof origin === 'object' && origin !== null && (origin as { plugin?: unknown }).plugin === 'orchestrator'
149
150/** One of the mod's sends in flight. */
151export type OwnSend = { agentId: string; text: string }
152
153/**
154 * Whether a SendMessage check is one of the mod's own sends: the exact text to a recipient one is in flight
155 * to; or, this plugin's origin and a send in flight to that recipient. The origin is never enough alone: it
156 * also covers the model inside an agent the mod spawned, so it counts only for a check from no loop (no `agentId`):
157 * the mod's own send runs in none. `to` names a recipient as `namesAgent` does.
158 */
159export const isOwnSend = (o: {
160 origin: unknown
161 /** The loop the check is decided in (`tool.check`'s `agentId`); absent for the main loop or none. */
162 agentId?: string
163 to: unknown
164 message: unknown
165 inFlight: readonly OwnSend[]
166 rows: readonly AgentRow[]
167}): boolean => {
168 const own = o.agentId === undefined && isOwnOrigin(o.origin)
169 const toward = (s: OwnSend) => {
170 if (typeof o.to === 'object' && o.to !== null) return (o.to as { agentId?: unknown }).agentId === s.agentId
171 return namesAgent(o.to, o.rows.find(a => a.id === s.agentId) ?? { id: s.agentId })
172 }
173 return o.inFlight.some(s => toward(s) && (own || (typeof o.message === 'string' && s.text === o.message)))
174}
175
176/** One of the mod's own tool calls in flight, other than a send: a respawn's Agent call, or a TaskStop of a replaced agent. */
177export type OwnCall = { tool: 'Agent'; prompt: string } | { tool: 'TaskStop'; taskId: string }
178
179/**
180 * Whether a tool.check input is one of the mod's own calls in flight: an Agent call with the exact prompt the mod
181 * spawned with, or that prompt with the HANDOVER clause the mod's own agent.spawn hook appends; a TaskStop of
182 * exactly the agent it is stopping. Anything malformed is not.
183 */
184export const isOwnCall = (input: unknown, inFlight: readonly OwnCall[]): boolean => {
185 if (typeof input !== 'object' || input === null) return false
186 const i = input as { prompt?: unknown; task_id?: unknown; shell_id?: unknown }
187 return inFlight.some(c =>
188 c.tool === 'Agent'
189 ? typeof i.prompt === 'string' && (i.prompt === c.prompt || i.prompt === c.prompt + HANDOVER_CLAUSE)
190 : i.task_id === c.taskId || i.shell_id === c.taskId,
191 )
192}
193
194// The run folder outlives a /clear (the session id changes, the run does not).
195let runDirCache = ''
196let paneOffered = false
197/** Main is mid-turn: from its `turn.start` (main's alone) to its `turn.complete`. */
198let mainBusy = false
199/** A main handover asked for mid-turn: it starts when that turn ends. */
200let mainHandoverQueued = false
201/** A main handover is being set up: a second press in the meantime does nothing. */
202let mainHandoverStarting = false
203/**
204 * One kind of read done off the render: the keys whose read is in flight (one per key at a time), and
205 * the keys triggered meanwhile (each read once more when its read settles).
206 */
207type ReadSlot = { busy: Set<string>; again: Set<string> }
208/** Transcript reads, keyed by agent id. */
209const transcriptReads: ReadSlot = { busy: new Set(), again: new Set() }
210/** Tool detail reads, keyed by `agentId:toolUseId`. */
211const toolReads: ReadSlot = { busy: new Set(), again: new Set() }
212/** What guardedRead gives when the key's read was already in flight (the trigger is remembered instead). */
213const IN_FLIGHT = Symbol('in flight')
214/**
215 * Ids of queued messages already delivered, newest last: a restore skips them, even from a state.json
216 * written by a persist that read the queue before the drain. Kept in memory (a /clear does not reset
217 * it) and in state.json (for a reload).
218 */
219const delivered: string[] = []
220/** How many delivered ids are kept. */
221const DELIVERED_MAX = 200
222/** Numbers queued messages within this module's life. */
223let deliverySeq = 0
224/** How long a drain waits for its respawns and HANDOVERs before letting the turn end go on. */
225const DRAIN_TIMEOUT_MS = 10_000
226/** A drain is under way: another hook does not start a second. */
227let draining = false
228/** Main was prompted to run a hooked turn for the outbox, and nothing has drained since: no second prompt. */
229let nudgedSinceDrain = false
230/**
231 * The mod's sends in flight. Under auto mode the engine judges a plugin's `$.session.send` as a
232 * SendMessage call, and the classifier refuses it because no user request asked for it. tool.check
233 * allows exactly these sends, and only while they are in flight.
234 */
235const ownSends: OwnSend[] = []
236/** A SendMessage check went unmatched while the mod had a send in flight, and was logged: once per load. */
237let checkMissLogged = false
238/**
239 * The mod's own tool calls in flight other than sends (a respawn's Agent call, a TaskStop): under auto mode the
240 * classifier refuses them, as it does the mod's sends. tool.check allows exactly these, and only while in flight.
241 */
242const ownCalls: OwnCall[] = []
243/** An Agent or TaskStop check went unmatched while the mod had a call in flight, and was logged: once per load. */
244let callMissLogged = false
245/**
246 * When the mod saw each transcript entry, per agent: a call's `tool_use_id` → ms, or a text's
247 * `textKey` → each time it was seen, oldest first (a repeated text takes its own). Module-level,
248 * so lost on reload; an entry it never saw draws `--:--`.
249 */
250const stamps = new Map<string, Map<string, number | number[]>>()
251/** The most times kept for one repeated text. */
252const TEXT_STAMPS_MAX = 50
253/** The most stamps kept per agent; the oldest goes first. */
254const STAMPS_MAX = 500
255// The id the agent.spawn hook last saw started: a plugin's own spawn may resolve without it.
256let lastSpawned: string | undefined
257/** Agents already told their registered name: one notice each, however many paths learn the rename. */
258const noticed = new Set<string>()
259/** activity.log's lines, read back once per load (undefined: not read yet). */
260let activity: string[] | undefined
261/** The path `activity` was read from: another path is read afresh. */
262let activityPath = ''
263/** Lines logged before the run folder was known; they go in with the next line written. */
264const activityPending: string[] = []
265/** activity.log writes, one after another. */
266let activityChain: Promise<void> = Promise.resolve()
267/** Agents whose Hand over is being set up: a second start in the meantime is refused. */
268const handoverStarting = new Set<string>()
269
270async function runDir($: $) {
271 let id = await read($, runAtom)
272 if (!id) {
273 id = await $.session.id()
274 await update($, runAtom, () => id)
275 }
276 const root = cfg.runRoot || `${await $.session.root()}/.orchestrator`
277 runDirCache = `${root}/${id}`
278 return runDirCache
279}
280
281/** Keeps the default run folder out of `git status` without touching the tracked .gitignore. */
282async function excludeFromGit($: $) {
283 if (cfg.runRoot) return
284 const exclude = `${await $.session.root()}/.git/info/exclude`
285 if (!(await $.fs.exists(exclude))) return
286 const text = await $.fs.read(exclude)
287 if (text.split('\n').some(line => line.trim() === '.orchestrator/')) return
288 await $.fs.write(exclude, `${text}${text.endsWith('\n') || text === '' ? '' : '\n'}.orchestrator/\n`)
289}
290
291/** After a /clear wiped the session's state, put it back from the run folder. */
292async function restore($: $) {
293 if (!runDirCache || (await read($, runAtom))) return
294 const path = `${runDirCache}/state.json`
295 if (!(await $.fs.exists(path))) return
296 const saved = JSON.parse(await $.fs.read(path)) as {
297 agents: AgentRow[]
298 mainPhase: MainPhase
299 runId: string
300 outbox?: Delivery[]
301 delivered?: string[]
302 }
303 await update($, runAtom, () => saved.runId)
304 await update($, agentsAtom, () => saved.agents)
305 await update($, phaseAtom, () => saved.mainPhase)
306 // Undelivered messages outlive a /clear too; anything queued since is kept after them.
307 const done = new Set([...delivered, ...(saved.delivered ?? [])])
308 await update($, outboxAtom, q => [...(saved.outbox ?? []).filter(d => !done.has(d.id)), ...q])
309}
310
311/**
312 * Appends a line to <run>/activity.log, keeping the last ACTIVITY_MAX (earlier lines read back once per load and
313 * per path). Never throws. Uses the run folder already known, never runDir: that writes the run id when it is
314 * empty, and after a /clear that would fork the run before restore. A line before the folder is known waits.
315 */
316async function writeActivity($: $, line: string) {
317 const run = activityChain.then(async () => {
318 try {
319 if (!runDirCache) {
320 activityPending.push(line)
321 activityPending.splice(0, Math.max(0, activityPending.length - ACTIVITY_MAX))
322 return
323 }
324 const path = `${runDirCache}/activity.log`
325 if (path !== activityPath) {
326 activity = undefined
327 activityPath = path
328 }
329 if (activity === undefined) {
330 activity = (await $.fs.exists(path)) ? (await $.fs.read(path)).split('\n').filter(Boolean) : []
331 }
332 activity.push(...activityPending.splice(0), line)
333 activity.splice(0, Math.max(0, activity.length - ACTIVITY_MAX))
334 await $.fs.write(path, `${activity.join('\n')}\n`)
335 } catch (err) {
336 try {
337 $.ui.log(`orchestrator: activity.log not written: ${String(err)}`, { to: 'debug' })
338 } catch {
339 // Nothing more to do.
340 }
341 }
342 })
343 activityChain = run
344 return run
345}
346
347/** Writes a line to activity.log only, not to the pane. Never throws, and never waits on the file. */
348async function trace($: $, text: string) {
349 try {
350 void writeActivity($, activityLine(await $.clock.now(), 'info', text))
351 } catch {
352 // The file is a record; a lost line breaks nothing.
353 }
354}
355
356async function log($: $, text: string, lvl: LogEntry['level'] = 'info') {
357 const t = await $.clock.now()
358 await update($, logAtom, l => [...l, { t, text, level: lvl }].slice(-200))
359 // Not awaited: a slow or hung file write never holds up the hook that logs (writeActivity never rejects).
360 void writeActivity($, activityLine(t, lvl, text))
361 if (lvl === 'warn') $.ui.toast(`orchestrator: ${text}`)
362}
363
364async function persist($: $) {
365 const dir = await runDir($)
366 const agents = await read($, agentsAtom)
367 const mainPhase = await read($, phaseAtom)
368 const runId = await read($, runAtom)
369 const outbox = await read($, outboxAtom)
370 await $.fs.write(`${dir}/state.json`, JSON.stringify({ runId, mainPhase, agents, outbox, delivered }))
371 await $.fs.write(`${dir}/roster.md`, rosterMarkdown(agents))
372 const subs = agents.filter(a => a.id !== 'main')
373 const live = subs.filter(a => LIVE.includes(a.status))
374 const main = agents.find(a => a.id === 'main')
375 const top = live.reduce((m, a) => Math.max(m, a.tokens), 0)
376 $.ui.status(
377 [
378 live.length ? `${live.length} agent${live.length === 1 ? '' : 's'}, max ${fmtTokens(top)}/${fmtTokens(cfg.agentThreshold)}` : '',
379 main ? `main ${fmtTokens(main.tokens)}/${fmtTokens(cfg.mainThreshold)}` : '',
380 mainPhase !== 'idle' && mainPhase !== 'cooldown' ? `handover: ${mainPhase}` : '',
381 ]
382 .filter(Boolean)
383 .join(' · ') || undefined,
384 )
385}
386
387// ---- pane presses ----------------------------------------------------
388
389function togglePinned($: $, id: string) {
390 return update($, pinnedAtom, p => (p === id ? '' : id))
391}
392
393async function toggleSelected($: $, id: string) {
394 await update($, selectedAtom, s => (s === id ? '' : id))
395 // Opening a transcript starts its read; the pane shows `loading…` until it settles.
396 if ((await read($, selectedAtom)) === id) schedulePeek($, id)
397}
398
399/** Copies to the pressing surface's clipboard and says so in a toast. Never throws. */
400async function copyText($: $, text: string, surface: RenderSurface) {
401 try {
402 const r = await $.ui.copy({ text, surface })
403 $.ui.toast(r.isCopied ? 'Copied' : `Not copied (${r.reason})`)
404 } catch (err) {
405 try {
406 $.ui.log(`orchestrator: copy failed: ${String(err)}`)
407 } catch {
408 // The toast still says it.
409 }
410 try {
411 $.ui.toast('Not copied')
412 } catch {
413 // Nothing more to do.
414 }
415 }
416}
417
418/** The one copy control: a small plain Button, keyed `copy:<id>`, desktop only (the terminal selects natively). */
419function copyButton($: $, ui: Ui, id: string, text: string) {
420 const { Button } = ui
421 return <Button key={`copy:${id}`} plain dimColor label="⧉" onPress={e => void copyText($, text, e.surface)} />
422}
423
424/** Collapses or expands a row's finished descendants. Never rejects: a failed write leaves the fold as it was. */
425async function toggleCollapsed($: $, id: string) {
426 try {
427 await update($, collapsedAtom, c => (c.includes(id) ? c.filter(x => x !== id) : [...c, id]))
428 } catch (err) {
429 try {
430 $.ui.log(`orchestrator: fold not saved: ${String(err)}`, { to: 'debug' })
431 } catch {
432 // nothing more to do
433 }
434 }
435}
436
437/** How long a transcript read may take before the pane calls it not readable. */
438export const PEEK_TIMEOUT_MS = 3_000
439/** The most items a transcript keeps: the desktop's; the terminal shows fewer. */
440const TRANSCRIPT_MAX = 12
441/** The terminal's transcript items. */
442const TRANSCRIPT_TERMINAL = 6
443/** The longest text item stored: whole enough to expand, short enough to keep state small. */
444const STEP_CHARS = 2000
445
446/**
447 * Records when the mod saw an entry of an agent's transcript: a call once, a text once per time it
448 * was said (`repeat`). Synchronous; never throws on good input.
449 */
450function stamp(agentId: string, key: string, t: number, repeat = false) {
451 let m = stamps.get(agentId)
452 if (!m) stamps.set(agentId, (m = new Map()))
453 const had = m.get(key)
454 m.delete(key)
455 m.set(key, repeat ? [...(Array.isArray(had) ? had : []), t].slice(-TEXT_STAMPS_MAX) : t)
456 if (m.size > STAMPS_MAX) m.delete(m.keys().next().value!)
457}
458
459/**
460 * Reads an agent's transcript into `transcriptsAtom` as its last items, off the render (a drawing never
461 * awaits it). The agent counts as in flight until the read itself settles, so a hung read never has
462 * others piled behind it; a trigger meanwhile is remembered, and one reread follows once it settles.
463 * A refusal, a failure or a read past PEEK_TIMEOUT_MS is stored as denied, unless items are already
464 * cached: an agent that finished, or a slow read, keeps the last items shown.
465 */
466async function refreshTranscript($: $, id: string) {
467 if (id === 'main') return
468 const messages = await guardedRead(
469 $,
470 transcriptReads,
471 id,
472 () => $.session.messages({ agentId: id }).then(f => ('deny' in f ? undefined : f)),
473 () => schedulePeek($, id),
474 )
475 if (messages === IN_FLIGHT) return
476 const startedAt = (await read($, agentsAtom)).find(r => r.id === id)?.startedAt ?? 0
477 const items = messages
478 ? transcriptItems(messages, key => stamps.get(id)?.get(key), { n: TRANSCRIPT_MAX, chars: STEP_CHARS, startedAt })
479 : undefined
480 await update($, transcriptsAtom, p =>
481 items ? { ...p, [id]: { items } } : p[id] && 'items' in p[id] ? p : { ...p, [id]: { denied: true as const } },
482 )
483}
484
485/**
486 * Runs `fetch` for `key` under the slot's guard. The key counts as in flight until the read itself
487 * settles, so a hung read never has others piled behind it; a trigger meanwhile is remembered and
488 * `rerun` follows once it settles. Resolves `undefined` on a failure or past PEEK_TIMEOUT_MS, and
489 * IN_FLIGHT (having remembered the trigger) when that key's read is already under way.
490 */
491async function guardedRead<T>(
492 $: $,
493 slot: ReadSlot,
494 key: string,
495 fetch: () => Promise<T | undefined>,
496 rerun: () => void,
497): Promise<T | undefined | typeof IN_FLIGHT> {
498 if (slot.busy.has(key)) {
499 slot.again.add(key)
500 return IN_FLIGHT
501 }
502 slot.busy.add(key)
503 const found = fetch()
504 .catch(() => undefined)
505 .finally(() => {
506 slot.busy.delete(key)
507 if (slot.again.delete(key)) rerun()
508 })
509 let timer: { cancel: () => void } | undefined
510 const late = new Promise<undefined>(resolve => {
511 timer = $.clock.after(PEEK_TIMEOUT_MS, () => resolve(undefined))
512 })
513 const value = await Promise.race([found, late])
514 timer?.cancel()
515 return value
516}
517
518/** Starts `run` outside the dispatch that asked for it, unless the key's read is in flight: then it is remembered. */
519function scheduleRead($: $, slot: ReadSlot, key: string, run: () => Promise<unknown>) {
520 if (slot.busy.has(key)) {
521 slot.again.add(key)
522 return
523 }
524 $.clock.after(0, () => void run().catch(() => undefined))
525}
526
527/** Starts a transcript read outside the dispatch that asked for it, so no hook or press waits on it. */
528function schedulePeek($: $, id: string) {
529 if (id === 'main') return
530 scheduleRead($, transcriptReads, id, () => refreshTranscript($, id))
531}
532
533/** Rereads the transcript of the agent an event came from, when it is the one open in the pane. Never throws. */
534async function peekIfSelected($: $, id: string | undefined) {
535 try {
536 if (id && id === (await read($, selectedAtom))) schedulePeek($, id)
537 } catch {
538 // The transcript is a convenience: whatever fails here must not reach a handover or a step's record.
539 }
540}
541
542/**
543 * Opens a tool call's detail in the dialog pane, or closes it when that call is the one open.
544 * Only state is awaited: the read is scheduled and the open is fire-and-forget, so a press never
545 * waits on either. A session that places no dialog (an older desktop) gets the detail inline.
546 */
547async function openTool($: $, agentId: string, toolUseId: string) {
548 const open = await read($, toolViewAtom)
549 if (open?.agentId === agentId && open.toolUseId === toolUseId) return closeTool($)
550 await update($, toolViewAtom, () => ({ agentId, toolUseId, inline: false }))
551 scheduleTool($, agentId, toolUseId)
552 const r = (await read($, agentsAtom)).find(a => a.id === agentId)
553 const tr = (await read($, transcriptsAtom))[agentId]
554 const item = tr && 'items' in tr ? tr.items.find(i => i.kind === 'tool' && i.toolUseId === toolUseId) : undefined
555 const title = `${r ? label(r) : agentId} · ${item?.kind === 'tool' ? item.tool : 'tool call'}`
556 void $.ui
557 .open({ id: TOOL_PANE, title, focus: true, closeOnEscape: true, holdToasts: true, rows: 20 })
558 .then(async res => {
559 if (res.isPlaced) return
560 await update($, toolViewAtom, v => (v && v.agentId === agentId && v.toolUseId === toolUseId ? { ...v, inline: true } : v))
561 })
562 .catch(() => undefined)
563}
564
565/** Closes the detail dialog and forgets the call it showed. */
566async function closeTool($: $) {
567 await update($, toolViewAtom, () => null)
568 await $.ui.close({ id: TOOL_PANE })
569}
570
571/**
572 * Reads one tool call's detail into `toolViewAtom`, off the render, as refreshTranscript reads a
573 * transcript: one read per call in flight, one reread for triggers meanwhile, PEEK_TIMEOUT_MS at most.
574 * It writes only while that call is still the one open. A call missing from the transcript, or a
575 * read refused, failed or late, is stored as denied unless a detail is already shown.
576 */
577async function refreshTool($: $, agentId: string, toolUseId: string) {
578 const use = await guardedRead(
579 $,
580 toolReads,
581 `${agentId}:${toolUseId}`,
582 () =>
583 $.session.messages({ agentId }).then(f => {
584 if ('deny' in f) return undefined
585 for (let i = f.length - 1; i >= 0; i--) {
586 const found = f[i]!.toolUses.find(u => u.tool_use_id === toolUseId)
587 if (found) return found
588 }
589 return undefined
590 }),
591 () => scheduleTool($, agentId, toolUseId),
592 )
593 if (use === IN_FLIGHT) return
594 const seen = stamps.get(agentId)?.get(toolUseId)
595 const detail = use ? toolDetail(use, typeof seen === 'number' ? seen : undefined) : undefined
596 await update($, toolViewAtom, v => {
597 if (!v || v.agentId !== agentId || v.toolUseId !== toolUseId) return v
598 if (detail) return { agentId, toolUseId, inline: v.inline, detail }
599 return v.detail ? v : { ...v, denied: true as const }
600 })
601}
602
603/** Starts a tool detail read outside the dispatch that asked for it. */
604function scheduleTool($: $, agentId: string, toolUseId: string) {
605 scheduleRead($, toolReads, `${agentId}:${toolUseId}`, () => refreshTool($, agentId, toolUseId))
606}
607
608/**
609 * Rereads the open tool call's detail while it is still loading or in flight: from its agent's steps
610 * (`agentId`) and from sync (no `agentId`). Off the render, through scheduleTool's guard. Never throws.
611 */
612async function toolIfPending($: $, agentId?: string) {
613 try {
614 const v = await read($, toolViewAtom)
615 if (!v || (agentId !== undefined && v.agentId !== agentId)) return
616 if (!v.detail || v.detail.isPending) scheduleTool($, v.agentId, v.toolUseId)
617 } catch {
618 // The dialog is a convenience: whatever fails here must not reach a step or a handover.
619 }
620}
621
622/** Rereads the open tool call's detail when its own call settles. Never throws. */
623async function toolIfOpen($: $, agentId: string | undefined, toolUseId: string | undefined) {
624 try {
625 const v = await read($, toolViewAtom)
626 if (agentId && v && v.agentId === agentId && v.toolUseId === toolUseId) scheduleTool($, agentId, toolUseId)
627 } catch {
628 // The dialog is a convenience: whatever fails here must not reach a handover.
629 }
630}
631
632type Ui = ReturnType<$['ui']['resolve']>
633
634/**
635 * A tool call's detail as elements: header, then each section labelled (D11). With `copy`, each label has a
636 * copy control (`copy:sec:<toolUseId>:<i>`); `$` serves only that control's press.
637 */
638function detailBody($: $, ui: Ui, d: ToolDetail, copy?: { toolUseId: string }) {
639 const { Box, Text, Code, Markdown } = ui
640 return (
641 <Box flexDirection="column" gap={1}>
642 <Text bold>
643 {d.time} ⚙ {d.tool}
644 {d.isError && <Text color="error"> failed</Text>}
645 {d.isPending && <Text dimColor> running…</Text>}
646 </Text>
647 {d.sections.map((sec, i) => (
648 <Box key={`s${i}`} flexDirection="column">
649 <Box flexDirection="row" gap={1}>
650 <Text dimColor bold>
651 {sec.label}
652 </Text>
653 {/* A Code block carries the desktop's own copy button: ours would be a second one. */}
654 {copy && sec.kind !== 'code' && copyButton($, ui, `sec:${copy.toolUseId}:${i}`, bodyOf(sec))}
655 </Box>
656 {sec.kind === 'code' ? (
657 <Code
658 source={sec.source}
659 language={sec.language}
660 path={sec.path}
661 startLine={sec.startLine}
662 format={sec.diff ? 'diff' : 'source'}
663 />
664 ) : sec.kind === 'markdown' ? (
665 <Markdown text={sec.text} />
666 ) : (
667 <Text wrap="wrap">{sec.text}</Text>
668 )}
669 </Box>
670 ))}
671 </Box>
672 )
673}
674
675/** What the dialog shows for the open call: its detail (with copy controls when `copyable`), or one dim line. */
676function toolBody($: $, ui: Ui, v: ToolView | null, copyable: boolean) {
677 const { Text } = ui
678 if (!v) return <Text dimColor>No tool call selected.</Text>
679 if (v.detail) return detailBody($, ui, v.detail, copyable ? { toolUseId: v.toolUseId } : undefined)
680 if (v.denied) return <Text dimColor>tool call not readable (teammate in another pane, or finished)</Text>
681 return <Text dimColor>loading…</Text>
682}
683
684// ---- subagents -------------------------------------------------------
685
686async function startHandover($: $, id: string, why: string) {
687 // Checked and taken before any await: two presses in the same tick both pass canHandOver otherwise.
688 if (handoverStarting.has(id)) {
689 await log($, `${id}: Hand over not started (already starting; ${why})`)
690 return false
691 }
692 handoverStarting.add(id)
693 try {
694 const r = (await read($, agentsAtom)).find(a => a.id === id)
695 if (!r) {
696 await log($, `${id}: Hand over not started (no such agent; ${why})`)
697 return false
698 }
699 if (!canHandOver(r)) {
700 await log($, `${label(r)}: Hand over not started (status ${r.status}; ${why})`)
701 return false
702 }
703 const attempt = (r.handovers ?? 0) + 1
704 const notePath = notePathFor(await runDir($), r, attempt)
705 const now = await $.clock.now()
706 await update($, agentsAtom, l =>
707 patch(l, id, { status: 'handing-over', notePath, handoverAt: now, handovers: attempt, handoverVia: undefined }),
708 )
709 if (r.status === 'idle') return await sendHandover($, id, why)
710 // A message would wait in a busy teammate's inbox until its turn ends: it rides on the next tool result instead.
711 await log($, `${label(r)} ${why}: HANDOVER goes with its next tool result`)
712 await persist($)
713 return true
714 } finally {
715 handoverStarting.delete(id)
716 }
717}
718
719/**
720 * Sends HANDOVER as a message, which wakes an idle teammate or resumes a finished subagent. From inside a
721 * turn.complete registration it is queued (`queue`), and its stall clock is stopped until it is sent.
722 */
723async function sendHandover($: $, id: string, why: string, queue = false) {
724 const r = (await read($, agentsAtom)).find(a => a.id === id)
725 if (!r?.notePath) return false
726 const text = handoverMessage(r.notePath, cfg.autoRespawn)
727 if (queue) {
728 // Unset, so a tool result that carries it before delivery marks it, and the queued send is skipped.
729 await update($, agentsAtom, l => patch(l, id, { handoverAt: undefined, handoverVia: undefined }))
730 await enqueue($, { kind: 'handover', agentId: id, text, what: `${label(r)} ${why}: HANDOVER not delivered` })
731 return true
732 }
733 return deliverHandover($, id, why, text)
734}
735
736/** Sends a HANDOVER now and starts its stall clock; one that cannot be delivered marks the agent stalled. */
737async function deliverHandover($: $, id: string, why: string, text: string) {
738 const r = (await read($, agentsAtom)).find(a => a.id === id)
739 // Rechecked at delivery: a tool result may have carried it since it was queued, or the agent been replaced.
740 if (r?.status !== 'handing-over' || r.handoverVia !== undefined) {
741 // A routine skip: to the file only, not the pane.
742 await trace(
743 $,
744 `${r ? label(r) : id}: HANDOVER not sent (status ${r?.status ?? 'gone'}${r?.handoverVia ? `, already via ${r.handoverVia}` : ''})`,
745 )
746 return false
747 }
748 const sent = await sendOwn($, id, text)
749 if (sent.isDelivered) {
750 const now = await $.clock.now()
751 await update($, agentsAtom, l => patch(l, id, { handoverVia: 'message', handoverAt: now }))
752 await log($, `${label(r)} ${why}: HANDOVER sent`)
753 } else {
754 await update($, agentsAtom, l => patch(l, id, { status: 'stalled' }))
755 await log($, `${label(r)}: HANDOVER could not be delivered (${sent.reason})`, 'warn')
756 }
757 await persist($)
758 return sent.isDelivered
759}
760
761/** A value for a debug line: a string as it is, anything else as JSON (or String when that fails), cut at 80. */
762function debugValue(v: unknown) {
763 let s: string
764 try {
765 s = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v))
766 } catch {
767 s = String(v)
768 }
769 return s.slice(0, 80)
770}
771
772/** The mod's only way to send: marks the send as its own for tool.check while it is in flight. */
773async function sendOwn($: $, agentId: string, text: string) {
774 const mark = { agentId, text }
775 const unmark = () => {
776 const i = ownSends.indexOf(mark)
777 if (i >= 0) ownSends.splice(i, 1)
778 }
779 ownSends.push(mark) // synchronously, before any await
780 // A send that never answers allows nothing past DRAIN_TIMEOUT_MS.
781 let timer: { cancel: () => void } | undefined
782 try {
783 timer = $.clock.after(DRAIN_TIMEOUT_MS, unmark)
784 } catch {
785 // no timer: the mark lasts as long as the send
786 }
787 try {
788 return await $.session.send({ to: { agentId }, text })
789 } finally {
790 timer?.cancel()
791 unmark()
792 }
793}
794
795/** Runs one of the mod's own calls, marked for tool.check while in flight (at most DRAIN_TIMEOUT_MS). */
796async function callOwn<T>($: $, mark: OwnCall, run: () => Promise<T>): Promise<T> {
797 const unmark = () => {
798 const i = ownCalls.indexOf(mark)
799 if (i >= 0) ownCalls.splice(i, 1)
800 }
801 ownCalls.push(mark) // synchronously, before any await
802 let timer: { cancel: () => void } | undefined
803 try {
804 timer = $.clock.after(DRAIN_TIMEOUT_MS, unmark)
805 } catch {
806 // no timer: the mark lasts as long as the call
807 }
808 try {
809 return await run()
810 } finally {
811 timer?.cancel()
812 unmark()
813 }
814}
815
816/**
817 * tool.check's verdict for an Agent or TaskStop call: allow when it is one of the mod's own in flight, else
818 * undefined (the engine decides). Logs the first miss while a call is in flight, by length only. Never throws.
819 */
820function ownCallVerdict($: $, tool: string, input: unknown) {
821 try {
822 if (ownCalls.length === 0) return undefined
823 if (isOwnCall(input, ownCalls)) return { decision: 'allow' as const, reason: 'orchestrator mod: its own call' }
824 if (!callMissLogged) {
825 callMissLogged = true
826 // No prompt text: the debug log may be shared.
827 const p = typeof input === 'object' && input !== null ? (input as { prompt?: unknown }).prompt : undefined
828 $.ui.log(`orchestrator: ${tool} check not matched: prompt=${typeof p === 'string' ? `${p.length} chars` : typeof p}`, {
829 to: 'debug',
830 })
831 }
832 } catch {
833 // fall through to the engine's verdict
834 }
835 return undefined
836}
837
838async function tellParent($: $, parentId: string | undefined, text: string) {
839 if (parentId === undefined || parentId === 'main') {
840 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
841 } else {
842 await sendOwn($, parentId, text)
843 }
844}
845
846/** When the engine registered an agent under another name than it asked for, queues it a notice (once) and logs it. Never throws. */
847async function noticeRename($: $, id: string, requested: string | undefined, name: string | undefined) {
848 try {
849 if (!requested || !name || name === requested) return
850 // Claimed before any await: the spawn hook's lookup and a respawn may both get here for one agent.
851 if (noticed.has(id)) return
852 noticed.add(id)
853 const text = nameNotice(name, requested)
854 if ((await read($, outboxAtom)).some(d => d.agentId === id && d.text === text)) return
855 await enqueue($, { agentId: id, text, what: `${name}: name notice not delivered`, liveOnly: true })
856 await log($, `"${requested}" registered as ${name} (${id}); told it so`)
857 } catch (err) {
858 $.ui.log(`orchestrator: name notice not queued: ${String(err)}`)
859 }
860}
861
862/**
863 * The engine suffixes a taken name (`orchestrator` → `orchestrator-2`): SendMessage, check-ins and reports go
864 * by the one it registered, which only its list says (F19). Read after the spawn returns, it renames the row
865 * (and adds a teammate's address) and queues the renamed agent its notice. An unlisted agent, or a list that
866 * fails, keeps the requested name. Never throws.
867 */
868async function registeredName($: $, id: string, requested: string | undefined) {
869 try {
870 const info = (await $.agent.list()).find(a => a.id === id)
871 if (!info) return
872 const r = (await read($, agentsAtom)).find(a => a.id === id)
873 if (!r) return
874 const name = info.name ?? requested
875 const teammateId = r.teammateId ?? info.teammateId
876 if (name !== r.name || teammateId !== r.teammateId) {
877 await update($, agentsAtom, l =>
878 patch(l, id, { name, ...(typeof teammateId === 'string' && { teammateId }) }),
879 )
880 await persist($)
881 }
882 await noticeRename($, id, requested, name)
883 } catch {
884 // the requested name stands
885 }
886}
887
888/** A respawn that did not start a replacement: marks the gap, saves the brief, and tells the parent, the person and the log. Never throws. */
889async function respawnFailed($: $, old: AgentRow, reason: string) {
890 try {
891 await update($, agentsAtom, l => patch(l, old.id, { status: 'respawn-failed', ...DISARMED }))
892 let briefPath: string | undefined
893 if (old.brief && old.notePath) {
894 try {
895 briefPath = briefPathFor(old.notePath)
896 await $.fs.write(briefPath, replacementPrompt(old.brief, old.notePath))
897 } catch {
898 briefPath = undefined
899 }
900 }
901 const rows = await read($, agentsAtom)
902 // A parent that handed over is carried on by its replacement: the notice goes there.
903 const to = latest(rows, old.parentId ?? 'main')
904 const text = respawnFailedNotice({
905 label: label(old),
906 id: old.id,
907 notePath: old.notePath ?? '(none)',
908 reason,
909 brief: old.brief,
910 briefPath,
911 })
912 const toRow = rows.find(r => r.id === to)
913 await log($, `${label(old)}: no replacement started (${reason}); told ${to === 'main' ? 'main' : toRow ? label(toRow) : to}`, 'warn')
914 // A refused notification is fine: the toast said it.
915 void $.ui.notify(`${label(old)} handed over, but no replacement started`, { title: 'orchestrator' }).catch(() => undefined)
916 if (to === 'main') $.clock.after(0, () => void $.prompt.submit({ text }))
917 else {
918 const sent = await sendOwn($, to, text)
919 if (!sent.isDelivered) await log($, `${label(old)}: parent not told of the failed respawn (${sent.reason})`, 'warn')
920 }
921 await persist($)
922 } catch (err) {
923 try {
924 $.ui.log(`orchestrator: failed respawn not reported: ${String(err)}`)
925 } catch {
926 // nothing more
927 }
928 }
929}
930
931/** Stops a replaced agent once its replacement has started, so a late message can't wake it to act. Logged, never thrown. */
932async function stopReplaced($: $, old: AgentRow) {
933 try {
934 // Bounded: a stop that never answers holds neither the drain nor the parent's notice past DRAIN_TIMEOUT_MS.
935 let timer: { cancel: () => void } | undefined
936 const late = new Promise<'late'>(resolve => {
937 try {
938 timer = $.clock.after(DRAIN_TIMEOUT_MS, () => resolve('late'))
939 } catch {
940 // no timer: the stop is awaited as it is
941 }
942 })
943 const stop = callOwn($, { tool: 'TaskStop', taskId: old.id }, () => $.tool.call({ tool: 'TaskStop', task_id: old.id }))
944 let res: Awaited<typeof stop> | 'late'
945 try {
946 res = await Promise.race([stop, late])
947 } finally {
948 timer?.cancel()
949 }
950 if (res === 'late') {
951 void stop.catch(() => undefined)
952 await log($, `${label(old)} (${old.id}) not stopped (no answer in ${DRAIN_TIMEOUT_MS / 1000} s)`)
953 return
954 }
955 const bad = res.deny ?? (res.isError ? String(res.text ?? 'error') : undefined)
956 await log($, bad === undefined ? `${label(old)} (${old.id}) stopped` : `${label(old)} (${old.id}) not stopped (${bad})`)
957 } catch (err) {
958 await log($, `${label(old)} (${old.id}) not stopped (${String(err)})`).catch(() => undefined)
959 }
960}
961
962/** Links a respawn's replacement: the old agent's children move under it. Never throws. */
963async function linkReplacement($: $, old: AgentRow, newId: string) {
964 try {
965 await update($, agentsAtom, l => adoptChildren(l, old.id, newId))
966 } catch (err) {
967 $.ui.log(`orchestrator: children of ${old.id} not moved: ${String(err)}`)
968 }
969}
970
971async function respawn($: $, old: AgentRow) {
972 const brief = old.brief
973 if (!brief || !old.notePath) return respawnFailed($, old, 'no brief recorded')
974 lastSpawned = undefined
975 const args = {
976 prompt: replacementPrompt(brief, old.notePath),
977 subagentType: brief.subagentType,
978 description: brief.description,
979 model: brief.model,
980 name: brief.name,
981 }
982 let res: Awaited<ReturnType<typeof $.agent.spawn>>
983 try {
984 res = await callOwn($, { tool: 'Agent', prompt: args.prompt }, () => $.agent.spawn(args))
985 } catch (err) {
986 return respawnFailed($, old, `spawn failed: ${String(err)}`)
987 }
988 const newId = res.agentId ?? lastSpawned
989 if (res.deny !== undefined || !newId) return respawnFailed($, old, res.deny ?? 'no agent id')
990 // A teammate's name is suffixed when the original still holds it; the engine delivers its final reply itself.
991 const info = (await $.agent.list()).find(a => a.id === newId)
992 const name = info?.name ?? brief.name
993 const isTeammate = info?.teammateId !== undefined
994 const now = await $.clock.now()
995 // The old row's parent as it is now, not as queued: a parent replaced meanwhile has adopted it.
996 let parentId = old.parentId
997 await update($, agentsAtom, l => {
998 parentId = l.find(a => a.id === old.id)?.parentId ?? old.parentId
999 const known = l.some(a => a.id === newId) ? l : [...l, newRow(newId, now)]
1000 return known.map(a =>
1001 a.id === old.id
1002 ? { ...a, replacedBy: newId, ...DISARMED }
1003 : a.id === newId
1004 ? {
1005 ...a,
1006 name,
1007 description: brief.description,
1008 type: brief.subagentType,
1009 brief,
1010 replaces: old.id,
1011 parentId,
1012 isOurs: true,
1013 isTeammate,
1014 background: old.background,
1015 checkinAt: old.checkinAt,
1016 checkinMins: old.checkinMins,
1017 checkinBy: old.checkinBy,
1018 }
1019 : a,
1020 )
1021 })
1022 await log($, `${label(old)} replaced by ${newId}, resuming from ${old.notePath}`)
1023 await linkReplacement($, old, newId)
1024 await noticeRename($, newId, brief.name, info?.name)
1025 await tellParent(
1026 $,
1027 // A parent that handed over is carried on by its replacement: the notice goes there.
1028 latest(await read($, agentsAtom), parentId ?? 'main'),
1029 `[orchestrator mod] Agent ${label(old)} (id ${old.id}) reached ${fmtTokens(old.tokens)} context and handed over; note at ${old.notePath}. ` +
1030 `Replacement id ${newId}${name ? ` (SendMessage name "${name}")` : ''} continues from the note. ` +
1031 `No action needed: do not spawn another.${!isTeammate && (parentId === 'main' || parentId === undefined) ? ' Its final report will be forwarded to you.' : ''} Update your roster.`,
1032 )
1033 // Last: the parent is told first, whatever the stop takes.
1034 await stopReplaced($, old)
1035}
1036
1037// ---- check-in and reports ---------------------------------------------
1038
1039/** A tool call's own arguments: beside the envelope keys on `e`, or under `e.input` on an engine that nests them. */
1040const argsOf = (e: object): Record<string, unknown> => {
1041 const raw = e as Record<string, unknown>
1042 const nested = raw.input && typeof raw.input === 'object' ? (raw.input as Record<string, unknown>) : {}
1043 return { ...nested, ...raw }
1044}
1045
1046/**
1047 * Serves the `checkin` tool: the calling loop (`agentId`, else main) arms, re-arms or (minutes <= 0)
1048 * cancels a check-in on one of its own children (a child of a loop it replaced counts), named by id
1049 * or name (`resolveAgent`: registered name, else an unambiguous requested name). Every answer is one line of result text starting `checkin:`; an unknown child or a bad
1050 * length stores nothing.
1051 */
1052async function checkin($: $, agentId: string | undefined, args: Record<string, unknown>) {
1053 const parent = agentId ?? 'main'
1054 const target = String(args.agent ?? '').trim()
1055 const minutes = Number(args.minutes)
1056 if (args.minutes === undefined || args.minutes === null || args.minutes === '' || !Number.isFinite(minutes)) {
1057 return { result: 'checkin: minutes must be a number (0 cancels); nothing changed.' }
1058 }
1059 try {
1060 await restore($)
1061 const rows = await read($, agentsAtom)
1062 const kids = rows.filter(r => r.id !== 'main' && latest(rows, r.parentId ?? 'main') === parent)
1063 const hit = resolveAgent(kids, target)
1064 if (!hit) return { result: `checkin: no agent "${target}" among the agents you spawned; nothing changed.` }
1065 if ('ambiguous' in hit) {
1066 const list = hit.ambiguous.map(k => `${label(k)} (id ${k.id})`).join(', ')
1067 return { result: `checkin: "${target}" could mean ${list}; name one by id or registered name; nothing changed.` }
1068 }
1069 const r = hit.row
1070 if (minutes <= 0) {
1071 await update($, agentsAtom, l => patch(l, r.id, DISARMED))
1072 await persist($)
1073 return { result: `checkin: check-in for ${label(r)} (id ${r.id}) cancelled.` }
1074 }
1075 const at = (await $.clock.now()) + minutes * 60_000
1076 await update($, agentsAtom, l => patch(l, r.id, { checkinAt: at, checkinMins: minutes, checkinBy: parent }))
1077 await persist($)
1078 return {
1079 result: `checkin: armed; if ${label(r)} (id ${r.id}) has not reported to you by ${hhmm(at)} UTC (${minutes} min), you will get a message.`,
1080 }
1081 } catch (err) {
1082 return { result: `checkin: failed (${String(err)}); nothing changed.` }
1083 }
1084}
1085
1086/**
1087 * After a subagent's tool call: a `SendMessage` naming its parent (or the parent's replacement) is this
1088 * run's report (kept with whether it succeeded) and disarms its check-in; any later call means the
1089 * report was not its last act. Called inside a `try`, after the HANDOVER is attached.
1090 */
1091async function noteReport($: $, id: string, tool: string, args: Record<string, unknown>, res: object) {
1092 const rows = await read($, agentsAtom)
1093 const c = rows.find(r => r.id === id)
1094 if (!c) return
1095 const parentId = c.parentId ?? 'main'
1096 // Its parent as spawned, or the replacement now carrying that parent's work.
1097 const parents = [parentId, latest(rows, parentId)].map(p => rows.find(r => r.id === p) ?? { id: p })
1098 // A SubagentHandback may reach main rather than the parent: it is no report to the parent (never ok), but
1099 // it is noted, so the forward that follows is flagged as a possible duplicate.
1100 const isReport = tool === 'SendMessage' && parents.some(p => namesAgent(args.to, p))
1101 if (isReport) {
1102 const out = res as { deny?: unknown; isError?: unknown; result?: unknown }
1103 const result = out.result && typeof out.result === 'object' ? (out.result as { success?: unknown }) : {}
1104 const ok = out.deny === undefined && !out.isError && result.success !== false
1105 const report = { t: await $.clock.now(), text: String(args.message ?? args.report ?? ''), ok, final: true }
1106 await update($, agentsAtom, l => patch(l, id, { report, ...DISARMED }))
1107 await persist($)
1108 } else if (tool === 'SubagentHandback' && c.report === undefined) {
1109 const report = { t: await $.clock.now(), text: String(args.message ?? args.report ?? ''), ok: false, final: false }
1110 await update($, agentsAtom, l => patch(l, id, { report }))
1111 await persist($)
1112 } else if (c.report?.final) {
1113 await update($, agentsAtom, l => patch(l, id, { report: { ...c.report!, final: false } }))
1114 await persist($)
1115 }
1116}
1117
1118/** How long before a turn end another agent's shell must have started for that turn end's list to cover it. */
1119const RECONCILE_MARGIN_MS = 30_000
1120
1121/**
1122 * Ends every tracked open shell a turn end's `background_tasks` no longer lists (the engine drops a finished one):
1123 * with the code its file holds, else `unknown`. It catches what polling missed. A missing list does nothing.
1124 * `rows` are those read before the hook's `next`: a shell tracked while it ran is missing from the list, not gone.
1125 * The stopping agent's (`agentId`) shells are ended at once. Another agent's only once started at least
1126 * RECONCILE_MARGIN_MS before the turn end (`at`): the session-wide list can predate that agent's new shell. So
1127 * another agent's shell gone at a turn end less than 30 s after it started is left to the poll or a later turn end;
1128 * if the poll can't read its file (polling off, the file missing or unreadable) and no turn end comes, only the
1129 * ceiling nudge catches it.
1130 */
1131async function reconcileShells($: $, tasks: unknown, rows: readonly AgentRow[] | undefined, agentId: string, at: number) {
1132 if (!rows) return
1133 try {
1134 const gone = goneShells(rows, tasks).filter(g => g.agentId === agentId || at - g.shell.startedAt >= RECONCILE_MARGIN_MS)
1135 for (const { shell } of gone) {
1136 await shellEnded($, shell.id, (await readExit($, shell.outputPath)) ?? 'unknown')
1137 }
1138 } catch (err) {
1139 $.ui.log(`orchestrator: shells not reconciled: ${String(err)}`)
1140 }
1141}
1142
1143/**
1144 * The hook on both turn ends (`classic.Stop`, `classic.SubagentStop`): bookkeeping after `next`, never a send, and it
1145 * never changes the answer. The rows are read before `next`, as the list was taken: a shell tracked while the hooks
1146 * beneath ran is not in it, yet still running. The stopping agent is `agent_id`, absent on main's Stop.
1147 */
1148async function reconcileAtStop<E extends { background_tasks?: unknown; agent_id?: string }, R>(
1149 $: $,
1150 e: E,
1151 next: (e: E) => Promise<R>,
1152) {
1153 let before: AgentRow[] | undefined
1154 let at = 0
1155 try {
1156 at = await $.clock.now()
1157 before = await read($, agentsAtom)
1158 } catch {
1159 before = undefined // nothing to reconcile against
1160 }
1161 const res = await next(e)
1162 try {
1163 await reconcileShells($, e.background_tasks, before, e.agent_id ?? 'main', at)
1164 } catch {
1165 // see above
1166 }
1167 return res
1168}
1169
1170// ---- delivery from timers ---------------------------------------------
1171//
1172// A run woken by `$.session.send` (or started by `$.agent.spawn`) from a `$.clock` callback runs
1173// without the mod's hooks: no tool.call, no turn.step, so no HANDOVER and no tracking. One woken from
1174// inside a hook registration runs without that registration alone. So timers never send, and the main
1175// turn.complete registration never sends or spawns: both queue, and a drain-only turn.complete
1176// registration (and /orch) deliver, so a woken run loses only a registration it never needs.
1177
1178/** Queues a message for the next turn end (or /orch) to deliver, and writes it to state.json so a /clear keeps it. */
1179async function enqueue($: $, d: Omit<Delivery, 't' | 'id'>) {
1180 const t = await $.clock.now()
1181 const id = `${t}-${++deliverySeq}`
1182 await update($, outboxAtom, q => [...q, { ...d, t, id }])
1183 await persist($)
1184}
1185
1186/**
1187 * Delivers every queued message from the hook that awaits it. It waits for one state update only:
1188 * the queue is taken and emptied at once (so two hooks never deliver one message twice), then every
1189 * send is started before it returns, still inside the hook, and none is awaited, so a send that never
1190 * answers holds up neither the hook nor a later drain. A failed send is warned and dropped. Never throws.
1191 */
1192async function drain($: $) {
1193 if (draining) return
1194 draining = true
1195 let taken: Delivery[] = []
1196 try {
1197 await update($, outboxAtom, q => {
1198 taken = q
1199 return []
1200 })hooks/detail.ts 241 lines1import { cleanBlock, hhmm } from './view'
2import type { ToolUseSummary } from 'claude-code'
3
4export { cleanBlock }
5
6/** One part of a tool call's detail view, drawn as `Code`, `Markdown` or `Text`. */
7export type DetailSection =
8 | { kind: 'code'; label: string; source: string; language?: string; path?: string; startLine?: number; diff?: true }
9 | { kind: 'markdown'; label: string; text: string }
10 | { kind: 'text'; label: string; text: string }
11
12/** A tool call, laid out for the detail dialog. */
13export type ToolDetail = {
14 tool: string
15 time: string
16 isError: boolean
17 isPending: boolean
18 sections: DetailSection[]
19}
20
21/** The most one section's body may hold before it is cut with a note. */
22export const SECTION_CHARS = 20_000
23/** The most all sections' bodies may hold together; under the Code element's 100,000. */
24export const TOTAL_CHARS = 60_000
25/** Room kept for the `\n… (N more characters)` note after a cut. */
26const NOTE = 40
27/** How many lines of a Read result the excerpt keeps. */
28const EXCERPT_LINES = 400
29
30
31/**
32 * One unified hunk turning `before` into `after`: shared leading and trailing
33 * lines as context, the rest removed then added. Edit carries no line numbers,
34 * so the hunk counts from line 1 of the edited text.
35 */
36/** A text's lines, CRLF or LF; the empty text has none. */
37const lines = (s: string): string[] => (s === '' ? [] : s.split(/\r?\n/))
38
39export const editDiff = (before: string, after: string): string => {
40 const a = lines(before)
41 const b = lines(after)
42 let head = 0
43 while (head < a.length && head < b.length && a[head] === b[head]) head++
44 let tail = 0
45 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
46 const body = [
47 ...a.slice(0, head).map((l) => ` ${l}`),
48 ...a.slice(head, a.length - tail).map((l) => `-${l}`),
49 ...b.slice(head, b.length - tail).map((l) => `+${l}`),
50 ...a.slice(a.length - tail).map((l) => ` ${l}`),
51 ]
52 return [`@@ -1,${a.length} +1,${b.length} @@`, ...body].join('\n')
53}
54
55const GUTTER = /^\s*(\d+)(?:→|\t)([^\n]*?)\r?$/
56
57/**
58 * A Read result without its line-number gutters (` 12→` or ` 12\t`), and the
59 * first line's number. Text with no gutter is returned as it is.
60 */
61export const readExcerpt = (text: string): { source: string; startLine?: number } => {
62 const rows = text.split(/\r?\n/)
63 const first = GUTTER.exec(rows[0] ?? '')
64 if (!first) return { source: text }
65 const kept: string[] = []
66 for (const line of rows) {
67 if (kept.length >= EXCERPT_LINES) break
68 const m = GUTTER.exec(line)
69 if (!m) break
70 kept.push(m[2] ?? '')
71 }
72 return { source: kept.join('\n'), startLine: Number(first[1]) }
73}
74
75const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
76const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
77
78/** A section as built, before cleaning and caps; a diff carries what to draw when it does not fit. */
79type Draft = DetailSection & { fallback?: DetailSection[] }
80
81const code = (label: string, source: string, extra: { language?: string; path?: string; startLine?: number } = {}): DetailSection => {
82 const s: DetailSection = { kind: 'code', label, source }
83 if (extra.language !== undefined) s.language = extra.language
84 if (extra.path !== undefined) s.path = extra.path
85 if (extra.startLine !== undefined) s.startLine = extra.startLine
86 return s
87}
88const text = (label: string, t: string): DetailSection => ({ kind: 'text', label, text: t })
89const markdown = (label: string, t: string): DetailSection => ({ kind: 'markdown', label, text: t })
90
91const json = (v: unknown): string => {
92 try {
93 return JSON.stringify(v, null, 2) ?? String(v)
94 } catch {
95 return String(v)
96 }
97}
98
99const CHANGE = 'Change (line numbers relative to the edit)'
100
101const editDrafts = (input: Record<string, unknown>, path: string | undefined): Draft[] => {
102 const edits: { old: string; neu: string }[] = Array.isArray(input.edits)
103 ? input.edits.map((e) => {
104 const r = (e ?? {}) as Record<string, unknown>
105 return { old: str(r.old_string) ?? '', neu: str(r.new_string) ?? '' }
106 })
107 : [{ old: str(input.old_string) ?? '', neu: str(input.new_string) ?? '' }]
108 const at = path === undefined ? {} : { path }
109 return [
110 {
111 kind: 'code',
112 label: CHANGE,
113 source: edits.map((e) => editDiff(e.old, e.neu)).join('\n'),
114 diff: true,
115 fallback: [
116 code('Old', edits.map((e) => e.old).join('\n\n'), at),
117 code('New', edits.map((e) => e.neu).join('\n\n'), at),
118 ],
119 },
120 ]
121}
122
123const drafts = (use: ToolUseSummary): Draft[] => {
124 const input = (use.input ?? {}) as Record<string, unknown>
125 const out = str(use.text)
126 const done = out !== undefined
127 const path = str(input.file_path)
128 const at = path === undefined ? {} : { path }
129 switch (use.tool) {
130 case 'Bash': {
131 const desc = str(input.description)
132 return [
133 ...(desc ? [text('Description', desc)] : []),
134 code('Command', str(input.command) ?? '', { language: 'bash' }),
135 ...(done ? [code('Output', out)] : []),
136 ]
137 }
138 case 'Edit':
139 case 'MultiEdit': {
140 const all = input.replace_all === true ? ' (all occurrences)' : ''
141 return [
142 text('File', `${path ?? ''}${all}`),
143 ...editDrafts(input, path),
144 ...(done ? [text('Result', out)] : []),
145 ]
146 }
147 case 'Write':
148 return [
149 text('File', path ?? ''),
150 code('Content', str(input.content) ?? '', { ...at, startLine: 1 }),
151 ...(done ? [text('Result', out)] : []),
152 ]
153 case 'Read': {
154 const offset = num(input.offset)
155 const limit = num(input.limit)
156 const range =
157 offset !== undefined && limit !== undefined
158 ? ` lines ${offset}–${offset + limit - 1}`
159 : offset !== undefined
160 ? ` from line ${offset}`
161 : limit !== undefined
162 ? ` lines 1–${limit}`
163 : ''
164 const ex = done ? readExcerpt(out) : undefined
165 return [
166 text('File', `${path ?? ''}${range}`),
167 ...(ex ? [code('Excerpt', ex.source, { ...at, ...(ex.startLine === undefined ? {} : { startLine: ex.startLine }) })] : []),
168 ]
169 }
170 case 'Grep':
171 case 'Glob': {
172 const fields: [string, unknown][] = [
173 ['pattern', input.pattern],
174 ['path', input.path],
175 ['glob', input.glob],
176 ['type', input.type],
177 ['output mode', input.output_mode],
178 ]
179 const lines = fields.flatMap(([k, v]) => (typeof v === 'string' && v !== '' ? [`${k}: ${v}`] : []))
180 return [text('Pattern', lines.join('\n')), ...(done ? [code('Results', out)] : [])]
181 }
182 case 'Agent':
183 case 'Task': {
184 const desc = str(input.description) ?? ''
185 const type = str(input.subagent_type)
186 return [
187 text('Task', type ? `${desc} (${type})` : desc),
188 markdown('Prompt', str(input.prompt) ?? ''),
189 ...(done ? [markdown('Result', out)] : []),
190 ]
191 }
192 default:
193 return [code('Input', json(input), { language: 'json' }), ...(done ? [code('Result', out)] : [])]
194 }
195}
196
197export const bodyOf = (s: DetailSection): string => (s.kind === 'code' ? s.source : s.text)
198const withBody = (s: DetailSection, b: string): DetailSection => (s.kind === 'code' ? { ...s, source: b } : { ...s, text: b })
199
200/**
201 * Cleans each draft and holds it to its section cap and what is left of the
202 * total. Room for one more note is kept back, so drafts that no longer fit are
203 * counted in a closing `… (N more sections)` line.
204 */
205const finish = (list: Draft[]): DetailSection[] => {
206 const kept: DetailSection[] = []
207 let used = 0
208 const queue = [...list]
209 while (queue.length > 0) {
210 const room = Math.min(SECTION_CHARS, TOTAL_CHARS - used - 2 * NOTE)
211 if (room <= 0) {
212 const n = queue.length
213 kept.push(text('Omitted', `… (${n} more section${n === 1 ? '' : 's'})`))
214 break
215 }
216 const { fallback, ...s } = queue.shift() as Draft
217 const b = cleanBlock(bodyOf(s as DetailSection))
218 if (fallback && b.length > room) {
219 queue.unshift(...fallback)
220 continue
221 }
222 const capped = b.length > room ? `${b.slice(0, room)}\n… (${b.length - room} more characters)` : b
223 used += capped.length
224 kept.push(withBody(s as DetailSection, capped))
225 }
226 return kept
227}
228
229/**
230 * A tool call laid out for its detail dialog, from its input and the result
231 * text the model read (a subagent's transcript stores no structured record).
232 * `t` is when the call was made, if the mod stamped it.
233 */
234export const toolDetail = (use: ToolUseSummary, t?: number): ToolDetail => ({
235 tool: cleanBlock(str(use.tool) ?? ''),
236 time: t === undefined ? '--:--' : hhmm(t),
237 isError: use.isError === true,
238 isPending: str(use.text) === undefined,
239 sections: finish(drafts(use)),
240})
241hooks/core.ts 447 lines1import type { AgentRow, Brief, Sample, Shell, ShellExit } from '../types'
2
3export const MAX_SAMPLES = 180
4
5/** The UTC hh:mm of a time, as the terminal pane shows it. */
6export const hhmm = (t: number): string => new Date(t).toISOString().slice(11, 16)
7
8type Usage = {
9 input_tokens?: number
10 cache_creation_input_tokens?: number | null
11 cache_read_input_tokens?: number | null
12 output_tokens?: number
13}
14
15/** The context a response was answered over: what the next request starts from. */
16export const contextOf = (u: Usage): number =>
17 (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
18
19/** Halves the older half of the samples once there are too many; first and last stay. */
20const thin = (s: Sample[]): Sample[] => {
21 if (s.length <= MAX_SAMPLES) return s
22 const half = Math.floor(s.length / 2)
23 const older = s.slice(0, half).filter((_, i) => i % 2 === 0)
24 return [...older, ...s.slice(half)]
25}
26
27export type Step = { id: string; tokens: number; t: number; tools: number }
28
29export const newRow = (id: string, t: number): AgentRow => ({
30 id,
31 description: id === 'main' ? 'main thread' : '',
32 type: id === 'main' ? 'main' : 'agent',
33 status: 'running',
34 tokens: 0,
35 peak: 0,
36 samples: [],
37 steps: 0,
38 tools: 0,
39 startedAt: t,
40 lastActive: t,
41})
42
43export const recordStep = (rows: AgentRow[], step: Step): AgentRow[] => {
44 const known = rows.some(r => r.id === step.id)
45 const all = known ? rows : [...rows, newRow(step.id, step.t)]
46 return all.map(r =>
47 r.id !== step.id
48 ? r
49 : {
50 ...r,
51 // A step proves it alive: idle wakes, and done (or respawn-failed) was resumed by a message. A stalled
52 // handover stays stalled: its run's end checks for the note it may still be writing and
53 // marks it replaced or done, which running would skip (its end would only set idle).
54 status: r.status === 'idle' || r.status === 'done' || r.status === 'respawn-failed' ? 'running' : r.status,
55 ...(r.status === 'done' || r.status === 'respawn-failed' ? { resumedAt: step.t } : {}),
56 tokens: step.tokens,
57 peak: Math.max(r.peak, step.tokens),
58 steps: r.steps + 1,
59 tools: r.tools + step.tools,
60 lastActive: step.t,
61 samples: thin([...r.samples, { t: step.t, tokens: step.tokens }]),
62 },
63 )
64}
65
66/** Live subagents at or past the threshold that have not been told yet. */
67export const crossed = (rows: AgentRow[], threshold: number): AgentRow[] =>
68 rows.filter(r => r.id !== 'main' && (r.status === 'running' || r.status === 'idle') && r.tokens >= threshold)
69
70export const level = (tokens: number, threshold: number): 'ok' | 'warn' | 'hot' =>
71 tokens >= threshold ? 'hot' : tokens >= threshold * 0.6 ? 'warn' : 'ok'
72
73export const fmtTokens = (n: number): string =>
74 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
75
76const BLOCKS = '▁▂▃▄▅▆▇█'
77
78/** One block per column: each bucket's peak over the ceiling, oldest left. */
79export const sparkline = (samples: readonly Sample[], width: number, ceiling: number): string => {
80 if (samples.length === 0 || width < 1) return ''
81 const cols = Math.min(width, samples.length)
82 let out = ''
83 for (let c = 0; c < cols; c++) {
84 const from = Math.floor((c * samples.length) / cols)
85 const to = Math.floor(((c + 1) * samples.length) / cols)
86 let peak = 0
87 for (let i = from; i < to; i++) peak = Math.max(peak, samples[i]?.tokens ?? 0)
88 const ratio = Math.min(1, Math.max(0, peak / ceiling))
89 out += BLOCKS[Math.round(ratio * (BLOCKS.length - 1))]
90 }
91 return out
92}
93
94/**
95 * One column per slice of the shared axis [t0, t1]: the agent's peak in that
96 * slice, its last reading carried through slices with none, blank before its
97 * first sample and after its last (unless still live).
98 */
99export const timeline = (
100 samples: readonly Sample[],
101 width: number,
102 t0: number,
103 t1: number,
104 ceiling: number,
105 isLive = false,
106): string => {
107 const cells: number[] = new Array(width).fill(-1)
108 if (samples.length === 0 || width < 1) return ' '.repeat(Math.max(0, width))
109 const span = Math.max(1, t1 - t0)
110 const col = (t: number) => Math.min(width - 1, Math.max(0, Math.floor(((t - t0) / span) * width)))
111 for (const s of samples) cells[col(s.t)] = Math.max(cells[col(s.t)] ?? -1, s.tokens)
112 const first = col(samples[0]?.t ?? t0)
113 const last = isLive ? width - 1 : col(samples[samples.length - 1]?.t ?? t0)
114 let carry = -1
115 let out = ''
116 for (let c = 0; c < width; c++) {
117 const v = cells[c] ?? -1
118 if (v >= 0) carry = v
119 if (c < first || c > last || carry < 0) {
120 out += ' '
121 continue
122 }
123 const ratio = Math.min(1, Math.max(0, carry / ceiling))
124 out += BLOCKS[Math.round(ratio * (BLOCKS.length - 1))]
125 }
126 return out
127}
128
129/** Main first, then each agent under its parent in spawn order; unknown parents at the root. */
130export const orderRows = (rows: readonly AgentRow[]): { row: AgentRow; depth: number }[] => {
131 const ids = new Set(rows.map(r => r.id))
132 const byStart = [...rows].sort((a, b) => (a.id === 'main' ? -1 : b.id === 'main' ? 1 : a.startedAt - b.startedAt))
133 const kids = new Map<string, AgentRow[]>()
134 const roots: AgentRow[] = []
135 for (const r of byStart) {
136 if (r.parentId !== undefined && ids.has(r.parentId) && r.parentId !== 'main') {
137 kids.set(r.parentId, [...(kids.get(r.parentId) ?? []), r])
138 } else roots.push(r)
139 }
140 const out: { row: AgentRow; depth: number }[] = []
141 const walk = (r: AgentRow, depth: number) => {
142 out.push({ row: r, depth })
143 for (const k of kids.get(r.id) ?? []) walk(k, depth + 1)
144 }
145 for (const r of roots) walk(r, 0)
146 return out
147}
148
149const slug = (s: string) =>
150 s
151 .toLowerCase()
152 .replace(/[^a-z0-9]+/g, '-')
153 .replace(/^-|-$/g, '')
154
155/** The old agent's children now hang under its replacement (display, waiting, collapse and check-ins follow parentId). */
156export const adoptChildren = (rows: AgentRow[], oldId: string, newId: string): AgentRow[] =>
157 rows.map(r => (r.parentId === oldId && r.id !== newId ? { ...r, parentId: newId } : r))
158
159export const notePathFor = (runDir: string, r: AgentRow, attempt: number): string =>
160 `${runDir}/handover/${r.name ? slug(r.name) : 'agent'}-${r.id}-${attempt}.md`
161
162export const label = (r: AgentRow): string => r.name ?? (r.description || r.id)
163
164export const HANDOVER_CLAUSE = [
165 '',
166 '---',
167 'Context-limit protocol (from the orchestrator mod): if you receive a message saying HANDOVER,',
168 'stop work at a safe point, write a cold-restart handover note to the path given (task, decisions made,',
169 'exact state of files and tests, what is done, what remains, next concrete step, gotchas, and every live',
170 'child agent you started with its name, id and state), reply with the path, and end.',
171].join('\n')
172
173export const handoverMessage = (notePath: string, isRespawned: boolean): string =>
174 [
175 `HANDOVER. Your context is near its ceiling. Stop at the next safe point and write your cold-restart handover note to ${notePath}.`,
176 isRespawned
177 ? `Then end with this final reply and nothing else: "HANDED OVER: note at ${notePath}. The orchestrator mod will spawn the replacement with my brief and tell you; no action needed unless it reports that none started."`
178 : `Then end with this final reply: "HANDED OVER: note at ${notePath}. Please spawn a replacement with my original brief plus: start from the handover note at ${notePath}."`,
179 ].join(' ')
180
181export const replacementPrompt = (brief: Brief, notePath: string): string =>
182 `${brief.prompt}\n\n---\nYou replace an agent that reached its context limit. Start from the handover note at ${notePath}: read it first and continue from its next concrete step; do not redo finished work.`
183
184/** Tells an agent the engine registered it under a suffixed name, so its children report to the right one. */
185export const nameNotice = (name: string, requested: string): string =>
186 `[orchestrator mod] You are registered as \`${name}\` (you asked for \`${requested}\`); tell your children to SendMessage \`${name}\`.`
187
188/** Where a failed respawn's replacement prompt is saved: beside its note, as `<note>.brief.md`. */
189export const briefPathFor = (notePath: string): string => notePath.replace(/\.md$/, '') + '.brief.md'
190
191/** Tells a parent that its child handed over but no replacement started, and how to start one itself. */
192export const respawnFailedNotice = (o: {
193 label: string
194 id: string
195 notePath: string
196 reason: string
197 brief?: Brief
198 briefPath?: string
199}): string => {
200 const head =
201 `[orchestrator mod] Agent ${o.label} (id ${o.id}) handed over (note at ${o.notePath}), but no replacement started: ${o.reason}. ` +
202 `Nothing is carrying its work now. Spawn the replacement yourself`
203 const startLine = `"Start from the handover note at ${o.notePath}: read it first and continue from its next concrete step."`
204 if (!o.brief) return `${head} with its original brief plus: ${startLine} No brief was recorded for it.`
205 const b = o.brief
206 const opts = [
207 b.name && `name "${b.name}"`,
208 `subagent_type "${b.subagentType}"`,
209 b.model && `model "${b.model}"`,
210 `description "${b.description}"`,
211 ]
212 .filter(Boolean)
213 .join(', ')
214 return o.briefPath
215 ? `${head}: the Agent tool with ${opts}, and as its prompt the text of ${o.briefPath} (its original brief plus the line to start from the note).`
216 : `${head}: the Agent tool with ${opts}, and its original brief plus: ${startLine}`
217}
218
219export const mainHandoverPrompt = (notePath: string): string =>
220 [
221 `HANDOVER (orchestrator mod): this conversation is near its context ceiling and will be cleared and restarted from a note.`,
222 `Write a cold-restart handover note to ${notePath} with: the user's goal and every decision they made, what is running (agents by name and id, background tasks), what is done, what remains, the next concrete step, file paths and gotchas.`,
223 `Write only the note, then reply with its path. Do not start new work.`,
224 ].join('\n')
225
226export const resumePrompt = (o: { note: string; notePath: string; rosterPath: string; agents: AgentRow[] }): string => {
227 const live = o.agents.filter(a => a.id !== 'main' && a.status !== 'done' && a.status !== 'replaced')
228 const list = live.length
229 ? live.map(a => `- ${label(a)} (id ${a.id}, ${a.type}, ${a.status}, ${fmtTokens(a.tokens)} context)`).join('\n')
230 : '- none'
231 return [
232 `The conversation was cleared at its context ceiling by the orchestrator mod. Resume from your handover note (${o.notePath}):`,
233 '',
234 o.note.trim(),
235 '',
236 `Agents still known to the session (full roster at ${o.rosterPath}); they kept running through the clear and will report as before:`,
237 list,
238 '',
239 `Re-orient (re-read anything the note points to), then continue from the note's next step.`,
240 ].join('\n')
241}
242
243const when = (t: number) => (t ? new Date(t).toISOString().slice(11, 19) : '')
244
245export const rosterMarkdown = (rows: readonly AgentRow[]): string =>
246 [
247 '| name | id | type | status | context | peak | started | note | replaced by / replaces |',
248 '| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
249 ...rows.map(
250 r =>
251 `| ${label(r)} | ${r.id} | ${r.type} | ${r.status} | ${fmtTokens(r.tokens)} | ${fmtTokens(r.peak)} | ${when(r.startedAt)} | ${r.notePath ?? ''} | ${r.replacedBy ? `→ ${r.replacedBy}` : r.replaces ? `← ${r.replaces}` : ''} |`,
252 ),
253 '',
254 ].join('\n')
255
256/** Word-wraps text to `width` columns, hard-breaking longer words, each line padded so it paints every cell. */
257export const wrapFill = (text: string, width: number): string[] => {
258 const lines: string[] = []
259 let line = ''
260 for (const word of text.split(/\s+/).filter(Boolean)) {
261 let w = word
262 if (line && line.length + 1 + w.length <= width) {
263 line += ` ${w}`
264 continue
265 }
266 if (line) lines.push(line)
267 while (w.length > width) {
268 lines.push(w.slice(0, width))
269 w = w.slice(width)
270 }
271 line = w
272 }
273 if (line || lines.length === 0) lines.push(line)
274 return lines.map(l => l.padEnd(width))
275}
276
277/** The most lines activity.log keeps; the oldest go first. */
278export const ACTIVITY_MAX = 2000
279
280/** One activity.log line: UTC ISO time, level, the text on one line. */
281export const activityLine = (t: number, level: 'info' | 'warn', text: string): string =>
282 `${new Date(t).toISOString()} ${level === 'warn' ? 'WARN' : 'info'} ${text.replace(/\r?\n/g, ' ⏎ ')}`
283
284// ---- background shells
285
286/** A number setting: the value when finite and not negative, else the default. */
287export const numberSetting = (v: unknown, d: number): number => {
288 const n = Number(v ?? d)
289 return Number.isFinite(n) && n >= 0 ? n : d
290}
291
292/** Where a backgrounded Bash call's result text says its output goes. */
293export const OUTPUT_PATH = /Output is being written to: (.+?\.output)\.(?:\s|$)/
294
295/**
296 * The shell a Bash call sent to the background, explicitly or by the engine: its id and output path ('' when the
297 * text names none). None for a deny, an error, a foreground call, or one that dies with its caller's final response.
298 */
299export const backgroundedShell = (res: unknown): { id: string; outputPath: string } | undefined => {
300 if (!res || typeof res !== 'object') return undefined
301 const r = res as { deny?: unknown; isError?: unknown; result?: unknown; text?: unknown }
302 if (r.deny || r.isError) return undefined
303 const result = (r.result ?? {}) as { backgroundTaskId?: unknown; backgroundEndsWithFinalResponse?: unknown }
304 const id = result.backgroundTaskId
305 if (typeof id !== 'string' || !id || result.backgroundEndsWithFinalResponse === true) return undefined
306 const m = typeof r.text === 'string' ? OUTPUT_PATH.exec(r.text) : null
307 return { id, outputPath: m?.[1] ?? '' }
308}
309
310/** The exit marker the engine appends to an output file, read at its end: a code, `killed`, or none yet. */
311export const shellExit = (text: string): number | 'killed' | undefined => {
312 const m = /\n\[(?:exited with code (\d+)|killed)\]\n$/.exec(text.slice(-256))
313 if (!m) return undefined
314 return m[1] === undefined ? 'killed' : Number(m[1])
315}
316
317export const shellEndWords = (exit: ShellExit): string =>
318 exit === 'killed' ? 'was killed' : exit === 'unknown' ? 'finished (exit code unknown)' : `exited with code ${exit}`
319
320export const openShells = (r: AgentRow): Shell[] => (r.shells ?? []).filter(s => s.exit === undefined)
321
322/** Tracks a shell on its agent's row (replacing one with the same id); a new shell re-arms the idle watchdog. */
323export const addShell = (rows: AgentRow[], agentId: string, shell: Shell): AgentRow[] =>
324 rows.some(r => r.id === agentId)
325 ? rows.map(r =>
326 r.id !== agentId
327 ? r
328 : { ...r, shells: [...(r.shells ?? []).filter(s => s.id !== shell.id), shell], watchdog: undefined },
329 )
330 : rows
331
332/** Ends an open shell, wherever it is tracked; its row's shellsDoneAt is set once no shell there is open. */
333export const endShell = (rows: AgentRow[], shellId: string, exit: ShellExit, t: number): AgentRow[] =>
334 rows.map(r => {
335 if (!r.shells?.some(s => s.id === shellId && s.exit === undefined)) return r
336 const shells = r.shells.map(s => (s.id === shellId ? { ...s, endedAt: t, exit } : s))
337 return { ...r, shells, ...(shells.some(s => s.exit === undefined) ? {} : { shellsDoneAt: t }) }
338 })
339
340/**
341 * Keeps only `keep`'s shells. shellsDoneAt goes with the last finished one; once none is open (the last open one
342 * dropped) and finished ones remain, an unset shellsDoneAt is their latest end. One already set is kept.
343 */
344const keepShells = (r: AgentRow, keep: (s: Shell) => boolean): AgentRow => {
345 const shells = (r.shells ?? []).filter(keep)
346 const finished = shells.filter(s => s.exit !== undefined)
347 if (!finished.length) return { ...r, shells, shellsDoneAt: undefined }
348 if (r.shellsDoneAt !== undefined || finished.length < shells.length) return { ...r, shells }
349 return { ...r, shells, shellsDoneAt: Math.max(...finished.map(s => s.endedAt ?? 0)) }
350}
351
352/** Stops tracking a shell, wherever it is (TaskStop). */
353export const dropShell = (rows: AgentRow[], shellId: string): AgentRow[] =>
354 rows.map(r => (r.shells?.some(s => s.id === shellId) ? keepShells(r, s => s.id !== shellId) : r))
355
356/**
357 * The agent picked up its finished shells: all of them, or with `mention` those whose id or output path it names.
358 * With `endedBy`, one that ended after it is kept: its notice came too late for the run that picked up.
359 */
360export const pickUpShells = (rows: AgentRow[], agentId: string, mention?: string, endedBy?: number): AgentRow[] =>
361 rows.map(r =>
362 r.id !== agentId
363 ? r
364 : keepShells(
365 r,
366 s =>
367 s.exit === undefined ||
368 (endedBy !== undefined && (s.endedAt ?? 0) > endedBy) ||
369 (mention !== undefined && !mention.includes(s.id) && !(s.outputPath && mention.includes(s.outputPath))),
370 ),
371 )
372
373/** The engine has finished this agent: it lists it ended, or no longer lists it (never main). No list: none. */
374const engineEnded = (r: AgentRow, engine?: ReadonlyMap<string, string>): boolean => {
375 if (!engine || r.id === 'main') return false
376 const status = engine.get(r.id)
377 return status === undefined || status === 'completed' || status === 'failed' || status === 'killed'
378}
379
380/** Idle agents (never main) whose shells have all finished and gone unpicked for the grace, counted from the later of their end and the agent's last activity. */
381export const graceDue = (rows: readonly AgentRow[], now: number, graceMs: number, engine?: ReadonlyMap<string, string>): AgentRow[] =>
382 graceMs <= 0
383 ? []
384 : rows.filter(
385 r =>
386 r.id !== 'main' &&
387 r.status === 'idle' &&
388 !engineEnded(r, engine) &&
389 r.shellsDoneAt !== undefined &&
390 openShells(r).length === 0 &&
391 (r.shells ?? []).some(s => s.exit !== undefined) &&
392 now - Math.max(r.shellsDoneAt, r.lastActive) >= graceMs,
393 )
394
395/** How a shell is named to its agent and in the log: its description, else its command cut to 80 characters. */
396export const shellName = (s: Shell): string => s.description || s.command.slice(0, 80)
397const shellEnd = (s: Shell) => `${shellEndWords(s.exit ?? 'unknown')} at ${hhmm(s.endedAt ?? 0)} UTC`
398const outputOf = (s: Shell) => (s.outputPath ? `; output: ${s.outputPath}` : '')
399
400/** Tells an agent which of its background commands finished without it picking them up. */
401export const shellNudge = (shells: readonly Shell[]): string =>
402 shells.length === 1 && shells[0]
403 ? `[orchestrator mod] your background command "${shellName(shells[0])}" (id ${shells[0].id}) ${shellEnd(shells[0])} and you haven't picked it up${outputOf(shells[0])}`
404 : [
405 "[orchestrator mod] your background commands finished and you haven't picked them up:",
406 ...shells.map(s => `- "${shellName(s)}" (id ${s.id}) ${shellEnd(s)}${outputOf(s)}`),
407 ].join('\n')
408
409/** Open shells running past the ceiling, not yet nudged, on live rows (main included). */
410export const ceilingDue = (
411 rows: readonly AgentRow[],
412 now: number,
413 ceilingMs: number,
414 engine?: ReadonlyMap<string, string>,
415): { row: AgentRow; shell: Shell }[] =>
416 ceilingMs <= 0
417 ? []
418 : rows
419 .filter(r => (r.status === 'running' || r.status === 'idle' || r.status === 'handing-over') && !engineEnded(r, engine))
420 .flatMap(r =>
421 openShells(r)
422 .filter(s => !s.ceilingNudged && now - s.startedAt >= ceilingMs)
423 .map(shell => ({ row: r, shell })),
424 )
425
426export const markCeiling = (rows: AgentRow[], shellId: string): AgentRow[] =>
427 rows.map(r =>
428 r.shells?.some(s => s.id === shellId)
429 ? { ...r, shells: r.shells.map(s => (s.id === shellId ? { ...s, ceilingNudged: true as const } : s)) }
430 : r,
431 )
432
433export const ceilingNudge = (shell: Shell, minutes: number): string =>
434 `[orchestrator mod] your background command "${shellName(shell)}" (id ${shell.id}) is still running after ${minutes} min; check it (e.g. a suspended process) or stop it (TaskStop).${shell.outputPath ? ` Output: ${shell.outputPath}` : ''}`
435
436/** Open tracked shells the engine's `background_tasks` list no longer names; nothing unless it is a list. */
437export const goneShells = (rows: readonly AgentRow[], tasks: unknown): { agentId: string; shell: Shell }[] => {
438 if (!Array.isArray(tasks)) return []
439 const listed = new Set(
440 tasks.map(t => (t && typeof t === 'object' ? (t as { id?: unknown }).id : undefined)).filter(id => typeof id === 'string'),
441 )
442 return rows.flatMap(r => openShells(r).filter(s => !listed.has(s.id)).map(shell => ({ agentId: r.id, shell })))
443}
444
445export const watchdogNudge = (minutes: number): string =>
446 `[orchestrator mod] you've been idle ${minutes} min with no running children or background commands; if you're waiting on something, check it; otherwise carry on or report.`
447hooks/view.ts 714 lines1import type { SessionMessage, ToolUseSummary } from 'claude-code'
2
3import type { AgentRow, AgentStatus, DisplayStatus, LogEntry, MainPhase, Sample, Transcript, TranscriptItem } from '../types'
4import { fmtTokens, hhmm, label } from './core'
5
6export type { DisplayStatus }
7
8export const MAIN_COLOR = '#6B7280'
9export const PALETTE: readonly string[] = ['#0072B2', '#E69F00', '#009E73', '#CC79A7', '#56B4E9', '#D55E00']
10export const LIMIT_COLOR = '#DC2626'
11
12/** Statuses the pane draws as live. */
13export const LIVE: readonly AgentStatus[] = ['running', 'idle', 'handing-over']
14
15/** Today's terminal header: live, handed-over and (when any) not-replaced subagents, the limits, the main mode and any phase. */
16export const paneHeader = (
17 rows: readonly AgentRow[],
18 o: { agentThreshold: number; mainThreshold: number; mainMode: string; phase: MainPhase },
19): string => {
20 const subs = rows.filter(r => r.id !== 'main')
21 const live = subs.filter(r => LIVE.includes(r.status)).length
22 const replaced = subs.filter(r => r.status === 'replaced').length
23 const failed = subs.filter(r => r.status === 'respawn-failed').length
24 return `${live} live · ${replaced} handed over${failed ? ` · ${failed} not replaced` : ''} · limits ${fmtTokens(o.agentThreshold)} / main ${fmtTokens(o.mainThreshold)} (${o.mainMode}${o.phase !== 'idle' ? `, ${o.phase}` : ''})`
25}
26
27/** Whether either pane offers Hand over main: only in ask mode, and not while main is already handing over. */
28export const mainButtonShown = (mode: string, phase: MainPhase): boolean =>
29 mode === 'ask' && phase !== 'writing' && phase !== 'clearing'
30
31/**
32 * main → MAIN_COLOR; every other row in `rows` order (spawn order) → PALETTE[i % PALETTE.length],
33 * so a later spawn never shifts an earlier agent's colour.
34 */
35export const seriesColors = (rows: readonly AgentRow[]): Map<string, string> => {
36 const out = new Map<string, string>()
37 let i = 0
38 for (const row of rows) {
39 out.set(row.id, row.id === 'main' ? MAIN_COLOR : (PALETTE[i++ % PALETTE.length] ?? MAIN_COLOR))
40 }
41 return out
42}
43
44/** Moved to core (shell nudges use it); still exported here for the pane's callers. */
45export { hhmm }
46
47/** An activity line cut to `fits` characters, ending in an ellipsis. */
48export const cut = (text: string, fits: number): string => `${text.slice(0, Math.max(1, fits - 1))}…`
49
50/** The terminal pane's rows left for an open expansion and the activity: viewport rows less the agent rows, the open transcript box and the frame (header, axis line, `Activity`, slack). */
51export const terminalBase = (o: { rows: number; agents: number; peekRows: number; buttonRow: boolean }): number =>
52 o.rows - o.agents - 8 - (o.buttonRow ? 1 : 0) - o.peekRows
53/** The most text lines an expansion may show: it and its border leave the activity at least 3 lines; 0 (none shown) when the pane is too small. */
54export const expandCap = (base: number): number => {
55 // Under 2 lines the text would be all cut marker: show none.
56 const c = base - 3 - 2
57 return c < 2 ? 0 : c
58}
59/** At most `max` lines; when cut, the last kept line is `…`. */
60export const capLines = (lines: readonly string[], max: number): string[] =>
61 lines.length <= max ? [...lines] : [...lines.slice(0, Math.max(0, max - 1)), '…']
62
63/** Text safe for one pane line: ANSI escapes and control characters removed. */
64const clean = (s: string): string =>
65 s.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
66
67/** What the pane shows when an agent's transcript cannot be read. */
68export const PEEK_DENIED = 'transcript not readable (teammate in another pane, or finished)'
69
70/** Model families the mod names, and the effort levels it abbreviates. */
71const FAMILY = 'opus|sonnet|haiku|fable'
72const EFFORT: Record<string, string> = { low: 'low', medium: 'med', high: 'high', xhigh: 'xhigh', max: 'max' }
73
74const capital = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
75
76/**
77 * A model id or alias as a short display name: `claude-sonnet-5-5` → `Sonnet 5.5`,
78 * `claude-3-5-sonnet-…` → `Sonnet 3.5`, `sonnet` → `Sonnet`, `inherit` → ''.
79 * Context suffixes, provider prefixes, versions and dates are dropped first;
80 * anything else stays as written, cut to 16 characters.
81 */
82export const modelName = (id: string): string => {
83 const raw = id.trim()
84 const s = raw
85 .replace(/\[[^\]]*\]$/, '')
86 .replace(/^.*anthropic\./, '')
87 .replace(/@.*$/, '')
88 .replace(/-v\d+(:\d+)?$/, '')
89 .replace(/-latest$/, '')
90 .replace(/-\d{8}$/, '')
91 .toLowerCase()
92 if (s === '' || s === 'inherit') return ''
93 const modern = new RegExp(`^claude-(${FAMILY})-(\\d+)(?:-(\\d{1,2}))?$`).exec(s)
94 if (modern) return `${capital(modern[1]!)} ${modern[2]}${modern[3] ? `.${modern[3]}` : ''}`
95 const legacy = new RegExp(`^claude-(\\d+)(?:-(\\d))?-(${FAMILY})$`).exec(s)
96 if (legacy) return `${capital(legacy[3]!)} ${legacy[1]}${legacy[2] ? `.${legacy[2]}` : ''}`
97 if (new RegExp(`^(${FAMILY})$`).test(s)) return capital(s)
98 return raw.length > 16 ? cut(raw, 16) : raw
99}
100
101/** An effort level abbreviated (`medium` → `med`), a token budget formatted (`32000` → `32k`), else as written. */
102export const effortAbbrev = (effort: string): string =>
103 EFFORT[effort] ?? (/^\d+$/.test(effort) ? fmtTokens(Number(effort)) : effort)
104
105/** `Sonnet 5.5 med`: '' when the model is unknown, the model alone when the effort is. */
106export const modelLabel = (model?: string, effort?: string): string => {
107 const name = model ? modelName(model) : ''
108 if (name === '') return ''
109 const e = effort ? effortAbbrev(effort) : ''
110 return e ? `${name} ${e}` : name
111}
112
113/** Statuses a child can hold that keep its idle parent waiting. */
114const BUSY: readonly AgentStatus[] = ['running', 'idle', 'handing-over']
115
116/**
117 * What the pane shows for a row: `waiting` when it is idle and a child (its
118 * `parentId` is this row's id) is running, idle or handing over, else its own
119 * status. An idle child counts, so waiting holds up a whole chain. Display only:
120 * every handover, stall and sync check keeps reading `row.status`.
121 */
122export const displayStatus = (row: AgentRow, rows: readonly AgentRow[]): DisplayStatus =>
123 row.status === 'idle' && rows.some(r => r.parentId === row.id && r.id !== row.id && BUSY.includes(r.status))
124 ? 'waiting'
125 : row.status
126
127/** The terminal's glyph and status-word style per status, in theme keys so it follows the theme. */
128export const statusStyle = (status: DisplayStatus): { color: string; dimColor?: true; bold?: true } => {
129 switch (status) {
130 case 'waiting':
131 return { color: 'planMode' }
132 case 'running':
133 return { color: 'success', bold: true }
134 case 'idle':
135 return { color: 'warning', dimColor: true }
136 case 'done':
137 return { color: 'success', dimColor: true }
138 case 'stalled':
139 return { color: 'error', bold: true }
140 case 'handing-over':
141 return { color: 'suggestion', bold: true }
142 case 'replaced':
143 return { color: 'inactive', dimColor: true }
144 case 'respawn-failed':
145 return { color: 'error', bold: true }
146 }
147}
148
149/** djb2 over UTF-16 code units, as an unsigned 32-bit number. */
150const djb2 = (s: string): number => {
151 let h = 5381
152 for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
153 return h >>> 0
154}
155
156/** djb2 of a string, base 36. */
157export const hashText = (s: string): string => djb2(s).toString(36)
158
159/** A running agent's runner, fixed per id: 🏃♂️ for an even hash, 🏃♀️ for an odd one. */
160export const runnerFor = (id: string): string => (djb2(id) % 2 === 0 ? '🏃♂️' : '🏃♀️')
161
162/** The desktop's status emoji, placed before the status word. */
163export const statusEmoji = (status: DisplayStatus, id: string): string => {
164 switch (status) {
165 case 'waiting':
166 return '⏳'
167 case 'running':
168 return runnerFor(id)
169 case 'idle':
170 return '😴'
171 case 'done':
172 return '✅'
173 case 'stalled':
174 return '🛑'
175 case 'handing-over':
176 return '📝'
177 case 'replaced':
178 return '♻️'
179 case 'respawn-failed':
180 return '⚠️'
181 }
182}
183
184/** Text safe for a Code, Markdown or Text body: ANSI escapes and every control character but `\n` and `\t` removed. */
185export const cleanBlock = (s: string): string =>
186 s
187 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
188 .replace(/(?:\x1b\[|\x9b)[0-?]*[ -\/]*[@-~]/g, '')
189 .replace(/\r\n/g, '\n')
190 .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
191
192/** Text as one pane line: control characters stripped, then whitespace collapsed and trimmed. */
193const oneLine = (s: string): string => clean(cleanBlock(s).replace(/\s+/g, ' ')).trim()
194
195/**
196 * The key a message's text is stamped and looked up under: `txt:` plus the hash of
197 * its role and its whole one-line text, so whitespace and escapes do not change it.
198 */
199export const textKey = (role: string, text: string): string => `txt:${hashText(`${role}:${oneLine(text)}`)}`
200
201/** `$.session.messages` gives at most this many entries; a list this long may have lost its first (the brief). */
202const MESSAGES_CAP = 4096
203
204/** The longest tool brief kept. */
205const BRIEF_CHARS = 300
206
207/** The input field each tool is summed up by; other tools use their first string field. */
208const BRIEF_FIELD: Record<string, string> = {
209 Bash: 'command',
210 Read: 'file_path',
211 Edit: 'file_path',
212 Write: 'file_path',
213 Grep: 'pattern',
214 Glob: 'pattern',
215 Agent: 'description',
216}
217
218/** A tool call in one line: Bash's command, a file's path, a search's `pattern in path`, … capped at 300. */
219export const toolBrief = (tool: string, input: Record<string, unknown>): string => {
220 const field = BRIEF_FIELD[tool]
221 const named = field !== undefined ? input[field] : undefined
222 let brief = typeof named === 'string' ? named : (Object.values(input).find(v => typeof v === 'string') as string | undefined) ?? ''
223 if ((tool === 'Grep' || tool === 'Glob') && typeof named === 'string' && typeof input.path === 'string' && input.path !== '') {
224 brief = `${named} in ${input.path}`
225 }
226 const line = oneLine(brief)
227 return line.length > BRIEF_CHARS ? cut(line, BRIEF_CHARS) : line
228}
229
230/**
231 * An agent's last `n` transcript items: per message, its text (cleaned, newlines kept,
232 * capped at `chars`) then one item per tool call. A user row of tool results alone adds
233 * nothing; its outcome is on the call. It reads from the end and stops once it has
234 * `n` items. Times come from `stampOf` (a call's id, a text's `textKey`, else the
235 * message's first call), message 0 (the brief, unless the list hit the 4096 cap)
236 * falls back to `startedAt`, and an
237 * item never seen has no `t`. A repeated text's keys are numbered over the whole list.
238 */
239export const transcriptItems = (
240 messages: readonly SessionMessage[],
241 stampOf: (key: string) => number | readonly number[] | undefined,
242 o: { n: number; chars: number; startedAt: number },
243): TranscriptItem[] => {
244 const groups: TranscriptItem[][] = []
245 let count = 0
246 let occ: Occurrences | undefined
247 /** A single stamp, or the newest of a list. */
248 const one = (key: string): number | undefined => {
249 const s = stampOf(key)
250 return typeof s === 'number' ? s : s?.[s.length - 1]
251 }
252 /** Occurrence `n` of `total` of a text: a single stamp serves all; a list is aligned from the end. */
253 const nthStamp = (key: string, n: number, total: number): number | undefined => {
254 const s = stampOf(key)
255 if (s === undefined || typeof s === 'number') return s
256 return s[n - (total - s.length)]
257 }
258 for (let i = messages.length - 1; i >= 0 && count < o.n; i--) {
259 const m = messages[i]!
260 const uses: readonly ToolUseSummary[] = m.toolUses
261 const fallback = i === 0 && messages.length < MESSAGES_CAP ? o.startedAt : undefined
262 const group: TranscriptItem[] = []
263 if (oneLine(m.text) !== '') {
264 occ ??= occurrences(messages)
265 const key = occ.keyOf(i)
266 const n = occ.nth[i]!
267 const t = nthStamp(key, n, occ.total.get(key) ?? n + 1) ?? (uses[0] ? one(uses[0].tool_use_id) : undefined) ?? fallback
268 const text = cleanBlock(m.text).trim()
269 const capped = text.length > o.chars ? cut(text, o.chars) : text
270 group.push({ kind: 'text', key: `${key}#${n}`, ...(t !== undefined && { t }), role: m.role, text: capped })
271 }
272 for (const u of uses) {
273 const t = one(u.tool_use_id) ?? fallback
274 group.push({
275 kind: 'tool',
276 key: `tool:${u.tool_use_id}`,
277 ...(t !== undefined && { t }),
278 toolUseId: u.tool_use_id,
279 tool: clean(u.tool),
280 brief: toolBrief(u.tool, u.input ?? {}),
281 ...(u.isError && { isError: true as const }),
282 ...(u.text === undefined && { isPending: true as const }),
283 })
284 }
285 if (group.length) groups.push(group)
286 count += group.length
287 }
288 return groups.reverse().flat().slice(-o.n)
289}
290
291type Occurrences = { nth: number[]; total: Map<string, number>; keyOf: (i: number) => string }
292
293/**
294 * Per message, how many earlier messages carry the same text key, counted over the whole list in
295 * one pass so a repeat's key stays put as the window slides, and each key's total. A text's key is
296 * hashed once per distinct raw text, not once per message.
297 */
298const occurrences = (messages: readonly SessionMessage[]): Occurrences => {
299 const byRaw = new Map<string, string>()
300 const seen = new Map<string, number>()
301 const keys: string[] = []
302 const nth = messages.map((m, i) => {
303 if (m.text === '') return 0
304 const raw = `${m.role}\u0000${m.text}`
305 let key = byRaw.get(raw)
306 if (key === undefined) byRaw.set(raw, (key = textKey(m.role, m.text)))
307 keys[i] = key
308 const k = seen.get(key) ?? 0
309 seen.set(key, k + 1)
310 return k
311 })
312 return { nth, total: seen, keyOf: i => keys[i] ?? textKey(messages[i]!.role, messages[i]!.text) }
313}
314
315export type TranscriptLine = {
316 id: string
317 time: string
318 short: string
319 full?: string
320 kind: TranscriptItem['kind']
321 toolUseId?: string
322 isError?: true
323 isPending?: true
324 role?: 'user' | 'assistant'
325}
326
327/**
328 * What the pane draws for a selected agent's cached transcript: one dim line
329 * (`loading…`, the not-readable line, or `no messages yet`), or its last `n`
330 * items, each with its `hh:mm` (or `--:--`) and its content on one line, cut to
331 * `width - 6` less any tool markers; `full` holds the whole content (newlines
332 * kept) only when it was cut.
333 */
334export const transcriptView = (
335 tr: Transcript | undefined,
336 width: number,
337 n: number,
338): { dim: string } | { items: TranscriptLine[] } => {
339 if (!tr) return { dim: 'loading…' }
340 if ('denied' in tr) return { dim: PEEK_DENIED }
341 if (tr.items.length === 0) return { dim: 'no messages yet' }
342 const fits = Math.max(1, width - 6)
343 return {
344 items: tr.items.slice(-n).map(it => {
345 const content = it.kind === 'text' ? it.text : `⚙ ${it.tool}${it.brief ? ` ${it.brief}` : ''}`
346 const line = it.kind === 'text' ? oneLine(content) : content
347 // Room for the markers drawn around a tool item: `✗ ` before it, ` (running)` after.
348 // A user text's `» ` takes two more.
349 const room =
350 it.kind === 'tool'
351 ? Math.max(1, fits - (it.isError ? 2 : 0) - (it.isPending ? 10 : 0))
352 : Math.max(1, fits - (it.role === 'user' ? 2 : 0))
353 const isCut = line.length > room
354 return {
355 id: `step-${it.key}`,
356 time: it.t === undefined ? '--:--' : hhmm(it.t),
357 short: isCut ? cut(line, room) : line,
358 ...(isCut && { full: content }),
359 kind: it.kind,
360 ...(it.kind === 'text'
361 ? { role: it.role }
362 : { toolUseId: it.toolUseId, ...(it.isError && { isError: it.isError }), ...(it.isPending && { isPending: it.isPending }) }),
363 }
364 }),
365 }
366}
367
368/** The context limit a row is held to: main's for main, the agents' otherwise. */
369export const ceilingOf = (r: AgentRow, t: { agentThreshold: number; mainThreshold: number }): number =>
370 r.id === 'main' ? t.mainThreshold : t.agentThreshold
371
372/** `log-${t}-${text.length}`: the id the terminal card already uses. */
373export const logId = (l: LogEntry): string => `log-${l.t}-${l.text.length}`
374
375/** clamp(bodyColumns * 8, 320, 1200) */
376export const chartWidthPx = (bodyColumns: number): number => Math.min(1200, Math.max(320, bodyColumns * 8))
377
378export type ChartInput = {
379 rows: readonly AgentRow[]
380 now: number
381 width: number // CSS px
382 height: number // CSS px
383 agentThreshold: number
384 mainThreshold: number
385 colors: ReadonlyMap<string, string>
386 selected?: string // agent id; '' or undefined = none
387 /** The left edge of the time axis; defaults to the earliest start among `rows` (or `now`). */
388 t0?: number
389}
390
391const M = { left: 44, right: 8, top: 8, bottom: 20 }
392const AXIS = `fill="${MAIN_COLOR}" font-family="system-ui, sans-serif" font-size="10"`
393
394const esc = (s: string): string =>
395 s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c)
396
397const r1 = (n: number) => Math.round(n)
398
399/** At most `max` samples: first and last kept, each bucket between reduced to its peak. */
400const downsample = (s: readonly Sample[], max: number): readonly Sample[] => {
401 if (s.length <= max || max < 2) return s
402 const inner = s.slice(1, -1)
403 const buckets = max - 2
404 const out: Sample[] = [s[0]!]
405 for (let b = 0; b < buckets; b++) {
406 const from = Math.floor((b * inner.length) / buckets)
407 const to = Math.floor(((b + 1) * inner.length) / buckets)
408 let peak: Sample | undefined
409 for (let i = from; i < to; i++) if (!peak || inner[i]!.tokens > peak.tokens) peak = inner[i]
410 if (peak) out.push(peak)
411 }
412 out.push(s[s.length - 1]!)
413 return out
414}
415
416/** Points shared by every series, so many agents still fit the Svg source limit. */
417const POINT_BUDGET = 6000
418/** The Svg element's source limit on remote surfaces. */
419const SOURCE_LIMIT = 131_072
420/** Tooltip labels are cut to this many characters. */
421const TITLE_LABEL = 80
422
423/** The context chart for remote surfaces: one step-after line per agent over a shared time axis. */
424export const contextChart = (i: ChartInput): { source: string; alt: string; width: number; height: number } => {
425 const W = i.width
426 const H = i.height
427 const plotW = W - M.left - M.right
428 const plotH = H - M.top - M.bottom
429 const right = M.left + plotW
430 const bottom = M.top + plotH
431 const series = i.rows.filter(r => r.samples.length > 0)
432 const t0 = i.t0 ?? i.rows.reduce((m, r) => Math.min(m, r.startedAt), i.now)
433 const span = Math.max(1, i.now - t0)
434 const peak = series.reduce((m, r) => Math.max(m, r.peak), 0)
435 const yMax = Math.max(i.agentThreshold, i.mainThreshold, peak, 1) * 1.05
436 const x = (t: number) => r1(Math.min(right, Math.max(M.left, M.left + ((t - t0) / span) * plotW)))
437 const y = (v: number) => r1(M.top + plotH * (1 - Math.min(1, Math.max(0, v / yMax))))
438 const ceiling = (r: AgentRow) => ceilingOf(r, i)
439 const sel = i.selected ?? ''
440
441 const head: string[] = [
442 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img">`,
443 `<line x1="${M.left}" y1="${bottom}" x2="${right}" y2="${bottom}" stroke="${MAIN_COLOR}" stroke-width="1"/>`,
444 `<text x="${M.left - 4}" y="${bottom}" text-anchor="end" ${AXIS}>0</text>`,
445 `<text x="${M.left}" y="${H - 4}" ${AXIS}>${hhmm(t0)}</text>`,
446 `<text x="${right}" y="${H - 4}" text-anchor="end" ${AXIS}>${hhmm(i.now)}</text>`,
447 ]
448
449 const limits =
450 i.agentThreshold === i.mainThreshold
451 ? [{ v: i.agentThreshold, text: `limit ${fmtTokens(i.agentThreshold)}` }]
452 : [
453 { v: i.agentThreshold, text: `agent limit ${fmtTokens(i.agentThreshold)}` },
454 { v: i.mainThreshold, text: `main limit ${fmtTokens(i.mainThreshold)}` },
455 ]
456 for (const l of limits) {
457 const ly = y(l.v)
458 head.push(
459 `<line x1="${M.left}" y1="${ly}" x2="${right}" y2="${ly}" stroke="${LIMIT_COLOR}" stroke-width="1" stroke-dasharray="4 3"/>`,
460 `<text x="${M.left + 2}" y="${ly - 2}" ${AXIS}>${esc(l.text)}</text>`,
461 )
462 }
463
464 const maxPoints = Math.max(2, Math.min(Math.floor(plotW / 3), Math.floor(POINT_BUDGET / Math.max(1, series.length))))
465 type Drawn = { path: string; circle: string; title: string }
466 const len = (s: Drawn, withCircleTitle: boolean) =>
467 s.path.length + s.circle.length + '</circle>'.length + (withCircleTitle ? s.title.length : 0)
468 const drawn = new Map<AgentRow, Drawn>()
469 for (const r of series) {
470 const pts = downsample(r.samples, maxPoints)
471 const first = pts[0]!
472 let d = `M${x(first.t)} ${y(first.tokens)}`
473 let lastY = y(first.tokens)
474 for (const p of pts.slice(1)) {
475 const py = y(p.tokens)
476 d += `H${x(p.t)}`
477 if (py !== lastY) d += `V${py}`
478 lastY = py
479 }
480 if (LIVE.includes(r.status)) d += `H${right}`
481 const last = pts[pts.length - 1]!
482 const color = i.colors.get(r.id) ?? MAIN_COLOR
483 const isSel = sel !== '' && r.id === sel
484 const width = isSel ? '3' : '1.5'
485 const opacity = sel !== '' && !isSel ? '0.3' : '1'
486 const name = label(r)
487 const shown = name.length > TITLE_LABEL ? cut(name, TITLE_LABEL) : name
488 const title = `<title>${esc(`${shown} · ${fmtTokens(r.tokens)} / ${fmtTokens(ceiling(r))} · ${r.status}`)}</title>`
489 drawn.set(r, {
490 path: `<path d="${d}" fill="none" stroke="${color}" stroke-width="${width}" opacity="${opacity}">${title}</path>`,
491 circle: `<circle cx="${x(last.t)}" cy="${y(last.tokens)}" r="3" fill="${color}" opacity="${opacity}">`,
492 title,
493 })
494 }
495
496 // Still past the limit: drop the oldest finished series until it fits; live ones always stay.
497 const tail = '</svg>'
498 let size = head.join('').length + tail.length + [...drawn.values()].reduce((n, s) => n + len(s, true), 0)
499 const droppable = series.filter(r => !LIVE.includes(r.status)).sort((a, b) => a.startedAt - b.startedAt)
500 let dropped = 0
501 for (const r of droppable) {
502 if (size < SOURCE_LIMIT) break
503 const s = drawn.get(r)
504 size -= s ? len(s, true) : 0
505 drawn.delete(r)
506 dropped++
507 }
508 // Live series alone still too long: the circles give up their tooltips; the lines keep theirs.
509 const circleTitles = size < SOURCE_LIMIT
510
511 const kept = series.filter(r => drawn.has(r))
512 const parts = kept.map(r => `${label(r)} ${fmtTokens(r.tokens)} of ${fmtTokens(ceiling(r))}`)
513 if (dropped) parts.push(`${dropped} finished agent${dropped === 1 ? '' : 's'} not drawn`)
514 const alt = series.length ? `Context over time: ${parts.join('; ')}` : 'No model requests yet.'
515 const body = kept.map(r => {
516 const s = drawn.get(r)!
517 return `${s.path}${s.circle}${circleTitles ? s.title : ''}</circle>`
518 })
519 return { source: [...head, ...body, tail].join(''), alt, width: W, height: H }
520}
521
522/**
523 * Whether a `SendMessage` `to` names this row: its id or name, or `main` for the main thread. A
524 * trailing ` [ref]` (as ListAgents prints a row) is ignored.
525 */
526export const namesAgent = (to: unknown, row: Pick<AgentRow, 'id' | 'name' | 'teammateId'>): boolean => {
527 if (typeof to !== 'string') return false
528 const name = to.replace(/\s*\[[^\]]*\]$/, '').trim()
529 if (name === '') return false
530 if (row.id === 'main') return name === 'main'
531 if (name === row.id || name === row.name) return true
532 // A teammate by its address: its teammateId, or its registered name at that address's team.
533 const tid = row.teammateId
534 if (typeof tid !== 'string' || !tid.includes('@')) return false
535 return name === tid || (row.name !== undefined && name === `${row.name}@${tid.slice(tid.lastIndexOf('@') + 1)}`)
536}
537
538/**
539 * The agent `target` means among `rows`: its id; else its registered name (the name SendMessage uses),
540 * a live holder first, else the newest; else the name it asked for at spawn (`brief.name`), when that
541 * picks one agent (or one live agent). A trailing ` [ref]` is ignored. `ambiguous` lists the candidates.
542 */
543export const resolveAgent = (
544 rows: readonly AgentRow[],
545 target: string,
546): { row: AgentRow } | { ambiguous: AgentRow[] } | undefined => {
547 const t = target.replace(/\s*\[[^\]]*\]$/, '').trim()
548 if (t === '') return undefined
549 const byId = rows.find(r => r.id === t)
550 if (byId) return { row: byId }
551 const isLive = (r: AgentRow) => LIVE.includes(r.status)
552 const named = rows.filter(r => r.name === t)
553 const holder = named.filter(isLive).at(-1) ?? named.at(-1)
554 if (holder) return { row: holder }
555 const asked = rows.filter(r => r.brief?.name === t)
556 if (asked.length === 0) return undefined
557 if (asked.length === 1) return { row: asked[0]! }
558 const live = asked.filter(isLive)
559 if (live.length === 1) return { row: live[0]! }
560 return { ambiguous: live.length ? live : asked }
561}
562
563/** The newest agent in an id's `replacedBy` chain: whoever now carries its work (the id itself when never replaced). */
564export const latest = (rows: readonly AgentRow[], id: string): string => {
565 const seen = new Set<string>()
566 let cur = id
567 while (!seen.has(cur)) {
568 seen.add(cur)
569 const next = rows.find(r => r.id === cur)?.replacedBy
570 if (next === undefined) break
571 cur = next
572 }
573 return cur
574}
575
576/**
577 * The agent a SendMessage `to` should reach when it names a replaced agent: the newest row `to` names
578 * (by id, name or teammate address, as namesAgent does) that is `replaced` with a `replacedBy`, followed
579 * to its `latest`. Undefined, so the message goes as addressed, when `to` also names a live agent (the
580 * engine routes a name to its live holder), when a newer agent not replaced holds it, when nothing replaced matches, when the replacement itself
581 * answers to `to`, or when the sender is that replacement.
582 */
583export const redirectTarget = (rows: readonly AgentRow[], to: unknown, sender?: string): AgentRow | undefined => {
584 if (typeof to !== 'string') return undefined
585 if (rows.some(r => LIVE.includes(r.status) && namesAgent(to, r))) return undefined
586 const old = rows
587 .filter(r => r.status === 'replaced' && r.replacedBy !== undefined && namesAgent(to, r))
588 .sort((a, b) => b.startedAt - a.startedAt)[0]
589 if (!old) return undefined
590 // A newer agent registered under that name, finished or not, is who the name means now.
591 if (rows.some(r => r.id !== old.id && r.status !== 'replaced' && r.startedAt > old.startedAt && namesAgent(to, r))) return undefined
592 const dest = rows.find(r => r.id === latest(rows, old.id))
593 // A replacement registered under the old name already receives it as addressed.
594 if (!dest || dest.id === old.id || dest.id === sender || namesAgent(to, dest)) return undefined
595 return dest
596}
597
598/** How to address `dest` in place of `to`: its teammate address when `to` was one, else its registered name, else its id. */
599export const addressFor = (dest: AgentRow, to: string): string =>
600 to.includes('@') && dest.teammateId ? dest.teammateId : (dest.name ?? dest.id)
601
602/**
603 * Whether two reports say the same: equal once whitespace is collapsed, or one holding the
604 * other and at least 80% of its length.
605 */
606export const sameReport = (a: string, b: string): boolean => {
607 const x = oneLine(a)
608 const y = oneLine(b)
609 if (x === '' || y === '') return false
610 if (x === y) return true
611 const [short, long] = x.length <= y.length ? [x, y] : [y, x]
612 return long.includes(short) && short.length >= long.length * 0.8
613}
614
615/** Rows whose check-in deadline has come. */
616export const checkinDue = (rows: readonly AgentRow[], now: number): AgentRow[] =>
617 rows.filter(r => r.checkinAt !== undefined && r.checkinAt <= now)
618
619/**
620 * Idle agents the idle watchdog nudges: never main, only one the engine too lists idle (not ended, not
621 * held on its own work), with no busy child and no background command, idle `idleMs` or longer. Once
622 * nudged, not again until a run besides the nudge's own has ended (`ends` 2). `idleMs` 0: none.
623 */
624export const watchdogDue = (rows: readonly AgentRow[], engine: ReadonlyMap<string, string>, now: number, idleMs: number): AgentRow[] =>
625 idleMs <= 0
626 ? []
627 : rows.filter(
628 r =>
629 r.id !== 'main' &&
630 r.status === 'idle' &&
631 engine.get(r.id) === 'idle' &&
632 displayStatus(r, rows) === 'idle' &&
633 !r.shells?.length &&
634 now - r.lastActive >= idleMs &&
635 (r.watchdog === undefined || r.watchdog.ends >= 2),
636 )
637
638/** The patch counting one more turn end since the idle watchdog's nudge; undefined (nothing to patch) without one. */
639export const watchdogTurnEnded = (w?: AgentRow['watchdog']) => w && { watchdog: { ...w, ends: w.ends + 1 } }
640
641/**
642 * Whether a child's final answer goes to its parent agent, and whether it may repeat what the child
643 * already sent. Skipped: a parent that is main or unknown (the engine tells main), a teammate or a
644 * foreground child (its answer is delivered), a handover, an empty or unfinished answer, and a run
645 * whose delivered report to the parent was its last act or says the same as the answer. A lost
646 * report costs more than a duplicate, so anything else is forwarded, to the parent's `latest`.
647 */
648export const forwardTarget = (
649 row: AgentRow,
650 answer: string,
651 reason: string,
652 rows: readonly AgentRow[] = [],
653): 'skip' | { agentId: string; mayDuplicate: boolean } => {
654 const parent = row.parentId
655 if (parent === undefined || parent === 'main') return 'skip'
656 // A replacement's own spawn was the mod's, so its answer reaches no one unless forwarded, foreground or not.
657 if (row.isTeammate || (row.background === false && row.replaces === undefined)) return 'skip'
658 if (row.status === 'handing-over' || row.status === 'stalled') return 'skip'
659 if (reason !== 'answer' || answer.trim() === '') return 'skip'
660 const r = row.report
661 if (r?.ok && (r.final || sameReport(r.text, answer))) return 'skip'
662 // A parent that handed over is carried on by its replacement: the answer goes there.
663 return { agentId: latest(rows, parent), mayDuplicate: r !== undefined }
664}
665
666/** A row's children: rows whose `parentId` is its id; main's also include rows with no parent. */
667export const childrenOf = (rows: readonly AgentRow[], id: string): AgentRow[] =>
668 rows.filter(r => r.id !== id && r.id !== 'main' && (r.parentId === id || (id === 'main' && r.parentId === undefined)))
669
670/** Every descendant of a row, each once (cycle-safe). */
671const descendants = (rows: readonly AgentRow[], id: string): AgentRow[] => {
672 const out: AgentRow[] = []
673 const seen = new Set<string>([id])
674 const walk = (at: string) => {
675 for (const c of childrenOf(rows, at)) {
676 if (seen.has(c.id)) continue
677 seen.add(c.id)
678 out.push(c)
679 walk(c.id)
680 }
681 }
682 walk(id)
683 return out
684}
685
686const FINISHED: readonly AgentStatus[] = ['done', 'replaced']
687
688/**
689 * The rows a collapsed tree hides: for each collapsed id naming a present row, its finished (done or
690 * replaced) descendants that have no live descendant of their own. Nothing live is ever hidden, and a
691 * finished agent above a live one stays, so the tree never orphans a live row. Display only.
692 */
693export const collapseHidden = (rows: readonly AgentRow[], collapsed: readonly string[]): Set<string> => {
694 const hidden = new Set<string>()
695 for (const id of collapsed) {
696 if (!rows.some(r => r.id === id)) continue
697 for (const d of descendants(rows, id)) {
698 if (FINISHED.includes(d.status) && descendants(rows, d.id).every(x => FINISHED.includes(x.status))) hidden.add(d.id)
699 }
700 }
701 return hidden
702}
703
704/** How many of a row's descendants are hidden. */
705export const hiddenCount = (rows: readonly AgentRow[], id: string, hidden: ReadonlySet<string>): number =>
706 descendants(rows, id).filter(d => hidden.has(d.id)).length
707
708/** Whether a row offers to fold: it is collapsed (so it can expand), or collapsing it would hide something. */
709export const canFold = (rows: readonly AgentRow[], id: string, collapsed: readonly string[]): boolean =>
710 collapsed.includes(id) || collapseHidden(rows, [id]).size > 0
711
712/** The disclosure glyph: `▸` when collapsed, `▾` when not. */
713export const foldGlyph = (isCollapsed: boolean): string => (isCollapsed ? '▸' : '▾')
714types/index.d.ts 178 lines1/** One reading of an agent's context: when, and how many tokens it held. */
2export type Sample = { t: number; tokens: number }
3
4/** What an agent was spawned with, kept so a replacement can be spawned alike. */
5export type Brief = {
6 prompt: string
7 subagentType: string
8 description: string
9 model?: string
10 name?: string
11}
12
13/** `respawn-failed`: handed over with its note written, but no replacement started to carry its work. */
14export type AgentStatus = 'running' | 'idle' | 'handing-over' | 'replaced' | 'done' | 'stalled' | 'respawn-failed'
15
16/** What the pane shows for a row: its status, or `waiting` for an idle row with live descendants. Never stored. */
17export type DisplayStatus = AgentStatus | 'waiting'
18
19/** How a background shell ended: its exit code, `killed` (TaskStop, or claude's graceful exit), or `unknown` (the engine stopped listing it and its file has no marker). */
20export type ShellExit = number | 'killed' | 'unknown'
21
22/** A Bash command an agent sent to the background, as the mod tracks it. */
23export type Shell = {
24 /** The result's backgroundTaskId. */
25 id: string
26 /** From the result's text; '' when it names none. */
27 outputPath: string
28 description: string
29 command: string
30 startedAt: number
31 endedAt?: number
32 exit?: ShellExit
33 /** The one ceiling nudge (or main's warning) was given. */
34 ceilingNudged?: true
35}
36
37/** One agent loop as the mod tracks it; the main thread is the row with id `main`. */
38export type AgentRow = {
39 id: string
40 name?: string
41 description: string
42 type: string
43 /** The loop that spawned it (for a replacement, its predecessor's parent). */
44 parentId?: string
45 status: AgentStatus
46 tokens: number
47 peak: number
48 samples: Sample[]
49 steps: number
50 tools: number
51 startedAt: number
52 lastActive: number
53 brief?: Brief
54 notePath?: string
55 handoverAt?: number
56 /**
57 * How the HANDOVER reached it: with a tool result while it ran, or as a message
58 * once idle (which a teammate only reads between turns). Absent until it has.
59 */
60 handoverVia?: 'tool' | 'message'
61 replaces?: string
62 replacedBy?: string
63 /** Spawned by this mod: its final answer is forwarded to `parentId`. */
64 isOurs?: boolean
65 /** A named teammate: the engine delivers its final reply to its parent, so the mod does not. */
66 isTeammate?: boolean
67 /** A teammate's address in its team, `<name>@<team>`, as the spawn result or the engine's list gave it. */
68 teammateId?: string
69 /** How many handovers this row has started; numbers its note paths. */
70 handovers?: number
71 /** A replacement that started too full for another handover to help: left to run. */
72 noHandover?: boolean
73 /** Spawned in the background (false: its answer is its parent's tool result). Absent when unknown. */
74 background?: boolean
75 /**
76 * This run's report to its parent, as last seen: a `SendMessage` naming the parent. A handback may reach
77 * main rather than the parent, so it is kept as never `ok`, only to flag a forward as a possible duplicate.
78 * `ok` when it succeeded; `final` while it is still the run's last tool call.
79 * Cleared when the run ends, so each run is judged on its own.
80 */
81 report?: { t: number; text: string; ok: boolean; final: boolean }
82 /** A check-in deadline its parent armed through the `checkin` tool, its length, and who armed it. */
83 checkinAt?: number
84 checkinMins?: number
85 checkinBy?: string
86 /** The model its last step ran on (the answering id, else the requested one). */
87 model?: string
88 /** Its last step's effort, as a string (`'medium'`, or a token budget such as `'32000'`). */
89 effort?: string
90 /** When a step last revived it from done: it was resumed by a message after it ended. */
91 resumedAt?: number
92 /** Its open background shells, and finished ones not yet picked up. */
93 shells?: Shell[]
94 /** When its last open shell ended. */
95 shellsDoneAt?: number
96 /** The idle watchdog's nudge, and how many of its turns have ended since. */
97 watchdog?: { t: number; ends: number }
98}
99
100export type LogEntry = { t: number; text: string; level: 'info' | 'warn' }
101
102/**
103 * One entry of an agent's transcript as the pane shows it: a message's text, or
104 * one of its tool calls. `key` is stable across reads; `t` is when the mod saw it
105 * (absent when it never did).
106 */
107export type TranscriptItem =
108 | { kind: 'text'; key: string; t?: number; role: 'user' | 'assistant'; text: string }
109 | { kind: 'tool'; key: string; t?: number; toolUseId: string; tool: string; brief: string; isError?: true; isPending?: true }
110
111/** An agent's transcript items as the pane last read them, off the render, or `denied`. */
112export type Transcript = { items: TranscriptItem[] } | { denied: true }
113
114/**
115 * One part of a tool call's detail, as `hooks/detail.ts` builds it. Declared here too because this
116 * contract must be self-contained; the two are structurally the same, so tsc checks they agree.
117 */
118export type DetailSection =
119 | { kind: 'code'; label: string; source: string; language?: string; path?: string; startLine?: number; diff?: true }
120 | { kind: 'markdown'; label: string; text: string }
121 | { kind: 'text'; label: string; text: string }
122
123/** A tool call laid out for the detail dialog (`toolDetail` in `hooks/detail.ts`). */
124export type ToolDetail = { tool: string; time: string; isError: boolean; isPending: boolean; sections: DetailSection[] }
125
126/**
127 * The tool call the detail dialog shows: which call, whether it is drawn inline in the
128 * main pane (no surface placed the dialog), and its detail once read, or `denied`.
129 */
130export type ToolView = { agentId: string; toolUseId: string; inline: boolean; detail?: ToolDetail; denied?: true }
131
132/**
133 * A message or spawn the mod could not safely make where it decided to. A run woken (or started) from
134 * a timer runs without the mod's hooks, and one woken from inside a hook registration runs without that
135 * registration; so such work is queued and a drain-only turn.complete registration (or /orch) does it.
136 */
137export type Delivery = {
138 id: string
139 /** A message (`send`, the default), a HANDOVER (its stall clock starts once sent), or a respawn of `agentId`. */
140 kind?: 'send' | 'handover' | 'respawn'
141 agentId: string
142 text: string
143 /** What the warning says if it fails, e.g. `check-in for impl-2 not delivered`. */
144 what: string
145 t: number
146 /** For a respawn: the replaced row as it ended, check-in included, for the replacement to inherit. */
147 row?: AgentRow
148 /** A message only worth sending while its agent is live: dropped unsent once it is not, so it wakes no finished agent. */
149 liveOnly?: true
150}
151
152export type MainPhase = 'idle' | 'offered' | 'writing' | 'clearing' | 'cooldown'
153
154declare module 'claude-code' {
155 interface PluginState {
156 orchestrator: {
157 agents: AgentRow[]
158 log: LogEntry[]
159 mainPhase: MainPhase
160 runId: string
161 /** The one cut line shown whole: an activity line (`log-…`) or a transcript text (`step-<agentId>-…`); '' for none. */
162 pinnedLog: string
163 /** The agent a click selected: its chart line highlighted, its transcript shown beneath its row ('' for none). */
164 selectedAgent: string
165 /** Each agent's last transcript items by id, read off the render; the pane draws the selected one's. */
166 transcripts: Record<string, Transcript>
167 /** Messages queued by timers, delivered by the next hook to run. */
168 outbox: Delivery[]
169 /** Rows collapsed by the fold glyph or `c`: their finished descendants are hidden from the pane (display only). */
170 collapsed: string[]
171 /** The tool call open in the detail dialog, or null. */
172 toolView: ToolView | null
173 /** A main handover asked for mid-turn, waiting for that turn to end. */
174 mainQueued: boolean
175 }
176 }
177}
178