SLOPSHOPPER

orchestrator

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.

newpanebandguardcommandtool
★ 1v0.3.8MITupdated 2026-10-12victor-bajanov/cc-orchestrator-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orchestrator
│ ┃ Orchestrator ✕ › fix the failing auth test and add an audit log call │ ┃ 0 live · 0 handed over · limits 450k / main │ ┃ 450k (ask) ⏺ Read(src/auth.ts) │ ┃ No model requests yet. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 08:5308:53 ⎿ Added 2 lines, removed 1 line │ ┃ Activity ⏺ Bash(bun test) │ ┃ Nothing yet. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /orch │ ⎿ orchestrator: Orchestrator pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Orchestrator
0 live · 0 handed over · limits 450k / main 450k (ask) [ Han No model requests yet. 08:5308:53 Activity Nothing yet.
Pane · orchestrator-tool
No tool call selected. [ Close ]
README

orchestrator: a Claude Code mod for long agent runs

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.

What it does

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):

  1. The mod gives it 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.
  2. When the agent ends with that note written, the mod spawns a replacement. The replacement gets the original brief, the same type, model and name, plus "start from the handover note at …". The engine suffixes a teammate's name while the original holds 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>\.
  3. The mod tells the old agent's parent about the swap, with the replacement's id and SendMessage name. A parent agent gets a message; the main thread gets a note in its conversation. The note says no action is needed, so the parent doesn't spawn a second replacement. The agent's own final reply says the mod will spawn it, and that the parent should act only if the mod reports that none started.
  4. Once the replacement has started, the mod stops the old agent (TaskStop), and moves its children under the replacement in the pane, the roster and check-ins. A SendMessage that still names the old agent, by name, id or teammate address, from any agent or main, is sent to the newest replacement instead, and the sender reads [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.
  5. When a replacement's original parent was the main thread, the mod forwards the replacement's final report there. A teammate's final report already reaches it, so that one isn't forwarded.

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:

  1. It writes a handover note.
  2. The mod runs /clear.
  3. The mod submits a fresh prompt containing the note, the live agents (name, id, type, status, context) and the roster path.

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:

  • a Hand over main button in the header (with mainHandover: ask; hidden while main is handing over)
  • a tree of agents (main, then each agent under its parent); the agent you opened from /tasks is shown inverted
  • each row's status glyph and status word in the status colour: running ● 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 theme
  • each row's model and effort, such as Sonnet 5.5 med (the model alone when the engine reports no effort)
  • click a subagent's name to show its transcript in a box beneath it: its last six entries, each with its time. A long entry ends in …; 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
  • a ▾ 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)
  • each agent's context drawn on one shared time axis, so you can see when each agent was active and how close it is to its limit (green, then amber from 60%, red at the threshold)
  • an activity log; a line too long for the pane ends in …, and a click shows its full text beneath it (a second click hides it); the log gives up the rows it takes

Desktop app. The pane has:

  • a chart of each agent's context over time, one coloured line per agent, with the limits as dashed red lines; hover a line for the agent's name, context and status
  • a tree of agents, each with its colour, model and effort (such as 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 ⚠️
  • click an agent to highlight its line in the chart and show its transcript beneath it: its last twelve entries, each with its time, a long one ending in … that a click expands in place. Click again to clear both (the agent in view stays highlighted)
  • a ▾ 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)
  • the agent open in the main view is marked in view and its line highlighted, unless you clicked another
  • a 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)
  • an activity log; a line too long for the pane ends in …, and clicking it expands the full text beneath it
  • a ⧉ 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 usual

VS 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.

The orchestrated-run skill

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.

Commands

/orchopen the pane
/orch closeclose the pane
/orch rosterprint the roster
/orch handoverhand 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.

Settings

Configure in /config (or pluginConfigs.orchestrator.options in settings):

optiondefault
agentThreshold450000tokens at which a subagent is handed over
mainThreshold450000tokens at which the main thread is offered (or given) a restart
mainHandoveraskask, auto or off
autoRespawntruespawn the replacement automatically; when false, the agent asks its parent to
injectClausetrueappend the HANDOVER clause to every Agent prompt
runRoot(empty)where run folders go; empty means <project>/.orchestrator
shellPollSeconds15how often background command output files are checked for their exit; 0: never
shellGraceSeconds60an idle agent that hasn't picked up its finished background commands within this is nudged; 0: never
idleWatchdogMinutes5an idle agent with no running children or background commands this long is nudged once; 0: never
shellCeilingMinutes60a background command still running this long gets its agent one nudge; 0: never

Install

/plugin install orchestrator --marketplace victor-bajanov/cc-orchestrator-mod

Or run it from a checkout:

claude --plugin-dir /path/to/cc-orchestrator-mod

In the desktop app

/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.

Run a checkout in the desktop app

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.

Develop

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

Source 5 files
hooks/register.tsx 2623 lines
1import { 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 lines
1import { 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})
241
hooks/core.ts 447 lines
1import 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.`
447
hooks/view.ts 714 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[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 ? '▸' : '▾')
714
types/index.d.ts 178 lines
1/** 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