Live subagents, background shells, Monitor watches and cron timers: an AGENTS band above the prompt (main model, each run with its model or command, task and…

Live subagents, background shells, Monitor watches and cron timers in Claude Code, in two places, a toast when one ends and ALL DONE when the last one does; and a code map of the files the tools touched:
◎ AGENTS main fable·5.1 ⟳ │ Explore haiku "find the hooks" ✓ · checker sonnet "check the prices" ⟳ │ skills: plugin-authoring ×2 [ ×2 ] [ ✕ ]
The main loop's model while a turn runs; one entry per run of the current batch (type or name, model, the task cut to 22 characters, ⟳ running, ✓ finished, ✗ error); the skills fired. On a narrow band the task texts go first (oldest first), then all skills but one, then the tail folds into +N more. Under 40 columns it is ● 2 agents ▰▰▰▱▱▱. ×N opens the pane; ✕ hides the band until a new run starts. The row is keyed zm-50-agents and takes its slot in the zehui-mods stack order (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70, NEXT 80); other mods' bands follow, and a survey takes the band's place while it is up.
A background shell (a Bash call run in the background, or moved there with ctrl+b or by its timeout) is an entry of its own: sh, the command cut to 22 characters, its elapsed time and ⟳, then ✓ when it completes or ✗ when it fails or is killed, after which it leaves with its batch like a subagent. A command that runs a coding agent's CLI (codex exec, codex "<prompt>", codex review, codex exec resume, opencode run, claude -p) is labelled with the agent and the model -m/--model names, wherever it sits in a pipeline or list (git diff | codex exec "review the diff"), and shows the prompt instead of the command (the command when the prompt comes on stdin):
◎ AGENTS Explore haiku "find the hooks" ⟳ · sh "pytest -q tests/" 0:42 ⟳ · codex gpt-5.4 "review the diff" 2:10 ✓ [ ×3 ] [ ✕ ]
The shell's words (sh, codex gpt-5.4) are grey, never a Claude model's colour. ×N counts shells too, and the short form says ● 3 tasks once a shell is among them.
/agents-info (or /agents-info open): cost, tokens and time tiles, then Running, Finished and Error, one card per run with a pixel mascot, a 10-cell ▰▱ bar (context fill, tier colour while running, amber on a slow step, green when finished, red on error) and ctx n% · tokens · ≈$cost · m:ss. While two or more runs are going, a main-loop card tops Running (2 subagents running, or 2 tasks running once shells are among them). Collapse switches to one line per run.A background shell's card has a grey mascot instead of a coral one (a grey bot with a terminal face; a grey bot in a blue cap for a coding agent's CLI), a grey spinner, the bold title sh · <command> (codex · <prompt>), its status word (running, finished, failed, killed), then background shell · m:ss (with the agent and model first for codex, and exit n once it failed) and the last line of its output, read from the task's output file once a second while the pane is open, and once on opening for a shell that ended while it was closed (colour codes stripped, the line cut to 240 characters; a file over 1 MiB is not read).
Every card, a subagent's or a shell's, starts with a framed button numbered 1 to 9 in pane order (Running, Finished, Error). The buttons are stubs for now: a press, or its digit while the pane has the focus, toasts "messages view comes with E3", as does /agents-info open <n>. The messages view itself comes in a later round (E3).
The mascots move: a one-pixel bob each second while running, a braille spinner, a bright flash on finish and an amber pulse on error; a run waiting on a tool's result for over 20 s sleeps. A shell's mascot bobs while it runs and turns red when it fails. All of it holds still under Claude Code's reduced-motion setting.
Mascot pools (0.4.0): every state has a pool of poses from the mascot pack, and a card shows the pose its run id hashes to (FNV-1a), so it never changes pose within a state while two runs in one state seldom look alike:
| State | Poses |
|---|---|
| running | 14: repairing-with-tools, writing-software-code, building-colorful-blocks, debugging-system-bugs, reading-data-document, thinking-in-code, examining-green-globe, lab-science-testing, holding-ancient-scroll, visualizing-tree-data, managing-server-rack, processing-logic-symbols, planting-small-trees, user-profile-settings |
| waiting (over 20 s on a tool) | 4: sleeping-soundly, thinking-pixel-bubble, in-constant-loop, it-depends-sign |
| finished | 7: with-success-checkmark, success-achievement-milestone, happy-pixel-celebrating, rocket-launch-success, api-success-pixel, in-glowing-aura, love-cloud-pixel |
| error | 10: confusion-spiral-pixel, dizzy-failed-state, error-state-pixel, failed-process-error, system-error-alert, marking-critical-error, crying-sad-reaction, angry-at-laptop, broken-heart-sad, disconnected-plug-pixel |
| main loop (hub card) | 3: artboard-25cla, multi-window-workflow, developer-pair-programming-scene, picked by the session's id (else its first main turn's) |
Before 0.4.0 every finished card showed the one checkmark pose (and every error card the one spiral), which is why finished runs all looked the same. Poses are 18 to 30 pixels wide and 10 to 23 tall; the mascot column is as wide as the widest pose, and a card is as tall as its pose (or its text). Shells keep their own grey mascots and codex fuzzies.
A Monitor call is a watch and a CronCreate call a cron, each an entry of the band and a row of the pane under a Watching head (no mascot, no number):
◎ AGENTS Explore haiku "find the hooks" ⟳ · watch deploy a15f4e9 ⏳ 12m · cron 20:30 in 41m [ ×3 ] [ ✕ ] ☕
watch <description> <target> ⏳ <time left>: the target is the herdr pane (w1:p6), commit (a15f4e9) or run id its command names, if any; the time left runs to its deadline (a persistent watch shows its elapsed time instead). Its event notifications set the line under its pane row; it ends on its end notification (✓ watch "deploy" ended after 12:00).cron <next fire, local> in <time left>, its prompt in the pane. A one-shot cron ends when its prompt fires; a recurring one moves to its next fire time; CronDelete ends either, with no toast.caffeinate option, on by default): while any subagent, shell or watch runs, one caffeinate -i -t 600 child (through $.process.spawn) holds idle sleep, spawned again once its 600 s run out while work still runs, and stopped as soon as the last one ends; ☕ sits at the end of the AGENTS row while it is held. A cron waiting to fire holds nothing. A child that ends within a minute (no caffeinate on the machine) is retried a minute later, not every second.★ all background work done, and with the sound option on a rising chime (assets/done.wav, the peers mod's). Both need toasts on.The pane's second view: c (or /agents-info code) shows it, w (or /agents-info who) goes back to the cards, p (or /agents-info peers) shows the Peers view (0.4.0), q (or /agents-info close) closes the pane. The keys work while the pane has the focus (ctrl+x tab).
Code map (11 files) (2 changed) · turn 4 ● editing kit.ts
[ w: who ] [ a: session ] [ d: diff ] [ q: close ]
╭────────────────────╮ ╭────────────────────╮ ╭────────────────────╮
│ 1 band.tsx ✓ │──▶│ 2 register.tsx ✓ │──▶│ 3 kit.ts ● │
│ +2 −2 · RE · 4m │ │ +9 −1 · E · 2m @1 │ │ +12 −3 · RE · 5s │
│ Padded chip │ │ Prune ended runs… │ │ pruneEnded, keep… │
╰────────────────────╯ ╰────────────────────╯ ╰────────────────────╯
ALSO READ token-weather.mjs TIMELINE.md mascots.ts
subagents [ 1 ] savvy-careful
cat reads, sed -i edits, > and >> write, git add reads, git checkout -- <file> and git restore rewrite, cp reads its sources and writes its target, mv writes its target), in the main loop and in subagents (by agentId, @n in the node is the subagent's card number). Relative paths resolve against the session's directory. A heredoc's body and a quoted > (grep -c '>' f) name no file; a [ in an edited or read path (app/[id]/page.tsx) is literal. A Bash command's exit code never marks its > target ✗.d), name and state glyph; +plus −minus · tools · time since the last touch (and the turn tags t3 in session scope); one line from the edit itself (the Edit's first changed line, the Write's first line, kept to 80 characters). States: ◌ read blue, ● editing amber for 30 s after an edit, ✓ changed green, ✗ failed red when the edit tool returned an error.git diff --numstat -- <file> runs in the file's folder ($.process.run, 5 s timeout), only when git rev-parse --is-inside-work-tree says the folder is in a repo (asked once per folder), and only for a file under the session's directory: git never runs in a folder elsewhere (a cloned repo's .git/config stays unread), so those nodes carry no counts. A new file git does not know yet counts the lines written.──▶ between consecutive ones, as many to a row as the pane width holds (a wrapped row starts with the arrow); read-only files in the ALSO READ chip row; the subagents that touched the files with their numbered buttons (stubs until E3, as in the Who view). Under 60 columns a tree list by folder instead.a (or /agents-info scope) toggles the whole session, the nodes then tagged with their turns.d then a number (or /agents-info diff <n>) runs /diff on that node's file through $.command.run, from the 1 s tick rather than the press. While d is armed the digits belong to the diff pick, not the subagents row.In the Who view the clear button is now x: clear done (c opens the Code view).
The pane's third view: p (or /agents-info peers) shows it from the Who or Code view; w, c and q work as before, and r reads herdr again. It draws what the peers mod's board draws, read by this mod itself (the code is ported, not imported across mod folders):
Peers · 4 in w1 · 1 blocked · read every 30 s
[ w: who ] [ c: code ] [ r: refresh ] [ q: close ]
hub · this pane ● working 12m w1:p2 claude "Orchestrate the review"
req skin · ⏳ 21m left · no result.md
▲ blocked 4m w1:p7 codex "Compare skills"
● working 4m w1:p9 claude "Skin, second half"
req rev-r4 · round 4/5 · result.md ✓ completed
● working 12m w1:p6 claude "Review round 4"
other peers
○ idle 3m w1:p8 claude "Not ours"
HERDR_WORKSPACE_ID), this pane left out: the state's glyph and word (▲ blocked, ● working, ○ idle, ✓ done, ? unknown) with the time in that state, the pane id, the agent and the title. Blocked rows are amber and come first. The time counts from the first read that saw the pane in that state (none on the first read); a read more than a minute old is not carried over.hub · this pane) when this session sent HERDR-ORCH requests; else, when a brief from another pane made this session a spoke, that pane's row (hub).req <id> with its round (round 4/5), its deadline countdown (⏳ 21m left, overdue 4m; gone once a result file is there) and its result file (result.md ✓ completed, ✗ failed, ▲ blocked, result.md (no status), no result.md), then its spokes. A group with a blocked spoke comes first, then the newest. A spoke is drawn under the newest request that names it. Peers no request names follow under other peers.prompt.submit), the peers mod's rule (POLICY.md section 2): [HERDR-ORCH v0] req=<id> from=<agent>@<pane> type=<type> at a line's start or after herdr agent prompt <pane> "; the spoke is the pane of that herdr agent prompt line (so a fan-out of one request to several panes groups them all), else to=<pane>, else the first herdr agent prompt pane in the text that is not the sender's, else the sender of a report, else the first other pane id in the prompt. A brief or follow-up that another pane sent to this session takes its target from its header line only; when that names no other pane it is no request of ours, and a brief's sender becomes the hub. Round, ceiling and deadline as the peers mod reads them (30 minutes for a brief that names none). The newest 40 requests are kept.herdr agent list (5 s limit) through $.process.run, only while the pane is open in this view: every 30 s from the existing 1 s tick, and at once on p, r or /agents-info peers. The result files (~/.local/state/herdr-orch/<req>/result.md, 64 KiB at most) through $.fs in the same read, and only when the file, with every symbolic link followed, lands at its own place in that folder. A failed read keeps the last rows; with no good read yet the head says why (herdr agent list failed (exit 1)).HERDR_ENV=1 with a workspace and pane id) the view says The Peers view needs a herdr pane and nothing runs.Toasts: one toast when a subagent or a background shell ends, never for the main loop: ✓ Explore "survey the mods" finished in 0:31, ✗ sh "pytest -q tests/" exit 1 after 0:42 (or killed after, or failed after when no exit code is known). The toasts option turns them off.
At the prompt of a terminal session:
/plugin install agents@zehui-mods
From the local marketplace folder (~/.claude/mods, added with claude plugin marketplace add ~/.claude/mods):
claude plugin install agents@zehui-mods --scope user
The install is a copy of the mod at that moment (under ~/.claude/plugins/cache/zehui-mods/agents/<version>/), so /reload-plugins reloads that copy, not this folder. After a version bump, refresh the copy and restart the session:
claude plugin marketplace update zehui-mods
claude plugin update agents@zehui-mods --scope user
| Option | Values | Default | What it does |
|---|---|---|---|
band | on, off | on | off hides the AGENTS band; the pane stays. |
toasts | on, off | on | off drops the toast for a subagent or shell that ends. |
keepEnded | 0, 5, 10, 25, 50 | 10 | How many ended runs (finished or failed) the pane keeps, newest first; older ones are dropped as runs end. The pane's x: clear done button and /agents-info clear drop them all. |
caffeinate | on, off | on | on holds idle sleep (caffeinate -i -t 600, re-armed) while any subagent, shell or watch runs; ☕ on the AGENTS row while held. |
sound | off, on | off | on plays the rising chime with the ALL DONE toast (needs toasts on). |
summaries | edit, haiku, off | edit | The code map node's third line: from the edit itself (edit); haiku is reserved and drawn as edit for now (no model call in this mod); off shows the file's folder. |
hooks/kit.ts (from the claude-api skill bundled with Claude Code 2.1.303). Cache writes are priced at the 5-minute rate (1.25x input), since the engine reports one write count. A model the table does not know shows ≈ ?.<task-notification>, status completed, failed or killed). A shell started by a synchronous subagent, which the engine stops with the subagent's final answer, ends (as killed) when that subagent ends.shell-terminal-face, B codex-hex-badge, C shell-window, D codex-blue-cap, in SHELL_POSES in hooks/mascots.ts). A is wired for plain shells and D for a coding agent's CLI; change SHELL_POSE or CODEX_POSE there to pick B or C instead.d + number passes the file to /diff as its argument; the plugin API does not say whether /diff narrows to it, so it may open the whole working-tree diff instead.<task-notification> rows with an <event> and no <status>; its end is one with <status>; a cron's fire is a row of origin scheduled-trigger holding its prompt.git (the code map's counts), caffeinate (the hold) and, from 0.4.0, herdr agent list (the Peers view, only while it is shown); nothing else, no network and no model.hooks/register.tsx 905 lines1// The agents mod: one run record per subagent and per background shell, the
2// /agents-info pane and the AGENTS band above the prompt. This file keeps the
3// books and raises the finish toasts; skin.tsx draws the pane and band.tsx the band.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
6
7import type { AgentRun, CodeTouch } from '../types'
8import { drawBand } from './band'
9import { EDITING_MS, EDIT_TOOLS, FILE_TOOLS, NUMSTAT_TIMEOUT_MS, addTouch, bashTouches, dirOf, parseNumstat, toolTouches } from './codemap'
10import type { Touch } from './codemap'
11import {
12 ALL_DONE,
13 CAFFEINATE,
14 SOUND_DONE,
15 closePane,
16 diffQueue,
17 isCaffeinateOn,
18 isInFlight,
19 isSoundOn,
20 setView,
21 LINGER_MS,
22 MAX_RUNS,
23 keepEndedOf,
24 pruneEnded,
25 MESSAGES_STUB,
26 PANE,
27 PULSE_MS,
28 TAIL_MAX_BYTES,
29 batchOf,
30 clip,
31 costOf,
32 isBandOn,
33 isShell,
34 isToastsOn,
35 openPane,
36 toastText,
37 togglePane,
38 tokensOf,
39 windowOf,
40} from './kit'
41import type { Usage } from './kit'
42import { labelOf, lastLineOf, notesOf, outputFileOf, textOfContent } from './shells'
43import type { TaskNote } from './shells'
44import { nodePath } from './code'
45import { NEEDS_HERDR, herdrIds, noteEnvelopes, pollPeers, tickPeers } from './peers'
46import { drawPane } from './skin'
47import { DONE_RECENT_MS, cronNext, eventsOf, targetOf } from './watch'
48
49const runs = atom({ plugin: 'agents', key: 'runs' } as const, [])
50const skills = atom({ plugin: 'agents', key: 'skills' } as const, {})
51const main = atom({ plugin: 'agents', key: 'main' } as const, { model: '', isRunning: false, startedAt: 0 })
52const now = atom({ plugin: 'agents', key: 'now' } as const, 0)
53const dismissedAt = atom({ plugin: 'agents', key: 'dismissedAt' } as const, 0)
54const isStill = atom({ plugin: 'agents', key: 'isStill' } as const, false)
55const tails = atom({ plugin: 'agents', key: 'tails' } as const, {})
56const isCaffeinated = atom({ plugin: 'agents', key: 'isCaffeinated' } as const, false)
57const code = atom({ plugin: 'agents', key: 'code' } as const, [])
58const turn = atom({ plugin: 'agents', key: 'turn' } as const, 0)
59const codeScope = atom({ plugin: 'agents', key: 'codeScope' } as const, 'turn')
60const view = atom({ plugin: 'agents', key: 'view' } as const, 'who')
61
62// A shell's command and task text as kept in its run record (drawings clip further).
63const COMMAND_MAX = 2_000
64const DESCRIPTION_MAX = 200
65
66// A caffeinate hold that ends sooner than this after its start is retried only this much later.
67const AWAKE_RETRY_MS = 60_000
68
69// The tick re-reads prefersReducedMotion every this many slow ticks.
70const MOTION_EVERY = 10
71
72// What the Agent call said that its spawn does not carry, by tool_use_id.
73const pending = new Map<string, { effort?: string }>()
74
75// Timers and the tick's memory: a reload drops them with the environment, and
76// session.start (raised again for a fresh load) starts them over.
77let slow: Timer | null = null
78let fast: Timer | null = null
79let wasActive = false
80let ticks = 0
81// Background task ids already ended, so a notification seen twice ends and toasts once.
82const endedTasks = new Set<string>()
83// Each shell's output file as last read (size and mtime), so an unchanged file is not read again.
84const tailSeen = new Map<string, string>()
85// Whether a subagent, shell or watch was in flight at the last tick: ALL DONE when it goes false.
86let wasInFlight = false
87// The caffeinate hold's child while it runs (spawned and stopped by the tick only).
88let awake: ReturnType<EngineInterface['process']['spawn']> | null = null
89// A hold that ended within a minute of its start (no caffeinate here, or it failed): not re-armed before this time.
90let awakeRetryAt = 0
91// The session's working directory: relative paths in the code map resolve against it.
92let cwd = ''
93// Folders already asked whether they sit in a git work tree.
94const repoDirs = new Map<string, boolean>()
95// Watch events already seen (task id and line): the queue record and the row of one event count once.
96const seenEvents = new Set<string>()
97
98const updateRun = ($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) =>
99 update($, runs, list => (list.some(a => a.agentId === agentId) ? list.map(a => (a.agentId === agentId ? change(a) : a)) : list))
100
101async function readMotion($: EngineInterface): Promise<void> {
102 try {
103 const settings = (await $.settings.read()) as Record<string, unknown>
104 const still = settings.prefersReducedMotion === true
105 if ((await read($, isStill)) !== still) await update($, isStill, () => still)
106 } catch {
107 // No settings: keep what was read last.
108 }
109}
110
111async function readModel($: EngineInterface): Promise<string> {
112 try {
113 return await $.session.model()
114 } catch {
115 return ''
116 }
117}
118
119async function readSessionId($: EngineInterface): Promise<string> {
120 try {
121 return await $.session.id()
122 } catch {
123 return ''
124 }
125}
126
127/** One toast for a run that ended, unless the `toasts` option is off; never for the main loop. */
128function toastEnd($: EngineInterface, run: AgentRun, options: PluginOptions): void {
129 if (!isToastsOn(options.toasts)) return
130 try {
131 $.ui.toast(toastText(run))
132 } catch {
133 // A toast is a courtesy.
134 }
135}
136
137/**
138 * True when another watch on `run`'s target ended done within DONE_RECENT_MS
139 * of `at`: a notification of this one greys its entry instead of toasting again.
140 */
141const isTargetDone = (list: readonly AgentRun[], run: AgentRun, at: number): boolean =>
142 run.kind === 'watch' &&
143 run.target !== undefined &&
144 list.some(
145 o =>
146 o.kind === 'watch' &&
147 o.id !== run.id &&
148 o.target === run.target &&
149 o.status === 'finished' &&
150 o.endedAt !== undefined &&
151 at - o.endedAt <= DONE_RECENT_MS,
152 )
153
154/**
155 * Ends a running shell or watch as a notification (or its subagent's end)
156 * reports, and toasts it once; a watch whose target is already done ends grey,
157 * without a toast.
158 */
159async function endShell($: EngineInterface, note: TaskNote, options: PluginOptions): Promise<void> {
160 // Claimed before the first await, so a second row for the task (its queue
161 // record) finds it taken; released unless the shell is ended, so that row retries.
162 if (endedTasks.has(note.id)) return
163 endedTasks.add(note.id)
164 if (endedTasks.size > 256) endedTasks.delete(endedTasks.values().next().value ?? '')
165 let isEnded = false
166 try {
167 const list = await read($, runs)
168 const run = list.find(a => (isShell(a) || a.kind === 'watch') && a.taskId === note.id && a.status === 'running')
169 if (!run) return
170 const at = await $.clock.now()
171 const isDone = note.status === 'completed'
172 const isStaleEnd = isTargetDone(list, run, at)
173 const ended: AgentRun = {
174 ...run,
175 ...(isStaleEnd ? { staleAt: at } : {}),
176 status: isDone ? 'finished' : 'error',
177 endedAt: at,
178 ...(isDone ? {} : { endWord: note.status === 'killed' ? 'killed' : 'failed' }),
179 ...(note.exitCode !== undefined ? { exitCode: note.exitCode } : {}),
180 ...(note.outputFile && !run.outputFile ? { outputFile: note.outputFile } : {}),
181 }
182 await update($, runs, l => l.map(a => (a.id === run.id ? ended : a)))
183 isEnded = true
184 await update($, now, () => at)
185 if (!isStaleEnd) toastEnd($, ended, options)
186 await pruneAfterEnd($, options)
187 } finally {
188 if (!isEnded) endedTasks.delete(note.id)
189 }
190}
191
192/**
193 * Each shell's last output line, read through $.fs.read while the pane is open:
194 * for the shells running or ended within the linger, a file read only when its
195 * size or mtime moved, and never one over TAIL_MAX_BYTES.
196 */
197async function pollTails($: EngineInterface, list: readonly AgentRun[], at: number): Promise<void> {
198 const shells = list.filter(
199 a => isShell(a) && a.outputFile && (a.status === 'running' || (a.endedAt !== undefined && at - a.endedAt <= LINGER_MS)),
200 )
201 for (const a of shells) {
202 const file = a.outputFile ?? ''
203 try {
204 const stat = await $.fs.stat(file)
205 const seen = `${stat.size}:${stat.mtimeMs}`
206 if (tailSeen.get(a.id) === seen) continue
207 tailSeen.set(a.id, seen)
208 if (tailSeen.size > 64) tailSeen.delete(tailSeen.keys().next().value ?? '')
209 if (stat.size > TAIL_MAX_BYTES) continue
210 const line = lastLineOf(await $.fs.read(file))
211 if (line === undefined || (await read($, tails))[a.id] === line) continue
212 const ids = new Set(list.map(r => r.id))
213 await update($, tails, t => ({ ...Object.fromEntries(Object.entries(t).filter(([id]) => ids.has(id))), [a.id]: line }))
214 } catch {
215 // No file yet, or none to read: the card keeps the line it had.
216 }
217 }
218}
219
220/**
221 * A watch's event notification: its last event line, and for a watch already
222 * ended (or whose target another watch already saw done) a grey entry for 10 s.
223 */
224async function noteEvent($: EngineInterface, id: string, event: string): Promise<void> {
225 const seen = `${id}\n${event}`
226 if (seenEvents.has(seen)) return
227 seenEvents.add(seen)
228 if (seenEvents.size > 256) seenEvents.delete(seenEvents.values().next().value ?? '')
229 const list = await read($, runs)
230 const run = list.find(a => a.kind === 'watch' && a.taskId === id)
231 if (!run) return
232 const at = await $.clock.now()
233 const isStale = run.status !== 'running' || isTargetDone(list, run, at)
234 await update($, runs, l =>
235 l.map(a => (a.id === run.id ? { ...a, ...(event ? { lastEvent: event } : {}), ...(isStale ? { staleAt: at } : {}) } : a)),
236 )
237 if (isStale) await update($, now, () => at)
238}
239
240/** A cron's stored prompt fired: a one-shot cron ends ✓, a recurring one moves to its next fire time. */
241async function cronFired($: EngineInterface, text: string): Promise<void> {
242 const body = text.trim()
243 if (!body) return
244 const list = await read($, runs)
245 const run = list.find(a => a.kind === 'cron' && a.status === 'running' && a.description && body.startsWith(a.description.replace(/…$/, '').slice(0, 120)))
246 if (!run) return
247 const at = await $.clock.now()
248 const fired: AgentRun = run.isRecurring
249 ? { ...run, nextAt: run.cron ? cronNext(run.cron, at) : undefined }
250 : { ...run, status: 'finished', endedAt: at, nextAt: undefined }
251 await update($, runs, l => l.map(a => (a.id === run.id ? fired : a)))
252 await update($, now, () => at)
253}
254
255/**
256 * Lines added and removed for an edited file, from `git diff --numstat -- <file>`
257 * run in its folder (only inside a git work tree, 5 s at most); a new file
258 * git does not know yet counts the lines written. Best effort, never awaited by a tool call.
259 */
260async function readNumstat($: EngineInterface, path: string, turnOf: number, newLines?: number): Promise<void> {
261 try {
262 const dir = dirOf(path)
263 let isRepo = repoDirs.get(dir)
264 if (isRepo === undefined) {
265 const probe = await $.process.run(['git', 'rev-parse', '--is-inside-work-tree'], { cwd: dir, timeoutMs: NUMSTAT_TIMEOUT_MS })
266 isRepo = probe.exitCode === 0 && probe.stdout.trim() === 'true'
267 repoDirs.set(dir, isRepo)
268 if (repoDirs.size > 64) repoDirs.delete(repoDirs.keys().next().value ?? '')
269 }
270 if (!isRepo) return
271 const out = await $.process.run(['git', 'diff', '--numstat', '--', path], { cwd: dir, timeoutMs: NUMSTAT_TIMEOUT_MS })
272 const counts = parseNumstat(out.stdout) ?? (newLines !== undefined ? { plus: newLines, minus: 0 } : undefined)
273 if (!counts) return
274 // The file's record for the turn the edit was made in.
275 await update($, code, list => list.map(c => (c.path === path && c.turn === turnOf ? { ...c, ...counts } : c)))
276 } catch {
277 // No git, a timeout, or no state: the node keeps no counts.
278 }
279}
280
281/** Records what one tool call touched in the code map, then reads the numstat of each file it edited. */
282async function recordTouches($: EngineInterface, touches: readonly Touch[], by: string, isError: boolean, newLines?: number): Promise<void> {
283 if (touches.length === 0) return
284 const at = await $.clock.now()
285 const t = await read($, turn)
286 await update($, code, list => touches.reduce<CodeTouch[]>((l, x) => addTouch(l, { ...x, turn: t, at, by, isError }), [...list]))
287 if (isError) return
288 // git runs only under the session's folder: a repo elsewhere (its .git/config) is never entered.
289 const root = cwd.replace(/\/+$/, '') + '/'
290 for (const x of touches) if (x.isEdit && cwd !== '' && x.path.startsWith(root)) void readNumstat($, x.path, t, x.tool === 'Write' ? newLines : undefined)
291}
292
293/**
294 * The caffeinate hold: while `want`, one `caffeinate -i -t 600` child runs
295 * (spawned again once it ends while work is still in flight); stopped as soon
296 * as the in-flight set empties. Spawned and stopped from the tick only.
297 */
298async function holdAwake($: EngineInterface, want: boolean, at: number): Promise<void> {
299 if (want && awake === null && at >= awakeRetryAt) {
300 const child = $.process.spawn({ argv: CAFFEINATE })
301 awake = child
302 await update($, isCaffeinated, () => true)
303 void (async () => {
304 try {
305 for await (const _piece of child) {
306 // caffeinate writes nothing; the loop is the child's life.
307 }
308 } catch {
309 // It could not start, or it was stopped.
310 }
311 if (awake === child) {
312 awake = null
313 // Ended within a minute (it could not run): wait a minute before the next try.
314 const end = await $.clock.now().catch(() => at)
315 if (end - at < AWAKE_RETRY_MS) awakeRetryAt = end + AWAKE_RETRY_MS
316 await update($, isCaffeinated, () => false).catch(() => {})
317 }
318 })()
319 } else if (!want && awake !== null) {
320 const child = awake
321 awake = null
322 void child.return({ code: null, signal: 'SIGTERM' }).catch(() => {})
323 await update($, isCaffeinated, () => false)
324 }
325}
326
327/** ALL DONE: one toast, and the chime when the `sound` option is on, both only with `toasts` on. */
328function allDone($: EngineInterface, options: PluginOptions): void {
329 if (!isToastsOn(options.toasts)) return
330 try {
331 $.ui.toast(ALL_DONE)
332 } catch {
333 // A toast is a courtesy.
334 }
335 if (isSoundOn(options.sound)) void $.audio.play({ asset: SOUND_DONE }).catch(() => {})
336}
337
338/** The /diff runs the Code view asked for, one by one through $.command.run. */
339async function runDiffs($: EngineInterface): Promise<void> {
340 while (diffQueue.length > 0) {
341 const path = diffQueue.shift() ?? ''
342 try {
343 await $.command.run({ command: 'diff', args: path })
344 } catch {
345 // No /diff to run: nothing more to do.
346 }
347 }
348}
349
350/**
351 * True while the code map changes on its own: a node turns from editing to
352 * changed 30 s after its edit, and the ages count while the Code view is open.
353 */
354async function isCodeMoving($: EngineInterface, at: number): Promise<boolean> {
355 if ((await read($, code)).some(c => c.lastEditAt !== undefined && at - c.lastEditAt <= EDITING_MS + 1_000)) return true
356 if ((await read($, view)) !== 'code') return false
357 try {
358 return (await $.ui.panes()).some(p => p.id === PANE)
359 } catch {
360 return false
361 }
362}
363
364const stopFast = () => {
365 fast?.cancel()
366 fast = null
367}
368
369/**
370 * Once a second: while any run runs, the band shows (or lingers), or a finish
371 * flash or error pulse is still on, move `now` so the clocks and spinners redraw;
372 * one last move once all that is over, which hides the band and clears its
373 * skills. A 100 ms tick runs beside it while the pane is open or the band shows,
374 * unless motion is reduced, so the spinner and the flashes move smoothly.
375 */
376/** Drops ended runs past the `keepEnded` option, newest kept; called right after a run ends. */
377async function pruneAfterEnd($: EngineInterface, options: PluginOptions): Promise<void> {
378 const keep = keepEndedOf(options.keepEnded)
379 await update($, runs, list => pruneEnded(list, keep))
380}
381
382async function tick($: EngineInterface, options: PluginOptions): Promise<void> {
383 // Not awaited: a /diff that holds the screen must not hold the tick.
384 if (diffQueue.length > 0) void runDiffs($)
385 const list = await read($, runs)
386 const at = await $.clock.now()
387 // The Peers view reads herdr every 30 s, only while the pane shows it; not awaited, so a slow herdr never holds the tick.
388 void tickPeers($, at).catch(() => {})
389 // ALL DONE and the caffeinate hold follow the in-flight set: subagents, shells and watches.
390 const inFlight = list.some(isInFlight)
391 if (wasInFlight && !inFlight) allDone($, options)
392 wasInFlight = inFlight
393 await holdAwake($, inFlight && isCaffeinateOn(options.caffeinate), at)
394 const isBand = isBandOn(options.band) && batchOf(list, at, await read($, dismissedAt)).length > 0
395 const isLive = inFlight
396 const isFlashing = list.some(a => a.endedAt !== undefined && at - a.endedAt < PULSE_MS)
397 if (!isLive && !isBand && !isFlashing) {
398 stopFast()
399 if (await isCodeMoving($, at)) {
400 await update($, now, () => at)
401 return
402 }
403 if (wasActive) {
404 wasActive = false
405 await update($, now, () => at)
406 await update($, skills, () => ({}))
407 }
408 return
409 }
410 wasActive = true
411 if (ticks++ % MOTION_EVERY === 0) await readMotion($)
412 await update($, now, () => at)
413 let isPaneOpen = false
414 try {
415 isPaneOpen = (await $.ui.panes()).some(p => p.id === PANE)
416 } catch {
417 // No surface to ask: the band alone decides.
418 }
419 if (isPaneOpen) await pollTails($, list, at)
420 // The 100 ms tick turns spinners: not for a band that shows only a cron waiting to fire.
421 const wantsFast = !(await read($, isStill)) && (isPaneOpen || isBand) && (isLive || isFlashing || isPaneOpen)
422 if (!wantsFast) stopFast()
423 else if (fast === null) {
424 fast = $.clock.every(100, () => {
425 void $.clock
426 .now()
427 .then(t => update($, now, () => t))
428 .catch(() => {})
429 })
430 }
431}
432
433export const register: Register = (on, options) => {
434 on('session.start', async ($, e, next) => {
435 const started = await next(e)
436 cwd = typeof e.cwd === 'string' ? e.cwd : ''
437 // A reload ended the last module's caffeinate child with it: no ☕ until the tick holds again.
438 await update($, isCaffeinated, () => false)
439 await $.command.register({
440 name: 'agents-info',
441 description:
442 'Show or hide the agents pane: subagents, shells, watches and crons (`who`), the files touched (`code`, `scope`, `diff <n>`), the herdr peers (`peers`); `clear` drops the ended ones, `close` closes it',
443 })
444 const model = await readModel($)
445 if (model) await update($, main, m => ({ ...m, model }))
446 // The hub card's pose is picked by the session's id: one pose for the session.
447 const sessionId = await readSessionId($)
448 if (sessionId) await update($, main, m => ({ ...m, id: sessionId }))
449 await readMotion($)
450 // A reload re-runs session.start: trim the ended runs then too, not only when a run ends.
451 await pruneAfterEnd($, options)
452 slow?.cancel()
453 stopFast()
454 slow = $.clock.every(1000, () => {
455 void tick($, options).catch(() => {})
456 })
457 return started
458 })
459
460 on('command.run', { command: 'agents-info' }, async ($, e) => {
461 const [verb, card] = e.args.trim().split(/\s+/)
462 // `/agents-info code` and `/agents-info who`: the pane in that view, the twins of `c` and `w`.
463 if (verb === 'code' || verb === 'who') {
464 await openPane($, verb)
465 return { text: `Agents pane: ${verb === 'code' ? 'Code' : 'Who'} view.` }
466 }
467 // `/agents-info peers`: the pane in the Peers view, herdr read now; the twin of `p` and of `r`.
468 if (verb === 'peers') {
469 await openPane($, 'peers')
470 await pollPeers($)
471 return { text: (await herdrIds($)) ? 'Agents pane: Peers view (read every 30 s while shown; r reads now).' : NEEDS_HERDR }
472 }
473 // `/agents-info close`: the twin of `q`.
474 if (verb === 'close') return { text: (await closePane($)) ? 'Agents pane closed.' : 'The agents pane is not open.' }
475 // `/agents-info scope`: the code map's turn or session scope, the twin of `a`.
476 if (verb === 'scope') {
477 await update($, codeScope, s => (s === 'turn' ? 'session' : 'turn'))
478 return { text: `Code map scope: ${(await read($, codeScope)) === 'session' ? 'the whole session' : 'this turn'}.` }
479 }
480 // `/agents-info diff <n>`: /diff on the code map's node n, the twin of `d` + n.
481 if (verb === 'diff') {
482 const path = card ? await nodePath($, Number(card)) : undefined
483 if (!path) return { text: card ? `No file ${card} in the code map.` : 'Name the node: /agents-info diff <n>.' }
484 diffQueue.push(path)
485 return { text: `Diff of ${path} coming up.` }
486 }
487 // `/agents-info clear`: drop every finished or failed run (subagents and shells); running ones stay.
488 if (verb === 'clear') {
489 const before = (await read($, runs)).length
490 await update($, runs, list => pruneEnded(list, 0))
491 const after = (await read($, runs)).length
492 return { text: `Cleared ${before - after} ended run${before - after === 1 ? '' : 's'}.` }
493 }
494 // `/agents-info open <n>`: the numbered card's messages, the twin of its button; a stub until E3.
495 if (verb === 'open' && card) return { text: `Card ${card}: ${MESSAGES_STUB}.` }
496 // `/agents-info open` opens the pane, or brings it forward, without toggling it.
497 if (verb === 'open') {
498 await openPane($)
499 return { text: 'Agents pane opened.' }
500 }
501 return { text: (await togglePane($)) ? 'Agents pane opened.' : 'Agents pane closed.' }
502 })
503
504 // The Agent call carries the effort its spawn does not; the spawn reads it by tool_use_id.
505 on('tool.call', { tool: 'Agent' }, ($, e, next) => {
506 if (e.tool === 'Agent' && e.tool_use_id) {
507 pending.set(e.tool_use_id, { effort: e.effort })
508 if (pending.size > 64) pending.delete(pending.keys().next().value ?? '')
509 }
510 return next(e)
511 }).catch(($, e, next) => (next.called ? undefined : next(e)))
512
513 // A Bash call that resolves with a background task id (run_in_background, or
514 // moved there by ctrl+b or its timeout) starts a shell run; its notification ends it.
515 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
516 let startedAt = 0
517 try {
518 startedAt = await $.clock.now()
519 } catch {
520 // Stamped after the call instead.
521 }
522 const result = await next(e)
523 try {
524 if (e.tool !== 'Bash' || result.deny !== undefined) return result
525 // The files the command names, for the code map (foreground or background alike).
526 // A non-zero exit says nothing about its `>` target (`grep x f > hits.txt` wrote hits.txt): never drawn ✗.
527 if (typeof e.command === 'string') await recordTouches($, bashTouches(e.command, cwd), e.agentId ?? 'main', false)
528 } catch {
529 // The code map is best effort.
530 }
531 try {
532 if (e.tool !== 'Bash' || result.deny !== undefined) return result
533 const out = result.result as { backgroundTaskId?: unknown; backgroundEndsWithFinalResponse?: unknown } | undefined
534 const taskId = typeof out?.backgroundTaskId === 'string' ? out.backgroundTaskId : ''
535 if (!taskId) return result
536 const command = typeof e.command === 'string' ? e.command : ''
537 const at = startedAt || (await $.clock.now())
538 const parsed = labelOf(command)
539 const outputFile = outputFileOf(result.text)
540 const id = `sh-${taskId}`
541 const run: AgentRun = {
542 id,
543 kind: 'shell',
544 taskId,
545 // Clipped as recorded (labelOf read the whole command): a long heredoc is not held twice per run.
546 command: clip(command, COMMAND_MAX),
547 type: parsed.label ?? 'sh',
548 ...(parsed.label ? { label: parsed.label } : {}),
549 description: clip(parsed.task, DESCRIPTION_MAX),
550 model: parsed.model ?? '',
551 ...(outputFile ? { outputFile } : {}),
552 ...(e.agentId ? { parentAgentId: e.agentId } : {}),
553 ...(out?.backgroundEndsWithFinalResponse === true ? { endsWithParent: true } : {}),
554 status: 'running',
555 startedAt: at,
556 contextTokens: 0,
557 contextMax: 0,
558 tokens: 0,
559 costUsd: 0,
560 steps: 0,
561 }
562 await update($, runs, list => [...list.filter(a => a.id !== id), run].slice(-MAX_RUNS))
563 await update($, now, () => at)
564 } catch {
565 // The books are best effort: the command goes on whatever happens here.
566 }
567 return result
568 }).catch(($, e, next) => (next.called ? undefined : next(e)))
569
570 // A background task's notification row ends its shell: ✓ completed, ✗ failed or killed.
571 on('session.append', async ($, e, next) => {
572 const stored = await next(e)
573 const kind = (e.origin as { kind?: string }).kind
574 if (kind === 'scheduled-trigger') {
575 try {
576 await cronFired($, textOfContent(e.message.content))
577 } catch {
578 // Best effort.
579 }
580 return stored
581 }
582 if (kind !== 'task-notification') return stored
583 try {
584 const text = textOfContent(e.message.content)
585 for (const ev of eventsOf(text)) await noteEvent($, ev.id, ev.event)
586 for (const note of notesOf(text)) await endShell($, note, options)
587 } catch {
588 // Best effort: the row is kept whatever happens here.
589 }
590 return stored
591 }).catch(($, e, next) => (next.called ? undefined : next(e)))
592
593 // A Monitor call starts a watch: its description, the target its command
594 // names and its deadline; its task notification ends it, as a shell's does.
595 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
596 const result = await next(e)
597 try {
598 if (e.tool !== 'Monitor' || result.deny !== undefined || result.isError) return result
599 const out = result.result as { taskId?: unknown; timeoutMs?: unknown; persistent?: unknown } | undefined
600 const taskId = typeof out?.taskId === 'string' ? out.taskId : ''
601 if (!taskId) return result
602 const at = await $.clock.now()
603 const command = typeof e.command === 'string' ? e.command : ''
604 const timeoutMs = typeof out?.timeoutMs === 'number' ? out.timeoutMs : 0
605 const target = targetOf(command)
606 const id = `w-${taskId}`
607 const run: AgentRun = {
608 id,
609 kind: 'watch',
610 taskId,
611 type: 'watch',
612 command: clip(command, COMMAND_MAX),
613 description: clip(typeof e.description === 'string' ? e.description : '', DESCRIPTION_MAX),
614 model: '',
615 ...(target ? { target } : {}),
616 ...(out?.persistent !== true && timeoutMs > 0 ? { nextAt: at + timeoutMs } : {}),
617 ...(e.agentId ? { parentAgentId: e.agentId } : {}),
618 status: 'running',
619 startedAt: at,
620 contextTokens: 0,
621 contextMax: 0,
622 tokens: 0,
623 costUsd: 0,
624 steps: 0,
625 }
626 await update($, runs, list => [...list.filter(a => a.id !== id), run].slice(-MAX_RUNS))
627 await update($, now, () => at)
628 } catch {
629 // Best effort: the watch runs whatever happens here.
630 }
631 return result
632 }).catch(($, e, next) => (next.called ? undefined : next(e)))
633
634 // A CronCreate call starts a cron entry (next fire time, schedule, prompt); it
635 // ends when it fires once (a one-shot) or when CronDelete removes it.
636 on('tool.call', { tool: 'CronCreate' }, async ($, e, next) => {
637 const result = await next(e)
638 try {
639 if (e.tool !== 'CronCreate' || result.deny !== undefined || result.isError) return result
640 const out = result.result as { id?: unknown; humanSchedule?: unknown; recurring?: unknown; nextFireAt?: unknown } | undefined
641 const job = typeof out?.id === 'string' ? out.id : ''
642 if (!job) return result
643 const at = await $.clock.now()
644 const expr = typeof e.cron === 'string' ? e.cron : ''
645 const nextAt = typeof out?.nextFireAt === 'number' ? out.nextFireAt : cronNext(expr, at)
646 const id = `cron-${job}`
647 const run: AgentRun = {
648 id,
649 kind: 'cron',
650 type: 'cron',
651 target: job,
652 cron: expr,
653 schedule: typeof out?.humanSchedule === 'string' ? out.humanSchedule : expr,
654 isRecurring: out?.recurring !== false && e.recurring !== false,
655 description: clip(typeof e.prompt === 'string' ? e.prompt.trim() : '', DESCRIPTION_MAX),
656 model: '',
657 ...(nextAt !== undefined ? { nextAt } : {}),
658 status: 'running',
659 startedAt: at,
660 contextTokens: 0,
661 contextMax: 0,
662 tokens: 0,
663 costUsd: 0,
664 steps: 0,
665 }
666 await update($, runs, list => [...list.filter(a => a.id !== id), run].slice(-MAX_RUNS))
667 await update($, now, () => at)
668 } catch {
669 // Best effort.
670 }
671 return result
672 }).catch(($, e, next) => (next.called ? undefined : next(e)))
673
674 on('tool.call', { tool: 'CronDelete' }, async ($, e, next) => {
675 const result = await next(e)
676 try {
677 if (e.tool !== 'CronDelete' || result.deny !== undefined || result.isError) return result
678 const id = `cron-${typeof e.id === 'string' ? e.id : ''}`
679 const at = await $.clock.now()
680 await update($, runs, list =>
681 list.map(a => (a.id === id && a.status === 'running' ? { ...a, status: 'finished' as const, endedAt: at, nextAt: undefined } : a)),
682 )
683 await update($, now, () => at)
684 await pruneAfterEnd($, options)
685 } catch {
686 // Best effort.
687 }
688 return result
689 }).catch(($, e, next) => (next.called ? undefined : next(e)))
690
691 // The code map: each file tool's call (main loop and subagents) records the file it touched.
692 // One hook for them all (MultiEdit, Grep and Glob are matched by name though this engine may not ship them).
693 const fileTools = new RegExp(`^(?:${FILE_TOOLS.join('|')})$`)
694 on('tool.call', { tool: fileTools }, async ($, e, next) => {
695 const result = await next(e)
696 const tool = e.tool
697 try {
698 // A refused call touched nothing; a failed read neither (a failed edit is drawn ✗).
699 if (result.deny !== undefined || (result.isError === true && !EDIT_TOOLS.has(tool))) return result
700 const input = e as unknown as Record<string, unknown>
701 const touches = toolTouches(tool, input, cwd)
702 const content = typeof input.content === 'string' ? input.content : ''
703 const created = (result.result as { type?: unknown } | undefined)?.type === 'create'
704 // A new file's lines, for its + count while git does not know it yet.
705 const newLines = tool === 'Write' && created ? (content === '' ? 0 : content.replace(/\n$/, '').split('\n').length) : undefined
706 await recordTouches($, touches, e.agentId ?? 'main', result.isError === true && EDIT_TOOLS.has(tool), newLines)
707 } catch {
708 // The code map is best effort: the call goes on whatever happens here.
709 }
710 return result
711 }).catch(($, e, next) => (next.called ? undefined : next(e)))
712
713 on('agent.spawn', async ($, e, next) => {
714 const started = await next(e)
715 if (started.deny !== undefined) return started
716 // Only a run with a local loop is kept: one with no agentId (a workflow's
717 // remote agent) never gets a step or an end here, so it would run for good.
718 const agentId = started.agentId
719 if (!agentId) {
720 pending.delete(e.tool_use_id)
721 return started
722 }
723 try {
724 const at = await $.clock.now()
725 const id = agentId
726 const call = pending.get(e.tool_use_id)
727 pending.delete(e.tool_use_id)
728 await update($, runs, list => {
729 const prev = list.find(a => a.id === id)
730 const run: AgentRun = {
731 id,
732 agentId,
733 type: e.subagentType || prev?.type || 'agent',
734 name: e.name ?? prev?.name,
735 description: e.description || prev?.description || '',
736 model: started.model,
737 effort: call?.effort ?? prev?.effort,
738 status: 'running',
739 startedAt: at,
740 contextTokens: prev?.contextTokens ?? 0,
741 contextMax: windowOf(started.model),
742 tokens: prev?.tokens ?? 0,
743 costUsd: prev?.costUsd ?? 0,
744 isUnpriced: prev?.isUnpriced,
745 steps: prev?.steps ?? 0,
746 }
747 return [...list.filter(a => a.id !== id), run].slice(-MAX_RUNS)
748 })
749 await update($, now, () => at)
750 } catch {
751 // The books are best effort: the spawn goes on whatever happens here.
752 }
753 return started
754 }).catch(($, e, next) => (next.called ? undefined : next(e)))
755
756 // The main loop's turn: its model and whether it runs. A woken subagent runs again.
757 on('turn.start', async ($, e, next) => {
758 const started = await next(e)
759 try {
760 const at = await $.clock.now()
761 if (e.agentId === undefined) {
762 const model = await readModel($)
763 // No session id read: the first main turn's id picks the hub pose instead, kept after.
764 await update($, main, m => ({ ...m, model: model || m.model, isRunning: true, startedAt: at, id: m.id || e.turnId }))
765 // A new main-loop turn: the code map's turn scope moves on (the turn before kept once).
766 await update($, turn, n => n + 1)
767 } else {
768 const agentId = e.agentId
769 if ((await read($, runs)).some(a => a.agentId === agentId && a.status !== 'running')) {
770 await updateRun($, agentId, a => ({ ...a, status: 'running', endedAt: undefined }))
771 }
772 }
773 } catch {
774 // Best effort.
775 }
776 return started
777 })
778
779 // Each model request of a subagent: one step, its context, tokens and cost.
780 on('turn.step', async function* ($, e, next) {
781 const agentId = e.agentId
782 const effort = typeof e.effort === 'string' ? e.effort : typeof e.effort === 'number' ? String(e.effort) : undefined
783 if (agentId) {
784 try {
785 const at = await $.clock.now()
786 await updateRun($, agentId, a => {
787 const cycle = a.stepStartedAt !== undefined ? Math.max(0, at - a.stepStartedAt) : undefined
788 return {
789 ...a,
790 effort: effort ?? a.effort,
791 stepStartedAt: at,
792 awaitingSince: undefined,
793 stepMsTotal: (a.stepMsTotal ?? 0) + (cycle ?? 0),
794 stepCycles: (a.stepCycles ?? 0) + (cycle === undefined ? 0 : 1),
795 }
796 })
797 } catch {
798 // Best effort.
799 }
800 }
801 const result = yield* next(e)
802 if (!agentId) return result
803 try {
804 const at = await $.clock.now()
805 const usage: Usage | null = result.usage
806 const model = result.usage?.model || e.model
807 await updateRun($, agentId, a => {
808 const cost = usage ? costOf(model, usage) : 0
809 return {
810 ...a,
811 model,
812 steps: a.steps + 1,
813 awaitingSince: result.toolUses.length > 0 ? at : undefined,
814 ...(usage
815 ? {
816 contextTokens: tokensOf(usage),
817 contextMax: windowOf(model),
818 tokens: a.tokens + tokensOf(usage),
819 costUsd: a.costUsd + (cost ?? 0),
820 isUnpriced: a.isUnpriced || cost === null || undefined,
821 }
822 : {}),
823 }
824 })
825 } catch {
826 // Best effort.
827 }
828 return result
829 })
830
831 on('turn.complete', async ($, e, next) => {
832 try {
833 const at = await $.clock.now()
834 const agentId = e.agentId
835 if (agentId === undefined) {
836 await update($, main, m => ({ ...m, isRunning: false }))
837 } else {
838 const before = (await read($, runs)).find(a => a.agentId === agentId)
839 await updateRun($, agentId, a => {
840 // A run whose steps went unseen still gets the turn's own sum.
841 const usage = a.steps === 0 ? e.usage : undefined
842 const cost = usage ? costOf(usage.model || a.model, usage) : 0
843 return {
844 ...a,
845 status: e.reason === 'answer' ? 'finished' : 'error',
846 endedAt: at,
847 stepStartedAt: undefined,
848 awaitingSince: undefined,
849 ...(usage
850 ? {
851 model: usage.model || a.model,
852 tokens: tokensOf(usage),
853 costUsd: cost ?? 0,
854 isUnpriced: cost === null || undefined,
855 }
856 : {}),
857 }
858 })
859 await update($, now, () => at)
860 if (before?.status === 'running') {
861 const ended = (await read($, runs)).find(a => a.agentId === agentId)
862 if (ended) toastEnd($, ended, options)
863 }
864 // Its shells that end with its final answer get no notification: they end here.
865 for (const sh of (await read($, runs)).filter(a => a.parentAgentId === agentId && a.endsWithParent && a.status === 'running')) {
866 if (sh.taskId) await endShell($, { id: sh.taskId, status: 'killed' }, options)
867 }
868 await pruneAfterEnd($, options)
869 }
870 } catch {
871 // Best effort.
872 }
873 return next(e)
874 })
875
876 // The Peers view's requests: the HERDR-ORCH envelope headers in this session's
877 // prompts (typed, pasted, a peer's message), kept once the prompt entered. Nothing is changed.
878 on('prompt.submit', async ($, e, next) => {
879 const r = await next(e)
880 try {
881 if (!e.command && r.drop === undefined) await noteEnvelopes($, e.text)
882 } catch {
883 // A failed note keeps the requests as they were.
884 }
885 return r
886 }).catch(($, e, next) => (next.called ? undefined : next(e)))
887
888 // Skills fired, for the band: counted once per expansion while its batch is
889 // live (a skill fired with no band up belongs to no batch), cleared when the band hides.
890 on('skill.prompt', async ($, e, next) => {
891 const result = await next(e)
892 try {
893 const at = await $.clock.now()
894 if (batchOf(await read($, runs), at, await read($, dismissedAt)).length === 0) return result
895 await update($, skills, s => ({ ...s, [e.skill]: (Object.hasOwn(s, e.skill) ? (s[e.skill] ?? 0) : 0) + 1 }))
896 } catch {
897 // Best effort.
898 }
899 return result
900 })
901
902 on('ui.render', { component: 'Pane', requestId: 'agents' }, ($, e) => drawPane($, e, options))
903 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawBand($, e, () => next(e), options))
904}
905hooks/band.tsx 232 lines1// The AGENTS band above the prompt, in the shape of the "Claude Code Mods"
2// deck (slide 15): a pink chip, the main loop's model while a turn runs, one
3// entry per run of the current batch (a subagent, or a background shell with
4// its elapsed time), the skills fired, then ×N (opens the pane) and ✕ (hides
5// the band until a new run starts). Its row sits in the zehui-mods stack at
6// AGENTS 50 (stackRow); other mods' bands follow.
7import { atom, read } from 'claude-code'
8import type { EngineInterface, PluginOptions, RenderElement, RenderInput } from 'claude-code'
9
10import type { AgentRun } from '../types'
11import {
12 MODEL_COLOR,
13 batchOf,
14 clip,
15 dismissBand,
16 elapsed,
17 familyOf,
18 fmtTime,
19 isBandOn,
20 isShell,
21 isStale,
22 isWatching,
23 modelTag,
24 openPane,
25 readRuns,
26 shellWords,
27 stackKey,
28 stackRow,
29 watchTime,
30} from './kit'
31
32const main = atom({ plugin: 'agents', key: 'main' } as const, { model: '', isRunning: false, startedAt: 0 })
33const skills = atom({ plugin: 'agents', key: 'skills' } as const, {})
34const dismissedAt = atom({ plugin: 'agents', key: 'dismissedAt' } as const, 0)
35const isCaffeinated = atom({ plugin: 'agents', key: 'isCaffeinated' } as const, false)
36
37export const CHIP = ' ◎ AGENTS ' // padded to the widest zehui-mods chip (HANDOFF) so the colour bars line up
38const CHIP_FG = '#0b0b0f'
39const CHIP_BG = '#f472b6'
40const RUNNING = '#f59e0b'
41const DONE = '#22c55e'
42const FAILED = '#ef4444'
43const SKILL = '#a3e635'
44/** A shell's words (`sh`, `codex gpt-5.4`): grey, so they never read as a Claude model. */
45export const SHELL_WORD = '#9a9a96'
46/** A stale entry's grey: a notification for a target already done. */
47export const STALE_GREY = '#6b7280'
48/** The caffeinate hold's mark at the end of the row. */
49export const CAFFEINE = '☕'
50/** The task text's cut. */
51export const TASK_MAX = 22
52/** Under this many columns the band takes its short form. */
53export const SHORT_BELOW = 40
54export const BAND_BAR = 6
55
56/** One run of text in the band's line: its words and how they are drawn. */
57type Seg = { text: string; color?: string; isDim?: boolean; isBold?: boolean; bg?: string }
58
59const glyphOf = (a: AgentRun): Seg =>
60 a.status === 'finished' ? { text: '✓', color: DONE } : a.status === 'error' ? { text: '✗', color: FAILED } : { text: '⟳', color: RUNNING }
61
62const modelSeg = (model: string, text: string): Seg => {
63 const color = MODEL_COLOR[familyOf(model)]
64 return color ? { text, color } : { text, isDim: true }
65}
66
67/** The words an entry starts with, the part the narrowest band cuts. */
68const labelOf = (a: AgentRun): string => (isWatching(a) ? (a.kind ?? 'watch') : isShell(a) ? shellWords(a) : a.name || a.type)
69
70/**
71 * One entry: type or name (cut to `labelMax`), model word, the task in quotes
72 * (unless dropped), the status glyph. A shell's: its grey words (`sh`, or the
73 * agent and model), the command or prompt in quotes, its elapsed time, the glyph.
74 */
75const entrySegs = (a: AgentRun, isTask: boolean, at: number, labelMax = Number.POSITIVE_INFINITY): Seg[] => {
76 if (isWatching(a)) {
77 // `watch deploy a15f4e9 ⏳ 12m`, `cron 20:30 in 41m`; ✓ or ✗ once ended; all grey while stale.
78 const segs: Seg[] = [{ text: clip(labelOf(a), labelMax), color: SHELL_WORD }]
79 if (a.kind === 'watch' && isTask && a.description) segs.push({ text: ' ' + clip(a.description, TASK_MAX), isDim: true })
80 if (a.kind === 'watch' && a.target) segs.push({ text: ' ' + a.target, isDim: true })
81 if (a.status === 'running') segs.push({ text: ' ' + watchTime(a, at) })
82 else segs.push({ text: ' ' }, glyphOf(a))
83 return isStale(a, at) ? segs.map(s => ({ text: s.text, color: STALE_GREY })) : segs
84 }
85 const task: Seg[] = isTask && a.description ? [{ text: ` "${clip(a.description, TASK_MAX)}"`, isDim: true }] : []
86 if (isShell(a)) {
87 return [{ text: clip(labelOf(a), labelMax), color: SHELL_WORD }, ...task, { text: ` ${fmtTime(elapsed(a, at))} ` }, glyphOf(a)]
88 }
89 return [{ text: clip(labelOf(a), labelMax) + ' ' }, modelSeg(a.model, familyOf(a.model) || 'model'), ...task, { text: ' ' }, glyphOf(a)]
90}
91
92const widthOf = (segs: readonly Seg[]): number => segs.reduce((w, s) => w + [...s.text].length, 0)
93
94export type BandParts = {
95 batch: readonly AgentRun[]
96 mainModel: string | null
97 skills: readonly [name: string, count: number][]
98 /** The time the shells' elapsed times are drawn at. */
99 at?: number
100}
101
102/**
103 * The full line fitted to `room` columns: task texts are dropped oldest first,
104 * then the skills down to one, then the tail of the entries folds into
105 * `+N more`, then the main segment shrinks to `main ⟳` and goes; last, the
106 * kept skill's name and the first entry's label are cut with `…`. The first
107 * entry and one skill always stay (at a very narrow room the line may still
108 * overrun, and the Text's truncate-end cuts its tail).
109 */
110export function fitLine(p: BandParts, room: number): Seg[] {
111 const n = p.batch.length
112 const hasTask = p.batch.map(() => true)
113 let skillCount = p.skills.length
114 let shown = n
115 let mainForm: 'full' | 'short' | 'none' = 'full'
116 let skillMax = Number.POSITIVE_INFINITY
117 let labelMax = Number.POSITIVE_INFINITY
118 const build = (): Seg[] => {
119 const segs: Seg[] = [{ text: CHIP, color: CHIP_FG, bg: CHIP_BG, isBold: true }, { text: ' ' }]
120 if (p.mainModel !== null && mainForm !== 'none') {
121 segs.push({ text: 'main ' })
122 if (mainForm === 'full') segs.push(modelSeg(p.mainModel, modelTag(p.mainModel)), { text: ' ' })
123 segs.push({ text: '⟳', color: RUNNING }, { text: ' │ ', isDim: true })
124 }
125 p.batch.slice(0, shown).forEach((a, i) => {
126 if (i) segs.push({ text: ' · ', isDim: true })
127 segs.push(...entrySegs(a, hasTask[i] ?? false, p.at ?? 0, i === 0 ? labelMax : undefined))
128 })
129 if (shown < n) segs.push({ text: ` +${n - shown} more`, isDim: true })
130 if (skillCount > 0) {
131 const list = p.skills
132 .slice(0, skillCount)
133 .map(([name, count]) => `${clip(name, skillMax)} ×${count}`)
134 .join(', ')
135 segs.push({ text: ' │ ', isDim: true }, { text: `skills: ${list}`, color: SKILL })
136 }
137 return segs
138 }
139 const over = (): number => widthOf(build()) - room
140 for (let i = 0; i < n && over() > 0; i++) hasTask[i] = false
141 if (over() > 0) skillCount = Math.min(skillCount, 1)
142 while (shown > 1 && over() > 0) shown--
143 if (p.mainModel !== null && over() > 0) mainForm = 'short'
144 if (p.mainModel !== null && over() > 0) mainForm = 'none'
145 const skill = p.skills[0]?.[0]
146 if (skillCount > 0 && skill !== undefined && over() > 0) skillMax = Math.max(2, skill.length - over())
147 const first = p.batch[0]
148 if (first && over() > 0) labelMax = Math.max(2, labelOf(first).length - over())
149 return build()
150}
151
152/** The short form's bar: `width` cells, filled by the share of the batch that ended. */
153export const shortBar = (batch: readonly AgentRun[], width = BAND_BAR): [filled: string, empty: string] => {
154 const ended = batch.filter(a => a.status !== 'running').length
155 const n = batch.length ? Math.round((ended / batch.length) * width) : 0
156 return ['▰'.repeat(n), '▱'.repeat(width - n)]
157}
158
159export async function drawBand($: EngineInterface, e: RenderInput<'AbovePrompt'>, pass: () => Promise<RenderElement>, options: PluginOptions) {
160 if (e.props.hasSurvey || !isBandOn(options.band) || e.surface !== 'terminal') return pass()
161 const { list, at } = await readRuns($)
162 const batch = batchOf(list, at, await read($, dismissedAt))
163 if (batch.length === 0) return pass()
164 const ordered = [...batch].sort((a, b) => a.startedAt - b.startedAt)
165 const m = await read($, main)
166 const fired = Object.entries(await read($, skills))
167 const isAwake = await read($, isCaffeinated)
168
169 // Other mods' bands beneath this row; stackRow puts this row in its slot
170 // among the zehui-mods rows and never nests the engine's own placeholder.
171 const below = await pass().catch(() => null)
172
173 const { Box, Text, Button } = $.ui.resolve(e)
174 const n = ordered.length
175 const crew = `×${n}`
176 const cols = e.props.bodyColumns > 0 ? e.props.bodyColumns : 80
177 // Framed buttons: `[ ×N ]` and `[ ✕ ]`, each after a one-column gap; ☕ (two cells) after them while held.
178 const buttonsW = crew.length + 4 + 1 + 5 + 1 + (isAwake ? 3 : 0)
179 const isLive = ordered.some(a => a.status === 'running')
180
181 let line: JSX.Element
182 if (cols < SHORT_BELOW) {
183 const [filled, empty] = shortBar(ordered)
184 // Shells count too: with any in the batch, the word is `tasks`.
185 const noun = ordered.some(a => isShell(a) || isWatching(a)) ? (n === 1 ? 'task' : 'tasks') : n === 1 ? 'agent' : 'agents'
186 line = (
187 <Text wrap="truncate-end">
188 <Text color={isLive ? RUNNING : DONE}>{`● ${n} ${noun} `}</Text>
189 <Text color={DONE}>{filled}</Text>
190 <Text dimColor>{empty}</Text>
191 </Text>
192 )
193 } else {
194 const segs = fitLine({ batch: ordered, mainModel: m.isRunning ? m.model || 'main' : null, skills: fired, at }, cols - buttonsW)
195 line = (
196 <Text wrap="truncate-end">
197 {segs.map((s, i) => (
198 <Text
199 key={`seg-${i}`}
200 {...(s.color ? { color: s.color } : {})}
201 {...(s.bg ? { backgroundColor: s.bg } : {})}
202 {...(s.isBold ? { bold: true } : {})}
203 {...(s.isDim ? { dimColor: true } : {})}
204 >
205 {s.text}
206 </Text>
207 ))}
208 </Text>
209 )
210 }
211
212 const row = (
213 <Box key={stackKey('agents')} flexDirection="row" gap={1}>
214 <Box key="agents-line" flexGrow={1} flexShrink={1}>
215 {line}
216 </Box>
217 <Box flexShrink={0}>
218 <Button key="agents-crew" label={crew} onPress={() => void openPane($)} />
219 </Box>
220 <Box flexShrink={0}>
221 <Button key="agents-dismiss" label="✕" role="dismiss" onPress={() => void dismissBand($)} />
222 </Box>
223 {isAwake ? (
224 <Box key="agents-awake" flexShrink={0}>
225 <Text>{CAFFEINE}</Text>
226 </Box>
227 ) : null}
228 </Box>
229 )
230 return stackRow(row, below)
231}
232hooks/codemap.ts 319 lines1// The code map's books: which files the tools touched (Read, Edit, Write,
2// MultiEdit, NotebookEdit, Grep, Glob, and the paths a Bash command names),
3// one record per file and main-loop turn, the one-line summaries taken from
4// the edits themselves, `git diff --numstat` read back, and the scopes the
5// Code view draws (this turn, the turn before, or the whole session).
6// Plain data work, no engine calls; register.tsx keeps the books, code.tsx draws.
7import type { CodeScope, CodeState, CodeTouch } from '../types'
8import { lexWords } from './shells'
9
10/** A node is drawn `editing` (amber) this long after an edit. */
11export const EDITING_MS = 30_000
12/** A node's width in cells, its round border included. */
13export const NODE_W = 22
14/** The arrow between consecutive edited files. */
15export const ARROW = '──▶'
16/** Under this many columns the Code view is a tree list. */
17export const TREE_BELOW = 60
18/** Records kept: the oldest turns' go first (the current and previous turn's always stay). */
19export const CODE_MAX = 400
20/** The tools whose calls the map reads, besides Bash. */
21export const FILE_TOOLS = ['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Grep', 'Glob'] as const
22/** The tools that edit the file they name. */
23export const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
24/** The `git diff --numstat` call's limit. */
25export const NUMSTAT_TIMEOUT_MS = 5_000
26
27/** The `summaries` option: `edit` (default), `haiku` (drawn as `edit`: see the README) or `off`. */
28export type Summaries = 'edit' | 'haiku' | 'off'
29export const summariesOf = (option: unknown): Summaries => (option === 'off' ? 'off' : option === 'haiku' ? 'haiku' : 'edit')
30
31/** One touch a tool call made: the file, read or edit, and by which tool. */
32export type Touch = { path: string; isEdit: boolean; tool: string; summary?: string }
33
34/** `/a/b/../c` → `/a/c`; a relative path joined to `cwd` first. */
35export function resolvePath(path: string, cwd: string): string {
36 const joined = path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`
37 const out: string[] = []
38 for (const part of joined.split('/')) {
39 if (part === '' || part === '.') continue
40 if (part === '..') out.pop()
41 else out.push(part)
42 }
43 return '/' + out.join('/')
44}
45
46/** A summary's length cap when recorded (a minified line must not bloat the state). */
47export const SUMMARY_MAX = 80
48const capped = (s: string | undefined): string | undefined => (s !== undefined && s.length > SUMMARY_MAX ? s.slice(0, SUMMARY_MAX - 1) + '…' : s)
49
50const firstLine = (text: string): string | undefined => capped(text.split('\n').map(l => l.trim()).find(l => l !== ''))
51
52/** The Edit's first changed line: the first line of the new text not in the old, else its first line. */
53export function editSummary(oldText: string, newText: string): string | undefined {
54 const old = new Set(oldText.split('\n').map(l => l.trim()))
55 return capped(
56 newText
57 .split('\n')
58 .map(l => l.trim())
59 .find(l => l !== '' && !old.has(l)),
60 ) ?? firstLine(newText)
61}
62
63const str = (v: unknown): string => (typeof v === 'string' ? v : '')
64
65/** What one file tool's call touched (Bash aside); empty for a call that names no path. */
66export function toolTouches(tool: string, input: Record<string, unknown>, cwd: string): Touch[] {
67 const isSearch = tool === 'Grep' || tool === 'Glob'
68 const path = str(input.file_path) || str(input.notebook_path) || (isSearch ? str(input.path) : '')
69 // Only a search's path can be a glob; an edited or read file's path is literal (`app/[id]/page.tsx`).
70 if (!path || (isSearch && /[*?[]/.test(path))) return []
71 const abs = resolvePath(path, cwd)
72 if (tool === 'Edit') return [{ path: abs, isEdit: true, tool, summary: editSummary(str(input.old_string), str(input.new_string)) }]
73 if (tool === 'MultiEdit') {
74 const first = Array.isArray(input.edits) ? (input.edits[0] as Record<string, unknown> | undefined) : undefined
75 return [{ path: abs, isEdit: true, tool, summary: first ? editSummary(str(first.old_string), str(first.new_string)) : undefined }]
76 }
77 if (tool === 'Write') return [{ path: abs, isEdit: true, tool, summary: firstLine(str(input.content)) }]
78 if (tool === 'NotebookEdit') return [{ path: abs, isEdit: true, tool, summary: firstLine(str(input.new_source)) }]
79 return [{ path: abs, isEdit: false, tool }]
80}
81
82const OPERATORS = new Set(['&&', '||', ';', '|', '&'])
83const base = (w: string): string => w.slice(w.lastIndexOf('/') + 1)
84/** A word that can be a file the map shows: no flag, glob, variable or device. */
85const isFile = (w: string): boolean => w !== '' && w !== '.' && w !== '-' && !w.startsWith('-') && !/[*?$`{}]/.test(w) && !w.startsWith('/dev/')
86
87/**
88 * The command with each heredoc's body dropped, up to its closing tag line:
89 * the line that opens it stays (`cat > notes.md <<'EOF'` still writes notes.md).
90 * `<<<` here-strings are left alone.
91 */
92export function withoutHeredocs(command: string): string {
93 const out: string[] = []
94 const tags: string[] = []
95 for (const line of command.split('\n')) {
96 if (tags.length > 0) {
97 if (line.replace(/^\t+/, '').trimEnd() === tags[0]) tags.shift()
98 continue
99 }
100 out.push(line)
101 for (const m of line.matchAll(/(?<![<\w])<<-?\s*(['"]?)([A-Za-z_][\w.-]*)\1/g)) tags.push(m[2] ?? '')
102 }
103 return out.join('\n')
104}
105
106/**
107 * The files a Bash command reads or writes, as far as its words say: `cat`
108 * reads, `sed -i` edits, `>` and `>>` write their target, `git add` reads,
109 * `git checkout -- <file>` and `git restore` rewrite, `cp` reads its sources
110 * and writes its target, `mv` writes its target. Anything else touches nothing.
111 */
112export function bashTouches(command: string, cwd: string): Touch[] {
113 const out: Touch[] = []
114 const add = (w: string, isEdit: boolean, tool: string) => {
115 if (isFile(w)) out.push({ path: resolvePath(w, cwd), isEdit, tool })
116 }
117 const segments: string[][] = [[]]
118 const words = lexWords(withoutHeredocs(command))
119 for (let k = 0; k < words.length; k++) {
120 const { text: w, isQuotedStart } = words[k] ?? { text: '', isQuotedStart: false }
121 if (OPERATORS.has(w) && !isQuotedStart) {
122 segments.push([])
123 continue
124 }
125 // A quoted `>` (`grep -c '>' f`) is an argument, not a redirect.
126 if (isQuotedStart) {
127 segments[segments.length - 1]?.push(w)
128 continue
129 }
130 // `>`/`>>` (stdout, or `1>`) write their file; `2>`, `<` and `>&` do not count.
131 const redirect = /^(1?)(>>?)(.*)$/.exec(w)
132 if (redirect && !w.startsWith('>&') && !/^\d*>&/.test(w)) {
133 const target = redirect[3] || words[++k]?.text || ''
134 add(target, true, 'Bash')
135 continue
136 }
137 if (/^\d*(?:[<>]{1,2}|&>{1,2})/.test(w)) {
138 if (/^\d*(?:[<>]{1,2}|&>{1,2})$/.test(w)) k++
139 continue
140 }
141 segments[segments.length - 1]?.push(w)
142 }
143 for (const seg of segments) {
144 let i = 0
145 while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(seg[i] ?? '') || seg[i] === 'sudo' || seg[i] === 'command') i++
146 const head = base(seg[i] ?? '')
147 const args = seg.slice(i + 1)
148 const files = args.filter(isFile)
149 if (head === 'cat') files.forEach(f => add(f, false, 'Bash'))
150 else if (head === 'sed' && args.some(a => /^-i|^--in-place|^-[a-zA-Z]*i/.test(a))) {
151 // The script is the first positional unless -e or -f gave it.
152 const hasScript = args.some(a => a === '-e' || a === '-f' || a.startsWith('--expression') || a.startsWith('--file'))
153 const pos: string[] = []
154 for (let j = 0; j < args.length; j++) {
155 const a = args[j] ?? ''
156 // BSD sed's `-i ''` (or `-i .bak`): the suffix word is not the script.
157 if (a === '-i' && (args[j + 1] === '' || args[j + 1]?.startsWith('.'))) j++
158 else if (a === '-e' || a === '-f') j++
159 else if (!a.startsWith('-')) pos.push(a)
160 }
161 pos.slice(hasScript ? 0 : 1).forEach(f => add(f, true, 'Bash'))
162 } else if (head === 'git') {
163 const sub = args.find(a => !a.startsWith('-'))
164 const rest = args.slice(args.indexOf(sub ?? '') + 1)
165 if (sub === 'add') rest.filter(isFile).forEach(f => add(f, false, 'git add'))
166 else if (sub === 'checkout') {
167 const dash = rest.indexOf('--')
168 if (dash >= 0) rest.slice(dash + 1).forEach(f => add(f, true, 'git checkout'))
169 } else if (sub === 'restore') rest.filter(isFile).forEach(f => add(f, true, 'git restore'))
170 } else if (head === 'cp' || head === 'mv') {
171 if (files.length < 2) continue
172 const target = files[files.length - 1] ?? ''
173 if (head === 'cp') files.slice(0, -1).forEach(f => add(f, false, 'Bash'))
174 add(target, true, 'Bash')
175 }
176 }
177 return out
178}
179
180/** Lines added and removed in `git diff --numstat` output; undefined when it names none (or only binaries). */
181export function parseNumstat(stdout: string): { plus: number; minus: number } | undefined {
182 let plus = 0
183 let minus = 0
184 let isAny = false
185 for (const line of stdout.split('\n')) {
186 const m = /^(\d+)\t(\d+)\t/.exec(line)
187 if (!m) continue
188 plus += Number(m[1])
189 minus += Number(m[2])
190 isAny = true
191 }
192 return isAny ? { plus, minus } : undefined
193}
194
195/** The parent folder of a path. */
196export const dirOf = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/'
197
198/**
199 * The records with one touch added: merged into the file's record for this
200 * turn (tools, who, last touch; for an edit its state, summary and edit
201 * times), or a new record. The oldest turns' records go past CODE_MAX.
202 */
203export function addTouch(list: readonly CodeTouch[], t: Touch & { turn: number; at: number; by: string; isError?: boolean }): CodeTouch[] {
204 const prev = list.find(c => c.path === t.path && c.turn === t.turn)
205 const rec: CodeTouch = prev
206 ? { ...prev, tools: [...prev.tools], by: [...prev.by] }
207 : { path: t.path, turn: t.turn, lastAt: t.at, state: 'read', tools: [], by: [] }
208 rec.lastAt = t.at
209 if (!rec.tools.includes(t.tool)) rec.tools.push(t.tool)
210 if (!rec.by.includes(t.by)) rec.by.push(t.by)
211 if (t.isEdit) {
212 rec.editedAt = rec.editedAt ?? t.at
213 rec.lastEditAt = t.at
214 rec.state = t.isError ? 'failed' : 'changed'
215 if (!t.isError && t.summary) rec.summary = t.summary
216 }
217 const next = prev ? list.map(c => (c === prev ? rec : c)) : [...list, rec]
218 if (next.length <= CODE_MAX) return next
219 const keepFrom = t.turn - 1
220 const old = next.filter(c => c.turn < keepFrom).sort((a, b) => a.turn - b.turn || a.lastAt - b.lastAt)
221 const drop = new Set(old.slice(0, next.length - CODE_MAX))
222 return next.filter(c => !drop.has(c))
223}
224
225/** A file as the map draws it: its records in the scope merged. */
226export type MapFile = {
227 path: string
228 editedAt?: number
229 lastEditAt?: number
230 lastAt: number
231 state: CodeState
232 plus?: number
233 minus?: number
234 tools: string[]
235 summary?: string
236 by: string[]
237 /** The main-loop turns it was touched in. */
238 turns: number[]
239}
240
241/** The map's scope: its files, the turn it shows (session: the current one), and whether it is the turn before. */
242export type ScopeView = { files: MapFile[]; turn: number; isPrevious: boolean; scope: CodeScope }
243
244/**
245 * The files the map shows: this turn's (the turn before's while this one
246 * touched nothing yet), or every turn's merged by path for the session. Edited
247 * files come first in edit order, then the read-only ones by last touch.
248 */
249export function scopeOf(list: readonly CodeTouch[], turn: number, scope: CodeScope): ScopeView {
250 let records = scope === 'session' ? [...list] : list.filter(c => c.turn === turn)
251 let isPrevious = false
252 if (scope === 'turn' && records.length === 0) {
253 const before = list.filter(c => c.turn < turn)
254 const last = before.length ? Math.max(...before.map(c => c.turn)) : undefined
255 if (last !== undefined) {
256 records = before.filter(c => c.turn === last)
257 isPrevious = true
258 }
259 }
260 const byPath = new Map<string, MapFile>()
261 for (const c of [...records].sort((a, b) => a.turn - b.turn || a.lastAt - b.lastAt)) {
262 const f = byPath.get(c.path)
263 if (!f) {
264 byPath.set(c.path, { ...c, tools: [...c.tools], by: [...c.by], turns: [c.turn] })
265 continue
266 }
267 f.lastAt = Math.max(f.lastAt, c.lastAt)
268 f.tools.push(...c.tools.filter(x => !f.tools.includes(x)))
269 f.by.push(...c.by.filter(x => !f.by.includes(x)))
270 if (!f.turns.includes(c.turn)) f.turns.push(c.turn)
271 if (c.editedAt !== undefined) {
272 f.editedAt = f.editedAt ?? c.editedAt
273 f.lastEditAt = c.lastEditAt
274 f.state = c.state
275 f.summary = c.summary ?? f.summary
276 if (c.plus !== undefined) {
277 f.plus = c.plus
278 f.minus = c.minus
279 }
280 }
281 }
282 const all = [...byPath.values()]
283 const edited = all.filter(f => f.editedAt !== undefined).sort((a, b) => (a.editedAt ?? 0) - (b.editedAt ?? 0))
284 const read = all.filter(f => f.editedAt === undefined).sort((a, b) => b.lastAt - a.lastAt)
285 return { files: [...edited, ...read], turn: isPrevious ? Math.max(0, ...records.map(c => c.turn)) : turn, isPrevious, scope }
286}
287
288/** What a node draws for its state now: `editing` for 30 s after an edit that did not fail. */
289export type ShownState = CodeState | 'editing'
290export const shownState = (f: Pick<MapFile, 'state' | 'lastEditAt'>, at: number): ShownState =>
291 f.state !== 'failed' && f.lastEditAt !== undefined && at - f.lastEditAt < EDITING_MS ? 'editing' : f.state
292
293/** How many nodes (each with its arrow) fit across `cols`; at least one. */
294export const nodesPerRow = (cols: number): number => Math.max(1, Math.floor((cols + ARROW.length) / (NODE_W + ARROW.length)))
295
296/** Each file's short name: its base name, with its parent folder when another file in view shares the base name. */
297export function namesOf(paths: readonly string[]): Map<string, string> {
298 const count = new Map<string, number>()
299 for (const p of paths) count.set(base(p), (count.get(base(p)) ?? 0) + 1)
300 return new Map(
301 paths.map(p => {
302 const b = base(p)
303 if ((count.get(b) ?? 0) < 2) return [p, b]
304 const dir = dirOf(p)
305 return [p, `${base(dir)}/${b}`]
306 }),
307 )
308}
309
310/** A path shown relative to `cwd` when under it. */
311export const relPath = (path: string, cwd: string): string => {
312 const root = cwd.replace(/\/+$/, '') + '/'
313 return cwd && path.startsWith(root) ? path.slice(root.length) : path
314}
315
316/** The tools as initials, in first-use order (`ER`), Bash as `B`, git's as `G`. */
317export const toolMarks = (tools: readonly string[]): string =>
318 [...new Set(tools.map(t => (t.startsWith('git') ? 'G' : t === 'MultiEdit' ? 'M' : t === 'NotebookEdit' ? 'N' : t.charAt(0))))].join('')
319hooks/kit.ts 484 lines1// Shared by register.tsx, skin.tsx and band.tsx: the state, the price table,
2// the tier colours and the small formatters.
3// The state atoms are declared again in each file that reads or writes them:
4// the engine reads a state source only from a const of the calling file.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface } from 'claude-code'
7
8import type { AgentRun, MainLoop, PaneView } from '../types'
9import { SHELL_POSE, codexPose, donePose, errorPose, runningPose, waitPose } from './mascots'
10import type { Pose } from './mascots'
11import { lastLineOf } from './shells'
12import { STALE_MS, fmtClock, fmtLeft } from './watch'
13import type { RenderElement, RenderNode } from 'claude-code'
14
15const runs = atom({ plugin: 'agents', key: 'runs' } as const, [])
16const skills = atom({ plugin: 'agents', key: 'skills' } as const, {})
17const main = atom({ plugin: 'agents', key: 'main' } as const, { model: '', isRunning: false, startedAt: 0 })
18const now = atom({ plugin: 'agents', key: 'now' } as const, 0)
19const dismissedAt = atom({ plugin: 'agents', key: 'dismissedAt' } as const, 0)
20const isCompact = atom({ plugin: 'agents', key: 'isCompact' } as const, false)
21const isStill = atom({ plugin: 'agents', key: 'isStill' } as const, false)
22const tails = atom({ plugin: 'agents', key: 'tails' } as const, {})
23const view = atom({ plugin: 'agents', key: 'view' } as const, 'who')
24const isDiffArmed = atom({ plugin: 'agents', key: 'isDiffArmed' } as const, false)
25
26export const PANE = 'agents'
27export const PANE_TITLE = 'Agents'
28/** The band stays up this long after the last run of its batch ends. */
29export const LINGER_MS = 10_000
30/** A run awaiting a tool's result this long sleeps. */
31export const WAIT_MS = 20_000
32/** The bar's bright flash on finish, and the amber pulse on error. */
33export const FLASH_MS = 600
34export const PULSE_MS = 2_000
35/** Runs kept in state; the oldest beyond this are dropped. */
36export const MAX_RUNS = 200
37/** Ended runs (finished or failed) the pane keeps by default; the `keepEnded` option. */
38export const KEEP_ENDED = 10
39/** The `keepEnded` option as a count: a whole number, else the default. */
40export const keepEndedOf = (option: unknown): number => {
41 const n = Number(option)
42 return Number.isInteger(n) && n >= 0 ? n : KEEP_ENDED
43}
44/** The list with only the newest `keep` ended runs (by end time); running runs always stay. */
45export const pruneEnded = (list: readonly AgentRun[], keep: number): AgentRun[] => {
46 const ended = list.filter(a => a.status !== 'running').sort((x, y) => (y.endedAt ?? 0) - (x.endedAt ?? 0))
47 const drop = new Set(ended.slice(keep).map(a => a.id))
48 return drop.size === 0 ? [...list] : list.filter(a => !drop.has(a.id))
49}
50/** A shell's output file over this size is not read for its last line. */
51export const TAIL_MAX_BYTES = 1_048_576
52/** What a numbered card button says until the messages view (E3) lands. */
53export const MESSAGES_STUB = 'messages view comes with E3'
54/** The ALL DONE toast, when the last subagent, shell or watch in flight ends. */
55export const ALL_DONE = '★ all background work done'
56/** The rising chime played with it (copied from the peers mod). */
57export const SOUND_DONE = 'assets/done.wav'
58/** The caffeinate hold's command: idle sleep held, for 600 s at most (the tick re-arms it while work runs). */
59export const CAFFEINATE = ['caffeinate', '-i', '-t', '600'] as const
60/** The `caffeinate` option: anything but `off` is on (default on). */
61export const isCaffeinateOn = (option: unknown): boolean => option !== 'off'
62/** The `sound` option: only `on` is on (default off). */
63export const isSoundOn = (option: unknown): boolean => option === 'on'
64
65/**
66 * Files the Code view's `d` + number (or `/agents-info diff <n>`) asked /diff
67 * for; the 1 s tick runs them through $.command.run, never the press itself.
68 * A reload drops the queue with the environment.
69 */
70export const diffQueue: string[] = []
71
72// --- tiers: the savvy-flow tier colours (savvy-progress register.tsx, Zehui's copy)
73
74export const TIER_COLOR: Record<string, string> = {
75 fable: '#7F77DD',
76 heavy: '#D85A30',
77 careful: '#BA7517',
78 medium: '#378ADD',
79 light: '#1D9E75',
80 other: '#888780',
81}
82
83/**
84 * A savvy-flow worker's tier comes from its type (`savvy-careful`); any other
85 * run's from its model and effort, the way the savvy tiers are defined
86 * (fable: Fable; heavy: Opus xhigh; careful: Opus high; medium: Opus medium;
87 * light: Opus low), with Sonnet as medium and Haiku as light.
88 */
89export const tierOf = (a: Pick<AgentRun, 'type' | 'model' | 'effort'>): string => {
90 const bare = a.type.replace(/^[^:]*:/, '')
91 const savvy = /^savvy-(\w+)$/i.exec(bare)?.[1]?.toLowerCase()
92 if (savvy && Object.hasOwn(TIER_COLOR, savvy)) return savvy
93 const family = familyOf(a.model)
94 if (family === 'fable' || family === 'mythos') return 'fable'
95 if (family === 'opus') {
96 if (a.effort === 'xhigh' || a.effort === 'max') return 'heavy'
97 if (a.effort === 'high') return 'careful'
98 if (a.effort === 'low') return 'light'
99 return 'medium'
100 }
101 if (family === 'sonnet') return 'medium'
102 if (family === 'haiku') return 'light'
103 return 'other'
104}
105
106export const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
107
108// --- models
109
110/** The deck's model colours (the "Claude Code Mods" deck, slide 15). */
111export const MODEL_COLOR: Record<string, string> = {
112 fable: '#d16bff',
113 mythos: '#d16bff',
114 opus: '#ff8c1a',
115 sonnet: '#3d9bff',
116 haiku: '#19c3a6',
117}
118
119const MODEL_RE = /(fable|mythos|opus|sonnet|haiku)(?:-(\d+)(?:-(\d{1,2})(?!\d))?)?/i
120
121/** `opus` for `claude-opus-5-5` (or the alias `opus`); '' for an unknown model. */
122export const familyOf = (model: string): string => MODEL_RE.exec(model)?.[1]?.toLowerCase() ?? ''
123
124/** `5.5` for `claude-opus-5-5`; '' when the id names no version. */
125export const versionOf = (model: string): string => {
126 const m = MODEL_RE.exec(model)
127 return m?.[2] ? `${m[2]}${m[3] ? '.' + m[3] : ''}` : ''
128}
129
130/** `Opus 5.5`: the card's model name; the id itself (less `claude-`) when unknown. */
131export const modelName = (model: string): string => {
132 const family = familyOf(model)
133 if (!family) return model.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
134 const v = versionOf(model)
135 return `${family.charAt(0).toUpperCase()}${family.slice(1)}${v ? ' ' + v : ''}`
136}
137
138/** `fable·5.1`: the band's word for the main loop's model. */
139export const modelTag = (model: string): string => {
140 const family = familyOf(model)
141 if (!family) return modelName(model)
142 const v = versionOf(model)
143 return v ? `${family}·${v}` : family
144}
145
146// USD per million tokens: input, output, cache read, cache write. From the
147// claude-api skill bundled with Claude Code 2.1.303 (model table cached
148// 2026-10-06; cache reads as listed there; cache writes at the 5-minute rate,
149// 1.25x input, since the engine reports one write count with no TTL split).
150type Price = readonly [input: number, output: number, cacheRead: number, cacheWrite: number]
151
152const PRICES: Record<string, Price> = {
153 'fable-5.1': [10, 50, 0.25, 12.5],
154 'mythos-5.1': [10, 50, 0.25, 12.5],
155 'fable-5': [10, 50, 1, 12.5],
156 'mythos-5': [10, 50, 1, 12.5],
157 'opus-5.5': [4, 20, 0.2, 5],
158 'opus-5': [5, 25, 0.5, 6.25],
159 'opus-4.8': [5, 25, 0.5, 6.25],
160 'opus-4.7': [5, 25, 0.5, 6.25],
161 'opus-4.6': [5, 25, 0.5, 6.25],
162 'sonnet-5.5': [2, 10, 0.2, 2.5],
163 'sonnet-5': [2, 10, 0.2, 2.5],
164 'sonnet-4.6': [3, 15, 0.3, 3.75],
165 'haiku-5.5': [0.1, 0.5, 0.01, 0.125],
166 'haiku-4.5': [1, 5, 0.1, 1.25],
167}
168// Haiku 5.5 is priced by prompt length: a request whose prompt (input and cache
169// reads and writes) is over 100K tokens pays these instead.
170const HAIKU_55_LONG: Price = [0.5, 2.5, 0.05, 0.625]
171const HAIKU_55_LONG_PROMPT = 100_000
172
173export type Usage = {
174 input_tokens: number
175 output_tokens: number
176 cache_read_input_tokens: number
177 cache_creation_input_tokens: number
178}
179
180export const promptOf = (u: Usage): number =>
181 (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
182
183export const tokensOf = (u: Usage): number => promptOf(u) + (u.output_tokens || 0)
184
185/** One request's cost in USD, or null for a model the table does not know. */
186export const costOf = (model: string, u: Usage): number | null => {
187 const key = `${familyOf(model)}-${versionOf(model)}`
188 const base = PRICES[key]
189 if (!base) return null
190 const [i, o, r, w] = key === 'haiku-5.5' && promptOf(u) > HAIKU_55_LONG_PROMPT ? HAIKU_55_LONG : base
191 return (
192 ((u.input_tokens || 0) * i + (u.output_tokens || 0) * o + (u.cache_read_input_tokens || 0) * r + (u.cache_creation_input_tokens || 0) * w) / 1e6
193 )
194}
195
196/** Haiku 4.5 and the Claude 3 models have a 200K window; the rest 1M. */
197export const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
198
199// --- formatting
200
201export const fmtTokens = (n: number): string =>
202 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
203
204export const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
205
206/** `≈$0.09`, or `≈ ?` for a run on a model with no price. */
207export const costText = (a: Pick<AgentRun, 'costUsd' | 'isUnpriced'>): string => (a.isUnpriced ? '≈ ?' : '≈' + fmtCost(a.costUsd))
208
209export const fmtTime = (ms: number): string => {
210 const s = Math.max(0, Math.round(ms / 1000))
211 const h = Math.floor(s / 3600)
212 const m = Math.floor((s % 3600) / 60)
213 const ss = String(s % 60).padStart(2, '0')
214 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
215}
216
217export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
218
219export const ctxPct = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
220
221/** Cuts `s` to `max` characters, the last an ellipsis. */
222export const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
223
224// --- run state
225
226export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
227/** The braille spinner's frame at `at` (one step per 100 ms); `●` when motion is reduced. */
228export const spinnerAt = (at: number, still: boolean): string => (still ? '●' : (SPINNER[Math.floor(at / 100) % SPINNER.length] ?? '●'))
229
230export const isWaiting = (a: AgentRun, at: number): boolean =>
231 a.status === 'running' && a.awaitingSince !== undefined && at - a.awaitingSince > WAIT_MS
232
233/** True for a background shell's run. */
234export const isShell = (a: Pick<AgentRun, 'kind'>): boolean => a.kind === 'shell'
235
236/** True for a Monitor watcher's or a CronCreate timer's run: the pane lists them under Watching. */
237export const isWatching = (a: Pick<AgentRun, 'kind'>): boolean => a.kind === 'watch' || a.kind === 'cron'
238
239/** In flight: a subagent, shell or watch still running (a cron waiting to fire is not). */
240export const isInFlight = (a: Pick<AgentRun, 'kind' | 'status'>): boolean => a.status === 'running' && a.kind !== 'cron'
241
242/** True while a stale notification greys the entry. */
243export const isStale = (a: Pick<AgentRun, 'staleAt'>, at: number): boolean => a.staleAt !== undefined && at - a.staleAt < STALE_MS
244
245/**
246 * A watch's or cron's time words: `⏳ 12m` left before a watch's deadline
247 * (its elapsed time when persistent), `20:30 in 41m` for a cron's next fire
248 * (its schedule when that is unknown).
249 */
250export const watchTime = (a: AgentRun, at: number): string => {
251 if (a.kind === 'cron') return a.nextAt !== undefined ? `${fmtClock(a.nextAt)} in ${fmtLeft(a.nextAt - at)}` : (a.schedule ?? '')
252 return a.nextAt !== undefined ? `⏳ ${fmtLeft(a.nextAt - at)}` : `⏳ ${fmtTime(elapsed(a, at))}`
253}
254
255/**
256 * The pose a run's card shows now: a pick from its state's pool by its run id,
257 * so it never changes pose within a state. A shell keeps the terminal window
258 * (a codex fuzzy for a coding agent's CLI).
259 */
260export const poseOf = (a: AgentRun, at: number): Pose =>
261 isShell(a)
262 ? a.label
263 ? codexPose(a.id)
264 : SHELL_POSE
265 : a.status === 'finished'
266 ? donePose(a.id)
267 : a.status === 'error'
268 ? errorPose(a.id)
269 : isWaiting(a, at)
270 ? waitPose(a.id)
271 : runningPose(a.id)
272
273/** The id the main loop's hub pose is picked by: the session's id, else its first main turn's, else `main`. */
274export const mainPoseId = (m: Pick<MainLoop, 'id'>): string => m.id || 'main'
275
276/**
277 * Each card's number, 1 up, in pane order: Running, Finished, then Error
278 * (watches and crons are not numbered). The Code view's subagents row uses the same.
279 */
280export const cardNumbers = (list: readonly AgentRun[]): Map<string, number> => {
281 const cards = list.filter(a => !isWatching(a))
282 const ordered = [...cards.filter(a => a.status === 'running'), ...cards.filter(a => a.status === 'finished'), ...cards.filter(a => a.status === 'error')]
283 return new Map(ordered.map((a, i) => [a.id, i + 1]))
284}
285
286/** A shell's words before its task: `sh`, or the agent and its model (`codex gpt-5.4`). */
287export const shellWords = (a: Pick<AgentRun, 'label' | 'model'>): string =>
288 a.label ? `${a.label}${a.model ? ' ' + clip(a.model, 16) : ''}` : 'sh'
289
290/** The toast's cut of a task text. */
291export const TOAST_TASK_MAX = 40
292
293/**
294 * The toast for a run that ended: `✓ Explore "survey the mods" finished in
295 * 0:31`; `✗ sh "pytest -q tests/" exit 1 after 0:42`, `killed after`, or
296 * `failed after` when no exit code is known.
297 */
298export const toastText = (a: AgentRun): string => {
299 if (isWatching(a)) {
300 const what = a.description ? ` "${clip(a.description, TOAST_TASK_MAX)}"` : ''
301 const span = fmtTime(elapsed(a, a.endedAt ?? a.startedAt))
302 return a.status === 'finished' ? `✓ ${a.kind}${what} ended after ${span}` : `✗ ${a.kind}${what} ${a.endWord ?? 'failed'} after ${span}`
303 }
304 const who = isShell(a) ? shellWords(a) : a.name || a.type
305 const task = a.description ? ` "${clip(a.description, TOAST_TASK_MAX)}"` : ''
306 const t = fmtTime(elapsed(a, a.endedAt ?? a.startedAt))
307 if (a.status === 'finished') return `✓ ${who}${task} finished in ${t}`
308 const how = a.endWord === 'killed' ? 'killed' : a.exitCode !== undefined ? `exit ${a.exitCode}` : 'failed'
309 return `✗ ${who}${task} ${how} after ${t}`
310}
311
312/** The `toasts` option: anything but `off` is on. */
313export const isToastsOn = (option: unknown): boolean => option !== 'off'
314
315/** True while the step in flight has taken twice the average of the run's finished steps. */
316export const isSlowStep = (a: AgentRun, at: number): boolean => {
317 const cycles = a.stepCycles ?? 0
318 if (a.status !== 'running' || cycles < 2 || a.stepStartedAt === undefined) return false
319 return at - a.stepStartedAt > (2 * (a.stepMsTotal ?? 0)) / cycles
320}
321
322const isLive = (a: AgentRun, at: number): boolean =>
323 a.status === 'running' || (a.endedAt !== undefined && at - a.endedAt <= LINGER_MS) || isStale(a, at)
324
325/**
326 * The band's batch: the live runs (running, or ended within LINGER_MS) plus the
327 * runs that ended at or after the earliest live start, one level only. Empty
328 * when nothing is live, or when no run of it started after `dismissed`.
329 */
330export function batchOf(list: readonly AgentRun[], at: number, dismissed = 0): AgentRun[] {
331 const live = list.filter(a => isLive(a, at))
332 if (live.length === 0) return []
333 // A watch or cron can wait for hours: it anchors the batch only when nothing else is live.
334 const anchors = live.filter(a => !isWatching(a))
335 const from = Math.min(...(anchors.length ? anchors : live).map(a => a.startedAt))
336 const batch = list.filter(a => isLive(a, at) || (a.endedAt ?? Number.POSITIVE_INFINITY) >= from)
337 return batch.some(a => a.startedAt > dismissed) ? batch : []
338}
339
340/** The time a drawing is drawn at: the tick's, or a later event's. */
341export const drawTime = (list: readonly AgentRun[], tick: number): number =>
342 Math.max(tick, 0, ...list.map(a => Math.max(a.startedAt, a.endedAt ?? 0)))
343
344/** The `band` option: anything but `off` is on. */
345export const isBandOn = (option: unknown): boolean => option !== 'off'
346
347/**
348 * The last output line of each ended shell the pane has none for (one that
349 * ended while the pane was closed, past the tick's linger reads), read once
350 * as the pane opens; never a file over TAIL_MAX_BYTES. Best effort.
351 */
352async function readEndedTails($: EngineInterface): Promise<void> {
353 try {
354 const list = await read($, runs)
355 const have = await read($, tails)
356 const found: Record<string, string> = {}
357 for (const a of list) {
358 if (!isShell(a) || !a.outputFile || a.status === 'running' || have[a.id] !== undefined) continue
359 try {
360 if ((await $.fs.stat(a.outputFile)).size > TAIL_MAX_BYTES) continue
361 const line = lastLineOf(await $.fs.read(a.outputFile))
362 if (line !== undefined) found[a.id] = line
363 } catch {
364 // No file any more: the card keeps its '…'.
365 }
366 }
367 if (Object.keys(found).length === 0) return
368 const ids = new Set(list.map(r => r.id))
369 await update($, tails, t => ({ ...Object.fromEntries(Object.entries(t).filter(([id]) => ids.has(id))), ...found }))
370 } catch {
371 // No state or files to read: the cards keep what they had.
372 }
373}
374
375/**
376 * Opens the pane (in `to`'s view when given), the tick's time brought up to
377 * date first, then fills the ended shells' last lines.
378 */
379export async function openPane($: EngineInterface, to?: PaneView): Promise<void> {
380 const at = await $.clock.now()
381 await update($, now, () => at)
382 if (to !== undefined) await setView($, to)
383 await $.ui.open({ id: PANE, title: PANE_TITLE })
384 await readEndedTails($)
385}
386
387/** Switches the pane's view (`w` Who, `c` Code); the diff pick is dropped. */
388export async function setView($: EngineInterface, to: PaneView): Promise<void> {
389 await update($, view, () => to)
390 await update($, isDiffArmed, () => false)
391}
392
393/** Closes the pane (`q`, `/agents-info close`); false when it was not open. */
394export async function closePane($: EngineInterface): Promise<boolean> {
395 if (!(await $.ui.panes()).some(p => p.id === PANE)) return false
396 await $.ui.close({ id: PANE })
397 return true
398}
399
400/** Toggles the pane; true when it is open afterwards. */
401export async function togglePane($: EngineInterface): Promise<boolean> {
402 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
403 if (isOpen) {
404 await $.ui.close({ id: PANE })
405 return false
406 }
407 await openPane($)
408 return true
409}
410
411/** Hides the band until a new run starts, and clears its skills. */
412export async function dismissBand($: EngineInterface): Promise<void> {
413 const at = await $.clock.now()
414 await update($, dismissedAt, () => at)
415 await update($, skills, () => ({}))
416}
417
418/** The shells' last output lines, by run id (subscribing the drawing to them). */
419export async function readTails($: EngineInterface): Promise<Readonly<Record<string, string>>> {
420 return read($, tails)
421}
422
423/** The runs and the tick's time, read together for a drawing (subscribing it to both). */
424export async function readRuns($: EngineInterface): Promise<{ list: readonly AgentRun[]; at: number }> {
425 const list = await read($, runs)
426 return { list, at: drawTime(list, await read($, now)) }
427}
428
429// Stack order of the zehui-mods band rows (plan 2026-10-20-boards-codemap-recap,
430// section 0): SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70,
431// NEXT 80. Copied unchanged from the canonical
432// ~/.claude/skills/building-claude-code-mods/references/stack-row.tsx; change it
433// there first, then in every band mod.
434
435/** The rows each zehui-mods band draws, by stack order. */
436export const STACK = { sys: 10, plan: 20, handoff: 30, cache: 40, agents: 50, goal: 60, here: 70, next: 80 } as const
437
438/** A row's key: `zm-<order>-<name>`. */
439export const stackKey = (name: keyof typeof STACK): string => `zm-${STACK[name]}-${name}`
440
441type Loose = { type?: unknown; props?: Record<string, unknown> | null; part?: unknown; children?: unknown }
442
443/** The order of a zehui-mods row (`zm-<order>-<name>`), or null for any other node. */
444export const stackOrderOf = (node: unknown): number | null => {
445 const key = node && typeof node === 'object' ? (node as Loose).props?.key : undefined
446 const m = typeof key === 'string' ? /^zm-(\d+)-/.exec(key) : null
447 return m ? Number(m[1]) : null
448}
449
450/**
451 * The column a band hook beneath built: a Box whose only prop is
452 * `flexDirection: 'column'` and whose direct children hold a zm row. A Box with
453 * other props (a border, a padding) is another mod's own drawing, kept whole.
454 */
455const isStack = (node: Loose): node is Loose & { children: unknown[] } =>
456 node.type === 'Box' &&
457 node.props?.flexDirection === 'column' &&
458 Object.keys(node.props).every(k => k === 'flexDirection') &&
459 Array.isArray(node.children) &&
460 node.children.some(c => stackOrderOf(c) !== null)
461
462/**
463 * `row` put in its slot among the zm rows of `below` (what `next(e)` drew):
464 * zm rows sorted by their number, then every other child in its original order.
465 * Nothing beneath (null, a failed next, the engine placeholder): the row alone.
466 */
467export function stackRow(row: RenderElement, below: unknown): RenderElement {
468 const tree = below && typeof below === 'object' && (below as Loose).type !== 'engine' ? (below as Loose) : null
469 if (!tree) return row
470 let items: unknown[] = [tree]
471 if (isStack(tree)) {
472 items = [...tree.children]
473 // The column's mark (whose drawing started there) moves to the one row
474 // that carries none, its own; the engine lets a mark move, never multiply.
475 const bare = items.filter(c => c && typeof c === 'object' && !(c as Loose).part)
476 if (tree.part && bare.length === 1) items = items.map(c => (c === bare[0] ? { ...(c as object), part: tree.part } : c))
477 }
478 const rows = items.filter(c => stackOrderOf(c) !== null)
479 const others = items.filter(c => stackOrderOf(c) === null)
480 // A stable sort: two rows of one number keep their order, this row after.
481 const sorted = [...rows, row].sort((a, b) => (stackOrderOf(a) ?? 0) - (stackOrderOf(b) ?? 0))
482 return { type: 'Box', props: { flexDirection: 'column' }, children: [...sorted, ...others] as RenderNode[] }
483}
484hooks/shells.ts 281 lines1// Background shells: what a command runs (a coding agent's CLI or a plain
2// shell), the task notification that ends one, and the last line of its output.
3// Plain string work, no engine calls; register.tsx keeps the books.
4
5/** What a shell's command is labelled with: the coding agent and its model, and the task text. */
6export type ShellLabel = {
7 /** `codex`, `opencode` or `claude`; absent for a plain shell. */
8 label?: string
9 /** The model named by `-m`/`--model` (codex also `-c model=`), when given. */
10 model?: string
11 /** The agent's prompt when one is found; else the command, its whitespace folded. */
12 task: string
13}
14
15/** One `<task-notification>` as the engine writes it into the conversation. */
16export type TaskNote = {
17 id: string
18 /** `completed`, `failed`, `killed`, or another word its producer wrote. */
19 status: string
20 toolUseId?: string
21 outputFile?: string
22 /** The exit code its summary names, when it does. */
23 exitCode?: number
24}
25
26const fold = (s: string): string => s.replace(/\s+/g, ' ').trim()
27
28/** One lexed word: its text, and whether its first character was quoted or escaped (so `'>'` is no redirect). */
29export type Word = { text: string; isQuotedStart: boolean }
30
31/** A command's words and control operators, quotes honoured and marked (a small shell lexer, no expansion). */
32export function lexWords(command: string): Word[] {
33 const out: Word[] = []
34 let word: string | null = null
35 let isQuotedStart = false
36 const add = (s: string, isQuoted: boolean) => {
37 if (word === null) isQuotedStart = isQuoted
38 word = (word ?? '') + s
39 }
40 const push = () => {
41 if (word !== null) out.push({ text: word, isQuotedStart })
42 word = null
43 }
44 for (let i = 0; i < command.length; i++) {
45 const c = command.charAt(i)
46 if (c === "'") {
47 const end = command.indexOf("'", i + 1)
48 const stop = end < 0 ? command.length : end
49 add(command.slice(i + 1, stop), true)
50 i = stop
51 } else if (c === '"') {
52 let s = ''
53 let j = i + 1
54 for (; j < command.length && command.charAt(j) !== '"'; j++) {
55 if (command.charAt(j) === '\\' && j + 1 < command.length) j++
56 s += command.charAt(j)
57 }
58 add(s, true)
59 i = j
60 } else if (c === '\\' && i + 1 < command.length) {
61 add(command.charAt(++i), true)
62 } else if (c === '&' && (command.charAt(i - 1) === '>' || command.charAt(i - 1) === '<' || command.charAt(i + 1) === '>')) {
63 // Part of a redirection (`2>&1`, `&>`), not an operator.
64 add(c, false)
65 } else if (c === '&' || c === '|' || c === ';' || c === '\n') {
66 push()
67 const two = command.slice(i, i + 2)
68 if (two === '&&' || two === '||') {
69 out.push({ text: two, isQuotedStart: false })
70 i++
71 } else out.push({ text: c === '\n' ? ';' : c, isQuotedStart: false })
72 } else if (/\s/.test(c)) push()
73 else add(c, false)
74 }
75 push()
76 return out
77}
78
79/** A command's words and control operators, quotes honoured (a small shell lexer, no expansion). */
80export const wordsOf = (command: string): string[] => lexWords(command).map(w => w.text)
81
82const OPERATORS = new Set(['&&', '||', ';', '|', '&'])
83/** Words that run the next word as the command. */
84const WRAPPERS = new Set(['nohup', 'time', 'command', 'exec', 'caffeinate'])
85/** Commands that only set up the one after them. */
86const SETUP = new Set(['cd', 'pushd', 'export', 'source', '.', 'set', 'unset'])
87
88/**
89 * The words of each command that runs, in order (a pipeline's and a list's
90 * segments alike): setup segments, assignments and wrappers skipped.
91 */
92function runSegments(words: readonly string[]): string[][] {
93 const segments: string[][] = [[]]
94 for (let k = 0; k < words.length; k++) {
95 const w = words[k] ?? ''
96 if (OPERATORS.has(w)) segments.push([])
97 else if (/^\d*(?:[<>]|&>)/.test(w)) {
98 // A redirection: `>`, `2>` and `&>` take the next word as their file; `2>&1` and `>out` hold it.
99 if (/^\d*(?:[<>]{1,2}|&>{1,2})$/.test(w)) k++
100 } else segments[segments.length - 1]?.push(w)
101 }
102 const out: string[][] = []
103 for (const seg of segments) {
104 let i = 0
105 for (;;) {
106 const w = seg[i]
107 if (w === undefined) break
108 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) i++
109 else if (WRAPPERS.has(w)) {
110 i++
111 while (seg[i]?.startsWith('-')) i++
112 } else if (w === 'env') {
113 i++
114 while (seg[i]?.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(seg[i] ?? '')) i++
115 } else if (w === 'timeout' || w === 'gtimeout') {
116 i++
117 while (seg[i]?.startsWith('-')) i += /^-(k|s)$|^--(kill-after|signal)$/.test(seg[i] ?? '') ? 2 : 1
118 i++ // the duration
119 } else break
120 }
121 const rest = seg.slice(i)
122 const head = rest[0]
123 if (head === undefined || SETUP.has(head)) continue
124 out.push(rest)
125 }
126 return out
127}
128
129const base = (w: string): string => w.slice(w.lastIndexOf('/') + 1)
130
131// Flags that take a value (the next word), per CLI; any other flag stands alone.
132const CODEX_VALUE = new Set([
133 '-m', '--model', '-c', '--config', '-C', '--cd', '-s', '--sandbox', '-a', '--ask-for-approval', '-p', '--profile',
134 '-i', '--image', '--add-dir', '-o', '--output-last-message', '--output-schema', '--color', '--enable', '--disable', '--local-provider',
135])
136const OPENCODE_VALUE = new Set(['-m', '--model', '-s', '--session', '--agent', '-f', '--file', '--format', '--port', '--attach', '--title', '--command', '--variant'])
137const CLAUDE_VALUE = new Set([
138 '--model', '--output-format', '--input-format', '--permission-mode', '--max-turns', '--allowedTools', '--allowed-tools',
139 '--disallowedTools', '--disallowed-tools', '--append-system-prompt', '--system-prompt', '--add-dir', '--settings', '--mcp-config',
140 '--agents', '--agent', '--session-id', '-r', '--resume', '--fallback-model', '--effort', '--setting-sources', '--plugin-dir',
141 '--betas', '--max-budget-usd', '--json-schema', '--tools', '-n', '--name',
142])
143/** codex subcommands that run no prompt. */
144const CODEX_IDLE = new Set([
145 'login', 'logout', 'mcp', 'mcp-server', 'app-server', 'completion', 'sandbox', 'debug', 'apply', 'a', 'cloud', 'features', 'help',
146 'responses-api-proxy', 'stdio-to-uds', 'generate-ts',
147])
148const CODEX_PROMPT_SUBS = new Set(['exec', 'e', 'review'])
149
150/** The words that are not flags or flag values, in order. */
151function positionals(words: readonly string[], valued: ReadonlySet<string>): string[] {
152 const out: string[] = []
153 for (let i = 0; i < words.length; i++) {
154 const w = words[i] ?? ''
155 if (w === '--') {
156 out.push(...words.slice(i + 1))
157 break
158 }
159 if (w.startsWith('-') && w !== '-') {
160 if (!w.includes('=') && valued.has(w)) i++
161 continue
162 }
163 out.push(w)
164 }
165 return out
166}
167
168/** The value of `-m`/`--model` (or `--model=x`), when given. */
169function modelFlag(words: readonly string[], names: readonly string[]): string | undefined {
170 for (let i = 0; i < words.length; i++) {
171 const w = words[i] ?? ''
172 for (const n of names) {
173 if (w === n && words[i + 1] !== undefined) return words[i + 1]
174 if (w.startsWith(n + '=')) return w.slice(n.length + 1)
175 }
176 }
177 return undefined
178}
179
180const promptOf = (p: string | undefined): string | undefined => (p && p !== '-' ? fold(p) || undefined : undefined)
181
182/** The agent one segment runs (its task, or the whole command when it names none); undefined for any other command. */
183function agentOf(words: readonly string[], command: string): ShellLabel | undefined {
184 const head = base(words[0] ?? '')
185 const args = words.slice(1)
186 if (head === 'codex') {
187 if (args.some(w => w === '--version' || w === '-V' || w === '--help' || w === '-h')) return undefined
188 const pos = positionals(args, CODEX_VALUE)
189 const sub = pos[0]
190 if (sub !== undefined && CODEX_IDLE.has(sub)) return undefined
191 const configModel = args.map((w, i) => (w === '-c' || w === '--config' ? args[i + 1] : undefined)).find(v => v?.startsWith('model='))
192 const model = modelFlag(args, ['-m', '--model']) ?? configModel?.slice('model='.length).replace(/^["']|["']$/g, '')
193 // `codex exec resume [SESSION_ID] [PROMPT]`: with `--last` the first word after `resume` is the prompt.
194 const resumed = pos[1] === 'resume' ? (args.includes('--last') ? pos[2] : pos[3]) : pos[1]
195 const prompt = sub !== undefined && CODEX_PROMPT_SUBS.has(sub) ? resumed : sub === 'resume' || sub === 'fork' ? undefined : sub
196 return { label: 'codex', ...(model ? { model } : {}), task: promptOf(prompt) ?? fold(command) }
197 }
198 if (head === 'opencode' && args[0] === 'run') {
199 const model = modelFlag(args, ['-m', '--model'])
200 const prompt = positionals(args.slice(1), OPENCODE_VALUE).join(' ')
201 return { label: 'opencode', ...(model ? { model } : {}), task: promptOf(prompt) ?? fold(command) }
202 }
203 if (head === 'claude' && args.some(w => w === '-p' || w === '--print')) {
204 const model = modelFlag(args, ['--model'])
205 return { label: 'claude', ...(model ? { model } : {}), task: promptOf(positionals(args, CLAUDE_VALUE)[0]) ?? fold(command) }
206 }
207 return undefined
208}
209
210/**
211 * What a background command runs: `codex exec`, `codex <prompt>` (or another
212 * codex subcommand that runs a prompt), `opencode run` or `claude -p` is that
213 * agent, with the model `-m`/`--model` names, wherever it sits in a pipeline
214 * or list (`git diff | codex exec "review"`); anything else is a plain shell.
215 */
216export function labelOf(command: string): ShellLabel {
217 for (const words of runSegments(wordsOf(command))) {
218 const agent = agentOf(words, command)
219 if (agent) return agent
220 }
221 return { task: fold(command) }
222}
223
224/** The output file a background Bash result names (`Output is being written to: <path>.`). */
225export function outputFileOf(text: string | undefined): string | undefined {
226 const m = /Output is being written to: (\S+)/.exec(text ?? '')
227 return m?.[1]?.replace(/\.$/, '') || undefined
228}
229
230const tag = (body: string, name: string): string | undefined => {
231 const m = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(body)
232 return m?.[1]?.trim() || undefined
233}
234
235/** Every `<task-notification>` in a row's text. */
236export function notesOf(text: string): TaskNote[] {
237 const out: TaskNote[] = []
238 for (const m of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
239 const body = m[1] ?? ''
240 const id = tag(body, 'task-id')
241 const status = tag(body, 'status')
242 if (!id || !status) continue
243 const code = /exit code (-?\d+)/.exec(tag(body, 'summary') ?? '')?.[1]
244 const toolUseId = tag(body, 'tool-use-id')
245 const outputFile = tag(body, 'output-file')
246 out.push({
247 id,
248 status,
249 ...(toolUseId ? { toolUseId } : {}),
250 ...(outputFile ? { outputFile } : {}),
251 ...(code !== undefined ? { exitCode: Number(code) } : {}),
252 })
253 }
254 return out
255}
256
257/** The text of a row's content: its text blocks joined (or the string itself). */
258export function textOfContent(content: unknown): string {
259 if (typeof content === 'string') return content
260 if (!Array.isArray(content)) return ''
261 return content
262 .map(b => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : ''))
263 .join('\n')
264}
265
266/** The longest last output line kept: wider than any pane, so a one-line dump is not held whole. */
267export const LINE_MAX = 240
268
269/** The last line of output with anything on it: escapes stripped, the last `\r` rewrite kept, clipped to LINE_MAX. */
270export function lastLineOf(output: string): string | undefined {
271 const lines = output.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').split('\n')
272 for (let i = lines.length - 1; i >= 0; i--) {
273 const parts = (lines[i] ?? '').split('\r').map(s => s.trimEnd())
274 const shown = [...parts].reverse().find(s => s.trim() !== '')
275 if (shown === undefined) continue
276 const line = fold(shown)
277 return line.length > LINE_MAX ? line.slice(0, LINE_MAX - 1) + '…' : line
278 }
279 return undefined
280}
281hooks/code.tsx 313 lines1// The agents pane's Code view: the files this turn (or the session) touched,
2// drawn as a map of round-bordered nodes 22 wide in edit order with ──▶
3// between them, wrapping by the pane's width; the read-only files in an
4// ALSO READ chip row; the subagents that touched them with their numbered
5// buttons. Under 60 columns a tree list instead; plain text off the terminal.
6// Keys while the pane has the focus: w Who, a turn/session, d then a number
7// /diff on that file (run from the tick), p Peers, q close.
8import { atom, read, update } from 'claude-code'
9import type { Elements, EngineInterface, PluginOptions, RenderInput } from 'claude-code'
10
11import type { AgentRun, CodeScope } from '../types'
12import { ARROW, NODE_W, TREE_BELOW, namesOf, nodesPerRow, scopeOf, shownState, summariesOf, toolMarks } from './codemap'
13import type { MapFile, ScopeView, ShownState } from './codemap'
14import { MESSAGES_STUB, cardNumbers, clip, closePane, diffQueue, setView } from './kit'
15import { showPeers } from './peers'
16import { fmtLeft } from './watch'
17
18const code = atom({ plugin: 'agents', key: 'code' } as const, [])
19const turn = atom({ plugin: 'agents', key: 'turn' } as const, 0)
20const codeScope = atom({ plugin: 'agents', key: 'codeScope' } as const, 'turn')
21const isDiffArmed = atom({ plugin: 'agents', key: 'isDiffArmed' } as const, false)
22
23/** The state colours: read blue, editing amber, changed green, failed red. */
24export const STATE_COLOR: Record<ShownState, string> = {
25 read: '#60a5fa',
26 editing: '#f59e0b',
27 changed: '#22c55e',
28 failed: '#ef4444',
29}
30/** The state glyphs, as the plan words them: `◌ read`, `● editing`, `✓ changed`, `✗ failed`. */
31export const STATE_GLYPH: Record<ShownState, string> = { read: '◌', editing: '●', changed: '✓', failed: '✗' }
32const CHIP_FG = '#0b0b0f'
33const ALSO_BG = '#9ca3af'
34/** Nodes numbered for `d` + number: one digit each. */
35export const MAX_DIFF = 9
36
37/** The edited files in scope, in the map's order: node n is the n-th. */
38const editedOf = (v: ScopeView): MapFile[] => v.files.filter(f => f.editedAt !== undefined)
39
40async function readScope($: EngineInterface): Promise<ScopeView> {
41 return scopeOf(await read($, code), await read($, turn), await read($, codeScope))
42}
43
44/** The path of the code map's node `n` (1 up) in the current scope, or undefined. */
45export async function nodePath($: EngineInterface, n: number): Promise<string | undefined> {
46 if (!Number.isInteger(n) || n < 1) return undefined
47 return editedOf(await readScope($))[n - 1]?.path
48}
49
50/** Queues /diff for a node's file (the tick runs it) and drops the pick. */
51async function pickDiff($: EngineInterface, path: string): Promise<void> {
52 diffQueue.push(path)
53 await update($, isDiffArmed, () => false)
54}
55
56export type CodeData = {
57 list: readonly AgentRun[]
58 at: number
59 cols: number
60 options: PluginOptions
61}
62
63/** `+12 −3`, or '' with no counts. */
64const countsOf = (f: MapFile): string => (f.plus !== undefined ? `+${f.plus} −${f.minus ?? 0}` : '')
65
66/** A folder's last two parts (`agents/hooks`), for the tree list and the plain view. */
67const shortDir = (path: string): string => path.split('/').slice(-3, -1).join('/')
68
69/** The words after the counts: tools, time since the last touch, turn tags (session), subagent numbers. */
70function metaOf(f: MapFile, at: number, scope: CodeScope, numbers: ReadonlyMap<string, number>): string {
71 const subs = f.by.filter(b => b !== 'main').map(b => numbers.get(b))
72 return [
73 countsOf(f),
74 toolMarks(f.tools),
75 fmtLeft(at - f.lastAt),
76 ...(scope === 'session' ? [f.turns.map(t => `t${t}`).join(',')] : []),
77 ...(subs.length ? [subs.map(n => (n ? `@${n}` : '@')).join('')] : []),
78 ]
79 .filter(Boolean)
80 .join(' · ')
81}
82
83/** The header's words: `Code map (11 files) (2 changed)`, and the file being edited. */
84function headOf(v: ScopeView, at: number): { counts: string; editing?: string; scope: string } {
85 const changed = v.files.filter(f => f.state === 'changed').length
86 const editing = [...v.files].filter(f => shownState(f, at) === 'editing').sort((a, b) => (b.lastEditAt ?? 0) - (a.lastEditAt ?? 0))[0]
87 const names = namesOf(v.files.map(f => f.path))
88 return {
89 counts: `(${v.files.length} file${v.files.length === 1 ? '' : 's'}) (${changed} changed)`,
90 ...(editing ? { editing: names.get(editing.path) ?? editing.path } : {}),
91 scope: v.scope === 'session' ? 'session' : v.isPrevious ? `turn ${v.turn} (previous)` : `turn ${v.turn}`,
92 }
93}
94
95export async function drawCode($: EngineInterface, e: RenderInput<'Pane'>, d: CodeData) {
96 const v = await readScope($)
97 const isArmed = await read($, isDiffArmed)
98 if (e.surface === 'terminal') return codeTerminal($, $.ui.resolve(e), v, isArmed, d)
99 return codePlain($.ui.resolve(e), v, d)
100}
101
102/** The two elements every surface's table has, the plain view's whole need. */
103type Shared = { Box: Elements['mobile']['Box']; Text: Elements['mobile']['Text'] }
104
105// --- plain text, for the desktop and the other remote surfaces
106
107function codePlain(ui: Shared, v: ScopeView, d: CodeData) {
108 const { Box, Text } = ui
109 const head = headOf(v, d.at)
110 const names = namesOf(v.files.map(f => f.path))
111 const edited = editedOf(v)
112 const read = v.files.filter(f => f.editedAt === undefined)
113 return (
114 <Box flexDirection="column">
115 <Text bold>{`Code map ${head.counts} · ${head.scope}${head.editing ? ` · editing ${head.editing}` : ''}`}</Text>
116 {v.files.length === 0 && <Text dimColor>No files touched yet.</Text>}
117 {edited.map((f, i) => (
118 <Text key={`plain-${f.path}`}>
119 {`${i + 1} ${STATE_GLYPH[shownState(f, d.at)]} ${names.get(f.path) ?? f.path} ${countsOf(f)}${f.summary ? ' · ' + clip(f.summary, 60) : ''}`}
120 </Text>
121 ))}
122 {read.length > 0 && <Text dimColor>{`Also read: ${read.map(f => names.get(f.path) ?? f.path).join(', ')}`}</Text>}
123 </Box>
124 )
125}
126
127// --- the terminal
128
129function codeTerminal($: EngineInterface, ui: Elements['terminal'], v: ScopeView, isArmed: boolean, d: CodeData) {
130 const { Box, Text, Button } = ui
131 const { at, cols } = d
132 const summaries = summariesOf(d.options.summaries)
133 const head = headOf(v, at)
134 const names = namesOf(v.files.map(f => f.path))
135 const edited = editedOf(v)
136 const alsoRead = v.files.filter(f => f.editedAt === undefined)
137 const numbers = cardNumbers(d.list)
138 const isTree = cols < TREE_BELOW
139
140 const header = (
141 <Box key="code-head">
142 <Text wrap="truncate-end">
143 <Text bold>Code map</Text>
144 <Text dimColor>{` ${head.counts} · ${head.scope}`}</Text>
145 {head.editing ? <Text color={STATE_COLOR.editing}>{` ● editing ${head.editing}`}</Text> : null}
146 </Text>
147 </Box>
148 )
149
150 // The view's keys: framed buttons, each with its /agents-info twin.
151 const keys = (
152 <Box key="code-keys" flexDirection="row" flexWrap="wrap" gap={1}>
153 <Button key="view-who" label="w: who" hotkey="w" onPress={() => void setView($, 'who')} />
154 <Button
155 key="code-scope"
156 label={v.scope === 'session' ? 'a: this turn' : 'a: session'}
157 hotkey="a"
158 onPress={() => void update($, codeScope, s => (s === 'turn' ? 'session' : 'turn'))}
159 />
160 {edited.length > 0 ? (
161 <Button key="code-diff" label={isArmed ? 'd: cancel' : 'd: diff'} hotkey="d" onPress={() => void update($, isDiffArmed, x => !x)} />
162 ) : null}
163 <Button key="view-peers" label="p: peers" hotkey="p" onPress={() => void showPeers($).catch(() => {})} />
164 <Button key="pane-close" label="q: close" hotkey="q" onPress={() => void closePane($)} />
165 </Box>
166 )
167
168 // After `d`: one framed button per numbered node; its digit runs /diff on that file.
169 const picks = isArmed ? (
170 <Box key="diff-picks" flexDirection="row" flexWrap="wrap" gap={1}>
171 <Text color={STATE_COLOR.editing}>diff which file?</Text>
172 {edited.slice(0, MAX_DIFF).map((f, i) => (
173 <Button key={`diff-${i + 1}`} label={String(i + 1)} hotkey={String(i + 1)} onPress={() => void pickDiff($, f.path)} />
174 ))}
175 </Box>
176 ) : null
177
178 const node = (f: MapFile, i: number) => {
179 const state = shownState(f, at)
180 const line3 = summaries === 'off' || !f.summary ? shortDir(f.path) : f.summary
181 return (
182 <Box key={`node-${i + 1}`} width={NODE_W} flexShrink={0} flexDirection="column" borderStyle="round" borderColor={STATE_COLOR[state]} paddingX={1}>
183 <Box flexDirection="row" justifyContent="space-between">
184 <Box flexShrink={1}>
185 <Text wrap="truncate-end">
186 {i < MAX_DIFF ? <Text dimColor>{`${i + 1} `}</Text> : null}
187 <Text bold>{names.get(f.path) ?? f.path}</Text>
188 </Text>
189 </Box>
190 <Box flexShrink={0}>
191 <Text color={STATE_COLOR[state]}>{' ' + STATE_GLYPH[state]}</Text>
192 </Box>
193 </Box>
194 <Text dimColor wrap="truncate-end">
195 {metaOf(f, at, v.scope, numbers) || ' '}
196 </Text>
197 <Text dimColor={state !== 'failed'} {...(state === 'failed' ? { color: STATE_COLOR.failed } : {})} wrap="truncate-end">
198 {state === 'failed' ? 'edit failed' : line3}
199 </Text>
200 </Box>
201 )
202 }
203 const arrow = (key: string) => (
204 <Box key={key} flexShrink={0} marginTop={2}>
205 <Text dimColor>{ARROW}</Text>
206 </Box>
207 )
208
209 // Nodes in edit order, `perRow` to a row; a wrapped row starts with the arrow from the row above.
210 const fitsOne = edited.length <= nodesPerRow(cols)
211 const perRow = fitsOne ? Math.max(1, edited.length) : Math.max(1, Math.floor(cols / (NODE_W + ARROW.length)))
212 const rows: MapFile[][] = []
213 for (let i = 0; i < edited.length; i += perRow) rows.push(edited.slice(i, i + perRow))
214 const map = (
215 <Box key="code-map" flexDirection="column">
216 {rows.map((row, r) => (
217 <Box key={`code-row-${r}`} flexDirection="row">
218 {row.flatMap((f, j) => {
219 const i = r * perRow + j
220 return [...(i > 0 ? [arrow(`arrow-${i}`)] : []), node(f, i)]
221 })}
222 </Box>
223 ))}
224 </Box>
225 )
226
227 // Under 60 columns: the edited files as a tree by folder.
228 const tree = () => {
229 const groups = new Map<string, { f: MapFile; i: number }[]>()
230 edited.forEach((f, i) => {
231 const dir = shortDir(f.path)
232 groups.set(dir, [...(groups.get(dir) ?? []), { f, i }])
233 })
234 return (
235 <Box key="code-tree" flexDirection="column">
236 {[...groups.entries()].map(([dir, items]) => (
237 <Box key={`tree-${dir}`} flexDirection="column">
238 <Text dimColor wrap="truncate-end">
239 {dir || '/'}
240 </Text>
241 {items.map(({ f, i }, k) => {
242 const state = shownState(f, at)
243 return (
244 <Box key={`tree-${i + 1}`}>
245 <Text wrap="truncate-end">
246 <Text dimColor>{`${k === items.length - 1 ? '└' : '├'} ${i < MAX_DIFF ? i + 1 : ' '} `}</Text>
247 <Text color={STATE_COLOR[state]}>{STATE_GLYPH[state] + ' '}</Text>
248 <Text bold>{names.get(f.path) ?? f.path}</Text>
249 <Text dimColor>{countsOf(f) ? ' ' + countsOf(f) : ''}</Text>
250 </Text>
251 </Box>
252 )
253 })}
254 </Box>
255 ))}
256 </Box>
257 )
258 }
259
260 const also =
261 alsoRead.length > 0 ? (
262 <Box key="code-also">
263 <Text wrap="wrap">
264 <Text bold color={CHIP_FG} backgroundColor={ALSO_BG}>
265 {' ALSO READ '}
266 </Text>
267 {alsoRead.map((f, i) => (
268 <Text key={`also-${i}`} dimColor>{` ${names.get(f.path) ?? f.path}`}</Text>
269 ))}
270 </Text>
271 </Box>
272 ) : null
273
274 // The subagents that touched these files, with the Who view's numbered buttons (stubs until E3).
275 const subIds = new Set(v.files.flatMap(f => f.by).filter(b => b !== 'main'))
276 const subs = d.list.filter(a => a.agentId !== undefined && subIds.has(a.agentId) && (numbers.get(a.id) ?? 0) > 0)
277 const subRow =
278 subs.length > 0 ? (
279 <Box key="code-subs" flexDirection="row" flexWrap="wrap" gap={1}>
280 <Text dimColor>subagents</Text>
281 {subs.map(a => {
282 const n = numbers.get(a.id) ?? 0
283 const isKeyed = !isArmed && n <= MAX_DIFF
284 return (
285 <Box key={`sub-${a.id}`} flexDirection="row" gap={1} flexShrink={0}>
286 <Button
287 key={`sub-num-${a.id}`}
288 label={String(n)}
289 {...(isKeyed ? { hotkey: String(n) } : {})}
290 onPress={() => $.ui.toast(MESSAGES_STUB)}
291 />
292 <Text>{clip(a.name || a.type, 16)}</Text>
293 </Box>
294 )
295 })}
296 </Box>
297 ) : null
298
299 return (
300 <Box flexDirection="column">
301 {header}
302 {keys}
303 {picks}
304 <Box flexDirection="column" marginTop={1}>
305 {v.files.length === 0 ? <Text dimColor>No files touched yet.</Text> : null}
306 {edited.length > 0 ? (isTree ? tree() : map) : null}
307 {also}
308 {subRow}
309 </Box>
310 </Box>
311 )
312}
313hooks/peers.ts 504 lines1// The agents pane's Peers view (0.4.0): the other agents of this herdr
2// workspace, as the peers mod's board shows them, with this session's
3// HERDR-ORCH spokes grouped under their request ids, the hub's row first.
4//
5// Ported, not imported: the peers mod's reading code (peers/hooks/kit.ts
6// `parsePeers`, peers/hooks/orch.ts envelope rule, request records, result
7// file check and row words), written again here, since one mod never imports
8// from another's folder. Ours (Zehui Lin, MIT), no third-party code.
9//
10// The envelope header is herdr-setup POLICY.md section 2:
11// [HERDR-ORCH v0] req=<id> from=<agent-name>@<pane-id> type=<type>
12// and the result file is section 4: ~/.local/state/herdr-orch/<req-id>/result.md,
13// valid iff its first two lines are exactly `req: <id>` and `status: <status>`
14// and a non-blank line follows.
15//
16// herdr is read with `$.process.run(['herdr', 'agent', 'list'])` only while the
17// pane shows this view (every 30 s from the 1 s tick, `r` or
18// `/agents-info peers` at once), and the result files with $.fs.
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface } from 'claude-code'
21
22import type { PeerBoard, PeerRequest, PeerRow, PeerStatus } from '../types'
23import { PANE, setView } from './kit'
24
25const view = atom({ plugin: 'agents', key: 'view' } as const, 'who')
26const peerBoard = atom({ plugin: 'agents', key: 'peerBoard' } as const, null)
27const peerRequests = atom({ plugin: 'agents', key: 'peerRequests' } as const, {})
28const peerHub = atom({ plugin: 'agents', key: 'peerHub' } as const, '')
29
30/** The view's read of herdr: every this many ms while shown. */
31export const PEERS_TICK_MS = 30_000
32/** `herdr agent list`'s limit. */
33export const HERDR_TIMEOUT_MS = 5_000
34/** What the view and `/agents-info peers` say outside a herdr pane. */
35export const NEEDS_HERDR = 'The Peers view needs a herdr pane (HERDR_ENV=1 with a workspace and pane id).'
36/** The envelope types POLICY.md lists. */
37export const ENVELOPE_TYPES = ['request', 'handoff', 'progress', 'blocked', 'needs-approval', 'completed', 'failed'] as const
38/** Not in POLICY.md's list, but the hubs send it: only an update to a request already kept. */
39export const FOLLOWUP = 'followup'
40/** POLICY.md section 4: a brief that names no deadline has 30 minutes. */
41export const DEFAULT_DEADLINE_MS = 30 * 60_000
42/** The requests kept; the oldest beyond this are dropped. */
43export const MAX_REQUESTS = 40
44/** A result file over this size is taken as present but not read. */
45export const RESULT_MAX_BYTES = 65_536
46/** The folder of the result files, under the home folder. */
47export const ORCH_DIR = '.local/state/herdr-orch'
48/** A title longer than this is cut, with an ellipsis. */
49export const TITLE_MAX = 48
50const RESULT_STATUSES = ['completed', 'failed', 'blocked']
51const STATUSES: readonly PeerStatus[] = ['idle', 'working', 'blocked', 'done', 'unknown']
52
53// --- herdr agent list
54
55/** One agent of `herdr agent list`. */
56export type Agent = { pane: string; agent: string; status: PeerStatus; title: string }
57
58const asStatus = (v: unknown): PeerStatus => (STATUSES.includes(v as PeerStatus) ? (v as PeerStatus) : 'unknown')
59const asText = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '')
60
61/**
62 * The agents of `workspace` in `herdr agent list`'s JSON (`.result.agents[]`),
63 * this pane among them, in herdr's order. Null when the output is not that
64 * JSON, so a bad read never looks like every peer leaving.
65 */
66export function parseAgents(stdout: string, workspace: string): Agent[] | null {
67 let agents: unknown
68 try {
69 agents = (JSON.parse(stdout) as { result?: { agents?: unknown } }).result?.agents
70 } catch {
71 return null
72 }
73 if (!Array.isArray(agents)) return null
74 const found: Agent[] = []
75 for (const a of agents) {
76 if (!a || typeof a !== 'object') continue
77 const r = a as Record<string, unknown>
78 const pane = asText(r.pane_id)
79 if (!pane || asText(r.workspace_id) !== workspace) continue
80 found.push({ pane, agent: asText(r.agent) || 'agent', status: asStatus(r.agent_status), title: asText(r.terminal_title_stripped).trim() })
81 }
82 return found
83}
84
85/**
86 * Each agent's row at a read at `at`: its time in its state carried from the
87 * last read when that read is recent (within two ticks; an older one, as after
88 * the view was left, may have missed changes) and saw it in the same state,
89 * else from now (then no time is drawn yet).
90 */
91export function rowsOf(agents: readonly Agent[], last: PeerBoard | null, at: number): PeerRow[] {
92 const recent = last && last.isHerdr && at - last.at <= 2 * PEERS_TICK_MS ? last : null
93 const before = recent ? [...recent.rows, ...(recent.self ? [recent.self] : [])] : []
94 return agents.map(a => {
95 const b = before.find(r => r.pane === a.pane)
96 return { ...a, since: b && b.status === a.status ? b.since : at }
97 })
98}
99
100// --- HERDR-ORCH envelopes and requests
101
102// At a line's start, or after `herdr agent prompt <pane>` and an opening quote
103// there (the hub's own Bash line, pasted or typed); never mid-sentence.
104const HEADER = /^[^\S\n]*(?:herdr agent prompt\s+(\S+)\s+)?["'`]?\[HERDR-ORCH v0\] req=(\S+) from=(\S+)@(\S+) type=(\S+)(.*)$/gm
105/** A herdr pane id: `w1:p6`, `wZ:p1`. */
106const PANE_ID = /\bw[A-Za-z0-9]+:p\d+\b/g
107
108/** One envelope header found in a prompt, with what the prompt around it says. */
109export type Envelope = {
110 req: string
111 fromAgent: string
112 fromPane: string
113 type: string
114 /** The spoke the envelope concerns; '' when the text does not say. */
115 target: string
116 round: number | null
117 ceiling: number | null
118 /** The deadline the text names, in milliseconds; null when none. */
119 deadlineMs: number | null
120}
121
122/** A request id safe to use as one folder name: letters, digits, `.`, `_`, `-`, never `.` or `..`. */
123export const isSafeReq = (req: string): boolean => /^[A-Za-z0-9._-]{1,128}$/.test(req) && req !== '.' && req !== '..'
124
125const num = (s: string | undefined): number | null => {
126 const n = s === undefined ? NaN : Number(s)
127 return Number.isInteger(n) && n > 0 && n < 1_000 ? n : null
128}
129
130/** `round 4/5`, `round 4 of 5`, `round=4`, else a `-r4` req suffix; the ceiling from the same or `ceiling 5`, `max rounds 5`. */
131export function roundOf(text: string, req: string): { round: number | null; ceiling: number | null } {
132 const pair = /\bround[ =:]*(\d+)\s*(?:\/|of)\s*(\d+)/i.exec(text)
133 const alone = /\bround[ =:]*(\d+)\b/i.exec(text)
134 const suffix = /-r(\d+)$/i.exec(req)
135 const cap = /\b(?:ceiling|max[ _-]?rounds)[ =:]*(\d+)/i.exec(text)
136 return { round: num(pair?.[1]) ?? num(alone?.[1]) ?? num(suffix?.[1]), ceiling: num(pair?.[2]) ?? num(cap?.[1]) }
137}
138
139/** `deadline: 45 minutes`, `Deadline 2 h`, `DEADLINE: 90 min` in milliseconds; null when the text names none. */
140export function deadlineOf(text: string): number | null {
141 const m = /\bdeadline\b[^0-9\n]{0,12}(\d+)\s*(minutes?|mins?|m|hours?|hrs?|h)\b/i.exec(text)
142 const n = num(m?.[1])
143 if (!m || n === null) return null
144 return /^h/i.test(m[2] ?? '') ? n * 3_600_000 : n * 60_000
145}
146
147const isBrief = (type: string): boolean => type === 'request' || type === 'handoff'
148/** A hub-to-spoke envelope: a brief or a follow-up; every other type is a spoke's report. */
149const isHubToSpoke = (type: string): boolean => isBrief(type) || type === FOLLOWUP
150
151/**
152 * The spoke an envelope concerns: the pane of the `herdr agent prompt <pane>`
153 * its header line starts with, a `to=<pane>` on that line, or (not for an
154 * incoming brief or follow-up, whose body often says how to wake its hub) a
155 * `herdr agent prompt <pane>` elsewhere in the text other than the sender's; else the sender, when that is not
156 * this pane (a spoke's report reaching this session); else the first other
157 * pane id in the text. '' when none. null when this pane is the spoke: a
158 * brief or follow-up from another pane naming no other target (sent to this
159 * pane), or a report this pane sends (its own, to its hub).
160 */
161function targetOf(text: string, line: string, rest: string, type: string, fromPane: string, selfPane: string): string | null {
162 const isIncoming = fromPane !== selfPane
163 if (!isIncoming && !isHubToSpoke(type)) return null
164 // The pane of the header's own `herdr agent prompt` line first (a fan-out names one per line), then `to=`, then the text's first.
165 const own = /^w[A-Za-z0-9]+:p\d+$/.test(line) ? line : undefined
166 const isToThisPane = isIncoming && isHubToSpoke(type)
167 const elsewhere = isToThisPane
168 ? undefined
169 : [...text.matchAll(/\bherdr agent prompt\s+(w[A-Za-z0-9]+:p\d+)\b/g)].map(m => m[1]).find(p => p !== fromPane)
170 const to = own ?? /\bto=(w[A-Za-z0-9]+:p\d+)\b/.exec(rest)?.[1] ?? elsewhere
171 if (to && to !== selfPane && to !== fromPane) return to
172 if (isToThisPane) return null
173 if (isIncoming && /^w[A-Za-z0-9]+:p\d+$/.test(fromPane)) return fromPane
174 const others = [...text.matchAll(PANE_ID)].map(m => m[0]).filter(p => p !== selfPane && p !== fromPane)
175 return others[0] ?? ''
176}
177
178/**
179 * Every envelope header in a prompt's text, in order, with the target, round
180 * and deadline the text gives; an envelope that makes this pane the spoke is
181 * left out of `envelopes`, and its sender (a brief's) is the `hub`: this
182 * session's hub, drawn first when this session sent no requests of its own.
183 */
184export function parseEnvelopes(text: string, selfPane: string): { envelopes: Envelope[]; hub: string } {
185 const envelopes: Envelope[] = []
186 let hub = ''
187 for (const m of text.matchAll(HEADER)) {
188 const [, line = '', req = '', fromAgent = '', fromPane = '', rawType = '', rest = ''] = m
189 // A one-line header in quotes ends its type with the closing quote.
190 const type = rawType.replace(/["'`]+$/, '')
191 if (!isSafeReq(req) || (!(ENVELOPE_TYPES as readonly string[]).includes(type) && type !== FOLLOWUP)) continue
192 const target = targetOf(text, line, rest, type, fromPane, selfPane)
193 if (target === null) {
194 if (fromPane !== selfPane && isBrief(type) && /^w[A-Za-z0-9]+:p\d+$/.test(fromPane)) hub = fromPane
195 continue
196 }
197 envelopes.push({ req, fromAgent, fromPane, type, target, ...roundOf(text, req), deadlineMs: deadlineOf(text) })
198 }
199 return { envelopes, hub }
200}
201
202/**
203 * The requests after these envelopes, submitted at `at`: a new request starts
204 * its deadline clock now (the text's deadline, else 30 minutes for a request
205 * or handoff); a known one keeps its start, adds the spoke pane it names, and
206 * takes a round or deadline the new envelope names, and its type. A follow-up
207 * only updates a known request, and a deadline it names counts from the
208 * follow-up. The newest MAX_REQUESTS are kept.
209 */
210export function recordEnvelopes(prev: Readonly<Record<string, PeerRequest>>, envs: readonly Envelope[], at: number): Record<string, PeerRequest> {
211 const next: Record<string, PeerRequest> = { ...prev }
212 for (const env of envs) {
213 const old = next[env.req]
214 const isFollowup = env.type === FOLLOWUP
215 if (isFollowup && !old) continue
216 const deadlineMs = env.deadlineMs ?? (isBrief(env.type) && !old?.deadlineAt ? DEFAULT_DEADLINE_MS : null)
217 const deadlineFrom = isFollowup ? at : (old?.sentAt ?? at)
218 const panes = old?.panes ?? []
219 next[env.req] = {
220 req: env.req,
221 panes: env.target && !panes.includes(env.target) ? [...panes, env.target] : [...panes],
222 type: env.type,
223 round: env.round ?? old?.round ?? null,
224 ceiling: env.ceiling ?? old?.ceiling ?? null,
225 sentAt: old?.sentAt ?? at,
226 deadlineAt: deadlineMs !== null ? deadlineFrom + deadlineMs : (old?.deadlineAt ?? null),
227 result: old?.result ?? null,
228 }
229 }
230 const kept = Object.values(next)
231 .sort((a, b) => b.sentAt - a.sentAt)
232 .slice(0, MAX_REQUESTS)
233 return Object.fromEntries(kept.map(r => [r.req, r]))
234}
235
236/** The folder of the result files: `<home>/.local/state/herdr-orch`. */
237export const orchRoot = (home: string): string => `${home.replace(/\/+$/, '')}/${ORCH_DIR}`
238/** The result file of a request: `<home>/.local/state/herdr-orch/<req>/result.md`. */
239export const resultPath = (home: string, req: string): string => `${orchRoot(home)}/${req}/result.md`
240
241/**
242 * A result file's status, POLICY.md section 4: the first line exactly
243 * `req: <req>`, the second exactly `status: <status>`, then a non-blank line;
244 * '' when it is not valid so, as while the spoke is still writing it.
245 */
246export function resultStatus(text: string, req: string): string {
247 const [first = '', second = '', ...body] = text.split(/\r?\n/)
248 if (first !== `req: ${req}`) return ''
249 const status = /^status: (\S+)$/.exec(second)?.[1] ?? ''
250 if (!body.some(line => line.trim() !== '')) return ''
251 return RESULT_STATUSES.includes(status) ? status : ''
252}
253
254/** The newest request of each spoke pane: a spoke is drawn under that one only. */
255export function requestByPane(requests: Readonly<Record<string, PeerRequest>>): Map<string, PeerRequest> {
256 const byPane = new Map<string, PeerRequest>()
257 for (const r of Object.values(requests)) {
258 for (const pane of r.panes) {
259 const had = byPane.get(pane)
260 if (!had || r.sentAt > had.sentAt) byPane.set(pane, r)
261 }
262 }
263 return byPane
264}
265
266// --- the view's layout
267
268/** Blocked rows first, the rest in herdr's order. */
269export const blockedFirst = (rows: readonly PeerRow[]): PeerRow[] => [
270 ...rows.filter(r => r.status === 'blocked'),
271 ...rows.filter(r => r.status !== 'blocked'),
272]
273
274/** One request and the spokes drawn under it. */
275export type PeerGroup = { request: PeerRequest; rows: PeerRow[] }
276
277/** What the view draws: the hub's row (and whether it is this pane), the request groups, the other peers. */
278export type PeerLayout = { hub: PeerRow | null; isSelfHub: boolean; groups: PeerGroup[]; others: PeerRow[] }
279
280/**
281 * The view's layout of a board: the hub's row first (this pane when this
282 * session sent requests, else the pane whose brief made this session a
283 * spoke), then each request with its spokes (the groups holding a blocked
284 * spoke first, then the newest first; blocked spokes first within), then the
285 * peers no request names (blocked first). A request no shown peer belongs to
286 * is left out.
287 */
288export function layoutOf(board: PeerBoard, requests: Readonly<Record<string, PeerRequest>>, hubPane: string): PeerLayout {
289 const isSelfHub = Object.values(requests).some(r => r.panes.length > 0)
290 const hub = isSelfHub ? board.self : (board.rows.find(r => r.pane === hubPane) ?? null)
291 const peers = board.rows.filter(r => r.pane !== hub?.pane)
292 const byPane = requestByPane(requests)
293 const groups = new Map<string, PeerGroup>()
294 const others: PeerRow[] = []
295 for (const row of peers) {
296 const request = byPane.get(row.pane)
297 if (!request) {
298 others.push(row)
299 continue
300 }
301 const g = groups.get(request.req) ?? { request, rows: [] }
302 g.rows.push(row)
303 groups.set(request.req, g)
304 }
305 const isBlocked = (g: PeerGroup) => g.rows.some(r => r.status === 'blocked')
306 const ordered = [...groups.values()]
307 .map(g => ({ ...g, rows: blockedFirst(g.rows) }))
308 .sort((a, b) => Number(isBlocked(b)) - Number(isBlocked(a)) || b.request.sentAt - a.request.sentAt)
309 return { hub, isSelfHub, groups: ordered, others: blockedFirst(others) }
310}
311
312// --- words
313
314/** `45s`, `12m`, `1h 05m`. */
315export function durationText(ms: number): string {
316 const s = Math.max(0, Math.floor(ms / 1000))
317 if (s < 60) return `${s}s`
318 const m = Math.floor(s / 60)
319 if (m < 60) return `${m}m`
320 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
321}
322
323/** Each state's glyph and colour; blocked is amber. */
324export const STATE_GLYPH: Record<PeerStatus, string> = { blocked: '▲', working: '●', idle: '○', done: '✓', unknown: '?' }
325export const STATE_COLOR: Record<PeerStatus, string> = {
326 blocked: '#f59e0b',
327 working: '#60a5fa',
328 idle: '#9ca3af',
329 done: '#22c55e',
330 unknown: '#9ca3af',
331}
332
333/** `▲ blocked 4m`: the glyph, the word and the time in the state (left out until a later read sees it so). */
334export const stateText = (r: PeerRow, at: number): string =>
335 `${STATE_GLYPH[r.status]} ${r.status}${r.since < at ? ' ' + durationText(at - r.since) : ''}`
336
337/** `"title"`, cut to TITLE_MAX characters; '' when none. */
338export function titleText(title: string): string {
339 if (!title) return ''
340 const chars = [...title]
341 return `"${chars.length > TITLE_MAX ? chars.slice(0, TITLE_MAX - 1).join('') + '…' : title}"`
342}
343
344/** `⏳ 26m left`, `overdue 4m`; '' when no deadline is known. */
345export function deadlineText(r: PeerRequest, at: number): string {
346 if (r.deadlineAt === null) return ''
347 const left = r.deadlineAt - at
348 return left > 0 ? `⏳ ${durationText(left)} left` : `overdue ${durationText(-left)}`
349}
350
351/** `result.md ✓ completed`, `result.md ✗ failed`, `result.md ▲ blocked`, `result.md (no status)`, `no result.md`. */
352export function resultText(result: string | null): string {
353 if (result === null) return 'no result.md'
354 if (result === '') return 'result.md (no status)'
355 return `result.md ${result === 'completed' ? '✓' : result === 'failed' ? '✗' : '▲'} ${result}`
356}
357
358/** A group's head: `req apr-alp-r4 · round 4/5 · ⏳ 26m left · no result.md`. */
359export function requestText(r: PeerRequest, at: number): string {
360 const round = r.round !== null ? `round ${r.round}${r.ceiling !== null ? '/' + r.ceiling : ''}` : ''
361 // A result file settles the request: its countdown no longer applies.
362 const deadline = r.result ? '' : deadlineText(r, at)
363 return [`req ${r.req}`, round, deadline, resultText(r.result)].filter(Boolean).join(' · ')
364}
365
366/** The view's head: `Peers · 3 in w1 · 1 blocked · read every 30 s`. */
367export function peersHead(board: PeerBoard | null): string {
368 if (!board) return 'Peers · reading herdr…'
369 if (!board.isHerdr) return 'Peers'
370 if (board.error) return `Peers · ${board.error}`
371 const n = board.rows.length
372 const blocked = board.rows.filter(r => r.status === 'blocked').length
373 return ['Peers', `${n} in ${board.workspace}`, ...(blocked > 0 ? [`${blocked} blocked`] : []), 'read every 30 s'].join(' · ')
374}
375
376// --- reading herdr and the result files (state lives here, so the atoms are declared above)
377
378type HerdrIds = { workspace: string; pane: string }
379
380/** This pane's herdr workspace and pane, or null outside herdr. */
381export async function herdrIds($: EngineInterface): Promise<HerdrIds | null> {
382 try {
383 if ((await $.env.get('HERDR_ENV')) !== '1') return null
384 const workspace = await $.env.get('HERDR_WORKSPACE_ID')
385 const pane = await $.env.get('HERDR_PANE_ID')
386 return workspace && pane ? { workspace, pane } : null
387 } catch {
388 return null
389 }
390}
391
392// One read at a time: a slow herdr never stacks reads.
393let isPolling = false
394// When herdr was last read for the view ($.clock time); the tick reads again 30 s after.
395let polledAt = 0
396
397/** The result file of each request a shown spoke belongs to, read with $.fs; only changed records are written. */
398async function readResults($: EngineInterface, board: PeerBoard): Promise<void> {
399 const home = await $.env.get('HOME')
400 if (!home) return
401 const shown = new Set(board.rows.map(r => r.pane))
402 // The folder where every link is followed: a result file is read only when it lands at its own place there.
403 const root = (await $.fs.stat(orchRoot(home), { resolve: true }).catch(() => undefined))?.realPath?.replace(/\/+$/, '')
404 const found: Record<string, string | null> = {}
405 for (const r of Object.values(await read($, peerRequests))) {
406 if (!r.panes.some(p => shown.has(p)) || !isSafeReq(r.req)) continue
407 const path = resultPath(home, r.req)
408 try {
409 if (root === undefined) throw new Error('no result folder')
410 const stat = await $.fs.stat(path, { resolve: true })
411 const isInside = stat.realPath === `${root}/${r.req}/result.md`
412 found[r.req] = isInside && stat.kind === 'file' && stat.size <= RESULT_MAX_BYTES ? resultStatus(await $.fs.read(path), r.req) : ''
413 } catch {
414 // No result file yet.
415 found[r.req] = null
416 }
417 }
418 const list = await read($, peerRequests)
419 if (!Object.entries(found).some(([req, v]) => list[req] !== undefined && list[req].result !== v)) return
420 await update($, peerRequests, l =>
421 Object.fromEntries(Object.entries(l).map(([req, r]) => [req, Object.hasOwn(found, req) ? { ...r, result: found[req] ?? null } : r])),
422 )
423}
424
425/**
426 * One read for the view: `herdr agent list` (5 s at most), this workspace's
427 * agents kept as the board (this pane apart, for the hub row), then the
428 * result files of the requests shown. Outside herdr the board says so and
429 * nothing runs. A failed read keeps the last good board; with none yet, the
430 * board says the read failed. Best effort.
431 */
432export async function pollPeers($: EngineInterface): Promise<void> {
433 if (isPolling) return
434 isPolling = true
435 try {
436 const at = await $.clock.now()
437 polledAt = at
438 const ids = await herdrIds($)
439 if (!ids) {
440 await update($, peerBoard, () => ({ at, workspace: '', isHerdr: false, self: null, rows: [] }))
441 return
442 }
443 const out = await $.process.run(['herdr', 'agent', 'list'], { timeoutMs: HERDR_TIMEOUT_MS }).catch(() => null)
444 const agents = out?.exitCode === 0 ? parseAgents(out.stdout, ids.workspace) : null
445 if (!agents) {
446 const error = !out ? 'herdr agent list did not run' : out.exitCode !== 0 ? `herdr agent list failed (exit ${out.exitCode})` : 'herdr agent list gave no agent list'
447 const last = await read($, peerBoard)
448 if (!last || !last.isHerdr || last.error) await update($, peerBoard, () => ({ at, workspace: ids.workspace, isHerdr: true, self: null, rows: [], error }))
449 return
450 }
451 const rows = rowsOf(agents, await read($, peerBoard), at)
452 const board: PeerBoard = {
453 at,
454 workspace: ids.workspace,
455 isHerdr: true,
456 self: rows.find(r => r.pane === ids.pane) ?? null,
457 rows: rows.filter(r => r.pane !== ids.pane),
458 }
459 await update($, peerBoard, () => board)
460 await readResults($, board)
461 } catch {
462 // herdr gave no answer: the view keeps its last read.
463 } finally {
464 isPolling = false
465 }
466}
467
468/** True while the agents pane is open. */
469async function isPaneOpen($: EngineInterface): Promise<boolean> {
470 try {
471 return (await $.ui.panes()).some(p => p.id === PANE)
472 } catch {
473 return false
474 }
475}
476
477/**
478 * From the 1 s tick: reads herdr when 30 s passed since the last read, and
479 * only while the pane is open in the Peers view. Never awaited by the tick.
480 */
481export async function tickPeers($: EngineInterface, at: number): Promise<void> {
482 if (at - polledAt < PEERS_TICK_MS || isPolling) return
483 if ((await read($, view)) !== 'peers' || !(await isPaneOpen($))) return
484 await pollPeers($)
485}
486
487/** `p`: the Peers view, read now. */
488export async function showPeers($: EngineInterface): Promise<void> {
489 await setView($, 'peers')
490 await pollPeers($)
491}
492
493/** Keeps the HERDR-ORCH requests (and the hub) in a prompt this session submitted. */
494export async function noteEnvelopes($: EngineInterface, text: string): Promise<void> {
495 if (!text.includes('[HERDR-ORCH v0]')) return
496 const ids = await herdrIds($)
497 if (!ids) return
498 const { envelopes, hub } = parseEnvelopes(text, ids.pane)
499 if (hub) await update($, peerHub, () => hub)
500 if (envelopes.length === 0) return
501 const at = await $.clock.now()
502 await update($, peerRequests, l => recordEnvelopes(l, envelopes, at))
503}
504hooks/skin.tsx 517 lines1// The agents pane (/agents-info): stat tiles, ruled section heads and one card
2// per run, a half-block Raster mascot at the left of each (a grey one for a
3// background shell) and a numbered button (1 to 9, a stub until the messages
4// view of E3). Adapted from Zehui's terminal skin for savvy-progress
5// (terminal-skin.tsx, 2026-10-19), with text ▰▱ bars in place of Raster bars.
6// Other surfaces get plain text.
7import { atom, read, update } from 'claude-code'
8import type { Elements, EngineInterface, PluginOptions, RenderInput } from 'claude-code'
9
10import type { AgentRun, MainLoop } from '../types'
11import { drawCode } from './code'
12import { showPeers } from './peers'
13import { drawPeers } from './peerview'
14import {
15 FLASH_MS,
16 cardNumbers,
17 closePane,
18 isStale,
19 isWatching,
20 setView,
21 watchTime,
22 MESSAGES_STUB,
23 PULSE_MS,
24 SPINNER,
25 colorOf,
26 costText,
27 ctxPct,
28 elapsed,
29 fmtCost,
30 fmtTime,
31 fmtTokens,
32 isShell,
33 isSlowStep,
34 isWaiting,
35 modelName,
36 poseOf,
37 readRuns,
38 readTails,
39 shellWords,
40 tierOf,
41 pruneEnded,
42 mainPoseId,
43} from './kit'
44import { boxOf, hubPose, mascotCells, mascotWidth } from './mascots'
45import type { Pose } from './mascots'
46
47const main = atom({ plugin: 'agents', key: 'main' } as const, { model: '', isRunning: false, startedAt: 0 })
48const isCompact = atom({ plugin: 'agents', key: 'isCompact' } as const, false)
49const isStill = atom({ plugin: 'agents', key: 'isStill' } as const, false)
50const runs = atom({ plugin: 'agents', key: 'runs' } as const, [])
51const view = atom({ plugin: 'agents', key: 'view' } as const, 'who')
52
53export const CARD_BAR = 10
54const AMBER = '#f59e0b'
55const GREEN = '#22c55e'
56const FLASH = '#86efac'
57const RED = '#ef4444'
58const AMBER_RGB = 0xf59e0b
59const RED_RGB = 0xef4444
60/** A shell card's spinner and words: grey, never a tier's colour. */
61const SHELL_GREY = '#9a9a96'
62/** Cards numbered 1 to this; the rest get a blank of the button's width. */
63export const MAX_NUMBERED = 9
64/** A framed button's width, `[ 1 ]`. */
65const NUM_W = 5
66/** Narrower than this, the cards draw without mascots. */
67export const MASCOT_MIN_COLS = 56
68
69/** `filled` of `width` cells: at least one while the run is live. */
70export const barFill = (a: AgentRun): number =>
71 a.status === 'finished' ? CARD_BAR : Math.max(1, Math.min(CARD_BAR, Math.round(ctxPct(a) / 10)))
72
73/** The bar's colour: tier while running (amber on a slow step), green, a bright flash on finish, red on error. */
74export const barColor = (a: AgentRun, at: number, still: boolean): string => {
75 if (a.status === 'error') return RED
76 if (a.status === 'finished') return !still && a.endedAt !== undefined && at - a.endedAt < FLASH_MS ? FLASH : GREEN
77 return isSlowStep(a, at) ? AMBER : colorOf(tierOf(a))
78}
79
80/** The status glyph: the spinner in the tier colour while running, ✓ green, ✗ red. */
81const glyphOf = (a: AgentRun, at: number, still: boolean): [glyph: string, color: string] =>
82 a.status === 'finished'
83 ? ['✓', GREEN]
84 : a.status === 'error'
85 ? ['✗', RED]
86 : [still ? '●' : (SPINNER[Math.floor(at / 100) % SPINNER.length] ?? '●'), colorOf(tierOf(a))]
87
88const statusWord = (a: AgentRun, at: number): string =>
89 a.status === 'finished'
90 ? 'finished'
91 : a.status === 'error'
92 ? isShell(a)
93 ? (a.endWord ?? 'failed')
94 : 'error'
95 : isWaiting(a, at)
96 ? 'waiting'
97 : 'running'
98
99/** A card's bold title: the task; a shell's `sh · <command>` (`codex · <prompt>`). */
100const titleOf = (a: AgentRun): string => (isShell(a) ? `${a.label ?? 'sh'} · ${a.description}` : a.description || a.name || a.type)
101
102/** A card's dim detail: context, tokens, cost and time; a shell's `background shell · <elapsed>`. */
103const detailOf = (a: AgentRun, at: number): string =>
104 isShell(a)
105 ? `${a.label ? shellWords(a) + ' · ' : ''}background shell · ${fmtTime(elapsed(a, at))}${a.exitCode !== undefined && a.exitCode !== 0 ? ` · exit ${a.exitCode}` : ''}`
106 : `ctx ${ctxPct(a)}% · ${fmtTokens(a.tokens)} · ${costText(a)} · ${fmtTime(elapsed(a, at))}`
107
108type Totals = { cost: string; tokens: string; time: string }
109
110const totalsOf = (all: readonly AgentRun[], at: number): Totals => {
111 // Watches and crons cost nothing and can wait for hours: the tiles count the cards only.
112 const list = all.filter(a => !isWatching(a))
113 const cost = list.reduce((s, a) => s + a.costUsd, 0)
114 const isUnpriced = list.some(a => a.isUnpriced)
115 const from = Math.min(...list.map(a => a.startedAt))
116 const to = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
117 return {
118 cost: '≈' + fmtCost(cost) + (isUnpriced ? '+?' : ''),
119 tokens: fmtTokens(list.reduce((s, a) => s + a.tokens, 0)),
120 time: list.length ? fmtTime(to - from) : '0:00',
121 }
122}
123
124type PaneData = {
125 list: readonly AgentRun[]
126 at: number
127 main: MainLoop
128 isCompact: boolean
129 still: boolean
130 cols: number
131 /** Each shell's last output line, by run id. */
132 tails: Readonly<Record<string, string>>
133}
134
135export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, options: PluginOptions = {}) {
136 const { list, at } = await readRuns($)
137 // The Code view (`c`, `/agents-info code`) draws the code map instead of the cards.
138 if ((await read($, view)) === 'code') return drawCode($, e, { list, at, cols: e.props.bodyColumns > 0 ? e.props.bodyColumns : 40, options })
139 // The Peers view (`p`, `/agents-info peers`) draws this herdr workspace's agents instead.
140 if ((await read($, view)) === 'peers') return drawPeers($, e)
141 const d: PaneData = {
142 list,
143 at,
144 main: await read($, main),
145 isCompact: await read($, isCompact),
146 still: await read($, isStill),
147 cols: e.props.bodyColumns > 0 ? e.props.bodyColumns : 40,
148 tails: await readTails($),
149 }
150 if (e.surface === 'terminal') return paneTerminal($, $.ui.resolve(e), d)
151 return panePlain($.ui.resolve(e), d)
152}
153
154/** The two elements every surface's table has, the plain pane's whole need. */
155type Shared = { Box: Elements['mobile']['Box']; Text: Elements['mobile']['Text'] }
156
157/** A watch's or cron's line: `watch deploy a15f4e9 · ⏳ 12m`, `cron 20:30 in 41m · "<prompt>"`. */
158const watchWords = (a: AgentRun, at: number): string =>
159 a.kind === 'cron'
160 ? `cron ${a.status === 'running' ? watchTime(a, at) : (a.schedule ?? '')}${a.description ? ` · "${a.description}"` : ''}`
161 : `watch ${a.description || 'monitor'}${a.target ? ' ' + a.target : ''}${a.status === 'running' ? ' · ' + watchTime(a, at) : ''}`
162
163// --- plain text, for the desktop and the other remote surfaces
164
165function panePlain(ui: Shared, d: PaneData) {
166 const { Box, Text } = ui
167 const t = totalsOf(d.list, d.at)
168 return (
169 <Box flexDirection="column">
170 <Text bold>Agents</Text>
171 <Text dimColor>{`Cost ${t.cost} · Tokens ${t.tokens} · Time ${t.time}`}</Text>
172 {d.list.length === 0 && <Text dimColor>No subagents yet.</Text>}
173 {d.list.map(a => (
174 <Text key={`row-${a.id}`}>
175 {isWatching(a)
176 ? `${a.status === 'finished' ? '✓' : a.status === 'error' ? '✗' : '◷'} ${watchWords(a, d.at)}`
177 : `${a.status === 'finished' ? '✓' : a.status === 'error' ? '✗' : '●'} ${titleOf(a)} · ${isShell(a) ? '' : modelName(a.model) + ' · '}${detailOf(a, d.at)}`}
178 </Text>
179 ))}
180 </Box>
181 )
182}
183
184// --- the terminal
185
186function paneTerminal($: EngineInterface, ui: Elements['terminal'], d: PaneData) {
187 const { Box, Text, Button, Raster } = ui
188 const { list, at, still } = d
189 const cols = d.cols
190 const cards = list.filter(a => !isWatching(a))
191 const watching = list.filter(isWatching)
192 const running = cards.filter(a => a.status === 'running')
193 const finished = cards.filter(a => a.status === 'finished')
194 const failed = cards.filter(a => a.status === 'error')
195 const isHub = running.length >= 2
196 const isMascots = !d.isCompact && cols >= MASCOT_MIN_COLS
197 const mascotCol = mascotWidth()
198 const t = totalsOf(list, at)
199 // Every card's number, in pane order: Running, Finished, Error.
200 const numberOf = cardNumbers(list)
201
202 // The card's numbered button: a stub until the messages view (E3) lands. Hotkeys work while the pane has focus.
203 const numButton = (a: AgentRun) => {
204 const n = numberOf.get(a.id) ?? 0
205 if (n < 1 || n > MAX_NUMBERED) return <Box key={`num-${a.id}`} flexShrink={0} width={NUM_W} />
206 return (
207 <Box key={`num-box-${a.id}`} flexShrink={0}>
208 <Button key={`num-${a.id}`} label={String(n)} hotkey={String(n)} onPress={() => $.ui.toast(MESSAGES_STUB)} />
209 </Box>
210 )
211 }
212
213 const toggle = (
214 <Button key="compact" label={d.isCompact ? 'Expand' : 'Collapse'} onPress={() => void update($, isCompact, v => !v)} />
215 )
216 // `x: clear done` drops every ended run; the twin of `/agents-info clear`. Drawn only when there is something to clear.
217 const ended = list.filter(a => a.status !== 'running').length
218 const clear =
219 ended > 0 ? (
220 <Box key="clear-box" flexShrink={0}>
221 <Button key="clear" label="x: clear done" hotkey="x" onPress={() => void update($, runs, l => pruneEnded(l, 0))} />
222 </Box>
223 ) : null
224 // The view keys: `c` the code map (`/agents-info code`), `p` the herdr peers (`/agents-info peers`), `q` close (`/agents-info close`).
225 const viewKeys = [
226 <Box key="view-code-box" flexShrink={0}>
227 <Button key="view-code" label="c: code" hotkey="c" onPress={() => void setView($, 'code')} />
228 </Box>,
229 <Box key="view-peers-box" flexShrink={0}>
230 <Button key="view-peers" label="p: peers" hotkey="p" onPress={() => void showPeers($).catch(() => {})} />
231 </Box>,
232 <Box key="pane-close-box" flexShrink={0}>
233 <Button key="pane-close" label="q: close" hotkey="q" onPress={() => void closePane($)} />
234 </Box>,
235 ]
236
237 // Three round tiles, a one-column gap between; one line when the longest label or value does not fit.
238 const tiles: [label: string, value: string][] = [
239 ['Cost', t.cost],
240 ['Tokens', t.tokens],
241 ['Time', t.time],
242 ]
243 const tileW = Math.floor((Math.max(24, cols) - 2) / 3)
244 const longest = Math.max(...tiles.flat().map(s => s.length))
245 const stats =
246 tileW - 4 < longest || d.isCompact ? (
247 <Text key="stats" wrap="truncate-end">
248 {tiles.map(([k, v], i) => (
249 <Text key={`stat-${i}`}>
250 {i ? <Text dimColor>{' · '}</Text> : null}
251 <Text dimColor>{k + ' '}</Text>
252 <Text bold>{v}</Text>
253 </Text>
254 ))}
255 </Text>
256 ) : (
257 <Box key="stats" flexDirection="row" gap={1}>
258 {tiles.map(([k, v], i) => (
259 <Box key={`tile-${i}`} width={tileW} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
260 <Text dimColor wrap="truncate-end">
261 {k}
262 </Text>
263 <Text bold wrap="truncate-end">
264 {v}
265 </Text>
266 </Box>
267 ))}
268 </Box>
269 )
270
271 // A section head with a thin rule to the body's right edge.
272 const head = (key: string, text: string) => (
273 <Text key={key} dimColor wrap="truncate-end">
274 {`${text} ${'─'.repeat(Math.max(0, cols - text.length - 1))}`}
275 </Text>
276 )
277
278 const mascot = (key: string, pose: Pose, isBobbed: boolean, tint?: number) => {
279 const box = boxOf(pose)
280 return (
281 <Box flexShrink={0} width={mascotCol}>
282 <Raster key={`mascot-${key}`} columns={box.columns} rows={box.rows} cells={mascotCells(pose, { isBobbed, tint })} />
283 </Box>
284 )
285 }
286 const isOddSecond = Math.floor(at / 1000) % 2 === 1
287
288 const bar = (a: AgentRun) => {
289 const n = barFill(a)
290 return (
291 <Box key={`bar-${a.id}`} flexShrink={0} paddingLeft={2}>
292 <Text>
293 <Text color={barColor(a, at, still)}>{'▰'.repeat(n)}</Text>
294 <Text dimColor>{'▱'.repeat(CARD_BAR - n)}</Text>
295 </Text>
296 </Box>
297 )
298 }
299
300 // A background shell's card: its grey mascot, ✓ ✗ or a grey spinner, `sh · <command>`,
301 // `background shell · <elapsed>` and the last line of its output.
302 const shellCard = (a: AgentRun) => {
303 const [glyph, glyphColor] = a.status === 'running' ? [still ? '●' : (SPINNER[Math.floor(at / 100) % SPINNER.length] ?? '●'), SHELL_GREY] : glyphOf(a, at, still)
304 const word = statusWord(a, at)
305 const wordColor = a.status === 'finished' ? GREEN : a.status === 'error' ? RED : SHELL_GREY
306 const tail = d.tails[a.id]
307 if (d.isCompact) {
308 return (
309 <Box key={a.id} flexDirection="row" gap={1}>
310 {numButton(a)}
311 <Box flexGrow={1} flexShrink={1}>
312 <Text wrap="truncate-end">
313 <Text color={glyphColor}>{glyph + ' '}</Text>
314 <Text bold>{titleOf(a)}</Text>
315 <Text dimColor>{` · ${detailOf(a, at)}`}</Text>
316 </Text>
317 </Box>
318 </Box>
319 )
320 }
321 const rows = (
322 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
323 <Box flexDirection="row" gap={1}>
324 {numButton(a)}
325 <Box flexShrink={0}>
326 <Text color={glyphColor}>{glyph}</Text>
327 </Box>
328 <Box flexGrow={1} flexShrink={1}>
329 <Text bold wrap="truncate-end">
330 {titleOf(a)}
331 </Text>
332 </Box>
333 <Box flexShrink={0}>
334 <Text color={wordColor}>{word}</Text>
335 </Box>
336 </Box>
337 <Box key={`detail-${a.id}`}>
338 <Text dimColor wrap="truncate-end">
339 {' ' + detailOf(a, at)}
340 </Text>
341 </Box>
342 <Box key={`tail-${a.id}`}>
343 <Text dimColor wrap="truncate-end">
344 {' ' + (tail ?? '…')}
345 </Text>
346 </Box>
347 </Box>
348 )
349 return (
350 <Box key={a.id} flexDirection="row" gap={1} marginBottom={1}>
351 {isMascots &&
352 mascot(a.id, poseOf(a, at), a.status === 'running' && !still && isOddSecond, a.status === 'error' ? RED_RGB : undefined)}
353 {rows}
354 </Box>
355 )
356 }
357
358 const card = (a: AgentRun) => {
359 if (isShell(a)) return shellCard(a)
360 const tier = tierOf(a)
361 const [glyph, glyphColor] = glyphOf(a, at, still)
362 const word = statusWord(a, at)
363 const wordColor = a.status === 'finished' ? GREEN : a.status === 'error' ? RED : word === 'waiting' ? AMBER : colorOf(tier)
364 const meta = [a.type, modelName(a.model), ...(a.effort ? [a.effort] : [])]
365 if (d.isCompact) {
366 return (
367 <Box key={a.id} flexDirection="row" gap={1}>
368 {numButton(a)}
369 <Box flexGrow={1} flexShrink={1}>
370 <Text wrap="truncate-end">
371 <Text color={glyphColor}>{glyph + ' '}</Text>
372 <Text bold>{titleOf(a)}</Text>
373 <Text dimColor>{` · ${modelName(a.model)} · ${detailOf(a, at)}`}</Text>
374 </Text>
375 </Box>
376 </Box>
377 )
378 }
379 const isPulse = a.status === 'error' && !still && a.endedAt !== undefined && at - a.endedAt < PULSE_MS && Math.floor(at / 250) % 2 === 0
380 const rows = (
381 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
382 <Box flexDirection="row" gap={1}>
383 {numButton(a)}
384 <Box flexShrink={0}>
385 <Text color={glyphColor}>{glyph}</Text>
386 </Box>
387 <Box flexGrow={1} flexShrink={1}>
388 <Text bold wrap="truncate-end">
389 {titleOf(a)}
390 </Text>
391 </Box>
392 <Box flexShrink={0}>
393 <Text color={wordColor}>{word}</Text>
394 </Box>
395 </Box>
396 <Text wrap="truncate-end">
397 {' '}
398 <Text color={colorOf(tier)}>{tier}</Text>
399 <Text dimColor>{' · ' + meta.join(' · ')}</Text>
400 </Text>
401 <Box flexDirection="row">
402 {bar(a)}
403 <Box flexShrink={1}>
404 <Text dimColor wrap="truncate-end">
405 {' ' + detailOf(a, at)}
406 </Text>
407 </Box>
408 </Box>
409 </Box>
410 )
411 return (
412 <Box key={a.id} flexDirection="row" gap={1} marginBottom={1}>
413 {isMascots && mascot(a.id, poseOf(a, at), a.status === 'running' && !still && isOddSecond && !isWaiting(a, at), isPulse ? AMBER_RGB : undefined)}
414 {rows}
415 </Box>
416 )
417 }
418
419 // A watch's or cron's row under Watching, no mascot: its glyph, words and time; a watch's last event beneath.
420 const watchRow = (a: AgentRun) => {
421 const [glyph, glyphColor] = a.status === 'running' ? ['◷', AMBER] : glyphOf(a, at, still)
422 const isGrey = isStale(a, at)
423 return (
424 <Box key={a.id} flexDirection="column">
425 <Text wrap="truncate-end" {...(isGrey ? { dimColor: true } : {})}>
426 <Text {...(isGrey ? {} : { color: glyphColor })}>{glyph + ' '}</Text>
427 <Text bold={!isGrey}>{watchWords(a, at)}</Text>
428 </Text>
429 {a.lastEvent ? (
430 <Text dimColor wrap="truncate-end">
431 {' ' + a.lastEvent}
432 </Text>
433 ) : null}
434 </Box>
435 )
436 }
437
438 // The main loop's card, at the top of Running while two or more subagents run.
439 // `N subagents running`, or `N tasks running` once shells are among them.
440 const runningText = `${running.length} ${running.some(isShell) ? 'tasks' : 'subagents'} running`
441 const hubCard = () => {
442 const m = d.main
443 const tier = tierOf({ type: 'main', model: m.model, effort: undefined })
444 const glyph = m.isRunning ? (still ? '●' : (SPINNER[Math.floor(at / 100) % SPINNER.length] ?? '●')) : '·'
445 const rows = (
446 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
447 <Box flexDirection="row" gap={1}>
448 <Box flexShrink={0}>
449 <Text color={colorOf(tier)}>{glyph}</Text>
450 </Box>
451 <Box flexGrow={1} flexShrink={1}>
452 <Text bold wrap="truncate-end">
453 main loop
454 </Text>
455 </Box>
456 <Box flexShrink={0}>
457 <Text color={colorOf(tier)}>{m.isRunning ? 'running' : 'idle'}</Text>
458 </Box>
459 </Box>
460 <Text wrap="truncate-end">
461 {' '}
462 <Text color={colorOf(tier)}>{tier}</Text>
463 <Text dimColor>{` · main · ${modelName(m.model || 'unknown')}`}</Text>
464 </Text>
465 <Text dimColor wrap="truncate-end">
466 {` ${runningText}${m.isRunning && m.startedAt ? ` · turn ${fmtTime(at - m.startedAt)}` : ''}`}
467 </Text>
468 </Box>
469 )
470 if (d.isCompact) {
471 return (
472 <Text key="main-loop" wrap="truncate-end">
473 <Text color={colorOf(tier)}>{glyph + ' '}</Text>
474 <Text bold>main loop</Text>
475 <Text dimColor>{` · ${modelName(m.model || 'unknown')} · ${runningText}`}</Text>
476 </Text>
477 )
478 }
479 return (
480 <Box key="main-loop" flexDirection="row" gap={1} marginBottom={1}>
481 {isMascots && mascot('main', hubPose(mainPoseId(m)), m.isRunning && !still && isOddSecond)}
482 {rows}
483 </Box>
484 )
485 }
486
487 return (
488 <Box flexDirection="column">
489 <Box flexDirection="row" justifyContent="space-between">
490 <Box flexShrink={1}>
491 <Text bold wrap="truncate-end">
492 Agents
493 </Text>
494 </Box>
495 <Box flexShrink={1} flexDirection="row" flexWrap="wrap" justifyContent="flex-end" gap={1}>
496 {clear}
497 {toggle}
498 {viewKeys}
499 </Box>
500 </Box>
501 {stats}
502 <Box flexDirection="column" marginTop={1}>
503 {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
504 {running.length > 0 && head('h-run', `Running · ${running.length}`)}
505 {isHub && hubCard()}
506 {running.map(card)}
507 {finished.length > 0 && head('h-done', `Finished · ${finished.length}`)}
508 {finished.map(card)}
509 {failed.length > 0 && head('h-error', `Error · ${failed.length}`)}
510 {failed.map(card)}
511 {watching.length > 0 && head('h-watch', `Watching · ${watching.filter(a => a.status === 'running').length}`)}
512 {watching.map(watchRow)}
513 </Box>
514 </Box>
515 )
516}
517hooks/watch.ts 129 lines1// Monitor watchers and CronCreate timers: what a watch's command targets,
2// a cron expression's next fire time, the time-left words, and the event
3// notifications a watch sends. Plain string and date work, no engine calls;
4// register.tsx keeps the books.
5import { wordsOf } from './shells'
6
7/** How long an entry stays grey after a stale notification. */
8export const STALE_MS = 10_000
9/**
10 * How recently another watch on the same target must have ended done for a
11 * notification to count as stale; a later wait on that target (a second
12 * round, a re-armed Monitor) is fresh and toasts.
13 */
14export const DONE_RECENT_MS = 60_000
15
16/**
17 * What a watch's command watches, for the band and for spotting a second
18 * watch on a target already done: a herdr pane (`w1:p6`), then a commit or
19 * run id (seven or more hex characters with a digit among them, or a number
20 * of five or more digits). Undefined when the command names none.
21 */
22export function targetOf(command: string): string | undefined {
23 const pane = /\bw\d+[A-Za-z]?:p\w+\b/.exec(command)?.[0]
24 if (pane) return pane
25 for (const w of wordsOf(command)) {
26 const word = w.replace(/^[#@]/, '')
27 if (/^[0-9a-f]{7,40}$/i.test(word) && /\d/.test(word) && /[a-f]/i.test(word)) return word.slice(0, 7)
28 if (/^\d{5,}$/.test(word)) return word
29 }
30 return undefined
31}
32
33/** `45s`, `12m`, `1h05m`, `2d`: a span of time in the band's words. */
34export function fmtLeft(ms: number): string {
35 const s = Math.max(0, Math.round(ms / 1000))
36 if (s < 60) return `${s}s`
37 const m = Math.round(s / 60)
38 if (m < 60) return `${m}m`
39 const h = Math.floor(m / 60)
40 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
41 return `${Math.floor(h / 24)}d`
42}
43
44/** `20:30`: a time of day, local, as the cron entry reads it. */
45export const fmtClock = (at: number): string => {
46 const d = new Date(at)
47 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
48}
49
50/** One cron field as the set of values it allows, or null when it cannot be read. */
51function fieldOf(text: string, min: number, max: number): Set<number> | null {
52 const out = new Set<number>()
53 for (const part of text.split(',')) {
54 const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part)
55 if (!m) return null
56 const step = m[2] ? Number(m[2]) : 1
57 if (step < 1) return null
58 let lo = min
59 let hi = max
60 if (m[1] !== '*') {
61 const [a, b] = (m[1] ?? '').split('-').map(Number)
62 lo = a ?? min
63 hi = b ?? (m[2] ? max : lo)
64 }
65 if (lo < min || hi > max || lo > hi) return null
66 for (let v = lo; v <= hi; v += step) out.add(v)
67 }
68 return out
69}
70
71/**
72 * The next time after `from` a five-field cron expression (`M H DoM Mon DoW`,
73 * local time; `*`, lists, ranges and steps) matches, within eight days (a
74 * recurring job expires after seven); undefined when it cannot be read or does
75 * not match in that span. Day of month and day of week combine as cron does:
76 * either matches when both are restricted.
77 */
78export function cronNext(expr: string, from: number): number | undefined {
79 const parts = expr.trim().split(/\s+/)
80 if (parts.length !== 5) return undefined
81 const [mi, ho, dom, mon, dow] = parts as [string, string, string, string, string]
82 const minutes = fieldOf(mi, 0, 59)
83 const hours = fieldOf(ho, 0, 23)
84 const days = fieldOf(dom, 1, 31)
85 const months = fieldOf(mon, 1, 12)
86 // Sunday is 0 or 7.
87 const weekdays = fieldOf(dow, 0, 7)
88 if (weekdays?.has(7)) weekdays.add(0)
89 if (!minutes || !hours || !days || !months || !weekdays) return undefined
90 const isDomAny = dom === '*'
91 const isDowAny = dow === '*'
92 const d = new Date(from)
93 d.setSeconds(0, 0)
94 d.setMinutes(d.getMinutes() + 1)
95 for (let i = 0; i < 8 * 24 * 60; i++) {
96 const dayOk =
97 isDomAny && isDowAny
98 ? true
99 : isDomAny
100 ? weekdays.has(d.getDay())
101 : isDowAny
102 ? days.has(d.getDate())
103 : days.has(d.getDate()) || weekdays.has(d.getDay())
104 if (months.has(d.getMonth() + 1) && dayOk && hours.has(d.getHours()) && minutes.has(d.getMinutes())) return d.getTime()
105 d.setMinutes(d.getMinutes() + 1)
106 }
107 return undefined
108}
109
110/** A watch's event notification: its task id and the first line of its event. */
111export type WatchEvent = { id: string; event: string }
112
113/**
114 * Every event notification in a row's text: a `<task-notification>` with an
115 * `<event>` and no `<status>` (a Monitor's stdout line, not its end).
116 */
117export function eventsOf(text: string): WatchEvent[] {
118 const out: WatchEvent[] = []
119 for (const m of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
120 const body = m[1] ?? ''
121 if (/<status>/.test(body)) continue
122 const id = /<task-id>([\s\S]*?)<\/task-id>/.exec(body)?.[1]?.trim()
123 const event = /<event>([\s\S]*?)<\/event>/.exec(body)?.[1]?.trim()
124 if (!id || event === undefined) continue
125 out.push({ id, event: (event.split('\n').find(l => l.trim() !== '') ?? '').trim().slice(0, 240) })
126 }
127 return out
128}
129hooks/mascots.ts 1260 lines1// Pixel art from the "Claude Mascot Pack" by getillustrations.com
2// (https://getillustrations.com/illustration-pack/claude-mascot-pack),
3// "Commercial and personal use license", pack downloaded 2026-10-19; the pack
4// itself is not redistributed, only the pixels of the poses used (POSES).
5//
6// The grey shell and codex mascots (SHELL_POSES, at the end) are not from the
7// pack: drawn by Zehui/Claude 2026-10-19 in the pack's proportions.
8//
9// Each pose is drawn as a half-block Raster: one column per pixel, one row per
10// two pixels (top pixel the foreground of ▀, bottom pixel its background).
11//
12// Every state has a pool of poses (0.4.0): a run keeps the pose its id hashes
13// to for as long as it stays in that state, so a card never changes pose
14// within a state, and two runs that finish together seldom look the same.
15
16/** Raster colour for the terminal's default (bit 24 alone). */
17const DEFAULT = 0x01000000
18const UPPER = 0x2580 // ▀
19const LOWER = 0x2584 // ▄
20const SPACE = 0x20
21// An empty cell still names a colour for its glyph, never the terminal's default foreground.
22const BLANK_FG = 0x000000
23
24/** The characters a pack pose's rows use, each the index of a colour in that pose's palette. */
25export const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
26
27/** A pose from the pack. */
28export type PackPose = keyof typeof POSES
29/** A shell or codex mascot of our own. */
30export type ShellPose = keyof typeof SHELL_POSES
31export type Pose = PackPose | ShellPose
32
33/** Running poses. */
34export const RUNNING_POOL: readonly PackPose[] = [
35 'repairing-with-tools',
36 'writing-software-code',
37 'building-colorful-blocks',
38 'debugging-system-bugs',
39 'reading-data-document',
40 'thinking-in-code',
41 'examining-green-globe',
42 'lab-science-testing',
43 'holding-ancient-scroll',
44 'visualizing-tree-data',
45 'managing-server-rack',
46 'processing-logic-symbols',
47 'planting-small-trees',
48 'user-profile-settings',
49]
50/** A run waiting on a tool's result (or the person) for a while. */
51export const WAIT_POOL: readonly PackPose[] = ['sleeping-soundly', 'thinking-pixel-bubble', 'in-constant-loop', 'it-depends-sign']
52/** A finished run. */
53export const DONE_POOL: readonly PackPose[] = [
54 'with-success-checkmark',
55 'success-achievement-milestone',
56 'happy-pixel-celebrating',
57 'rocket-launch-success',
58 'api-success-pixel',
59 'in-glowing-aura',
60 'love-cloud-pixel',
61]
62/** A run that ended in an error. */
63export const ERROR_POOL: readonly PackPose[] = [
64 'confusion-spiral-pixel',
65 'dizzy-failed-state',
66 'error-state-pixel',
67 'failed-process-error',
68 'system-error-alert',
69 'marking-critical-error',
70 'crying-sad-reaction',
71 'angry-at-laptop',
72 'broken-heart-sad',
73 'disconnected-plug-pixel',
74]
75/** The main loop's card, while two or more subagents run. */
76export const HUB_POOL: readonly PackPose[] = ['artboard-25cla', 'multi-window-workflow', 'developer-pair-programming-scene']
77
78/**
79 * The background shell's card mascot: candidate C (`shell-window`), Zehui's
80 * pick on 2026-10-19; A (`shell-terminal-face`) is the other choice.
81 */
82export const SHELL_POSE: ShellPose = 'shell-window'
83/**
84 * A shell running a coding agent's CLI (codex, opencode, claude -p) shows one
85 * of the five Codex fuzzies, chosen by a hash of the run id (see codexPose);
86 * `codex-blue-cap` and `codex-hex-badge` are the earlier grey-bot candidates.
87 */
88export const CODEX_POOL: readonly ShellPose[] = [
89 'fuzzy-pink-headphones',
90 'fuzzy-blue-beret',
91 'fuzzy-yellow-triangle',
92 'fuzzy-green-square',
93 'fuzzy-purple-heart',
94]
95export const CODEX_POSE: ShellPose = CODEX_POOL[0] ?? 'fuzzy-blue-beret'
96
97const isShellPose = (pose: Pose): pose is ShellPose => Object.hasOwn(SHELL_POSES, pose)
98
99const poseRows = (pose: Pose): readonly string[] => (isShellPose(pose) ? SHELL_POSES[pose] : POSES[pose].rows)
100
101/** A row character's colour in `pose`, or undefined for none. */
102const colourOf = (pose: Pose, ch: string): number | undefined => {
103 if (isShellPose(pose)) return Object.hasOwn(SHELL_PALETTE, ch) ? SHELL_PALETTE[ch] : undefined
104 const palette: readonly number[] = POSES[pose].palette
105 const i = ALPHABET.indexOf(ch)
106 return i >= 0 ? palette[i] : undefined
107}
108
109/** A pose's pixel width and height. */
110export const pixelsOf = (pose: Pose): { w: number; h: number } => {
111 const rows = poseRows(pose)
112 return { w: rows[0]?.length ?? 0, h: rows.length }
113}
114
115/**
116 * The Raster box a pose takes: one column per pixel, and rows for its height
117 * plus the one-pixel bob, so a bobbing mascot never changes its card's height.
118 * The card is as tall as its pose (or its text, when that is taller).
119 */
120export const boxOf = (pose: Pose): { columns: number; rows: number } => {
121 const { w, h } = pixelsOf(pose)
122 return { columns: w, rows: Math.ceil((h + 1) / 2) }
123}
124
125/**
126 * The widest pose: the mascot column of every card, so the cards' text lines up.
127 * A function, since the pixel data is declared below the code.
128 */
129export const mascotWidth = (): number =>
130 Math.max(...([...Object.keys(POSES), ...Object.keys(SHELL_POSES)] as Pose[]).map(p => pixelsOf(p).w))
131
132/** FNV-1a of a run id, for stable picks. */
133const hashOf = (id: string): number => {
134 let h = 0x811c9dc5
135 for (let i = 0; i < id.length; i++) {
136 h ^= id.charCodeAt(i)
137 h = Math.imul(h, 0x01000193)
138 }
139 return h >>> 0
140}
141
142/** A stable pick from `pool` for an id. */
143const pickOf = <P extends Pose>(pool: readonly P[], id: string, fallback: P): P => pool[hashOf(id) % pool.length] ?? fallback
144
145/** A stable pick from the running pool for a run id. */
146export const runningPose = (id: string): Pose => pickOf(RUNNING_POOL, id, 'thinking-in-code')
147
148/** A stable pick from the waiting pool for a run id. */
149export const waitPose = (id: string): Pose => pickOf(WAIT_POOL, id, 'sleeping-soundly')
150
151/** A stable pick from the finished pool for a run id. */
152export const donePose = (id: string): Pose => pickOf(DONE_POOL, id, 'with-success-checkmark')
153
154/** A stable pick from the error pool for a run id. */
155export const errorPose = (id: string): Pose => pickOf(ERROR_POOL, id, 'confusion-spiral-pixel')
156
157/** The main loop's pose: a stable pick from the hub pool for the session's id (or its main turn's). */
158export const hubPose = (id: string): Pose => pickOf(HUB_POOL, id, 'artboard-25cla')
159
160/** A stable pick from the codex fuzzies for a shell's run id. */
161export const codexPose = (id: string): ShellPose => pickOf(CODEX_POOL, id, CODEX_POSE)
162
163/** Blends a colour toward `tint` by `k` (0..1). */
164const blend = (c: number, tint: number, k: number): number => {
165 const mix = (shift: number) => Math.round(((c >> shift) & 0xff) * (1 - k) + ((tint >> shift) & 0xff) * k)
166 return (mix(16) << 16) | (mix(8) << 8) | mix(0)
167}
168
169export type MascotOptions = {
170 /** Shifts the pose down one pixel row (the bob on odd seconds). */
171 isBobbed?: boolean
172 /** Blends every pixel halfway toward this 0xRRGGBB colour (the error pulse). */
173 tint?: number
174}
175
176const memo = new Map<string, string>()
177const MEMO_MAX = 128
178
179/**
180 * A pose's Raster cells, row-major base64 u32 triplets [codePoint, fg, bg], in
181 * the box `boxOf(pose)` gives. Both pixels set: ▀ with the top as foreground
182 * and the bottom as background; only the top: ▀; only the bottom: ▄; neither:
183 * a space. The background of a half-filled cell is the terminal's default.
184 */
185export function mascotCells(pose: Pose, options: MascotOptions = {}): string {
186 const shift = options.isBobbed ? 1 : 0
187 const key = `${pose}|${shift}|${options.tint ?? ''}`
188 const hit = memo.get(key)
189 if (hit !== undefined) return hit
190 const rows = poseRows(pose)
191 const { columns, rows: cellRows } = boxOf(pose)
192 const pixel = (x: number, y: number): number | null => {
193 const ch = rows[y - shift]?.[x]
194 if (ch === undefined || ch === '.') return null
195 const c = colourOf(pose, ch)
196 if (c === undefined) return null
197 return options.tint === undefined ? c : blend(c, options.tint, 0.5)
198 }
199 const words = new Uint32Array(columns * cellRows * 3)
200 for (let r = 0; r < cellRows; r++) {
201 for (let x = 0; x < columns; x++) {
202 const top = pixel(x, r * 2)
203 const bottom = pixel(x, r * 2 + 1)
204 const i = (r * columns + x) * 3
205 if (top !== null) {
206 words[i] = UPPER
207 words[i + 1] = top
208 words[i + 2] = bottom ?? DEFAULT
209 } else if (bottom !== null) {
210 words[i] = LOWER
211 words[i + 1] = bottom
212 words[i + 2] = DEFAULT
213 } else {
214 words[i] = SPACE
215 words[i + 1] = BLANK_FG
216 words[i + 2] = DEFAULT
217 }
218 }
219 }
220 // The hooks environment has the web's Uint8Array.prototype.toBase64; the es2023 lib does not type it.
221 const cells = (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
222 if (memo.size >= MEMO_MAX) memo.clear()
223 memo.set(key, cells)
224 return cells
225}
226
227// --- pixel data (generated from the pack's 1x PNGs with tools/pixgrid.py, grid count 46; see the header)
228
229/**
230 * Each pack pose: its own palette (0xRRGGBB, most used first) and its pixel
231 * rows, top to bottom; '.' is empty, any other character indexes the pose's
232 * palette (0-9, a-z, A-Z). One palette per pose keeps every colour exact.
233 */
234export const POSES = {
235 'repairing-with-tools': {
236 palette: [0xd97757, 0xf4e84d, 0x7296aa, 0x5b39c9, 0xffffff, 0x2f2f38],
237 rows: [
238 '...........11...........',
239 '.......1111111111.......',
240 '......111111111111......',
241 '.....11111111111111.....',
242 '.....11111111111111.....',
243 '.....11111111111111...2.',
244 '....1111111111111111..2.',
245 '2..2.00000000000000...2.',
246 '2222.00000000000000...2.',
247 '.22..00000000000000...2.',
248 '.22..00000000000000...2.',
249 '.22..00050000005000..333',
250 '.22..00000400400000...3.',
251 '.2000000000440000000003.',
252 '.2000000000000000000003.',
253 '22000000000000000000003.',
254 '2..2.0..0......0..0..333',
255 '....0..0........0..0....',
256 ],
257 },
258 'writing-software-code': {
259 palette: [0xd97757, 0x3bf497, 0x2c8c5e, 0x15586d, 0xffffff, 0x2f2f38],
260 rows: [
261 '..........222......2........',
262 '.......111.......2...2......',
263 '............................',
264 '.......00000000000000.2.....',
265 '111....00000001110000.......',
266 '...333.00001110000000.......',
267 '.......000000000000001...1..',
268 '.......00005000005001001..1.',
269 '....00000000000000010001...1',
270 '....00000000044400001001..1.',
271 '....002200000000000001...1..',
272 '....11.00000000000000.......',
273 '.......0..0......0..0.......',
274 '......0..0......0..0........',
275 ],
276 },
277 'building-colorful-blocks': {
278 palette: [0xd97757, 0xda64dd, 0x6d61e0, 0xf9f950, 0x3f6a99, 0x39e5bc, 0x2ee565, 0xff6f48, 0x5ab1ff, 0xb726ed, 0x2f2f38, 0xffffff],
279 rows: [
280 '...00000000000000...',
281 '...00000000000000...',
282 '...00000000000000...',
283 '...0000a0000a0000...',
284 '...000a0a00a0a000...',
285 '...00000b00b00000...',
286 '000000000bb000000000',
287 '00000000000000000000',
288 '00000000000000000000',
289 '...0..0......0..0...',
290 '...0..0......0..0...',
291 '..1111222255553333..',
292 '..1111222255553333..',
293 '..1111222255553333..',
294 '44446666777788889999',
295 '44446666777788889999',
296 '44446666777788889999',
297 '..2222333311114444..',
298 '..2222333311114444..',
299 '..2222333311114444..',
300 ],
301 },
302 'debugging-system-bugs': {
303 palette: [0xd97757, 0x456ec9, 0x25cc50, 0x8a46c6, 0xcaf419, 0x1414c9, 0x2f2f38],
304 rows: [
305 '..1.........33.......',
306 '1111.......3333......',
307 '11111......3333......',
308 '155511......33.......',
309 '115111.......3.......',
310 '111100000000000000...',
311 '....0000000000002222.',
312 '....0000000000002222.',
313 '....0000000000002222.',
314 '....0000000006002222.',
315 '...0000000000000002.2',
316 '000000000000000000000',
317 '000000440000000000000',
318 '...004444....00.00...',
319 '...0.4444.....0......',
320 '......44.............',
321 ],
322 },
323 'reading-data-document': {
324 palette: [0xd97757, 0xdbe00b, 0xa50e97, 0xbcbc2b, 0x598dd8, 0xffffff, 0x2f2f38],
325 rows: [
326 '..00000000000000..',
327 '..00000000000000..',
328 '..00222000022200..',
329 '..02464222246420..',
330 '..00222000022200..',
331 '.00000005500000000',
332 '.00000005500000000',
333 '.11111100000000000',
334 '001333100000000000',
335 '00113331....0..0..',
336 '...111111.........',
337 '....111111........',
338 ],
339 },
340 'thinking-in-code': {
341 palette: [0xd97757, 0x27ff69, 0x2f2f38, 0xffffff],
342 rows: [
343 '...1..............1...',
344 '...1..............1...',
345 '..1..........1.....1..',
346 '.1..........1.......1.',
347 '1..........1.........1',
348 '11..00000100000000..11',
349 '..1.00001000000000.1..',
350 '...1000000000000001...',
351 '...1000000000000001...',
352 '....00020000002000....',
353 '....00000300300000....',
354 '.00000000000000000000.',
355 '.00000000000000000000.',
356 '....0..0......0..0....',
357 '....0..0......0..0....',
358 '....0..0......0..0....',
359 ],
360 },
361 'examining-green-globe': {
362 palette: [0xd97757, 0xaaff15, 0x17aaa3, 0x3b3bcc, 0x68e0bb, 0xffffff, 0x2f2f38],
363 rows: [
364 '..111.................',
365 '..111.................',
366 '.12221................',
367 '1242421...............',
368 '1422241.00000000000000',
369 '1122211.00000000600000',
370 '..111.3.00000000000000',
371 '.......300000550000000',
372 '.......300000550000000',
373 '.....00030000000000000',
374 '.....00003000000000000',
375 '.....00000030000000000',
376 '........0.0.3000...0.0',
377 '........0.0..300...0.0',
378 ],
379 },
380 'lab-science-testing': {
381 palette: [0xd97757, 0xb5b3ba, 0x8557ce, 0x1327e2, 0x351daa, 0x11ea82, 0x3340c1, 0xffffff, 0x2f2f38],
382 rows: [
383 '.........................5',
384 '.111....................5.',
385 '..1..................5.5..',
386 '..1...................5...',
387 '..2.......................',
388 '.222.................11111',
389 '26262.................111.',
390 '626260..............00333.',
391 '...000..............00343.',
392 '...0000000000000000000434.',
393 '......00000000000000..343.',
394 '......00008000080000..434.',
395 '......00000700700000...4..',
396 '......00000077000000......',
397 '......00000000000000......',
398 '......00000000000000......',
399 '......00000000000000......',
400 '......00000000000000......',
401 '.....0...0......0...0.....',
402 '.....0...0......0...0.....',
403 ],
404 },
405 'holding-ancient-scroll': {
406 palette: [0xd97757, 0xffd255, 0xeab839, 0xf9f2de, 0x2f2f38],
407 rows: [
408 '....333.................',
409 '...3...3................',
410 '..3....000..............',
411 '1131221100..............',
412 '.132332100..............',
413 '..132211..000000000000..',
414 '..111111..000000000000..',
415 '..122221..044004000000..',
416 '.1111111..000000000000..',
417 '.1222211..000330000000..',
418 '11111111..000000000000..',
419 '1111111...00000000000000',
420 '..........00000000000000',
421 '..........00000000000000',
422 '..........0...0......0.0',
423 '.........0....0.....0..0',
424 ],
425 },
426 'visualizing-tree-data': {
427 palette: [0xd97757, 0x6240e2, 0x30ef4b, 0xffffff],
428 rows: [
429 '...............2........',
430 '...............1..11..1.',
431 '..............211.11.1.2',
432 '...............1..111.1.',
433 '.000000000000.....11....',
434 '.0000000000000....11....',
435 '.0000000000000....11....',
436 '.0000000000000...1111...',
437 '.0000000003000..1.11.1..',
438 '.0000000000000...1..1...',
439 '00000000000000001.......',
440 '0000000000000000........',
441 '0000000000000000........',
442 '0.00.....00.00..........',
443 '0........0...0..........',
444 ],
445 },
446 'managing-server-rack': {
447 palette: [0xd97757, 0x6f6ad3, 0x4139aa, 0x4c4ce0, 0xffffff, 0x2f2f38, 0x82ef2e],
448 rows: [
449 '..............000...........',
450 '..............0003333333222.',
451 '0000000000000000111111111222',
452 '00000000500050..111111111222',
453 '00000000040400...3333333222.',
454 '00000000004000..111111111222',
455 '00000000004000..111111111222',
456 '00000000000000..111161111222',
457 '0000000000000000011111111222',
458 '0000000000000000011111111222',
459 '0..0......0..0..111161111222',
460 '0..0......0..0..111111111222',
461 ],
462 },
463 'processing-logic-symbols': {
464 palette: [0xd97757, 0xd322a5, 0x2f2f38, 0xe83c00, 0x38e540, 0x7d3ec6, 0x5a39e2, 0x359cf9],
465 rows: [
466 '...1......................',
467 '..111................3.3..',
468 '..111................3.3..',
469 '...100..............003...',
470 '...000..............0303..',
471 '...000..............000...',
472 '......00000000000000......',
473 '44....00000000000000...666',
474 '4400..00020000002000..006.',
475 '4400..00020000002000..006.',
476 '.000..00202000020200..000.',
477 '.000..00000000000000..000.',
478 '..7...00000000000000..5.5.',
479 '..700.00000000000000.050..',
480 '.7700.00000000000000.0555.',
481 '..000.0.0........0.0.000..',
482 '..000.0.0........0.0.000..',
483 '.......0.0......0.0.......',
484 ],
485 },
486 'planting-small-trees': {
487 palette: [0xd97757, 0x4f4aa8, 0x7c7cce, 0xffeb99, 0x6de26d, 0xefd760, 0x13ce1b, 0x828293, 0xffffff],
488 rows: [
489 '.....00000000000000..',
490 '.....00000000000000..',
491 '.....00000000000000..',
492 '.....00000000000000..',
493 '.12..00000000000000..',
494 '2122.00000000000000..',
495 '121104600080000000000',
496 '.12006400000000000000',
497 '.7000470000000000000.',
498 '35335350......0...0..',
499 ],
500 },
501 'user-profile-settings': {
502 palette: [0xd97757, 0xdbd235, 0x42c642, 0xf2e970, 0x7667e8, 0xffffff, 0xd89336, 0x6251b7, 0x2f2f38],
503 rows: [
504 '.2222222222...............',
505 '22522552222...............',
506 '22222222222...............',
507 '1111111111100000000000000.',
508 '1444666661100000000000000.',
509 '1474111111100000000000000.',
510 '1444333333100080000008000.',
511 '1777111111100000005000000.',
512 '1111333331100000550000000.',
513 '.1111111110000000000000000',
514 '.........00000000000000000',
515 '.........00000000000000000',
516 '...........0...0......0.0.',
517 '...........0...0......0.0.',
518 ],
519 },
520 'sleeping-soundly': {
521 palette: [0xd97757, 0x5b1fdd, 0x1a9643, 0x2f2f38, 0xffe05c, 0xff6136, 0x70ed20, 0xffffff],
522 rows: [
523 '......1111..........',
524 '........1....44.....',
525 '.......1......44....',
526 '.2222.1111..........',
527 '...2.........55.....',
528 '..2...........55....',
529 '.2222......66.......',
530 '...00000000066000...',
531 '...00000000000000...',
532 '...00000000000000...',
533 '...00303000030300...',
534 '...00030000003000...',
535 '...00000000000000...',
536 '00000000070000000000',
537 '00000000000000000000',
538 '00000000000000000000',
539 '...0..0......0..0...',
540 '....0..0......0..0..',
541 ],
542 },
543 'thinking-pixel-bubble': {
544 palette: [0xd97757, 0x3cd694, 0x2f2f38, 0x3d45d3, 0xffffff],
545 rows: [
546 '..................111111..',
547 '.................11111111.',
548 '................1111111111',
549 '................1131131111',
550 '................1111111111',
551 '.................11111111.',
552 '..................111111..',
553 '................1.........',
554 '................11........',
555 '..000000000000000.........',
556 '..00000000000001000.......',
557 '..00000000000000000.......',
558 '..00022200002220000.......',
559 '..000000000000000.........',
560 '..000000040000000.........',
561 '00000000000000000.........',
562 '00000000000000000.........',
563 '00000000000000000.........',
564 '..0..00.....00..0.........',
565 '..0..00.....00..0.........',
566 ],
567 },
568 'in-constant-loop': {
569 palette: [0xd97757, 0x9c17e2, 0x2f2f38, 0xffffff],
570 rows: [
571 '............1...............',
572 '.............1..............',
573 '.........111111...11........',
574 '........1....1......1.1.....',
575 '.......1....1........11.....',
576 '......1.............111.....',
577 '............................',
578 '..1....00000000000000...1...',
579 '.111...00000000000000....1..',
580 '1.1.1..00000000000000....1..',
581 '..1....00202000020200....1..',
582 '..1.00000020000002000000.1..',
583 '..1.00000000000000000000.1..',
584 '..1.00000000033000000001.1.1',
585 '..1....00000000000000...111.',
586 '...1...00000000000000....1..',
587 '......0..0.......0...0......',
588 '.....111..0.....0...01......',
589 '.....11........1....1.......',
590 '.....1.1......1....1........',
591 '........11...111111.........',
592 '..............1.............',
593 '...............1............',
594 ],
595 },
596 'it-depends-sign': {
597 palette: [0xd97757, 0xef509c, 0xaded51, 0xffffff, 0x8c7670],
598 rows: [
599 '1111111111111111111111......',
600 '1112112211111111111111......',
601 '1111111111111111111111......',
602 '1111111111111111111111......',
603 '1121122122122122212211......',
604 '1122121122121121212121......',
605 '1121122111122111212211......',
606 '.11111111141111111111.......',
607 '..........4.................',
608 '.........0000000000000000...',
609 '........00000000000000000...',
610 '........00000000000000000...',
611 '.........0000000000000000...',
612 '..........400000333300000...',
613 '...........00000000000000...',
614 '...........00000000000000000',
615 '...........00000000000000000',
616 '...........00000000000000000',
617 '...........00000000000000000',
618 '...........0..0......0..0...',
619 '...........0............0...',
620 ],
621 },
622 'with-success-checkmark': {
623 palette: [0xd97757, 0x15aa51, 0x3aedb2, 0x2f2f38, 0xffffff],
624 rows: [
625 '.......111111.......',
626 '...2..11111111..2...',
627 '.2...1111111111...2.',
628 '.....1111111211.....',
629 '2....1111112111....2',
630 '.....1112121111.....',
631 '000..1111211111..000',
632 '000...11111111...000',
633 '000....111111....000',
634 '...00000000000000...',
635 '...00000000000000...',
636 '...00030000003000...',
637 '...00303000030300...',
638 '...00000400400000...',
639 '...00000044000000...',
640 '...00000000000000...',
641 '...00000000000000...',
642 '...00000000000000...',
643 '...0..0......0..0...',
644 '..0...0......0...0..',
645 ],
646 },
647 'success-achievement-milestone': {
648 palette: [0xd97757, 0xfcfc56, 0x64e2a6, 0x2f2f38, 0xffffff],
649 rows: [
650 '........2.........1.....',
651 '........2...............',
652 '........2...............',
653 '.......2..........1.....',
654 '...2...2........1.1.1...',
655 '....2.2..........212....',
656 '...0000000000000021211.1',
657 '...00000000000001.1.1...',
658 '...00030000003000.1.....',
659 '...00030000003000.1.....',
660 '...00303000030300.......',
661 '...00000000000000.1.....',
662 '00000000044000000000....',
663 '00000000000000000000....',
664 '...00000000000000.......',
665 '...0..0......0..0.......',
666 '...0..0......0..0.......',
667 '...0..0......0..0.......',
668 ],
669 },
670 'happy-pixel-celebrating': {
671 palette: [0xd97757, 0x50ffb4, 0x873be0, 0xffff39, 0xaa399f, 0x2f2f38, 0x1fa028, 0xffffff, 0xf4552a],
672 rows: [
673 '......1............2......',
674 '...001.1..........2.200...',
675 '...0001............2000...',
676 '...000..............000...',
677 '..........................',
678 '......................3...',
679 '...4.................3.3..',
680 '.0404.00000000000000..300.',
681 '.004..00000000000000..000.',
682 '.000..00000000000000..000.',
683 '......00000000000000......',
684 '......00550000005500......',
685 '.6....00000700700000....8.',
686 '6.600.00000077000000.008.8',
687 '.6000.00000000000000.0008.',
688 '..000.00000000000000.000..',
689 '......0..0......0..0......',
690 '.......0..0....0..0.......',
691 ],
692 },
693 'rocket-launch-success': {
694 palette: [0xd97757, 0x96f916, 0xffbd03, 0xea3f54, 0x1944f7, 0xffffff, 0x3564f9, 0x2f2f38, 0x7697ff],
695 rows: [
696 '.......................466',
697 '......................1146',
698 '.................2...31814',
699 '................2...11311.',
700 '...................13113..',
701 '................3341131...',
702 '...............3..4411....',
703 '............2....2044..2..',
704 '...........2...0022.3.2...',
705 '.00000000000000020..3.....',
706 '.00000000000000000.3......',
707 '.00007000000700...........',
708 '.00000050050000...2.......',
709 '.00000005500000..2........',
710 '.00000000000000...........',
711 '.00000000000000000........',
712 '.00000000000000000........',
713 '.00000000000000000........',
714 '.0..0......0..0...........',
715 '0..0........0..0..........',
716 ],
717 },
718 'api-success-pixel': {
719 palette: [0xd97757, 0xc6c1e0, 0x4f37db, 0x23d8b5, 0xffffff, 0x2f2f38],
720 rows: [
721 '.22222222222................',
722 '1111111111111.......3333333.',
723 '1112112221211......111111111',
724 '1121212121111......111111311',
725 '1122212221211......111113111',
726 '1121212111211000000113131111',
727 '1111111111111000000111311111',
728 '.11111111111000000001111111.',
729 '.......00000000000000.......',
730 '.......00050000005000.......',
731 '.......00000000000000.......',
732 '....00000000444400000000....',
733 '....00000000000000000000....',
734 '....00000000000000000000....',
735 '.......0..0......0..0.......',
736 '.......0..0......0..0.......',
737 ],
738 },
739 'in-glowing-aura': {
740 palette: [0xd97757, 0xdddddd, 0x2f2f38, 0xffffff],
741 rows: [
742 '...1.....11.....1...',
743 '..111....11....111..',
744 '...111...11...111...',
745 '...111...11...111...',
746 '....1..........1....',
747 '....................',
748 '...00000000000000...',
749 '11100000000000000111',
750 '11100202000020200111',
751 '...00020000002000...',
752 '...00020000002000...',
753 '...00000300300000...',
754 '00000000033000000000',
755 '00000000000000000000',
756 '...0..0......0..0...',
757 '....1..0....0..1....',
758 '...111........111...',
759 '...111........111...',
760 '..111....11....111..',
761 '...1.....11.....1...',
762 '.........11.........',
763 ],
764 },
765 'love-cloud-pixel': {
766 palette: [0xf1ff6c, 0xd97757, 0xdd3e68, 0xffcf6e, 0xffffff, 0x2f2f38, 0xed6992],
767 rows: [
768 '..2.222.................',
769 '.2226622................',
770 '.2222222................',
771 '..22222.................',
772 '..2222...00000000000000.',
773 '...2....000000000000000.',
774 '....1111111111111100000.',
775 '....1111111111111100000.',
776 '....1111111111111100000.',
777 '....1115111111511100000.',
778 '..111111511111111111110.',
779 '..111111141114111111110.',
780 '..111111114441111111110.',
781 '.0001111111111111100000.',
782 '000011111111111111000000',
783 '000133313331133133000000',
784 '000100010001100100000000',
785 '000000000000000000000000',
786 '.0000000000000000000000.',
787 '..000000000000000000000.',
788 ],
789 },
790 'confusion-spiral-pixel': {
791 palette: [0xd97757, 0x602aad, 0x674bef, 0x2f2f38, 0xffffff],
792 rows: [
793 '....1.21212121..1...',
794 '.....1..2121..2.....',
795 '....2..1....2.1.2...',
796 '....2..1....2.1.2...',
797 '....1.2..12.1.2.....',
798 '....2.1.21.12.1.2...',
799 '....1.2...2...2.1...',
800 '....21.1212121.12...',
801 '......2.......2.....',
802 '...01001212121001...',
803 '...01001212121001...',
804 '...00000000000000...',
805 '...00030000003000...',
806 '...00303000030300...',
807 '00000000044000000000',
808 '00000000000000000000',
809 '00000000000000000000',
810 '00000000000000000000',
811 '...00000000000000...',
812 '...0..0......0..0...',
813 '....0..0....0..0....',
814 ],
815 },
816 'dizzy-failed-state': {
817 palette: [0xd97757, 0xb51f8a, 0xe058c0, 0xffffff, 0x2f2f38, 0xf75b2b],
818 rows: [
819 '..111.................',
820 '.11311................',
821 '1111111...............',
822 '13111311....5.........',
823 '11131131.....5....2..2',
824 '11311111...........2..',
825 '1111111.............2.',
826 '.11111....22......2..2',
827 '.....00002222000000...',
828 '.....00000000000000...',
829 '.....00000000000000...',
830 '.....00000000000000...',
831 '.....00400000004000...',
832 '.....00443000304000...',
833 '..0000000033300000000.',
834 '.000000000000000000000',
835 '.000000000000000000000',
836 '..0000000000000000000.',
837 '.....0..0......0.00...',
838 ],
839 },
840 'error-state-pixel': {
841 palette: [0xd97757, 0xff97d0, 0x5a9bcc, 0x2f2f38, 0xffffff, 0x8659ce],
842 rows: [
843 '...........1...1............',
844 '...........1...1............',
845 '............1.1.....22222...',
846 '.............1.....2111112..',
847 '............1.1...211111112.',
848 '.000000000000001.21114141112',
849 '.00030000003000..21111411112',
850 '.00303000030300..21114141112',
851 '.00303000030300..21114141112',
852 '.00030000003000...214111412.',
853 '.00000044000000...52111112..',
854 '000000000000000005..22222...',
855 '000000000000000500..........',
856 '.0..0......0..0.............',
857 '0..0......0..0..............',
858 '0..0......0..0..............',
859 ],
860 },
861 'failed-process-error': {
862 palette: [0xd97757, 0x1620d8, 0x5ccc71, 0xcc5eb0, 0xffffff, 0x0c9379, 0x2f2f38],
863 rows: [
864 '...22222....................',
865 '..2111112...................',
866 '.211111112..................',
867 '21131313112..00000000000000.',
868 '21113331112..00000000000000.',
869 '21313331312..00000000000000.',
870 '21113331112..00060000060000.',
871 '.213131312...00000000000000.',
872 '..21111125...00000444000000.',
873 '...22222..500000040004000000',
874 '..........050000000000000000',
875 '..........005000000000000000',
876 '.............0..0......0..0.',
877 '..............0..0......0..0',
878 ],
879 },
880 'system-error-alert': {
881 palette: [0xd97757, 0xffc0ff, 0xe842e0, 0xfc277e, 0xffffff, 0xec8fef, 0xe5d942, 0x2f2f38],
882 rows: [
883 '.22222222222222...............',
884 '2262626222222222..............',
885 '2222222222222222..............',
886 '1111131111111111..............',
887 '1111333111111111..............',
888 '1113343311111111..............',
889 '1113343311113111..............',
890 '11333333311131110000000000000.',
891 '11333433311111110000000000000.',
892 '13333333331131110700070000000.',
893 '11111111111111110000000000000.',
894 '11155555111111110044400000000.',
895 '11111111111111110000000000000.',
896 '11111111111110000000000000000.',
897 '.............00000000000000000',
898 '.............00000000000000000',
899 '................0..0......0..0',
900 '................0..0......0..0',
901 ],
902 },
903 'marking-critical-error': {
904 palette: [0xd97757, 0xad2aad, 0xff7b8e, 0x2f2f38, 0xffffff],
905 rows: [
906 '.......111111.......',
907 '...2..11111111..2...',
908 '.2...1121111211...2.',
909 '.....1112112111.....',
910 '..2..1111221111..2..',
911 '.....1112112111.....',
912 '.....1121111211.....',
913 '......11111111......',
914 '.......111111.......',
915 '...00000000000000...',
916 '...00303000030300...',
917 '...00030000003000...',
918 '...00303044030300...',
919 '...00000400400000...',
920 '...00000000000000...',
921 '00000000000000000000',
922 '00000000000000000000',
923 '00000000000000000000',
924 '...0..0......0..0...',
925 '..0....0....0....0..',
926 ],
927 },
928 'crying-sad-reaction': {
929 palette: [0xd97757, 0x3eadff, 0x83d0ff, 0xffffff, 0x2f2f38],
930 rows: [
931 '.....2..........2.....',
932 '..120020000000020021..',
933 '.1..20010000001002..1.',
934 '1...00000000000000...1',
935 '....01040000004010....',
936 '..001110003300011100..',
937 '..001110003300011100..',
938 '..012111000000111210..',
939 '..021211000000112120..',
940 '..112111100001111211..',
941 '...11111.0..0.11111...',
942 '....111........111....',
943 ],
944 },
945 'angry-at-laptop': {
946 palette: [0xd97757, 0xffc431, 0x5b30d1, 0xff6833, 0xaf32ce, 0x2f2f38, 0xffffff],
947 rows: [
948 '................1........',
949 '.................1..3....',
950 '................11.313...',
951 '.........1.....111..3....',
952 '........131..1.11........',
953 '.........1..1..131.......',
954 '............111311.1..1..',
955 '000.........1133311..1...',
956 '000..........11311..111.1',
957 '00000000000000111...1131.',
958 '...00000000000000....111.',
959 '...00000000000000........',
960 '...00000000000000..222222',
961 '...00005000005000.222422.',
962 '...00000500050000.224222.',
963 '...00000006000000222222..',
964 '...000000000000444444....',
965 '...00000000000000000.....',
966 '...0..0......0..0........',
967 '....0..0......0..0.......',
968 ],
969 },
970 'broken-heart-sad': {
971 palette: [0xd97757, 0xe03890, 0x2f2f38, 0x993add, 0x50a8ff, 0xffffff],
972 rows: [
973 '..13.11.............',
974 '.113.111............',
975 '.1113.11............',
976 '..13.11.............',
977 '...3.1..............',
978 '...00000000000000...',
979 '...00000000000000...',
980 '...00000000000000...',
981 '...00202000020200...',
982 '...04020000002040...',
983 '...40000055000004...',
984 '00000000500500000000',
985 '00000000000000000000',
986 '00000000000000000000',
987 '...0..0......0..0...',
988 '..0..0......0..0....',
989 ],
990 },
991 'disconnected-plug-pixel': {
992 palette: [0xd97757, 0x6e31f4, 0x3381f2, 0x2f2f38, 0xffffff, 0xffd939],
993 rows: [
994 '......2225.1112.....',
995 '.....1222..11121....',
996 '....1.2225.1212.1...',
997 '....1.2225.1212.1...',
998 '....1..22..111...1..',
999 '...1..............1.',
1000 '...1..............1.',
1001 '000..............000',
1002 '000..............000',
1003 '1..00000000000000..1',
1004 '1..00000000000000..1',
1005 '1..00000000000000..1',
1006 '.1.00030000003000.1.',
1007 '...00030000003000...',
1008 '...00000044000000...',
1009 '...00000400400000...',
1010 '...00000000000000...',
1011 '...00000000000000...',
1012 '...00000000000000...',
1013 '..0...0......0...0..',
1014 '..0...0......0...0..',
1015 ],
1016 },
1017 'artboard-25cla': {
1018 palette: [0xd97757, 0x2f2f38, 0xffffff],
1019 rows: [
1020 '.......00000000000000.......',
1021 '.......00000000000000.......',
1022 '.......00000000000000.......',
1023 '.......00000000000000.......',
1024 '.......00010000001000.......',
1025 '.......00000200200000.......',
1026 '....00000000022000000000....',
1027 '....00000000000000000000....',
1028 '....00000000000000000000....',
1029 '.......0..0......0..0.......',
1030 '.00000.0..0......0..0.00000.',
1031 '.01010................01010.',
1032 '0000000..00000..0000.0000000',
1033 '0.0.0.0..01010..0101.0.0.0.0',
1034 '........0000000.0000........',
1035 '........0.0.0.0.0..0........',
1036 ],
1037 },
1038 'multi-window-workflow': {
1039 palette: [0xd97757, 0xc5e2dd, 0xe3c0ff, 0xffd89f, 0xc15bb9, 0x51e5c8, 0x4735ef, 0xf4a321, 0xffffff, 0x9d33e2, 0x2f2f38],
1040 rows: [
1041 '...................44444444.',
1042 '............999...2222222222',
1043 '...........22222..2222222222',
1044 '...........22222..2222222222',
1045 '.5555555...22222..2222222222',
1046 '111111111.........2222222222',
1047 '111111111...................',
1048 '111111111000000000000.......',
1049 '111111111000000000000.......',
1050 '111111111000000000000.......',
1051 '111111111000000000000666666.',
1052 '.......000a000000a0011111111',
1053 '.......000008008000011111111',
1054 '....000000000880000011111111',
1055 '....000000000000000011111111',
1056 '....77777700000000000000....',
1057 '...33333333......0..0.......',
1058 '...33333333......0..0.......',
1059 '...33333333.................',
1060 '...33333333.................',
1061 ],
1062 },
1063 'developer-pair-programming-scene': {
1064 palette: [0xd97757, 0x27d834, 0x7af4d4, 0x9736a8, 0x3b35aa, 0xefa262, 0xffffff],
1065 rows: [
1066 '...................44.....',
1067 '....................4544..',
1068 '.........1..11.......555..',
1069 '........1...11..1....55...',
1070 '........1...1...1....55...',
1071 '........1..1....1....111..',
1072 '.........1.1........11111.',
1073 '...................1111111',
1074 '...0000000000000...1111111',
1075 '...00000000000000..5111111',
1076 '...00000000000000..5511115',
1077 '...00000000000000..5511115',
1078 '...00000000000222222222225',
1079 '...00000000000022222222233',
1080 '00000000060000022222222233',
1081 '0000000000000000222222233.',
1082 '000000000000000004444443..',
1083 '..00.00.....00.00.333.33..',
1084 '...0..0......0..0.333.33..',
1085 '..................333.33..',
1086 '..................333.33..',
1087 '..................444444..',
1088 ],
1089 },
1090} as const
1091
1092// --- shell and codex mascots: drawn by Zehui/Claude 2026-10-19, not from the pack
1093
1094/** The shell mascots' colours, as 0xRRGGBB, by the letter their rows use. */
1095export const SHELL_PALETTE: Readonly<Record<string, number>> = {
1096 g: 0x8a8f98,
1097 G: 0x5b606a,
1098 k: 0x1a1c22,
1099 w: 0xf5f5f4,
1100 n: 0x19c3a6,
1101 o: 0xd97757,
1102 y: 0xffe05c,
1103 b: 0x3d9bff,
1104 p: 0xd16bff,
1105 // the codex fuzzies' colours (drawn by us after Codex's own mascots, not copied)
1106 r: 0xff7ab6,
1107 R: 0xd94f8e,
1108 B: 0x1f5fd6,
1109 Y: 0xd99c0a,
1110 l: 0x7ad12a,
1111 L: 0x55a318,
1112 v: 0xb04cff,
1113 V: 0x862fd1,
1114}
1115
1116/** Each shell mascot's pixel rows, top to bottom: '.' is empty, a letter a SHELL_PALETTE key. */
1117export const SHELL_POSES = {
1118 // A. grey bot with a terminal face (">_" in mint): background shells
1119 'shell-terminal-face': [
1120 '.......GGGGGG.......',
1121 '.......G....G.......',
1122 '...GGGGGGGGGGGGGG...',
1123 '...GggggggggggggG...',
1124 '...GgkkkkkkkkkkgG...',
1125 '...Gknn.kkkkkkkkgG..',
1126 '...GkknnkkkkkkkkgG..',
1127 '...GkknnkknnnnkkgG..',
1128 '...GknnkkkkkkkkkgG..',
1129 '...GgkkkkkkkkkkgG...',
1130 '...GggggggggggggG...',
1131 'GGGGggggggggggggGGGG',
1132 'GGGGggggggggggggGGGG',
1133 '...Gg.gg....gg.gG...',
1134 '...Gg.gg....gg.gG...',
1135 ],
1136 // B. grey bot with a white hexagon badge on the chest: codex
1137 'codex-hex-badge': [
1138 '.......GGGGGG.......',
1139 '.......G....G.......',
1140 '...GGGGGGGGGGGGGG...',
1141 '...GggggggggggggG...',
1142 '...GgkkkkkkkkkkgG...',
1143 '...GgkwwkkkkwwkgG...',
1144 '...GgkwwkkkkwwkgG...',
1145 '...GgkkkkkkkkkkgG...',
1146 '...GggggggggggggG...',
1147 '...Ggggg.ww.ggggG...',
1148 '...Gggg.w..w.gggG...',
1149 'GGGGggg.w..w.gggGGGG',
1150 'GGGGgggg.ww.ggggGGGG',
1151 '...Gg.gg....gg.gG...',
1152 '...Gg.gg....gg.gG...',
1153 ],
1154 // C. a terminal window with a cursor, no character: background shells
1155 'shell-window': [
1156 'GGGGGGGGGGGGGGGGGGGG',
1157 'GoGyGnGGGGGGGGGGGGGG',
1158 'GGGGGGGGGGGGGGGGGGGG',
1159 'Gkkkkkkkkkkkkkkkkk.G',
1160 'Gknkkkkkkkkkkkkkkk.G',
1161 'Gkknkkkkkkkkkkkkkk.G',
1162 'Gknkkkkkkkkkkkkkkk.G',
1163 'Gkkkknnnnnnkkkkkkk.G',
1164 'Gkkkkkkkkkkkkkkkkk.G',
1165 'Gkkkkkkkkkkkkkkkkk.G',
1166 'Gkkkkkkkkkkkkkkkkk.G',
1167 'Gkkkkkkkkkkkkkkkkk.G',
1168 'GGGGGGGGGGGGGGGGGGGG',
1169 ],
1170 // D. grey bot wearing a small blue cap with a "C": codex
1171 'codex-blue-cap': [
1172 '......bbbbbbb.......',
1173 '.....bbwwbbbbb......',
1174 '.....bbwbbbbbb......',
1175 '.....bbwwbbbbbbb....',
1176 '...GGGGGGGGGGGGGG...',
1177 '...GggggggggggggG...',
1178 '...GgkkkkkkkkkkgG...',
1179 '...GgkwwkkkkwwkgG...',
1180 '...GgkkkkkkkkkkgG...',
1181 '...GggggggggggggG...',
1182 '...GggggggggggggG...',
1183 'GGGGggggggggggggGGGG',
1184 'GGGGggggggggggggGGGG',
1185 '...Gg.gg....gg.gG...',
1186 '...Gg.gg....gg.gG...',
1187 ],
1188 // Codex fuzzies, five shapes: a codex shell picks one by a hash of its id
1189 'fuzzy-pink-headphones': [
1190 '......rrrrrrrr......',
1191 '....rrrrrrrrrrrr....',
1192 '...rrrrrrrrrrrrrr...',
1193 '..kkrrrrrrrrrrrkk...',
1194 '.kkkrrrrrrrrrrrrkkk.',
1195 '.kkkrrkkrrrrkkrrkkk.',
1196 '.kkkrrrkkrrrrkkrkkk.',
1197 '.kkkrrrrrrrrrrrkkkk.',
1198 '..kkrrrrrrrrrrrkk...',
1199 '...rrrrrrrrrrrrrr...',
1200 '....rrrrrrrrrrrr....',hooks/peerview.tsx 144 lines1// The agents pane's Peers view (`p`, `/agents-info peers`): the head and its
2// framed keys, the hub's row first, then this session's HERDR-ORCH requests,
3// each with its spokes beneath, then the other peers of the herdr workspace.
4// A row is the state's glyph and word with the time in it, the pane id, the
5// agent and the title; blocked rows come first and are amber. Box, Text and
6// Button only; the plain surfaces get the same rows as text.
7import { atom, read } from 'claude-code'
8import type { Elements, EngineInterface, RenderInput } from 'claude-code'
9
10import type { PeerBoard, PeerRow } from '../types'
11import { closePane, setView } from './kit'
12import { NEEDS_HERDR, STATE_COLOR, layoutOf, peersHead, pollPeers, requestText, stateText, titleText } from './peers'
13import type { PeerLayout } from './peers'
14
15const peerBoard = atom({ plugin: 'agents', key: 'peerBoard' } as const, null)
16const peerRequests = atom({ plugin: 'agents', key: 'peerRequests' } as const, {})
17const peerHub = atom({ plugin: 'agents', key: 'peerHub' } as const, '')
18
19const AMBER = STATE_COLOR.blocked
20
21/** One row as plain words: `▲ blocked 4m w1:p7 codex "title"`. */
22const rowWords = (r: PeerRow, at: number): string => [stateText(r, at), r.pane, r.agent, titleText(r.title)].filter(Boolean).join(' ')
23
24export async function drawPeers($: EngineInterface, e: RenderInput<'Pane'>) {
25 const board = await read($, peerBoard)
26 const layout = board?.isHerdr ? layoutOf(board, await read($, peerRequests), await read($, peerHub)) : null
27 if (e.surface === 'terminal') return peersTerminal($, $.ui.resolve(e), board, layout)
28 return peersPlain($.ui.resolve(e), board, layout)
29}
30
31/** The two elements every surface's table has, the plain view's whole need. */
32type Shared = { Box: Elements['mobile']['Box']; Text: Elements['mobile']['Text'] }
33
34function peersPlain(ui: Shared, board: PeerBoard | null, layout: PeerLayout | null) {
35 const { Box, Text } = ui
36 const at = board?.at ?? 0
37 return (
38 <Box flexDirection="column">
39 <Text bold>{peersHead(board)}</Text>
40 {board && !board.isHerdr ? <Text dimColor>{NEEDS_HERDR}</Text> : null}
41 {layout?.hub ? <Text key="plain-hub">{`${layout.isSelfHub ? 'hub · this pane' : 'hub'} ${rowWords(layout.hub, at)}`}</Text> : null}
42 {layout?.groups.map(g => (
43 <Box key={`plain-req-${g.request.req}`} flexDirection="column">
44 <Text dimColor>{requestText(g.request, at)}</Text>
45 {g.rows.map(r => (
46 <Text key={`plain-${r.pane}`}>{' ' + rowWords(r, at)}</Text>
47 ))}
48 </Box>
49 ))}
50 {layout?.others.map(r => (
51 <Text key={`plain-${r.pane}`}>{rowWords(r, at)}</Text>
52 ))}
53 </Box>
54 )
55}
56
57function peersTerminal($: EngineInterface, ui: Elements['terminal'], board: PeerBoard | null, layout: PeerLayout | null) {
58 const { Box, Text, Button } = ui
59 // The view moves on its reads: times and countdowns are as of the last one.
60 const at = board?.at ?? 0
61
62 // One peer's row; `lead` is the words before it (`hub`, or an indent under a request).
63 const row = (r: PeerRow, lead: string, key: string) => {
64 const isBlocked = r.status === 'blocked'
65 const amber = isBlocked ? { color: AMBER } : {}
66 return (
67 <Box key={key} flexDirection="row" gap={1}>
68 {lead ? (
69 <Box key={`lead-${r.pane}`} flexShrink={0}>
70 <Text bold={lead.trim() !== ''}>{lead}</Text>
71 </Box>
72 ) : null}
73 <Box key={`state-${r.pane}`} flexShrink={0}>
74 <Text color={STATE_COLOR[r.status]} bold={isBlocked}>
75 {stateText(r, at)}
76 </Text>
77 </Box>
78 <Box key={`pane-${r.pane}`} flexShrink={0}>
79 <Text {...amber}>{r.pane}</Text>
80 </Box>
81 <Box key={`agent-${r.pane}`} flexShrink={0}>
82 <Text dimColor={!isBlocked} {...amber}>
83 {r.agent}
84 </Text>
85 </Box>
86 {r.title ? (
87 <Box key={`title-${r.pane}`} flexGrow={1} flexShrink={1}>
88 <Text wrap="truncate-end" {...amber}>
89 {titleText(r.title)}
90 </Text>
91 </Box>
92 ) : null}
93 </Box>
94 )
95 }
96
97 // The view's keys: framed buttons, each with its /agents-info twin.
98 const keys = (
99 <Box key="peers-keys" flexDirection="row" flexWrap="wrap" gap={1}>
100 <Button key="view-who" label="w: who" hotkey="w" onPress={() => void setView($, 'who')} />
101 <Button key="view-code" label="c: code" hotkey="c" onPress={() => void setView($, 'code')} />
102 <Button key="peers-refresh" label="r: refresh" hotkey="r" onPress={() => void pollPeers($).catch(() => {})} />
103 <Button key="pane-close" label="q: close" hotkey="q" onPress={() => void closePane($)} />
104 </Box>
105 )
106
107 const isEmpty = layout !== null && !layout.hub && layout.groups.length === 0 && layout.others.length === 0
108 return (
109 <Box flexDirection="column">
110 <Box key="peers-head">
111 <Text bold wrap="truncate-end">
112 {peersHead(board)}
113 </Text>
114 </Box>
115 {keys}
116 <Box flexDirection="column" marginTop={1}>
117 {board && !board.isHerdr ? (
118 <Text key="peers-needs-herdr" dimColor>
119 {NEEDS_HERDR}
120 </Text>
121 ) : null}
122 {isEmpty ? <Text dimColor>{`No other agents in herdr workspace ${board?.workspace ?? ''}.`}</Text> : null}
123 {layout?.hub ? row(layout.hub, layout.isSelfHub ? 'hub · this pane' : 'hub', 'peer-hub') : null}
124 {layout?.groups.map(g => (
125 <Box key={`req-${g.request.req}`} flexDirection="column">
126 <Box key={`req-head-${g.request.req}`}>
127 <Text dimColor wrap="truncate-end">
128 {requestText(g.request, at)}
129 </Text>
130 </Box>
131 {g.rows.map(r => row(r, ' ', `peer-${r.pane}`))}
132 </Box>
133 ))}
134 {layout && layout.others.length > 0 && (layout.hub || layout.groups.length > 0) ? (
135 <Text key="peers-others" dimColor>
136 other peers
137 </Text>
138 ) : null}
139 {layout?.others.map(r => row(r, '', `peer-${r.pane}`))}
140 </Box>
141 </Box>
142 )
143}
144types/index.d.ts 224 lines1export type AgentStatus = 'running' | 'finished' | 'error'
2
3/**
4 * A subagent's run, a background shell's (a Bash call moved to the background),
5 * a Monitor watcher's (`watch`) or a CronCreate timer's (`cron`).
6 */
7export type RunKind = 'agent' | 'shell' | 'watch' | 'cron'
8
9/** One run, a subagent's or a background shell's, as register.tsx records it. */
10export type AgentRun = {
11 id: string
12 /** Absent on runs recorded before shells were: an agent. */
13 kind?: RunKind
14 agentId?: string
15 /**
16 * The agent type (`Explore`, `general-purpose`, a plugin's agent); for a
17 * shell, `sh` or the coding agent its command runs (`codex`, `opencode`, `claude`).
18 */
19 type: string
20 /** A shell's command as the Bash call gave it, clipped to 2,000 characters. */
21 command?: string
22 /** A shell's background task id, as the Bash result and its notification name it. */
23 taskId?: string
24 /** The coding agent a shell's command runs (`codex`); absent for a plain shell. */
25 label?: string
26 /** The file the shell's output is written to, from the Bash result. */
27 outputFile?: string
28 /** The subagent whose Bash call started the shell; absent for main. */
29 parentAgentId?: string
30 /** True when the shell ends with its subagent's final answer (no notification follows). */
31 endsWithParent?: boolean
32 /** A failed shell's exit code, when its notification says. */
33 exitCode?: number
34 /** How a shell that did not complete ended: `failed` or `killed`. */
35 endWord?: 'failed' | 'killed'
36 /** The Agent call's `name`, when it gave one. */
37 name?: string
38 /** The Agent call's short description of the task. */
39 description: string
40 /** The model id the run answers on. */
41 model: string
42 effort?: string
43 status: AgentStatus
44 startedAt: number
45 endedAt?: number
46 /** The context of the run's last request: input, cache read and write, output. */
47 contextTokens: number
48 contextMax: number
49 /**
50 * A watch's target (the pane, commit or run id its command names), or a
51 * cron's job id; absent when the command names none.
52 */
53 target?: string
54 /** When a watch times out, or a cron fires next; absent for a persistent watch or an unknown fire time. */
55 nextAt?: number
56 /** A cron's schedule as the engine words it (`11 18 26 9 *`). */
57 schedule?: string
58 /** A cron's five-field expression, for its next fire time once it fired. */
59 cron?: string
60 /** True for a recurring cron; a one-shot one ends when it fires. */
61 isRecurring?: boolean
62 /** A watch's last event line, from its event notifications. */
63 lastEvent?: string
64 /** When a notification for a target already done came in: the entry greys for 10 s instead of toasting again. */
65 staleAt?: number
66 /** Every token the run's requests counted. */
67 tokens: number
68 costUsd: number
69 /** True once a request came from a model the price table does not know. */
70 isUnpriced?: boolean
71 /** Model requests seen. */
72 steps: number
73 /** When the request in flight started. */
74 stepStartedAt?: number
75 /** The summed length of the finished step cycles (request to next request). */
76 stepMsTotal?: number
77 /** How many step cycles that sum covers. */
78 stepCycles?: number
79 /** When the last request ended asking for tools; cleared when the next starts. */
80 awaitingSince?: number
81}
82
83/** A file's state in the code map: read only, changed by an edit, or its last edit failed. */
84export type CodeState = 'read' | 'changed' | 'failed'
85
86/** One file as one main-loop turn touched it, for the code map (the agents pane's Code view). */
87export type CodeTouch = {
88 /** The path as the tool named it, a relative one resolved against the session's directory. */
89 path: string
90 /** The main-loop turn (the `turn` counter) it was touched in. */
91 turn: number
92 /** The first edit in this turn: the edit order the map lays nodes in; absent while only read. */
93 editedAt?: number
94 /** The last edit, successful or not: `editing` for 30 s after it. */
95 lastEditAt?: number
96 /** The last touch of any kind. */
97 lastAt: number
98 state: CodeState
99 /** Lines added and removed, as `git diff --numstat` reports them (or a new file's lines). */
100 plus?: number
101 minus?: number
102 /** The tools used on it, in first-use order (`Read`, `Edit`, `Bash`). */
103 tools: string[]
104 /** One line from the edit itself: the Edit's first changed line or the Write's first line. */
105 summary?: string
106 /** Who touched it: `main`, or a subagent's agentId. */
107 by: string[]
108}
109
110/** The agents pane's view: the run cards (Who), the code map (Code) or the herdr peers (Peers). */
111export type PaneView = 'who' | 'code' | 'peers'
112
113/** A herdr agent's state as `herdr agent list` reports it (`agent_status`). */
114export type PeerStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown'
115
116/** One agent of this herdr workspace as the Peers view's last read saw it. */
117export type PeerRow = {
118 /** The herdr pane id (`w1:p6`). */
119 pane: string
120 /** The agent herdr names (`claude`, `codex`). */
121 agent: string
122 status: PeerStatus
123 /** The pane's terminal title without its spinner; '' when none. */
124 title: string
125 /**
126 * When a read first saw the pane in this state (`$.clock.now()` ms), carried
127 * from the read before when that one is recent; the read's own time otherwise.
128 */
129 since: number
130}
131
132/** The Peers view's last read of herdr. */
133export type PeerBoard = {
134 /** When herdr was read, `$.clock.now()` milliseconds. */
135 at: number
136 /** The herdr workspace read; '' outside herdr. */
137 workspace: string
138 /** False outside a herdr pane: the view says it needs one, and nothing is read. */
139 isHerdr: boolean
140 /** This pane's own row (the hub row when this session sent requests); null when herdr does not list it. */
141 self: PeerRow | null
142 /** The other agents of the workspace, in herdr's order. */
143 rows: PeerRow[]
144 /** Why the read failed, set only while no read has succeeded yet (the rows are then empty). */
145 error?: string
146}
147
148/**
149 * A HERDR-ORCH request this session sent or took part in, from the envelope
150 * headers (`[HERDR-ORCH v0] req=<id> from=<agent>@<pane> type=<type>`) in its prompts.
151 */
152export type PeerRequest = {
153 /** The request id (`req=`), also the folder under `~/.local/state/herdr-orch/`. */
154 req: string
155 /** The spoke panes the envelopes named, in the order first named. */
156 panes: string[]
157 /** The type of the last envelope seen. */
158 type: string
159 /** The round, from `round 4/5`, `round 4 of 5`, `round 4` or a `-r4` req suffix; null when none. */
160 round: number | null
161 /** The round ceiling, from `round 4/5`, `ceiling 5`, `max rounds 5`; null when none. */
162 ceiling: number | null
163 /** When the first envelope of the request was submitted here, `$.clock.now()` milliseconds. */
164 sentAt: number
165 /** When the deadline passes; null when unknown. */
166 deadlineAt: number | null
167 /** The result file as last read: null when missing or not read, '' without a valid status, else its status. */
168 result: string | null
169}
170
171/** The code map's scope: this main-loop turn, or the whole session with turn tags. */
172export type CodeScope = 'turn' | 'session'
173
174/** The main loop: its model and whether a turn of it runs. */
175export type MainLoop = {
176 model: string
177 isRunning: boolean
178 startedAt: number
179 /**
180 * The session's id (`$.session.id()`), else the first main-loop turn's id:
181 * the hub card's pose is picked by it, so it keeps one pose for the session.
182 */
183 id?: string
184}
185
186declare module 'claude-code' {
187 interface PluginState {
188 agents: {
189 runs: AgentRun[]
190 /** Skills fired since the band last hid: name to count. */
191 skills: Record<string, number>
192 main: MainLoop
193 /** The tick's last time. */
194 now: number
195 /** When the person last dismissed the band. */
196 dismissedAt: number
197 /** The pane's one-line-per-agent view. */
198 isCompact: boolean
199 /** Claude Code's prefersReducedMotion setting, as last read. */
200 isStill: boolean
201 /** Each shell's last output line, by run id, read while the pane is open. */
202 tails: Record<string, string>
203 /** True while the caffeinate hold runs (the band's ☕). */
204 isCaffeinated: boolean
205 /** The pane's view: `who` (the cards) or `code` (the code map). */
206 view: PaneView
207 /** The files touched, one record per file and main-loop turn. */
208 code: CodeTouch[]
209 /** The main-loop turn counter: one more at each main turn start. */
210 turn: number
211 /** The code map's scope. */
212 codeScope: CodeScope
213 /** True after `d` in the Code view: the next digit runs /diff on that node's file. */
214 isDiffArmed: boolean
215 /** The Peers view's last read of herdr; null until the view first reads. */
216 peerBoard: PeerBoard | null
217 /** The HERDR-ORCH requests seen in this session's prompts, by request id (the newest 40). */
218 peerRequests: Record<string, PeerRequest>
219 /** The pane whose brief made this session a spoke (its hub); '' when none. */
220 peerHub: string
221 }
222 }
223}
224