A live view of what Claude and its helper Claudes are doing: a desk per Claude with what it is on now, folder cards of the files touched and changed, a window…

crew-map)A live view of what Claude and its helper Claudes are doing, for when you give Claude Code a task and want to see the work instead of scrolling through a transcript.
/plugin install crew-map --marketplace D3K1D/claude-crew-mods
Open it with /crew (it also opens by itself in a wide window). The repository's README covers other ways to install it and troubleshooting.
Desks. Across the top, every Claude at work has a desk: a small animated Claude (bobbing quickly when busy, slowly when thinking, with a "!" when it needs you and a tick when it's done), what it's on right now in large text (a file, or a command in plain words), the folder it's in, and the last few files it touched. A running Codex gets a desk too. Desks keep the same size, so nothing jumps as work comes and goes.
Folder cards. Below the desks, every folder the crew touched gets a card, in the order the work first reached it. Its files are chips you can click to open: green with +/− line counts when edited, red with a count when a build or test failed on them, dim when nobody has touched them for five minutes, and with a coloured dot while a Claude is on them. A card shows ten chips, and +N more opens the rest. Files outside your project get their own card, which ✕ hide puts away (👁 show outside brings it back). Typing in the search box keeps only the matching files and their folders.
The tree map. ⊞ tree map in the toolbar switches to the older view: every file as an icon hung under its folder or the file that imports it, with little Claudes swinging between them. Outlines say what happened to a file (dashed: not opened, light: read, green: edited, red: errors), and work that isn't a file gets a station down the right (the terminal, the web, browsers, computer use, documents, other MCP apps). Move it with ◀ ▲ ▼ ▶, zoom with − / +, and ⤢ fit shows all of it. ▤ desks switches back.
The windows. Under the desks and cards, one window per Claude shows:
Waiting on you. When a Claude is waiting for a permission, an answer or a plan approval, the header says "⏸ N waiting on you", its desk and window turn amber and move to the front, and they say what it needs ("allow running Clear the old build folder").
What else it's waiting on. The main Claude's desk and window title also say when it's waiting on its helpers (in purple, with their names: "⏳ waiting on Scout"), on a command it runs or left running in the background (in orange, in Claude's own words: "⏳ waiting on Build the app"), or simply on you, once its turn is over and nothing else is running ("○ your turn").
Finished helpers keep their window for 20 seconds, then fold into a button on the log's title row. Click it to open the window again, and – fold puts it away.
The bar shows a percentage only when the Claude keeps a plan (Claude Code's task list, or a to-do list), since that's the only real measure of progress. It counts the tasks that Claude made which are finished, out of those still part of the work since your latest message ("40% · 2 of 5 tasks done"), with the one it's on shaded and named on the now row. Tasks finished before your latest message drop out, and a helper's tasks fill its own bar. Otherwise the window shows what's known: how many steps it has taken and for how long. A shell command shows as Claude's own one-line description of it. Every window keeps the same rows, and long lines are cut with "…".
Compaction. When the conversation is compacted to free up room, the Claude shows "Compacting memory", the header says "🗜 compacting", and the log says how many messages went in and came out.
Tests. When a Claude runs a test command (cargo test, npm test, jest, vitest, pytest, go test and the like):
The pass and fail counts land in its window, the log and a pop-up.
Problems. When a build or test fails, each file it names turns red in its folder card with a count of its errors, and a Problems list shows file:line and the message. Click an error to open the file at that line. A clean build or test run clears the list. It understands rustc and cargo panics, tsc, C#/MSBuild, gcc/clang, Go, Python tracebacks, pytest, and jest/vitest stack frames.
Stuck warning. A Claude gets a red "Stuck?" bubble, a warning in its window and a pop-up when:
The warning clears once it gets past the problem. It may just need a hint from you.
Spending. The spend row shows each Claude's share as a bar. Type a dollar amount into budget $ to get a pop-up as the session passes 50%, 80% and 100% of it. The header shows how much has been spent against the budget, and the budget is remembered between sessions.
Replay. Press ⏪ replay to step back through the session, one tool call at a time. You can play, pause, step, jump to either end, or go back to live.
Task summary. When a task ends, a green card sums it up: time taken, files changed with lines added and removed, tests run and the last result, which Claudes and how many Codex runs were involved, and the cost. Copy as Markdown puts it on your clipboard, ready for a PR description.
Opening files. Click a card's folder name (marked ↗) to open that folder in your file manager, and any file chip to open the file. Pictures, video, audio, documents, archives, fonts and 3D files open in your system's app for that type (Photos for a .png on Windows, Preview on a Mac). Code and text open in the editor you pick. Programs, and scripts when no editor is picked, are shown selected in their folder rather than run. 📁 folder opens the project folder. ▸ files lists every file touched this session with lines added and removed, and every file there, in the Problems list and in the Codex windows is a button too. A file edited this session opens as a diff against your last commit. Pick where code opens (VS Code, Cursor, or your system's default app) from the code in picker.
The log. Everything in time order, one line per action or thought, colour-coded by who did it.
Codex. With the codex-bridge mod installed, a running Codex gets its own desk, window and log lines. On the tree map it gets its own tree (Codex, its threads, and the files each thread read or edited), with a teal robot on the file it's on.
The intro. When the mod starts and when you open /crew, a six-second intro plays: the title rises a letter at a time, and four little Claudes drop onto it and hop across the letters before the pane takes over. skip ▸ jumps past it, don't show again turns it off, and ⏻ intro in the header plays it again (and turns it back on).
⏻ intro plays the start-up intro again.▶ demo runs three pretend Claudes over a pretend project for half a minute. It includes a failing test, a pretend Codex run and a summary card at the end.Search files... keeps only the matching files.⏪ replay steps back through the session.▸ files N lists every file touched this session.⊞ tree map / ▤ desks switches between the two views. On the tree map, ◀ ▲ ▼ ▶ moves it and − / + and ⤢ fit zoom.code in picks where code and text files open. Other files always open in their own app.budget $ sets the spending budget.The mod hooks Claude Code's events:
When a code file is first opened, the mod reads that file's imports to build the tree map (JS/TS, Python, Rust including use crate::…, C/C++, CSS). The desks and cards are plain text and buttons, and the tree map is an SVG that only redraws when something on it changes.
A warning also pops up when two Claudes edit the same file within a minute of each other.
Part of claude-crew-mods by D3K1D, made with Claude in Claude Code. MIT licensed.
hooks/register.tsx 5173 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent, Change, CodexRun, Crew, Frame, LogLine, MapNode, MapView, PlanTask, Problem, Replay, ShellRun, Summary, TestRun } from '../types'
5
6// ─── State ──────────────────────────────────────────────────────────────────
7
8const PANE = 'crew-map'
9/** Shown in the pane's header, so it is plain which version a chat is running. Keep in step with plugin.json. */
10const VERSION = '1.3.6'
11const MAX_AGENTS = 12
12const MAX_NODES = 60
13const MAX_GHOSTS = 14
14const LINGER_MS = 90_000
15
16const crew = atom({ plugin: 'crew-map', key: 'crew' } as const, {
17 agents: [],
18 nodes: {},
19 trails: [],
20 log: [],
21 calls: 0,
22 startedAt: 0,
23 changes: {},
24 zoom: 1,
25 showFiles: false,
26 demoCodex: [],
27 editor: 'code',
28 tests: [],
29 problems: [],
30 timeline: [],
31 budget: 0,
32 budgetWarned: 0,
33 turnBase: { at: 0, goal: '', changes: {}, tokens: 0, cost: 0, tests: 0, failed: 0, codex: 0 },
34 summary: null,
35 spent: { tokens: 0, cost: 0 },
36} as Crew)
37const replay = atom({ plugin: 'crew-map', key: 'replay' } as const, { on: false, at: 0, playing: false } as Replay)
38const intro = atom({ plugin: 'crew-map', key: 'intro' } as const, { until: 0, played: false } as { until: number; played?: boolean })
39const tick = atom({ plugin: 'crew-map', key: 'tick' } as const, 0)
40/** The map's window: where it sits over the map, how far zoomed, whether it follows the fit. */
41const mapView = atom({ plugin: 'crew-map', key: 'view' } as const, { x: 0, y: 0, k: 1, auto: true, rev: 'fit', sel: false, follow: 0 } as MapView)
42/** The search box: what is typed, which hit is shown, and a stamp per change. */
43const search = atom({ plugin: 'crew-map', key: 'search' } as const, { q: '', i: 0, rev: 0 })
44/** What the pointer rests on, on the map: a click target's id, or ''. */
45const hoverOn = atom({ plugin: 'crew-map', key: 'hover' } as const, '')
46/** The map's pan module has had the mouse here: interaction works, and the search can be a field of our own. */
47const mapLive = atom({ plugin: 'crew-map', key: 'mapLive' } as const, false)
48/** Files untouched this long fade; folders untouched this long fold up by themselves. */
49const FADE_MS = 5 * 60_000
50const FOLD_MS = 10 * 60_000
51
52// ─── Tests ──────────────────────────────────────────────────────────────────
53
54/**
55 * A command tidied, with heredoc bodies taken out (the text it carries, not what it runs).
56 * Only a real heredoc counts: `cat <<EOF`, not the shift in `$((1<<4))`.
57 */
58const cmdHead = (cmd: string) =>
59 tidyCmd(
60 cmd
61 .replace(/\\\r?\n/g, ' ')
62 .replace(/(^|\s)<<-?\s*(['"]?)([A-Za-z_]\w*)\2([^\n]*)\n[\s\S]*?\n\s*\3[ \t]*(?=\n|$)/g, '$1‹‹$3 …$4') // marked ‹‹ so the next step, for an unclosed one, leaves it be
63 .replace(/(^|\s)(<<-?\s*['"]?[A-Za-z_]\w*['"]?[^\n]*)\n[\s\S]*$/, '$1$2 …')
64 // Each line is its own command.
65 .replace(/\s*\r?\n\s*/g, ' ; '),
66 )
67
68/**
69 * A command's parts (`a && b; c | d`), each as [program, ...arguments]: a shell's quoted script
70 * (`bash -c "…"`, `powershell -Command "…"`) is unwrapped, other quoted text blanked, and
71 * wrappers skipped (npx, `python -m`, `uv run`, timeout, env…), so what is left is what runs.
72 */
73const cmdParts = (cmd: string): string[][] => {
74 const unwrapped = cmdHead(cmd)
75 // Combined flags count too: Codex runs everything as `bash -lc "…"`.
76 .replace(/\b(?:bash|sh|zsh|pwsh|powershell)(?:\.exe)?\s+(?:-\w+\s+)*?(?:-[A-Za-z]*c|-Command)\s+(["'])(.*?)\1/gi, (_m, _q, inner: string) => ` ; ${inner} ; `)
77 // A quoted program ("C:/Program Files/nodejs/npx.cmd") is still the program: keep its name.
78 .replace(/(^|[;&|]\s*)(["'])([^"']+)\2/g, (_m, pre: string, _q, p: string) => `${pre}${p.replace(/^.*[\\/]/, '').replace(/\s+/g, '_')}`)
79 .replace(/"[^"]*"|'[^']*'/g, '""')
80 // Redirects (2>&1, >/dev/null, >> out.txt) are not commands: their & would split one in two.
81 .replace(/\s*\d*[<>]&\d+/g, ' ')
82 .replace(/\s*\d*>>?\s*\S+/g, ' ')
83 const prog = (w: string) => w.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat)$/, '')
84 return unwrapped
85 .split(/&&|\|\||[;|&\n]/)
86 .map(part => {
87 const w = part.trim().split(/\s+/).filter(Boolean)
88 while (w.length && /^[A-Za-z_]\w*=/.test(w[0])) w.shift()
89 for (;;) {
90 const p = prog(w[0] ?? '')
91 if (['npx', 'bunx', 'pnpx', 'sudo', 'env', 'time', 'nice', 'exec'].includes(p)) w.shift()
92 else if (p === 'timeout') {
93 w.shift()
94 while (w[0]?.startsWith('-')) w.shift()
95 if (/^\d/.test(w[0] ?? '')) w.shift()
96 } else if (['uv', 'poetry', 'pipenv', 'hatch', 'pdm'].includes(p) && w[1] === 'run') w.splice(0, 2)
97 else if (['pnpm', 'yarn'].includes(p) && ['exec', 'dlx'].includes(w[1])) w.splice(0, 2)
98 // The Windows launcher's version flag (py -3.12 -m pytest) picks a Python, not what runs.
99 else if (p === 'py' && /^-\d/.test(w[1] ?? '')) w.splice(1, 1)
100 else if (/^(python3?|py)$/.test(p) && w[1] === '-m' && w[2]) w.splice(0, 2)
101 else break
102 }
103 if (w[0]) w[0] = prog(w[0])
104 return w
105 })
106 .filter(w => w.length)
107}
108
109/** Tools whose first word after the program says what ran (git status, npm install, claude plugin validate). */
110const SUBCOMMANDS = /^(git|npm|pnpm|yarn|bun|cargo|go|docker|kubectl|gh|claude|dotnet|uv|pip|pip3|brew|terraform|make|deno|winget|choco|rustup|poetry)$/
111/** Housekeeping that says nothing about the work: left out when a command does more. */
112const CHORES = /^(cd|pushd|popd|echo|printf|true|false|set|export|sleep|test|\[|exit|wait|head|tail|grep|rg|wc|sort|uniq|cut|tr|tee|xargs|less|more|cat|jq)$/
113/**
114 * What a command runs, in a few words and without its arguments, for when Claude gave no
115 * description: "ran sed, cp, diff", "ran a Python script", "ran claude plugin validate".
116 */
117const cmdGist = (cmd: string) => {
118 const names: string[] = []
119 for (const w of cmdParts(cmd)) {
120 const p = w[0]
121 if (!p || p.startsWith('$') || p === '""') continue
122 const args = w.slice(1).filter(x => x && x !== '""' && x !== '…')
123 const firstArg = args.find(x => !x.startsWith('-')) ?? ''
124 let name = p
125 if (/^(python3?|py|node|deno|bun|ruby|php|perl|pwsh|powershell|bash|sh)$/.test(p) && (args.includes('-') || args.includes('-e') || args.includes('-c') || /^‹‹/.test(firstArg))) {
126 const lang = /^(python3?|py)$/.test(p) ? 'Python' : /^(node|deno|bun)$/.test(p) ? 'Node' : p === 'ruby' ? 'Ruby' : p === 'php' ? 'PHP' : p === 'perl' ? 'Perl' : /^(pwsh|powershell)$/.test(p) ? 'PowerShell' : 'shell'
127 name = `a ${lang} script`
128 } else if (SUBCOMMANDS.test(p)) {
129 const subs = args.filter(x => !x.startsWith('-') && /^[a-z][\w:-]*$/.test(x)).slice(0, p === 'claude' || p === 'gh' || ((p === 'npm' || p === 'pnpm' || p === 'yarn' || p === 'bun') && args.find(x => !x.startsWith('-')) === 'run') ? 2 : 1)
130 name = [p, ...subs].join(' ')
131 } else if (/^(python3?|py|node|deno|bun|ruby|php|bash|sh|pwsh|powershell)$/.test(p) && /\.(py|m?js|ts|rb|php|sh|ps1)$/i.test(firstArg)) name = `${p} ${firstArg.split(/[\\/]/).pop()}`
132 if (!names.includes(name)) names.push(name)
133 }
134 const work = names.filter(n => !CHORES.test(n))
135 const shown = (work.length ? work : names).slice(0, 3)
136 return shown.length ? `ran ${shown.join(', ')}${(work.length ? work : names).length > 3 ? ', …' : ''}` : 'ran a shell command'
137}
138
139/** A path that names tests: tests/…, test_x.py, x.test.ts, run_tests.sh, spec/… (not "latest"). */
140const TESTY = /(^|[\\/_.-])(tests?|specs?)([\\/_.-]|$)/i
141const TEST_PROGS = /^(pytest|py\.test|jest|vitest|mocha|ava|tap|nextest|ctest|rspec|phpunit|unittest|nose2|tox|karma)$/
142/** Whether one part of a command runs tests. */
143const isTestPart = (w: string[]) => {
144 const [p, a = '', b = ''] = w
145 if (TEST_PROGS.test(p)) return !/^(--version|-v|-V|--help|init|install)$/.test(a)
146 if (p === 'cargo') return /^(test|t|nextest)$/.test(a)
147 if (['go', 'dotnet', 'swift', 'flutter', 'deno', 'dart', 'mix', 'bun'].includes(p) && a === 'test') return true
148 if (/^(npm|pnpm|yarn|bun)$/.test(p)) return /^(test|t)$/.test(a) || (a === 'run' && /^test/.test(b))
149 if (p === 'playwright') return a === 'test'
150 if (/^(mvn|mvnw|gradle|gradlew)$/.test(p)) return w.slice(1).some(x => /^(test|check|verify)$/.test(x))
151 if (p === 'make') return /^(test|tests|check)$/.test(a)
152 // A script that runs tests: node tests/run.mjs, python run_tests.py, bash scripts/test.sh.
153 if (/^(node|deno|bun|python3?|py|sh|bash|pwsh|powershell|ruby|php)$/.test(p)) return TESTY.test(w.slice(1).find(x => !x.startsWith('-')) ?? '')
154 return false
155}
156/**
157 * A command that runs tests, or waits on a run by watching its log; not one that only mentions
158 * them (`mkdir tests`, `pip install pytest`, `cat jest.config.js`, `python fetch_latest.py`).
159 */
160const isTestCmd = (cmd: string) => cmdParts(cmd).some(isTestPart) || TESTY.test(watchedFile(cmd) === 'a log file' ? '' : watchedFile(cmd))
161const TEST_SHOW_MS = 10 * 60_000
162/** A command in the foreground shows as running once it has run this long (a quick `ls` never flashes up). */
163const SHELL_SHOW_MS = 2000
164/** A background command nobody hears about again is let go after this long. */
165const SHELL_GIVE_UP_MS = 6 * 60 * 60_000
166/** The shell commands running now that are worth showing: background ones, and foreground ones past a moment. */
167const runningShells = (c: Crew, now: number) => (c.shells ?? []).filter(x => x.status === 'running' && (x.background || now - x.startedAt >= SHELL_SHOW_MS))
168/** A background shell's end, as its id names it: in what Claude read of it, a stop, or the app's task notice. */
169const endShell = (c: Crew, shellId: string, status: ShellRun['status'], now: number): Crew => {
170 const x = (c.shells ?? []).find(r => r.status === 'running' && r.shellId && r.shellId === shellId)
171 if (!x) return c
172 const d = { ...c, shells: (c.shells ?? []).map(r => (r === x ? { ...r, status, endedAt: now } : r)) }
173 return log(d, x.agentId, 'info', `${status === 'failed' ? '✗' : status === 'stopped' ? '■' : '✓'} background command ${status === 'done' ? 'finished' : status} after ${elapsed(now - x.startedAt)} · ${x.label}`)
174}
175
176/**
177 * A test command as a person would say it: no timeout wrapper, output
178 * redirects or pipes, and programs by name rather than by path
179 * (`timeout 2400 .venv/Scripts/python.exe -m unittest … > "C:/…/out.txt"` → `python -m unittest …`).
180 */
181const testLabel = (cmd: string) => {
182 const watched = watchedFile(cmd)
183 if (watched) return `watching ${watched}`
184 let s = tidyCmd(cmd)
185 s = s.replace(/\s*\|.*$/, '')
186 s = s.replace(/\s*\d?>>?\s*(?:&\d|"[^"]*"|'[^']*'|\S+)/g, '')
187 s = s.replace(/^\s*timeout\s+(?:-\S+\s+)*\d+[smh]?\s+/i, '')
188 s = s.replace(/(^|\s)(?:"([^"]*[\\/])?([^"\\/]+)"|(\S*[\\/])([^\s\\/]+))/g, (m, sp, _d1, q, _d2, b) => {
189 const name = (q ?? b ?? '').replace(/\.(exe|cmd|bat)$/i, '')
190 // Only programs (the first word) and things that look like executables lose their folders.
191 return sp === '' || /\.(exe|cmd|bat)$/i.test(q ?? b ?? '') ? `${sp}${name}` : m
192 })
193 return s.trim() || tidyCmd(cmd)
194}
195
196/**
197 * The file a polling loop keeps reading (`until grep … log; do sleep 5; done`, `tail -f log`):
198 * such a command waits on a test run rather than running one. '' when it is not one.
199 */
200const watchedFile = (cmd: string) => {
201 const isLoop = (/\b(until|while)\b/.test(cmd) && /\bsleep\b/.test(cmd)) || /\btail\s+(-\w+\s+)*-[fF]\b/.test(cmd)
202 if (!isLoop) return ''
203 const paths = [...cmd.matchAll(/(?:^|[\s"'(=])((?:[A-Za-z]:)?[\w./\\-]*[\w-]\.(?:txt|log|out|json|xml|jsonl|md|csv))(?=$|[\s"');|])/g)].map(m => m[1])
204 return paths[paths.length - 1] ?? 'a log file'
205}
206
207/**
208 * What a test run checks, in a few words: the targets named after the runner
209 * (`cargo test chunk` → "chunk", `pytest tests/test_auth.py -k login` → "test_auth.py, login"),
210 * or "all tests" when it names none.
211 */
212const testTargets = (cmd: string) => {
213 // From the command as shown: no redirects, pipes or timeout wrapper to mistake for targets.
214 const s = testLabel(cmd).replace(/\s*[|;&].*$/, '')
215 const at = s.search(/\b(test|tests|pytest|jest|vitest|mocha|nextest|ctest|rspec|phpunit|unittest)\b/i)
216 if (at < 0) return ''
217 const words = s.slice(at).split(/\s+/).slice(1)
218 const out: string[] = []
219 for (let i = 0; i < words.length && out.length < 3; i++) {
220 const w = words[i].replace(/^["']|["']$/g, '')
221 if (!w || w === '--' || /^(run|discover|watch|test|tests)$/i.test(w)) continue
222 // A flag's value counts only for the ones that pick tests (-k, -t, --filter, -p, --test, -s).
223 if (w.startsWith('-')) {
224 if (/^(-k|-t|--filter|--testNamePattern|-p|--package|--test|-s)$/.test(w) && words[i + 1]) {
225 out.push(words[i + 1].replace(/^["']|["']$/g, ''))
226 i++
227 }
228 continue
229 }
230 out.push(w.split(/[\\/]/).filter(Boolean).pop() ?? w)
231 }
232 return out.join(', ')
233}
234
235/** The title of a test run: what Claude said the command does, else its kind and targets. */
236const testPurpose = (cmd: string, description: string) => {
237 const said = oneLine(description).replace(/\.$/, '')
238 if (said) return said
239 const watched = watchedFile(cmd)
240 if (watched) return `Waiting on a test run (${baseName(watched)})`
241 const targets = testTargets(cmd)
242 const kind = testKind(cmd)
243 return targets ? `${kind[0].toUpperCase()}${kind.slice(1)}: ${targets}` : `All ${kind}`
244}
245
246/** The kind of test run, in words, from the runner the command uses. */
247const testKind = (cmd: string) =>
248 /\bcargo\b/.test(cmd) ? 'Rust tests (cargo)'
249 : /\bpytest\b/.test(cmd) ? 'Python tests (pytest)'
250 : /\bunittest\b/.test(cmd) ? 'Python tests (unittest)'
251 : /\bvitest\b/.test(cmd) ? 'JS tests (vitest)'
252 : /\bjest\b/.test(cmd) ? 'JS tests (jest)'
253 : /\bmocha\b/.test(cmd) ? 'JS tests (mocha)'
254 : /\b(npm|pnpm|yarn|bun)\b/.test(cmd) ? 'JS tests'
255 : /\bgo\s+test\b/.test(cmd) ? 'Go tests'
256 : /\bdotnet\b/.test(cmd) ? '.NET tests'
257 : /\bctest\b/.test(cmd) ? 'C/C++ tests (ctest)'
258 : 'tests'
259
260/** Pass and fail counts from a test run's output, across the common runners. */
261const testCounts = (out: string): { passed: number; failed: number } | undefined => {
262 // cargo: one "test result:" line per test binary; sum them.
263 const cargo = [...out.matchAll(/test result: \w+\. (\d+) passed; (\d+) failed/g)]
264 if (cargo.length) return cargo.reduce((s, m) => ({ passed: s.passed + Number(m[1]), failed: s.failed + Number(m[2]) }), { passed: 0, failed: 0 })
265 // jest / vitest summary line ("Tests: 1 failed, 41 passed, 42 total", "Tests 41 passed | 1 failed (42)").
266 const summary = out.split(/\r?\n/).reverse().find(l => /^\s*Tests?:?\s/.test(l) && /\d+ (passed|failed)/.test(l))
267 // pytest ("=== 1 failed, 40 passed in 2.1s ==="), go, mocha ("41 passing", "1 failing") and the rest: the last line that counts.
268 const line = summary ?? out.split(/\r?\n/).reverse().find(l => /\d+ (passed|failed|passing|failing)/.test(l))
269 if (!line) return undefined
270 const n = (re: RegExp) => Number(line.match(re)?.[1] ?? 0)
271 return { passed: n(/(\d+) pass(?:ed|ing)/), failed: n(/(\d+) fail(?:ed|ing)/) }
272}
273
274const testWords = (t: { status: string; passed: number; failed: number }) =>
275 t.passed || t.failed
276 ? [t.failed ? `${fmtK(t.failed)} failed` : '', t.passed ? `${fmtK(t.passed)} passed` : ''].filter(Boolean).join(', ')
277 : t.status === 'passed'
278 ? 'passed'
279 : t.status === 'failed'
280 ? 'failed'
281 : 'no result seen'
282
283const elapsed = (ms: number) => {
284 const s = Math.max(0, Math.round(ms / 1000))
285 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, '0')}m`
286}
287
288/** A tool result's text, whatever shape it came back in. */
289const resultText = (r: unknown): string => {
290 if (!r || typeof r !== 'object') return typeof r === 'string' ? r : ''
291 const o = r as { text?: unknown; content?: unknown; output?: unknown; stdout?: unknown; stderr?: unknown }
292 const parts = [o.text, o.output, o.stdout, o.stderr].filter((x): x is string => typeof x === 'string')
293 if (typeof o.content === 'string') parts.push(o.content)
294 else if (Array.isArray(o.content))
295 for (const b of o.content) if (b && typeof (b as { text?: unknown }).text === 'string') parts.push((b as { text: string }).text)
296 return parts.join('\n').slice(-20_000)
297}
298
299/** Settles a running test from its output and outcome; the log and a toast say how it went. */
300const settleTest = (c: Crew, testId: string, out: string, isError: boolean, now: number): Crew => {
301 const t = c.tests.find(x => x.id === testId)
302 if (!t || t.status !== 'running') return c
303 const counts = testCounts(out)
304 const failed = isError || (counts ? counts.failed > 0 : /\b(FAIL|FAILED|failures?:\s*[1-9])\b/.test(out))
305 const done: typeof t = { ...t, endedAt: now, status: failed ? 'failed' : 'passed', passed: counts?.passed ?? 0, failed: counts?.failed ?? 0 }
306 const secs = Math.max(1, Math.round((now - t.startedAt) / 1000))
307 const d = { ...c, tests: c.tests.map(x => (x.id === testId ? done : x)), turnBase: failed && t.startedAt >= c.turnBase.at && !t.agentId.startsWith('demo-') ? { ...c.turnBase, failed: (c.turnBase.failed ?? 0) + 1 } : c.turnBase }
308 return log(d, t.agentId, 'info', `${failed ? '✗ tests failed' : '✓ tests passed'} (${testWords(done)}) in ${secs}s · ${trunc(t.purpose || testPurpose(t.command, ''), 60)}`)
309}
310
311// ─── Problems: errors a build or test names, by file and line ──────────────
312
313/** Builds, type checks and linters, named as a command runs them (so `rm -rf build` is not one). */
314/** Whether one part of a command builds, type-checks or lints (so `rm -rf build`, `npm i -D eslint` do not). */
315const isBuildPart = (w: string[]) => {
316 const [p, a = '', b = ''] = w
317 if (/^(tsc|webpack|esbuild|rollup|gcc|g\+\+|clang|clang\+\+|javac|mypy|ruff|eslint|msbuild|ninja|rustc|pyright|biome)$/.test(p)) return !/^(--version|-v|--help|--init|init)$/.test(a)
318 if (p === 'make') return !/^(test|tests|check|install|clean)$/.test(a)
319 if (p === 'cmake') return a === '--build'
320 if (p === 'cargo') return /^(build|b|check|c|clippy)$/.test(a)
321 if (p === 'go') return /^(build|vet)$/.test(a)
322 if (p === 'dotnet') return a === 'build'
323 if (/^(npm|pnpm|yarn|bun)$/.test(p)) return a === 'run' ? /^(build|lint|typecheck|check|tsc)/.test(b) : /^(build|lint|typecheck)$/.test(a)
324 if (/^(vite|next|nuxt|astro)$/.test(p)) return a === 'build'
325 if (/^(gradle|gradlew)$/.test(p)) return w.slice(1).some(x => /^(build|assemble|compile\w*)$/.test(x))
326 if (/^(mvn|mvnw)$/.test(p)) return w.slice(1).some(x => /^(compile|package|install)$/.test(x))
327 return false
328}
329const isBuildCmd = (cmd: string) => cmdParts(cmd).some(isBuildPart)
330const MAX_PROBLEMS = 30
331const SRC = String.raw`((?:[A-Za-z]:)?[^\s:()"'<>|*?]*[\w-]\.[A-Za-z][\w]{0,5})`
332
333/**
334 * Errors in a build's or test's output that name a file: rustc and cargo
335 * panics, tsc, C#/MSBuild, gcc/clang/go, Python tracebacks and pytest,
336 * jest/vitest stack frames. Paths come back as written; the caller resolves them.
337 */
338const parseProblems = (out: string): { path: string; line: number; message: string }[] => {
339 const found: { path: string; line: number; message: string }[] = []
340 const add = (path: string, line: string | number, message: string) => {
341 const p = path.trim()
342 if (!p || /node_modules|site-packages|[\\/]rustc[\\/]|[\\/]\.cargo[\\/]registry|<anonymous>|^internal[\\/]/i.test(p)) return
343 if (found.some(f => f.path === p && f.line === Number(line))) return
344 found.push({ path: p, line: Number(line) || 0, message: trunc(oneLine(message), 160) })
345 }
346 const text = out.replace(/\x1b\[[0-9;]*m/g, '')
347 // rustc: "error[E0308]: mismatched types" then " --> src/a.rs:12:5".
348 for (const m of text.matchAll(new RegExp(String.raw`^error(?:\[\w+\])?: (.+)\r?\n\s*--> ${SRC}:(\d+)`, 'gm'))) add(m[2], m[3], m[1])
349 // cargo test panics: "thread 'x' panicked at src/a.rs:12:5:\nmessage".
350 for (const m of text.matchAll(new RegExp(String.raw`panicked at ${SRC}:(\d+):\d+:?\r?\n(.*)`, 'g'))) add(m[1], m[2], m[3] || 'panicked')
351 // tsc and MSBuild: "src/a.ts(12,5): error TS2322: ..."; tsc pretty: "src/a.ts:12:5 - error TS2322: ...".
352 for (const m of text.matchAll(new RegExp(String.raw`^\s*${SRC}\((\d+),\d+\): (?:fatal )?error \w+: (.+?)(?: \[[^\]]*\])?$`, 'gm'))) add(m[1], m[2], m[3])
353 for (const m of text.matchAll(new RegExp(String.raw`^${SRC}:(\d+):\d+ - error \w+: (.+)$`, 'gm'))) add(m[1], m[2], m[3])
354 // gcc, clang, go, mypy, eslint-unix: "src/a.c:12:5: error: ..." (go has no "error:").
355 for (const m of text.matchAll(new RegExp(String.raw`^\s*${SRC}:(\d+)(?::\d+)?: (?:fatal )?error:? (.+)$`, 'gm'))) add(m[1], m[2], m[3])
356 for (const m of text.matchAll(new RegExp(String.raw`^(?:\.\/)?${SRC}:(\d+):\d+: (?!warning|note)(.+)$`, 'gm'))) if (/\.go$/.test(m[1])) add(m[1], m[2], m[3])
357 // Python: the deepest project frame of each traceback, with the exception line under it.
358 for (const tb of text.split(/^Traceback \(most recent call last\):/m).slice(1)) {
359 const frames = [...tb.matchAll(/^\s*File "([^"]+)", line (\d+)/gm)].filter(f => !/site-packages|[\\/]lib[\\/]python|<frozen/i.test(f[1]))
360 const exc = tb.split(/\r?\n/).find(l => /^\w[\w.]*(Error|Exception|Exit|Interrupt)\b/.test(l)) ?? 'exception'
361 const last = frames[frames.length - 1]
362 if (last) add(last[1], last[2], exc)
363 }
364 // pytest: "tests/test_a.py:12: AssertionError" and "FAILED tests/test_a.py::test_x - AssertionError: ...".
365 for (const m of text.matchAll(new RegExp(String.raw`^${SRC}:(\d+): (\w*(?:Error|Exception)\b.*)$`, 'gm'))) add(m[1], m[2], m[3])
366 for (const m of text.matchAll(new RegExp(String.raw`^FAILED ${SRC}::(\S+)(?: - (.+))?$`, 'gm'))) add(m[1], 0, `${m[2]}${m[3] ? `: ${m[3]}` : ''}`)
367 // jest / vitest: the first project frame after a failure heading.
368 for (const block of text.split(/^\s*(?:●|FAIL\b|×|✗|❯ .*\.(?:test|spec)\.)/m).slice(1)) {
369 const head = oneLine(block.split(/\r?\n/).find(l => /\S/.test(l)) ?? '')
370 const why = block.split(/\r?\n/).find(l => /(Error|expected|Expected|toBe|toEqual|AssertionError)/.test(l)) ?? head
371 const frame = block.match(new RegExp(String.raw`(?:\(|❯ |at )${SRC}:(\d+):\d+`))
372 if (frame) add(frame[1], frame[2], why)
373 }
374 return found.slice(0, MAX_PROBLEMS)
375}
376
377/** The folder a command ran in: a leading `cd X &&` moves it from the session's. */
378const cmdDir = (raw: string, base: string) => {
379 const m = raw.match(/^\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*(?:&&|;)/)
380 if (!m) return base
381 const d = m[1].replace(/^["']|["']$/g, '')
382 return /^([A-Za-z]:[\\/]|[\\/])/.test(d) ? norm(d) : norm(`${base}/${d}`)
383}
384
385/** A path from tool output made absolute against the folder the command ran in. */
386const resolvePath = (p: string, dir: string) => {
387 const s = p.replace(/^\.[\\/]/, '')
388 return /^([A-Za-z]:[\\/]|[\\/])/.test(s) || !dir ? norm(s) : norm(`${dir}/${s}`)
389}
390
391// ─── Stuck: the same thing failing over and over ────────────────────────────
392
393const STUCK_WINDOW_MS = 10 * 60_000
394/** Why an agent looks stuck from its recent calls, or ''. */
395const stuckReason = (recent: Agent['recent'], now: number): string => {
396 const live = recent.filter(r => now - r.at < STUCK_WINDOW_MS)
397 const last = live[live.length - 1]
398 if (!last) return ''
399 if (!last.ok && last.sig.startsWith('$ ')) {
400 // The same command failing, with no success of it in between.
401 let n = 0
402 for (let i = live.length - 1; i >= 0; i--) {
403 if (live[i].sig !== last.sig) continue
404 if (live[i].ok) break
405 n++
406 }
407 if (n >= 3) return `ran the same command ${n} times (${cmdGist(last.sig.slice(2)).replace(/^ran /, '')}) and it failed every time`
408 }
409 const tail = live.slice(-5)
410 if (tail.length === 5 && tail.every(r => !r.ok)) return 'its last 5 tool calls all failed'
411 if (last.sig.startsWith('✎ ')) {
412 const edits = live.filter(r => r.sig === last.sig && now - r.at < 5 * 60_000).length
413 if (edits >= 8) return `edited ${last.sig.slice(2)} ${edits} times in 5 minutes`
414 }
415 return ''
416}
417
418// ─── Summary of a finished task ─────────────────────────────────────────────
419
420/** A path relative to the session's folder when it is inside it; demo paths lose their prefix. */
421const relPath = (p: string) =>
422 p.startsWith('demo/') ? p.slice(5) : sessionDir && p.toLowerCase().startsWith(`${sessionDir.toLowerCase()}/`) ? p.slice(sessionDir.length + 1) : shortPath(p)
423
424const summaryMarkdown = (s: Summary) => {
425 const lines = [
426 `### ${s.goal ? trunc(s.goal, 120) : 'Claude Code task'}`,
427 '',
428 `- **Time:** ${elapsed(s.ms)}`,
429 `- **Files changed:** ${s.files.length}${s.files.length ? ` (+${s.files.reduce((n, f) => n + f.added, 0)} −${s.files.reduce((n, f) => n + f.removed, 0)} lines)` : ''}${s.read ? `, ${s.read} more read` : ''}`,
430 `- **Tests:** ${s.tests.runs ? `${s.tests.runs} run${s.tests.runs === 1 ? '' : 's'}${s.tests.failed ? `, ${s.tests.failed} failed` : ''}; last: ${s.tests.last}` : 'none run'}`,
431 `- **Claudes:** ${s.claudes.join(', ')}${s.codex ? ` · Codex runs: ${s.codex}` : ''}`,
432 `- **Cost:** ${s.tokens.toLocaleString()} tokens ≈ ${fmtUsd(s.cost)} at API rates`,
433 ...(s.problems ? [`- **Open problems:** ${s.problems} error${s.problems === 1 ? '' : 's'} from the last build/test`] : []),
434 ]
435 if (s.files.length) lines.push('', '| File | + | − |', '| --- | ---: | ---: |', ...s.files.slice(0, 30).map(f => `| \`${relPath(f.path)}\` | ${f.added} | ${f.removed} |`))
436 return lines.join('\n')
437}
438
439/** Builds the summary of the task that just ended, from what changed since it began. */
440const summarise1 = (c: Crew, now: number, codexRuns: number): Summary => {
441 const base = c.turnBase
442 const files = Object.entries(c.changes)
443 .filter(([p, ch]) => !p.startsWith('demo/') && ch.at >= base.at && ch.edits > 0)
444 .map(([path, ch]) => ({ path, added: ch.added - (base.changes[path]?.added ?? 0), removed: ch.removed - (base.changes[path]?.removed ?? 0) }))
445 .filter(f => f.added || f.removed)
446 .sort((x, y) => y.added + y.removed - (x.added + x.removed))
447 const read = Object.entries(c.changes).filter(([p, ch]) => !p.startsWith('demo/') && ch.at >= base.at && ch.edits === 0).length
448 const lastTest = [...c.tests].reverse().find(t => t.startedAt >= base.at && t.status !== 'running' && !t.agentId.startsWith('demo-'))
449 const worked = new Set(c.timeline.filter(f => f.at >= base.at).map(f => f.agentId))
450 return {
451 at: now,
452 goal: base.goal,
453 ms: now - (base.at || now),
454 files,
455 read,
456 tests: { runs: base.tests ?? 0, failed: base.failed ?? 0, last: lastTest ? `${lastTest.status} (${testWords(lastTest)})` : 'a run still going' },
457 tokens: Math.max(0, (c.spent?.tokens ?? 0) - base.tokens),
458 cost: Math.max(0, (c.spent?.cost ?? 0) - base.cost),
459 claudes: c.agents.filter(a => a.id === 'main' || worked.has(a.id)).map(a => a.name),
460 codex: Math.max(0, codexRuns - base.codex),
461 problems: c.problems.length,
462 }
463}
464
465// ─── Replay: the map as it stood at a step of the timeline ──────────────────
466
467const MAX_FRAMES = 300
468/** The crew as it was at frame `i`: each Claude where its last step put it, edits summed so far. */
469const crewAt = (c: Crew, i: number, now: number): Crew => {
470 const frames = c.timeline.slice(0, i + 1)
471 const cur = frames[frames.length - 1]
472 const lastOf = new Map<string, { f: Frame; k: number }>()
473 frames.forEach((f, k) => lastOf.set(f.agentId, { f, k }))
474 const agents: Agent[] = []
475 for (const [id, { f, k }] of lastOf) {
476 // A Claude drops off the map a dozen steps after its last one.
477 if (i - k > 12) continue
478 const known = c.agents.find(a => a.id === id)
479 const prev = frames.slice(0, k).reverse().find(x => x.agentId === id)
480 agents.push({
481 ...(known ?? blank(id, f.name, 'agent', f.hue)),
482 name: f.name,
483 hue: f.hue,
484 node: f.node,
485 from: prev?.node ?? '',
486 movedAt: f === cur && prev && prev.node !== f.node ? now : 0,
487 tool: f.tool,
488 detail: f.detail,
489 state: f === cur ? 'busy' : 'thinking',
490 thoughts: [],
491 stuck: '',
492 doing: '',
493 })
494 }
495 const changes: Crew['changes'] = {}
496 for (const f of frames) {
497 if (!f.node || isSpecial(f.node)) continue
498 const ch = changes[f.node] ?? { added: 0, removed: 0, edits: 0, reads: 0, by: f.name, hue: f.hue, at: f.at }
499 changes[f.node] = f.added || f.removed ? { ...ch, added: ch.added + f.added, removed: ch.removed + f.removed, edits: ch.edits + 1, by: f.name, hue: f.hue, at: f.at } : { ...ch, reads: ch.reads + 1 }
500 }
501 const seen = new Set(frames.map(f => f.node))
502 const nodes = Object.fromEntries(Object.entries(c.nodes).filter(([id, n]) => seen.has(id) || n.kind === 'ghost'))
503 return { ...c, agents, changes, nodes, tests: [], problems: [] }
504}
505
506/** Where code and text files open: 'code' (VS Code), 'cursor' or 'system', chosen in the pane. Other files open in their own app. */
507let editor = 'code'
508/** The session's folder, for paths in tool output; set at session start. */
509let sessionDir = ''
510let platform: 'win' | 'mac' | 'linux' | undefined
511/** Which OS this runs on: Windows says so in OS; elsewhere uname tells macOS from Linux. */
512const platformOf = async ($: EngineInterface) => {
513 if (platform) return platform
514 if ((await $.env.get('OS').catch(() => undefined)) === 'Windows_NT') return (platform = 'win')
515 const u = await $.process.run(['uname', '-s'], { timeoutMs: 5000 }).catch(() => undefined)
516 return (platform = /darwin/i.test(u?.stdout ?? '') ? 'mac' : 'linux')
517}
518let tempDir = ''
519
520/**
521 * Anthropic API list prices, dollars per million tokens (cached 2026-09):
522 * input, output, cache read, cache write. Matched on the model id.
523 */
524const PRICES: [RegExp, [number, number, number, number]][] = [
525 [/fable|mythos/i, [10, 50, 0.25, 12.5]],
526 [/opus-5-5/i, [4, 20, 0.2, 5]],
527 [/opus/i, [5, 25, 0.5, 6.25]],
528 [/sonnet-5/i, [2, 10, 0.2, 2.5]],
529 [/sonnet/i, [3, 15, 0.3, 3.75]],
530 [/haiku/i, [1, 5, 0.1, 1.25]],
531]
532type Usage = { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; model?: string }
533/** A count that can be added up: a finite number of zero or more, else 0 (a junk usage never poisons a total). */
534const fin = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
535const tokensOf = (u: Usage) => fin(u.input_tokens) + fin(u.output_tokens) + fin(u.cache_read_input_tokens) + fin(u.cache_creation_input_tokens)
536const costOf = (u: Usage) => {
537 const [i, o, r, w] = (PRICES.find(([re]) => re.test(typeof u.model === 'string' ? u.model : '')) ?? PRICES[1])[1]
538 return (fin(u.input_tokens) * i + fin(u.output_tokens) * o + fin(u.cache_read_input_tokens) * r + fin(u.cache_creation_input_tokens) * w) / 1e6
539}
540/** A count in at most five characters: 7, 950, 1.2k, 12k, 345k, 1.0M, 33M, 2.1B. */
541const fmtK = (v: number) => {
542 let n = Math.max(0, Number(v) || 0)
543 if (n < 999.5) return String(Math.round(n))
544 let u = 0
545 while (n >= 999.5 && u < 4) {
546 n /= 1000
547 u++
548 }
549 return `${n < 9.95 ? n.toFixed(1) : Math.round(n)}${['', 'k', 'M', 'B', 'T'][u]}`
550}
551/** Dollars, short at any size: $0, <$0.01, $0.42, $12.30, $123, $1.2k. */
552const fmtUsd = (n: number) => (!(n > 0) ? '$0' : n < 0.01 ? '<$0.01' : n < 100 ? `$${n.toFixed(2)}` : n < 999.5 ? `$${Math.round(n)}` : `$${fmtK(n)}`)
553
554// Set from session.start, so timers and handlers outlive the dispatch that made them.
555let write: ((fn: (c: Crew) => Crew) => Promise<unknown>) | undefined
556/** Every write of the crew waits for the one before it: parallel tool calls never race (a race made each retry, N² writes). */
557let writeQueue: Promise<unknown> = Promise.resolve()
558/** The 1 s clock: one, kept so a second session start replaces it rather than adding another. */
559let ticker: { cancel(): void } | undefined
560let readCrew: (() => Promise<Crew>) | undefined
561let after: ((ms: number, fn: () => void) => void) | undefined
562let every: ((ms: number, fn: () => void) => { cancel: () => void }) | undefined
563let readFile: ((path: string) => Promise<string>) | undefined
564let exists: ((path: string) => Promise<boolean>) | undefined
565let demo: { cancel: () => void } | undefined
566
567// ─── Vocabulary ─────────────────────────────────────────────────────────────
568
569const PALETTE = ['#D97757', '#6CB6FF', '#7EE787', '#D2A8FF', '#FFA657', '#56D4DD', '#FF7B72', '#E3B341']
570const NAMES: Record<string, string> = { Explore: 'Scout', Plan: 'Architect', 'general-purpose': 'Helper', fork: 'Twin' }
571const VERB: Record<string, string> = {
572 Read: 'reading',
573 Edit: 'editing',
574 MultiEdit: 'editing',
575 Write: 'writing',
576 NotebookEdit: 'editing',
577 Grep: 'searching',
578 Glob: 'finding',
579 Bash: 'running',
580 PowerShell: 'running',
581 WebFetch: 'fetching',
582 WebSearch: 'searching',
583 Agent: 'delegating',
584 Task: 'delegating',
585 TodoWrite: 'planning',
586 TaskCreate: 'planning',
587 TaskUpdate: 'planning',
588 Skill: 'learning',
589}
590const ACT_COLOR: Record<string, string> = {
591 reading: '#79C0FF',
592 editing: '#7EE787',
593 writing: '#7EE787',
594 searching: '#E3B341',
595 finding: '#E3B341',
596 running: '#FFA657',
597 fetching: '#56D4DD',
598 delegating: '#D2A8FF',
599 planning: '#D2A8FF',
600 thinking: '#8B949E',
601}
602const ICON: Record<string, string> = { Read: '◇', Edit: '✎', MultiEdit: '✎', Write: '✎', Bash: '$', PowerShell: '$', Grep: '⌕', Glob: '⌕', WebFetch: '◎', WebSearch: '◎', Agent: '⇢', Task: '⇢', 'mcp__codex-bridge__codex': '◎' }
603VERB['mcp__codex-bridge__codex'] = 'asking Codex'
604const SPECIAL_LABEL: Record<string, string> = {
605 $terminal: 'terminal',
606 '@web': 'web',
607 '?search': 'search',
608 '@browser': 'browser',
609 '@computer': 'this PC',
610 '@remote': 'remote PC',
611 '@docs': 'documents',
612 '@claude': 'Claude app',
613}
614/** Map nodes that are tools rather than files: the stations down the right of the map. */
615const isSpecial = (id: string) => id in SPECIAL_LABEL || id.startsWith('@app:')
616
617// ─── Tools beyond the built-in ones: MCP servers, browsers, the computer ───
618
619const KNOWN_APPS: Record<string, string> = {
620 blender: 'Blender',
621 Roblox_Studio: 'Roblox Studio',
622 'DaVinci_Resolve': 'DaVinci Resolve',
623 visualize: 'visuals',
624 'scheduled-tasks': 'schedules',
625 'mcp-registry': 'connectors',
626 terminal: 'terminal tabs',
627 github: 'GitHub',
628 slack: 'Slack',
629 linear: 'Linear',
630 notion: 'Notion',
631 figma: 'Figma',
632}
633/** A server name as a person would say it: `Roblox_Studio` → "Roblox Studio"; an id-like name → "a connector". */
634const appName = (raw: string) => {
635 // A plugin's server is named plugin_<plugin>_<server>: the server's own name is the last part.
636 const server = raw.replace(/^plugin_[^_]+_/, '')
637 return (
638 KNOWN_APPS[server] ??
639 (/^[0-9a-f]{8}-[0-9a-f-]{20,}$/i.test(server)
640 ? 'a connector'
641 : server
642 .replace(/[_-]+/g, ' ')
643 .replace(/\b\w/g, ch => ch.toUpperCase())
644 .trim())
645 )
646}
647/** `mcp__server__tool` → its server and tool, else undefined. */
648const mcpParts = (tool: string) => {
649 const m = tool.match(/^mcp__(.+?)__(.+)$/)
650 return m ? { server: m[1], name: m[2] } : undefined
651}
652/**
653 * The station a tool works at, when it is not a file: a browser (the app's own
654 * pane, Claude in Chrome, Playwright…), this computer or a remote one, the
655 * documents tools, the Claude app's own controls, or another MCP server (an app
656 * such as Blender). '' for the built-in tools and Codex.
657 */
658const stationOf = (tool: string): string => {
659 if (tool === 'Artifact' || tool === 'ArtifactData' || tool === 'ArtifactComments') return '@docs'
660 const mcp = mcpParts(tool)
661 if (!mcp || /__codex$/.test(tool)) return ''
662 const { server, name } = mcp
663 if (server === 'remote-devices') return /browser/i.test(name) ? '@browser' : /computer/i.test(name) ? '@remote' : '@app:remote-devices'
664 if (/browser|chrome|playwright|puppeteer/i.test(server)) return '@browser'
665 if (/computer-use|^computer$/i.test(server)) return '@computer'
666 if (/^ccd_/.test(server)) return '@claude'
667 // A docs connector (named by an id) whose tools create and edit documents.
668 if (/^[0-9a-f]{8}-/i.test(server) && /^(batch|create|update|read|export|delete|query|guide)$/.test(name)) return '@docs'
669 return `@app:${server}`
670}
671const stationLabel = (id: string) => SPECIAL_LABEL[id] ?? (id.startsWith('@app:') ? appName(id.slice(5)) : '')
672const STATION_ICON: Record<string, string> = { '@browser': '◍', '@computer': '▣', '@remote': '▣', '@docs': '▤', '@claude': '✻' }
673/** The pseudo-tool a Claude is "using" while the engine compacts its conversation. */
674const COMPACT = '__compact'
675/** A doc rather than code: what Claude writes for people to read. */
676const DOC_EXT = /\.(md|mdx|markdown|txt|rst|adoc|docx?|odt|rtf|html?)$/i
677const CODE_EXT = /\.(m?[jt]sx?|cjs|py|rs|c|h|cc|cpp|hpp|css|scss|vue|svelte)$/i
678
679/** What a Claude's bubble says it is doing, and the bubble's colour. */
680const activityOf = (a: Agent): [string, string] => {
681 if (a.needs) return ['Needs you', '#E3B341']
682 if (a.state !== 'busy')
683 return /\b(maybe|could|alternativ|options?|ideas?|what if|perhaps|approach|consider|instead)\b/i.test(a.thoughts[0] ?? '')
684 ? ['Brainstorming', '#D2A8FF']
685 : ['Thinking', '#E5E7EB']
686 const cmd = a.detail
687 switch (a.tool) {
688 case 'Edit':
689 case 'MultiEdit':
690 case 'Write':
691 case 'NotebookEdit':
692 return DOC_EXT.test(a.node) ? ['Writing docs', '#E3B341'] : ['Coding', '#7EE787']
693 case 'Read':
694 return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(a.node) ? ['Viewing an image', '#79C0FF'] : /\.pdf$/i.test(a.node) ? ['Reading a PDF', '#79C0FF'] : ['Reading', '#79C0FF']
695 case 'Grep':
696 case 'Glob':
697 return ['Searching', '#E3B341']
698 case 'Bash':
699 case 'PowerShell':
700 if (isTestCmd(cmd)) return ['Waiting on tests', '#56D4DD']
701 if (isBuildCmd(cmd)) return ['Building', '#FFA657']
702 if (/\bgit\s+(commit|push|merge|rebase)\b/i.test(cmd)) return ['Committing', '#F778BA']
703 if (/\b(gdb|lldb|debug|RUST_BACKTRACE)\b/i.test(cmd)) return ['Debugging', '#FF7B72']
704 return ['Running', '#FFA657']
705 case 'WebFetch':
706 case 'WebSearch':
707 return ['Researching', '#56D4DD']
708 case 'Agent':
709 case 'Task':
710 return ['Delegating', '#D2A8FF']
711 case 'TodoWrite':
712 case 'TaskCreate':
713 case 'TaskUpdate':
714 return ['Planning', '#D2A8FF']
715 case 'Skill':
716 return ['Using a skill', '#D2A8FF']
717 case COMPACT:
718 return ['Compacting memory', '#8B949E']
719 default: {
720 if (/__codex$/.test(a.tool)) return ['Asking Codex', '#56D4DD']
721 const st = stationOf(a.tool)
722 if (st === '@browser') return ['Browsing', '#56D4DD']
723 if (st === '@computer') return ['Using the PC', '#FFA657']
724 if (st === '@remote') return ['Using the remote PC', '#FFA657']
725 if (st === '@docs') return ['Writing a document', '#E3B341']
726 if (st === '@claude') return ['Using the app', '#8B949E']
727 if (st) return [`Using ${stationLabel(st)}`, '#D2A8FF']
728 return ['Working', '#E5E7EB']
729 }
730 }
731}
732
733const colorOf = (a: { hue: number }) => PALETTE[a.hue % PALETTE.length]
734const verbOf = (a: Agent) =>
735 a.state === 'busy' ? (VERB[a.tool] ?? activityOf(a)[0].toLowerCase()) : a.state === 'thinking' ? 'thinking' : a.state === 'done' ? 'done' : 'idle'
736const baseName = (p: string) => p.split('/').filter(Boolean).pop() ?? p
737/** The last three segments of a path: enough to tell files apart. */
738const shortPath = (p: string) => {
739 const parts = p.split('/').filter(Boolean)
740 return parts.length > 3 ? `…/${parts.slice(-3).join('/')}` : parts.join('/')
741}
742const dirOf = (p: string) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')
743const trunc = (s: string, n: number) => (n <= 1 ? '' : s.length > n ? `${s.slice(0, n - 1)}…` : s)
744const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
745/**
746 * Text cut short to fit, done by the pane itself. The desktop draws a `wrap: "truncate"` Text in
747 * a row at its full length and the box's edge clips it (no "…"), so before a desktop pane is
748 * returned, every such Text is cut to the columns its row leaves it. Widths are in text cells: the
749 * pane's columns, less borders, padding and what else stands in the row; a wide glyph counts two,
750 * and a little is kept spare because the desktop's font is proportional (some letters are wider).
751 */
752type FitNode = { type?: string; props?: Record<string, unknown>; children?: unknown[] } | string | number | null | undefined | false
753const kidsOf = (x: FitNode): FitNode[] => (x && typeof x === 'object' ? ((x.children ?? (x.props?.children as unknown[] | undefined) ?? []) as FitNode[]) : [])
754// The element as given when nothing in it changed; else a copy with its children where it keeps
755// them. The engine keeps them on the element and refuses a "children" prop (a whole pane drew
756// nothing for it once), so they are never moved into props.
757const withKids = (x: Exclude<FitNode, string | number | null | undefined | false>, kids: FitNode[]) => {
758 const was = kidsOf(x)
759 if (kids.length === was.length && kids.every((k, i) => k === was[i])) return x
760 if (Array.isArray(x.children)) return { ...x, children: kids }
761 if (x.props && Array.isArray(x.props.children)) return { ...x, props: { ...x.props, children: kids } }
762 return { ...x, children: kids }
763}
764const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
765const glyphW = (ch: string) => (WIDE.test(ch) ? 2 : 1)
766const textW = (t: string) => {
767 let n = 0
768 for (const ch of t) n += glyphW(ch)
769 return n
770}
771/** A string cut to `w` cells, ending in "…" when cut. */
772const cutTo = (t: string, w: number) => {
773 if (textW(t) <= w) return t
774 if (w <= 1) return w === 1 ? '…' : ''
775 let out = ''
776 let n = 0
777 for (const ch of t) {
778 const g = glyphW(ch)
779 if (n + g > w - 1) break
780 out += ch
781 n += g
782 }
783 return `${out.trimEnd()}…`
784}
785const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
786/** Columns a Box's frame takes: border, padding, side margins. */
787const frameW = (p: Record<string, unknown>) =>
788 (p.borderStyle ? 2 : 0) + 2 * num(p.paddingX ?? p.padding) + num(p.paddingLeft) + num(p.paddingRight) + num(p.marginLeft) + num(p.marginRight) + 2 * num(p.marginX ?? p.margin)
789/** The columns a node takes at its natural size. */
790const natW = (x: FitNode): number => {
791 if (x === null || x === undefined || x === false) return 0
792 if (typeof x === 'string' || typeof x === 'number') return textW(String(x))
793 const p = x.props ?? {}
794 if (p.position === 'absolute' || p.display === 'none') return 0
795 if (x.type === 'Svg') return Math.ceil(num(p.width) / CELL_W)
796 if (x.type === 'Client') return num(p.width)
797 if (x.type === 'Select') return 14
798 if (x.type === 'Input') return 24
799 const kids = kidsOf(x)
800 if (x.type === 'Text' || x.type === 'Button') return kids.reduce<number>((n, k) => n + natW(k), 0) || (typeof p.label === 'string' ? textW(p.label) : 0)
801 if (typeof p.width === 'number') return p.width
802 const row = (p.flexDirection ?? 'row') === 'row'
803 const gap = num(p.columnGap ?? p.gap)
804 const inner = row ? kids.reduce<number>((n, k) => n + natW(k), 0) + gap * Math.max(0, kids.filter(k => natW(k) > 0).length - 1) : Math.max(0, ...kids.map(natW))
805 return inner + frameW(p)
806}
807/** Whether a node holds a Text it may cut short (so its row may narrow it). */
808const canShrink = (x: FitNode): boolean => {
809 if (!x || typeof x !== 'object') return false
810 const p = x.props ?? {}
811 if (x.type === 'Text') return p.wrap === 'truncate'
812 if (x.type !== 'Box' || p.flexShrink === 0 || typeof p.width === 'number') return false
813 return kidsOf(x).some(canShrink)
814}
815/** A Text's strings cut so the whole is at most `w` cells (nested Texts in order). */
816const cutText = (x: FitNode, w: number): [FitNode, number] => {
817 if (x === null || x === undefined || x === false) return [x, 0]
818 if (typeof x === 'string' || typeof x === 'number') {
819 const t = cutTo(String(x), Math.max(0, w))
820 return [t, textW(t)]
821 }
822 let left = w
823 const kids = kidsOf(x).map(k => {
824 const [c, used] = cutText(k, left)
825 left -= used
826 return c
827 })
828 return [withKids(x, kids), w - left]
829}
830/** The node fitted into `avail` columns. */
831const fitNode = (x: FitNode, avail: number): FitNode => {
832 if (!x || typeof x !== 'object') return x
833 const p = x.props ?? {}
834 if (x.type === 'Text') return p.wrap === 'truncate' && natW(x) > avail ? cutText(x, Math.max(1, Math.floor(avail)))[0] : x
835 if (x.type !== 'Box' || p.position === 'absolute') return x
836 const own = typeof p.width === 'number' ? Math.min(p.width, avail) : typeof p.width === 'string' && p.width.endsWith('%') ? Math.floor((avail * parseFloat(p.width)) / 100) : avail
837 const inner = Math.max(0, own - frameW(p))
838 const kids = kidsOf(x)
839 if ((p.flexDirection ?? 'row') !== 'row' || p.flexWrap === 'wrap') return withKids(x, kids.map(k => fitNode(k, inner)))
840 // A row: what cannot narrow keeps its width; what can shares the rest, in proportion to its length.
841 const gap = num(p.columnGap ?? p.gap)
842 const widths = kids.map(natW)
843 const shrinks = kids.map(canShrink)
844 const fixed = widths.reduce((n, w, i) => n + (shrinks[i] ? 0 : w), 0) + gap * Math.max(0, widths.filter(w => w > 0).length - 1)
845 const want = widths.reduce((n, w, i) => n + (shrinks[i] ? w : 0), 0)
846 const room = Math.max(0, inner - fixed)
847 return withKids(
848 x,
849 kids.map((k, i) => (shrinks[i] ? fitNode(k, want <= room ? widths[i] : Math.floor((room * widths[i]) / Math.max(1, want))) : fitNode(k, widths[i]))),
850 )
851}
852/** A file name cut to `w` cells, keeping its start and extension: `a-long-name.test.ts` → `a-long….test.ts`. */
853const midCut = (name: string, w: number) => {
854 if (textW(name) <= w) return name
855 // Both ends, by cells: the start, and the end with its extension.
856 const chars = [...name]
857 const tailW = Math.max(Math.min(textW(name.match(/\.[^.\/]{1,6}$/)?.[0] ?? ''), w - 4), Math.floor((w - 1) / 2))
858 let tail = ''
859 for (let i = chars.length - 1; i >= 0 && textW(chars[i] + tail) <= tailW; i--) tail = chars[i] + tail
860 return `${cutTo(name.slice(0, name.length - tail.length), w - textW(tail)).replace(/…$/, '')}…${tail}`
861}
862/** The most desks shown at once this session: the desks keep that much room, so the cards below never jump. */
863let deskMax = 0
864
865/** The pane fitted to its columns, a little spare for the desktop's proportional font. */
866const fitPane = <T,>(tree: T, cols: number): T => fitNode(tree as unknown as FitNode, Math.floor(cols * 0.97) - 1) as unknown as T
867
868/** A text in two lines of `w` characters, broken between words; the second line cut short if need be. */
869const twoLines = (s: string, w: number): [string, string] => {
870 if (s.length <= w) return [s, '']
871 let at = s.lastIndexOf(' ', w)
872 if (at < w * 0.5) at = w
873 return [s.slice(0, at).trimEnd(), trunc(s.slice(at).trimStart(), w)]
874}
875/** How long a helper's window stays (marked done) after it finishes, so windows do not blink in and out. */
876const WINDOW_LINGER_MS = 20_000
877/** Waiting on the person: its window's border, its words, its bubble. */
878const NEEDS = '#E3B341'
879/** Rows of the log: fixed, so lines coming in never move what is below. */
880const LOG_ROWS = 12
881
882/**
883 * A turn the app started by itself, not a prompt: a background task or monitor
884 * reporting in, a CI event, a slash command's output. Returns a short line for
885 * the log, or '' for a real prompt.
886 */
887const SYSTEM_TAG = /^\s*<(task-notification|agent-message|system-reminder|ci-monitor-event|local-command-stdout|local-command-stderr|local-command-caveat|command-name|command-message|command-args|bash-notification|bash-input|bash-stdout|user-memory-input|[a-z][\w-]*-(?:notification|event|reminder))[\s>]/i
888const systemNote = (full: string) => {
889 const text = full.length > 8000 ? full.slice(0, 8000) : full
890 const tag = text.match(SYSTEM_TAG)?.[1]?.toLowerCase()
891 if (!tag) return ''
892 const inner = (name: string) => text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'i'))?.[1]?.replace(/<[^>]*>/g, ' ') ?? ''
893 const said = tag === 'agent-message' ? '' : oneLine(inner('summary') || inner('command-name') || inner('status') || '')
894 const kind = tag === 'task-notification' ? 'background task' : tag === 'agent-message' ? 'a helper reported back' : tag.startsWith('ci-') ? 'CI' : tag.includes('command') ? 'command' : 'update'
895 return trunc(said ? `${kind}: ${said}` : kind, 120)
896}
897const clock = (ms: number) => new Date(ms).toTimeString().slice(0, 8)
898const hash = (s: string) => {
899 let h = 2166136261
900 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
901 return h >>> 0
902}
903
904const norm = (p: string) => {
905 const raw = p.replace(/\\/g, '/')
906 const parts: string[] = []
907 for (const seg of raw.split('/')) {
908 if (seg === '..') parts.pop()
909 else if (seg !== '.' && seg !== '') parts.push(seg)
910 }
911 const joined = (raw.startsWith('//') ? '//' : raw.startsWith('/') ? '/' : '') + parts.join('/')
912 return /^[a-z]:/.test(joined) ? joined[0].toUpperCase() + joined.slice(1) : joined
913}
914
915const nodeOf = (tool: string, a: Record<string, unknown>): string => {
916 const str = (k: string) => (typeof a[k] === 'string' ? (a[k] as string) : '')
917 const file = str('file_path') || str('notebook_path')
918 if (file) return norm(file.slice(0, 2048))
919 if (tool === 'Grep' || tool === 'Glob') {
920 const p = str('path')
921 return p && /\.[A-Za-z0-9]+$/.test(p) ? norm(p) : '?search'
922 }
923 if (tool === 'Bash' || tool === 'PowerShell') return '$terminal'
924 if (tool === 'WebFetch' || tool === 'WebSearch') return '@web'
925 return stationOf(tool)
926}
927
928const detailOf = (tool: string, a: Record<string, unknown>): string => {
929 const str = (k: string) => (typeof a[k] === 'string' ? (a[k] as string) : '')
930 // The command (tidied) is kept for telling tests and builds apart; what is shown of it is its gist.
931 if (tool === 'Bash' || tool === 'PowerShell') return cmdHead(str('command')).slice(0, 400)
932 if (tool === 'Grep' || tool === 'Glob') return str('pattern')
933 if (tool === 'WebFetch') return str('url').replace(/^https?:\/\//, '').split('/')[0]
934 if (tool === 'WebSearch') return str('query')
935 if (tool === 'Agent' || tool === 'Task') return str('description')
936 if (/__codex$/.test(tool)) return `${str('thread') ? `[${str('thread')}] ` : ''}${oneLine(str('task')).slice(0, 70)}`
937 if (tool === 'Skill') return str('skill')
938 const st = stationOf(tool)
939 if (st) {
940 // What it is doing there, never what it types: a page, an app, an action.
941 const url = str('url').replace(/^https?:\/\//, '').replace(/\/$/, '')
942 const apps = Array.isArray(a.apps) ? (a.apps as unknown[]).filter((x): x is string => typeof x === 'string').join(', ') : ''
943 const verb = (mcpParts(tool)?.name ?? tool).split('__').pop()!.replace(/^(browser|computer)_/, '').replace(/_/g, ' ')
944 const what = url || str('app') || apps || str('title') || (st === '@computer' || st === '@remote' || st === '@browser' ? str('action').replace(/_/g, ' ') : '')
945 return trunc(`${verb}${what && what !== verb ? ` ${what}` : ''}`, 80)
946 }
947 return ''
948}
949
950const actionLine = (tool: string, node: string, detail: string, said = '') => {
951 // A command by what Claude said it does, else what it runs in a few words: never the raw command.
952 if (tool === 'Bash' || tool === 'PowerShell') return `$ ${trunc(oneLine(said), 120) || cmdGist(detail)}`
953 if (tool === COMPACT) return '🗜 compacting the conversation'
954 const st = stationOf(tool)
955 if (st) return `${STATION_ICON[st] ?? '▤'} ${stationLabel(st)}: ${detail}`.trim()
956 const icon = ICON[tool] ?? '•'
957 const what = node && !isSpecial(node) ? baseName(node) : detail
958 return `${icon} ${VERB[tool] ?? tool.split('__').pop()} ${what}`.trim()
959}
960
961/** The first meaningful sentence or two of a block of model text. */
962const summarise = (text: string): string => {
963 // Markdown marks go; underscores stay, since code names like read_chunk use them.
964 const clean = oneLine(text.replace(/```[\s\S]*?```/g, ' ').replace(/[#*`>]/g, ''))
965 if (!clean) return ''
966 // A sentence ends at . ! or ? before a space or the end, so chunk.rs stays whole.
967 const sentences = clean.split(/(?<=[.!?])\s+/)
968 let out = ''
969 for (const s of sentences) {
970 if (out.length > 40) break
971 out += (out ? ' ' : '') + s
972 }
973 return trunc(oneLine(out), 160)
974}
975
976// ─── Crew bookkeeping ───────────────────────────────────────────────────────
977
978const blank = (id: string, name: string, kind: string, hue: number): Agent => ({
979 id,
980 name,
981 kind,
982 hue,
983 node: '',
984 visited: [],
985 tool: '',
986 detail: '',
987 pending: [],
988 state: 'idle',
989 doneAt: 0,
990 calls: 0,
991 goal: '',
992 thoughts: [],
993 actions: [],
994 progress: -1,
995 progressSrc: 'est',
996 todosDone: 0,
997 todosTotal: 0,
998 doing: '',
999 since: 0,
1000 from: '',
1001 movedAt: 0,
1002 tokens: 0,
1003 cost: 0,
1004 stuck: '',
1005 recent: [],
1006})
1007
1008/** Files with an app of their own (pictures, sound, video, documents, archives, fonts, 3D): the system's app for the type opens them. */
1009const OWN_APP =
1010 /\.(png|jpe?g|jfif|gif|bmp|webp|tiff?|ico|heic|heif|avif|psd|cr2|nef|arw|dng|exr|hdr|dds|tga|xcf|kra|aseprite|mp3|wav|flac|ogg|oga|m4a|aac|wma|opus|aiff?|midi?|mp4|m4v|mov|mkv|avi|wmv|webm|flv|mpe?g|3gp|pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|epub|xps|zip|7z|rar|tar|gz|tgz|bz2|xz|iso|dmg|ttf|otf|woff2?|blend|fbx|glb|stl|3mf|rbxl|rbxm|unitypackage)$/i
1011/** Programs, which the system would run rather than open: shown in their folder instead. */
1012const PROGRAM = /\.(exe|com|msi|msp|msix|appx|scr|pif|lnk|url|cpl|hta|jar|app|appimage|run|dll|sys|appref-ms)$/i
1013/** Scripts the system would run if handed one: opened in an editor, else shown in their folder. */
1014const SCRIPT = /\.(bat|cmd|ps1|psm1|vbs|vbe|js|jse|wsf|wsh|reg|sh|command|desktop)$/i
1015/**
1016 * Where a click on a file sends it: the system's app for its type, the editor chosen in the pane (code and
1017 * text), or its folder with it picked out (a program, or a script with no editor to open it in).
1018 */
1019const routeOf = (path: string, os: string, choice = editor): 'app' | 'editor' | 'reveal' => {
1020 if (PROGRAM.test(path)) return 'reveal'
1021 if (OWN_APP.test(path)) return 'app'
1022 // cmd would act on these characters in a path, so such a file goes to the system, not the editor's launcher.
1023 if (choice === 'system' || (os === 'win' && /[&|<>^%!"]/.test(path))) return SCRIPT.test(path) ? 'reveal' : 'app'
1024 return 'editor'
1025}
1026/** How the system opens a path with its default app, and how it shows a file picked out in its folder. */
1027const defaultApp = (os: string, path: string) => (os === 'win' ? ['explorer', path.replace(/\//g, '\\')] : os === 'mac' ? ['open', path] : ['xdg-open', path])
1028// Explorer's /select wants the path in quotes of its own, which an argument list cannot pass when the path
1029// has a space; PowerShell passes it as written (the path in a literal string, its quotes doubled).
1030const revealIn = (os: string, path: string) =>
1031 os === 'win'
1032 ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Start-Process explorer.exe -ArgumentList ('/select,"' + '${path.replace(/\//g, '\\').replace(/'/g, "''")}' + '"')`]
1033 : os === 'mac'
1034 ? ['open', '-R', path]
1035 : ['xdg-open', dirOf(path) || '/']
1036
1037/**
1038 * Opens a file: pictures, media and documents in the system's app for their type; code and text in the
1039 * person's editor, where a file this session edited, inside a git repository, opens as a diff against
1040 * HEAD in VS Code or Cursor; programs (and scripts with nowhere else to go) picked out in their folder,
1041 * never run.
1042 */
1043const openInEditor = async ($: EngineInterface, path: string, hasEdits: boolean, line = 0): Promise<Opened> => {
1044 const os = await platformOf($)
1045 const isWin = os === 'win'
1046 const run = (argv: string[]) => $.process.run(argv, { timeoutMs: 20_000 }).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
1047 // Explorer and `open` hand the file on and may exit non-zero; an editor's launcher fails with a message.
1048 const handedOn = (r: { exitCode: number; stderr: string }) => (r.exitCode === -1 ? { ok: false, why: r.stderr } : { ok: true, why: '' })
1049 const launched = (r: { exitCode: number; stderr: string }) =>
1050 r.exitCode === 0 ? { ok: true, why: '' } : { ok: false, why: oneLine(r.stderr).slice(0, 160) || `${editor} exited with code ${r.exitCode}` }
1051 // Editors' command-line launchers are .cmd scripts on Windows, which only cmd runs; cmd would act on
1052 // `&`, `|` or `%` in a path, so such a path opens through Explorer instead (the default app, no diff).
1053 const shell = (argv: string[]) => (isWin ? ['cmd', '/d', '/c', ...argv] : argv)
1054 const route = routeOf(path, os)
1055 if (route === 'reveal') return handedOn(await run(revealIn(os, path)))
1056 if (route === 'app') return handedOn(await run(defaultApp(os, path)))
1057 const canDiff = /^(code|cursor|codium|windsurf)$/.test(editor)
1058 if (line > 0 && canDiff) {
1059 return launched(await run(shell([editor, '-g', `${path}:${line}`])))
1060 }
1061 if (hasEdits && canDiff) {
1062 const top = await run(['git', '-C', dirOf(path), 'rev-parse', '--show-toplevel'])
1063 const root = top?.exitCode === 0 ? norm(top.stdout.trim()) : ''
1064 if (root && path.startsWith(`${root}/`)) {
1065 const head = await run(['git', '-C', root, 'show', `HEAD:${path.slice(root.length + 1)}`])
1066 if (head?.exitCode === 0) {
1067 if (!tempDir) tempDir = norm((await $.env.get('TEMP')) || (await $.env.get('TMPDIR')) || (isWin ? 'C:/Windows/Temp' : '/tmp'))
1068 const name = baseName(path)
1069 const dot = name.lastIndexOf('.')
1070 const before = `${tempDir}/crew-map-HEAD/${hash(path).toString(36)}/${dot > 0 ? `${name.slice(0, dot)} (HEAD)${name.slice(dot)}` : `${name} (HEAD)`}`
1071 await $.fs.write(before, head.stdout).catch(() => undefined)
1072 return launched(await run(shell([editor, '--diff', before, path])))
1073 }
1074 }
1075 }
1076 return launched(await run(shell(canDiff ? [editor, '-g', path] : [editor, path])))
1077}
1078
1079/** How an open went: whether the program started, and if not, why. */
1080type Opened = { ok: boolean; why: string }
1081
1082/** A shell command as a person would read it: leading variable assignments and `cd … &&` dropped. */
1083const tidyCmd = (cmd: string) => {
1084 let s = oneLine(cmd.replace(/\\\r?\n/g, ' '))
1085 for (;;) {
1086 const next = s
1087 // A value may be `$(…)` with spaces inside (one level of nesting), as in `start=$(grep -c x f)`.
1088 .replace(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\$\((?:[^()]|\([^()]*\))*\)|\S+)\s*;?\s*/, '')
1089 .replace(/^cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/, '')
1090 if (next === s) break
1091 s = next
1092 }
1093 return s || oneLine(cmd)
1094}
1095
1096/** Lines added and removed between two texts, as multisets of lines. */
1097const lineDiff = (before: string, after: string) => {
1098 const left = new Map<string, number>()
1099 for (const l of before ? before.split('\n') : []) left.set(l, (left.get(l) ?? 0) + 1)
1100 let added = 0
1101 for (const l of after ? after.split('\n') : []) {
1102 const n = left.get(l) ?? 0
1103 if (n > 0) left.set(l, n - 1)
1104 else added++
1105 }
1106 let removed = 0
1107 for (const n of left.values()) removed += n
1108 return { added, removed }
1109}
1110
1111const record = (c: Crew, path: string, id: string, delta: { added: number; removed: number } | 'read'): Crew => {
1112 const a = c.agents.find(x => x.id === id)
1113 const cur: Change = c.changes[path] ?? { added: 0, removed: 0, edits: 0, reads: 0, by: '', hue: 0, at: 0 }
1114 const next1: Change =
1115 delta === 'read'
1116 ? { ...cur, reads: cur.reads + 1, at: Date.now(), by: cur.by || (a?.name ?? ''), hue: cur.by ? cur.hue : (a?.hue ?? 0) }
1117 : { ...cur, added: cur.added + delta.added, removed: cur.removed + delta.removed, edits: cur.edits + 1, at: Date.now(), by: a?.name ?? cur.by, hue: a?.hue ?? cur.hue }
1118 const changes = { ...c.changes, [path]: next1 }
1119 const keys = Object.keys(changes)
1120 if (keys.length > MAX_CHANGES)
1121 for (const [p] of Object.entries(changes)
1122 .filter(([p, ch]) => !ch.edits && p !== path)
1123 .sort((x, y) => x[1].at - y[1].at)
1124 .slice(0, keys.length - MAX_CHANGES))
1125 delete changes[p]
1126 // Edited files too past twice that, oldest first, but never this task's own.
1127 const left = Object.keys(changes).length
1128 if (left > MAX_CHANGES * 2)
1129 for (const [p] of Object.entries(changes)
1130 .filter(([p, ch]) => p !== path && ch.at < (c.turnBase?.at ?? 0))
1131 .sort((x, y) => x[1].at - y[1].at)
1132 .slice(0, left - MAX_CHANGES * 2))
1133 delete changes[p]
1134 // And a hard cap whatever they are: the oldest go.
1135 const still = Object.keys(changes).length
1136 if (still > MAX_CHANGES * 2)
1137 for (const [p] of Object.entries(changes)
1138 .filter(([p]) => p !== path)
1139 .sort((x, y) => x[1].at - y[1].at)
1140 .slice(0, still - MAX_CHANGES * 2))
1141 delete changes[p]
1142 return { ...c, changes }
1143}
1144const MAX_CHANGES = 300
1145/**
1146 * A Claude still "thinking" a while after its last call has gone quiet: idle (main) or done (helper).
1147 * Top-level, so the timer keeps only the id and the stamp (not the call and its result), and it
1148 * writes only if the Claude is still as it was left.
1149 */
1150const settleLater = (id: string, stamp: number) =>
1151 after?.(LINGER_MS, () => {
1152 void (async () => {
1153 const c = await readCrew?.()
1154 if (!c?.agents.some(x => x.id === id && x.state === 'thinking' && x.doneAt === stamp)) return
1155 await write?.(d => ({ ...d, agents: d.agents.map(x => (x.id === id && x.state === 'thinking' && x.doneAt === stamp ? { ...x, state: x.id === 'main' ? 'idle' : 'done' } : x)) }))
1156 })()
1157 })
1158const MAIN = () => blank('main', 'Claude', 'main', 0)
1159
1160const freshName = (list: Agent[], kind: string) => {
1161 const base = NAMES[kind] ?? (kind === 'agent' ? 'Helper' : (kind.split(':').pop() ?? kind))
1162 const n = list.filter(w => w.name === base || w.name.startsWith(`${base} `)).length
1163 return n ? `${base} ${n + 1}` : base
1164}
1165const newSub = (list: Agent[], id: string, kind: string) =>
1166 blank(id, freshName(list, kind), kind, list.reduce((m, w) => Math.max(m, w.hue), 0) + 1)
1167
1168const upsert = (c: Crew, id: string, fn: (a: Agent) => Agent): Crew => {
1169 const found = c.agents.find(a => a.id === id)
1170 const made = fn(found ?? (id === 'main' ? MAIN() : newSub(c.agents, id, 'agent')))
1171 const agents = found ? c.agents.map(a => (a.id === id ? made : a)) : [...c.agents, made]
1172 while (agents.length > MAX_AGENTS) {
1173 const i = agents.findIndex(a => a.id !== 'main' && (a.state === 'done' || a.state === 'idle'))
1174 agents.splice(i >= 0 ? i : 1, 1)
1175 }
1176 return { ...c, agents }
1177}
1178
1179const log = (c: Crew, id: string, kind: LogLine['kind'], text: string): Crew => {
1180 const a = c.agents.find(x => x.id === id)
1181 if (!a || !text) return c
1182 return { ...c, log: [...c.log, { at: Date.now(), who: a.name, hue: a.hue, kind, text: trunc(text, 300) }].slice(-40) }
1183}
1184
1185const think = (c: Crew, id: string, line: string): Crew => {
1186 if (!line || c.agents.find(a => a.id === id)?.thoughts[0] === line) return c
1187 return log(upsert(c, id, a => ({ ...a, thoughts: [line, ...a.thoughts].slice(0, 6) })), id, 'think', line)
1188}
1189
1190// ─── The map's graph ────────────────────────────────────────────────────────
1191
1192/** Spring layout in a unit square, wider than tall like the cells it lands on. */
1193const relayout = (nodes: Record<string, MapNode>, trails: Crew['trails']): Record<string, MapNode> => {
1194 const list = Object.values(nodes).map(n => ({ ...n }))
1195 const at = new Map(list.map((n, i) => [n.id, i]))
1196 const springs: [number, number, number, number][] = []
1197 for (const n of list)
1198 for (const t of n.imports) {
1199 const j = at.get(t)
1200 if (j !== undefined) springs.push([at.get(n.id)!, j, 0.2, 0.09])hooks/search.tsx 70 lines1// The map's search field, drawn here on the surface so it is just a field: no Enter button beside
2// it. A click gives it the keyboard (Escape gives it back); each letter typed searches at once.
3
4type Props = {
5 /** The search as the hooks module last had it; a different one (cleared elsewhere) replaces the typed text. */
6 q: string
7}
8/** `sent`: what this field posted that has not come back yet, oldest first. */
9type Live = { q: string; seen: string; focused: boolean; sent: string[] }
10
11const MAX = 80
12const lives = new WeakMap<object, Live>()
13
14const Search = (props: Props, surface: any) => {
15 const { Box, Text } = surface.elements
16 let live: Live | undefined = lives.get(surface) ?? surface.state
17 if (!live) {
18 const me: Live = { q: props.q, seen: props.q, focused: false, sent: [] }
19 live = me
20 lives.set(surface, me)
21 surface.setState({ ...me })
22 const change = (q: string) => {
23 me.q = q.slice(0, MAX)
24 me.sent = [...me.sent.slice(-99), me.q]
25 surface.post({ q: me.q })
26 surface.setState({ ...me })
27 }
28 surface.onPointer((e: any) => {
29 if (e.type === 'down' && !me.focused) {
30 me.focused = true
31 surface.setState({ ...me })
32 }
33 })
34 surface.onKey((e: any) => {
35 if (!me.focused) {
36 me.focused = true
37 surface.setState({ ...me })
38 }
39 if (e.ctrl || e.meta) return
40 if (e.key === 'backspace') change(me.q.slice(0, -1))
41 else if (e.key === 'delete') change('')
42 else if (e.key === 'return') surface.post({ q: me.q, next: true })
43 else if (e.key.length === 1) change(me.q + e.key)
44 })
45 }
46 // The hooks module's search comes back a frame or more behind the typing: one this field sent is its
47 // own echo (and everything sent before it is stale), so only a search changed elsewhere replaces the text.
48 if (props.q !== live.seen) {
49 live.seen = props.q
50 const echo = live.sent.indexOf(props.q)
51 if (echo >= 0) live.sent = live.sent.slice(echo + 1)
52 else {
53 live.q = props.q
54 live.sent = []
55 }
56 }
57 const q = live.q
58 // One line: the text typed (or the placeholder), and a caret once it has the keyboard.
59 return (
60 <Box width={surface.columns || undefined} flexDirection="row">
61 <Text color={q ? '#E6EDF3' : '#8B949E'} wrap="truncate">
62 {q || 'Search files...'}
63 </Text>
64 {live.focused ? <Text color="#79C0FF">{'▏'}</Text> : null}
65 </Box>
66 )
67}
68
69export default Search
70hooks/map.tsx 249 lines1// The mind map: a character-cell graph of the files the crew touches, drawn
2// and animated here on the surface so updates never reload anything.
3
4type MapNodeProp = {
5 id: string
6 label: string
7 kind: 'file' | 'ghost' | 'special'
8 x: number
9 y: number
10 heat: number
11 color?: string
12 count: number
13 typing: boolean
14}
15type MapEdgeProp = { a: string; b: string; k: 'import' | 'trail' }
16type MapAgentProp = { id: string; initial: string; color: string; node: string; next?: string; busy: boolean }
17type MapProps = { nodes: MapNodeProp[]; edges: MapEdgeProp[]; agents: MapAgentProp[]; rows: number }
18
19type Zip = { node: string; from: string; start: number }
20type State = { t: number; zips: Record<string, Zip> }
21
22type Cell = { ch: string; fg?: string; bold?: boolean; dim?: boolean }
23type Placed = { id: string; x0: number; y: number; len: number; cx: number }
24
25const TICK = 80
26const ZIP_MS = 720
27const NBSP = ' '
28const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
29const IMPORT = '#3B4C6B'
30const TRAIL = '#4B5363'
31const TEXT = '#C9D1D9'
32const GHOST = '#6E7681'
33const SPECIAL: Record<string, [string, string]> = {
34 $terminal: ['$', '#FFA657'],
35 '@web': ['◎', '#56D4DD'],
36 '?search': ['⌕', '#E3B341'],
37 '@browser': ['◍', '#56D4DD'],
38 '@computer': ['▣', '#FFA657'],
39 '@remote': ['▣', '#79C0FF'],
40 '@docs': ['▤', '#E3B341'],
41 '@claude': ['✻', '#D97757'],
42}
43/** Any other station (an MCP app such as Blender): a plain tile mark. */
44const STATION: [string, string] = ['▤', '#D2A8FF']
45
46const LINES = '─│╭╮╰╯┼'
47const isLine = (ch: string) => LINES.includes(ch)
48
49/** The map clock runs only while something moves: a busy Claude spins, a zip is under way. */
50let liveUntil = 0
51
52export default function CrewMap(p: MapProps, s: any) {
53 const { Box, Text } = s.elements
54 const st = s.state as State | undefined
55 if (!st) {
56 s.every(TICK, () => {
57 const cur = s.state as State
58 // Nothing moving: no redraw (the next change of props draws, and starts the clock again).
59 if (!cur || cur.t > liveUntil) return
60 s.setState({ ...cur, t: cur.t + TICK })
61 })
62 s.setState({ t: 0, zips: {} })
63 return <Text dimColor>{NBSP}</Text>
64 }
65
66 // Start a zip for every agent whose node changed since the last call.
67 let changed = false
68 const zips: Record<string, Zip> = { ...st.zips }
69 for (const a of p.agents) {
70 const z = zips[a.id]
71 if (!z) {
72 zips[a.id] = { node: a.node, from: a.node, start: -1e9 }
73 changed = true
74 } else if (z.node !== a.node) {
75 zips[a.id] = { node: a.node, from: z.node, start: st.t }
76 changed = true
77 }
78 }
79 // Agents that left take their zips with them.
80 for (const id of Object.keys(zips)) if (!p.agents.some(a => a.id === id)) { delete zips[id]; changed = true }
81 if (changed) s.setState({ ...st, zips })
82 liveUntil = Math.max(...Object.values(zips).map(z => z.start + ZIP_MS), p.agents.some(a => a.busy) ? st.t + 1000 : 0, changed ? st.t + TICK * 2 : 0)
83
84 const cols = Math.max(30, s.columns || 80)
85 const rows = Math.max(6, s.rows || p.rows)
86 const t = st.t
87 const grid: Cell[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ ch: ' ' })))
88 const put = (x: number, y: number, ch: string, fg?: string, more: Partial<Cell> = {}) => {
89 if (x < 0 || y < 0 || x >= cols || y >= rows) return
90 const cur = grid[y][x]
91 if (isLine(ch) && isLine(cur.ch) && cur.ch !== ch) {
92 const vert = (c: string) => c === '│'
93 const horiz = (c: string) => c === '─'
94 if ((vert(ch) && horiz(cur.ch)) || (horiz(ch) && vert(cur.ch))) ch = '┼'
95 }
96 grid[y][x] = { ch, fg, ...more }
97 }
98
99 if (p.nodes.length === 0) {
100 const msg = 'no files yet · the map grows as Claudes open files'
101 const y = Math.floor(rows / 2)
102 const x = Math.max(0, Math.floor((cols - msg.length) / 2))
103 for (let i = 0; i < msg.length; i++) put(x + i, y, msg[i], GHOST)
104 }
105
106 // Place labels: most important first, nudged off each other.
107 const labelOf = (n: MapNodeProp) => {
108 const sp = SPECIAL[n.id] ?? (n.kind === 'special' ? STATION : undefined)
109 const mark = sp ? sp[0] : n.kind === 'ghost' ? '○' : n.count > 1 ? '★' : n.count ? '◆' : '◇'
110 return `${mark} ${n.label}`
111 }
112 const taken: boolean[][] = Array.from({ length: rows }, () => Array(cols).fill(false))
113 const placed = new Map<string, Placed>()
114 const order = [...p.nodes].sort((a, b) => b.count - a.count || b.heat - a.heat)
115 for (const n of order) {
116 const text = labelOf(n).slice(0, cols - 2)
117 const len = text.length
118 const bx = Math.round(n.x * (cols - len - 2)) + 1
119 const by = Math.round(n.y * (rows - 1))
120 let best: { x0: number; y: number } | undefined
121 search: for (const dy of [0, -1, 1, -2, 2, -3, 3, -4, 4]) {
122 for (const dx of [0, -4, 4, -8, 8, -12, 12]) {
123 const y = by + dy
124 const x0 = Math.min(Math.max(1, bx + dx), cols - len - 1)
125 if (y < 0 || y >= rows) continue
126 let free = true
127 for (let x = x0 - 1; x <= x0 + len; x++) if (taken[y][x]) free = false
128 if (free) {
129 best = { x0, y }
130 break search
131 }
132 }
133 }
134 if (!best) continue
135 for (let x = best.x0 - 1; x <= best.x0 + len; x++) if (x >= 0 && x < cols) taken[best.y][x] = true
136 placed.set(n.id, { id: n.id, x0: best.x0, y: best.y, len, cx: best.x0 + Math.floor(len / 2) })
137 }
138
139 // An elbow route between two labels: along A's row, then down or up into B.
140 const route = (aId: string, bId: string): [number, number, string][] => {
141 const a = placed.get(aId)
142 const b = placed.get(bId)
143 if (!a || !b || a === b) return []
144 const out: [number, number, string][] = []
145 if (a.y === b.y) {
146 const [l, r] = a.x0 < b.x0 ? [a, b] : [b, a]
147 for (let x = l.x0 + l.len + 1; x < r.x0 - 1; x++) out.push([x, a.y, '─'])
148 return a.x0 < b.x0 ? out : out.reverse()
149 }
150 const right = b.cx >= a.cx
151 const down = b.y > a.y
152 const sx = right ? a.x0 + a.len + 1 : a.x0 - 2
153 if ((right && b.cx >= sx) || (!right && b.cx <= sx)) {
154 for (let x = sx; right ? x < b.cx : x > b.cx; x += right ? 1 : -1) out.push([x, a.y, '─'])
155 out.push([b.cx, a.y, right ? (down ? '╮' : '╯') : down ? '╭' : '╰'])
156 for (let y = a.y + (down ? 1 : -1); down ? y < b.y : y > b.y; y += down ? 1 : -1) out.push([b.cx, y, '│'])
157 } else {
158 // B sits over A's label: leave from A's middle straight up or down.
159 for (let y = a.y + (down ? 1 : -1); down ? y < b.y : y > b.y; y += down ? 1 : -1) out.push([a.cx, y, '│'])
160 }
161 return out
162 }
163
164 // Edges.
165 const zipRoutes = new Map<string, [number, number, string][]>()
166 const hot = new Map<string, string>()
167 for (const a of p.agents) {
168 const z = zips[a.id]
169 if (z && z.from !== z.node && t - z.start < ZIP_MS + 600) hot.set([z.from, z.node].sort().join('|'), a.color)
170 }
171 for (const e of p.edges) {
172 const color = hot.get([e.a, e.b].sort().join('|'))
173 for (const [x, y, ch] of route(e.a, e.b)) {
174 if (e.k === 'trail' && !color) put(x, y, '┄' === ch ? ch : ch === '─' ? '┄' : ch === '│' ? '┆' : ch, TRAIL)
175 else put(x, y, ch, color ?? IMPORT, color ? { bold: true } : {})
176 }
177 }
178
179 // Where each agent might head next: a faint dotted lead.
180 for (const a of p.agents) {
181 if (!a.next || !a.busy) continue
182 const r = route(a.node, a.next)
183 r.forEach(([x, y], i) => {
184 if (i % 2 === 0 && !isLine(grid[y][x].ch)) put(x, y, '·', a.color, { dim: true })
185 })
186 }
187
188 // Labels.
189 for (const n of p.nodes) {
190 const pl = placed.get(n.id)
191 if (!pl) continue
192 const text = labelOf(n).slice(0, pl.len)
193 const sp = SPECIAL[n.id] ?? (n.kind === 'special' ? STATION : undefined)
194 const fg = n.count > 1 ? '#E3B341' : n.color ?? (sp ? sp[1] : n.kind === 'ghost' ? GHOST : TEXT)
195 for (let i = 0; i < text.length; i++)
196 put(pl.x0 + i, pl.y, text[i], i === 0 && sp && !n.color ? sp[1] : fg, { bold: n.count > 0, dim: n.kind === 'ghost' })
197 if (n.typing && (t / 400) % 2 < 1) put(pl.x0 + pl.len, pl.y, '▍', n.color ?? TEXT)
198 }
199
200 // Agents: parked over their node, or zipping along the route.
201 const parked = new Map<string, number>()
202 for (const a of p.agents) {
203 const z = zips[a.id] ?? { node: a.node, from: a.node, start: -1e9 }
204 const pl = placed.get(a.node)
205 if (!pl) continue
206 const glyph = a.busy ? SPIN[Math.floor(t / TICK) % SPIN.length] : '◌'
207 const k = Math.min(1, (t - z.start) / ZIP_MS)
208 if (k < 1 && z.from !== z.node && placed.has(z.from)) {
209 const r = route(z.from, z.node)
210 if (r.length) {
211 const i = Math.floor(k * (r.length - 1))
212 for (let j = Math.max(0, i - 4); j < i; j++) put(r[j][0], r[j][1], '•', a.color, { dim: j < i - 2 })
213 put(r[i][0], r[i][1], a.initial, a.color, { bold: true })
214 continue
215 }
216 }
217 const slot = parked.get(a.node) ?? 0
218 parked.set(a.node, slot + 1)
219 const above = pl.y > 0 && !taken[pl.y - 1][pl.x0 + slot * 3]
220 const y = above ? pl.y - 1 : Math.min(rows - 1, pl.y + 1)
221 const x = pl.x0 + slot * 3
222 put(x, y, glyph, a.color)
223 put(x + 1, y, a.initial, a.color, { bold: true })
224 }
225
226 // Merge each row into runs of one style.
227 return (
228 <Box flexDirection="column">
229 {grid.map(row => {
230 const runs: { text: string; c: Cell }[] = []
231 for (const c of row) {
232 const last = runs[runs.length - 1]
233 if (last && last.c.fg === c.fg && !!last.c.bold === !!c.bold && !!last.c.dim === !!c.dim) last.text += c.ch
234 else runs.push({ text: c.ch, c })
235 }
236 return (
237 <Box flexDirection="row">
238 {runs.map(r => (
239 <Text color={r.c.fg} bold={r.c.bold} dimColor={r.c.dim}>
240 {r.text.replace(/ /g, NBSP)}
241 </Text>
242 ))}
243 </Box>
244 )
245 })}
246 </Box>
247 )
248}
249hooks/pan.tsx 91 lines1// The map's hand, drawn over the map picture on the desktop: click to open what is under the
2// pointer, rest on something to see what it is. The map does not move under a drag (the arrow
3// buttons move it). It draws nothing itself; it tells the hooks module where the pointer is.
4
5type View = { x: number; y: number; k: number }
6type Props = {
7 /** One text cell in CSS pixels, to turn pointer cells into pixels. */
8 cw: number
9 ch: number
10 /** The window's size in pixels. */
11 vw: number
12 vh: number
13 /** Where the hooks module last put the window, and its stamp. */
14 view: View
15 rev: string
16}
17type Live = {
18 props: Props
19 press: null | { x: number; y: number; moved: boolean; ctrl: boolean }
20 hover: [number, number] | null
21}
22
23/** How far the pointer may wander before a press stops counting as a click, in pixels. */
24const SLOP = 4
25
26const lives = new WeakMap<object, Live>()
27
28const Pan = (props: Props, surface: any) => {
29 const { Box } = surface.elements
30 let live: Live | undefined = lives.get(surface) ?? surface.state
31 if (!live) {
32 const me: Live = { props, press: null, hover: null }
33 live = me
34 lives.set(surface, me)
35 surface.setState(me)
36 // The pointer in window pixels, as a share of the region: a surface may draw this region's cells
37 // at another size than the pane's text rows, so cells are never multiplied by a guessed size.
38 const px = (e: any): [number, number] => {
39 const p = me.props
40 const cols = surface.columns || p.vw / p.cw
41 const rows = surface.rows || p.vh / p.ch
42 return [((e.fine?.x ?? e.x + 0.5) / cols) * p.vw, ((e.fine?.y ?? e.y + 0.5) / rows) * p.vh]
43 }
44 // The window the pointer was measured against goes with every report, so a click lands on what was drawn;
45 // and how coarse the pointer is here (one cell, in window pixels), so a small file near it still counts.
46 const send = (extra: Record<string, unknown> = {}) => {
47 const p = me.props
48 const cell = [+(p.vw / (surface.columns || p.vw / p.cw)).toFixed(2), +(p.vh / (surface.rows || p.vh / p.ch)).toFixed(2)]
49 surface.post({ view: p.view, rev: p.rev, hover: me.hover, cell, ...extra })
50 }
51 surface.onPointer((e: any) => {
52 const [x, y] = px(e)
53 // A press counts unless it names another button: a surface may not say which one went down.
54 if (e.type === 'down' && (e.button === undefined || e.button === 'left')) {
55 me.press = { x, y, moved: false, ctrl: e.ctrl === true }
56 return
57 }
58 if (e.type === 'move' && me.press) {
59 if (Math.hypot(x - me.press.x, y - me.press.y) >= SLOP) me.press.moved = true
60 return
61 }
62 if (e.type === 'up' && me.press) {
63 const p = me.press
64 me.press = null
65 if (!p.moved) send({ click: [x, y], ctrl: p.ctrl || e.ctrl === true })
66 return
67 }
68 if (e.type === 'move' || e.type === 'enter') {
69 me.hover = [x, y]
70 send()
71 return
72 }
73 if (e.type === 'leave') {
74 me.press = null
75 me.hover = null
76 send()
77 }
78 })
79 // The keyboard, once a click gave the map the focus: + and − zoom, 0 shows all of it.
80 surface.onKey((e: any) => {
81 if (e.key === '+' || e.key === '=') send({ zoom: 1.25 })
82 else if (e.key === '-' || e.key === '_') send({ zoom: 0.8 })
83 else if (e.key === '0') send({ fit: true })
84 })
85 }
86 live.props = props
87 return <Box width="100%" height="100%" />
88}
89
90export default Pan
91types/index.d.ts 280 lines1export type AgentState = 'busy' | 'thinking' | 'idle' | 'done'
2
3export type Agent = {
4 /** 'main' for the main loop, a subagent's id, or 'demo-N'. */
5 id: string
6 name: string
7 /** 'main', or the subagent type (Explore, Plan, general-purpose, ...). */
8 kind: string
9 /** Index into the palette; 0 is Claude orange. */
10 hue: number
11 /** The map node it is at ('' before its first file). */
12 node: string
13 /** Nodes it visited, newest first. */
14 visited: string[]
15 tool: string
16 detail: string
17 pending: string[]
18 state: AgentState
19 doneAt: number
20 calls: number
21 goal: string
22 /** Summarised chain-of-thought lines, newest first. */
23 thoughts: string[]
24 /** Short action lines, newest first. */
25 actions: string[]
26 /** 0-100, or -1 while unknown. */
27 progress: number
28 progressSrc: 'todos' | 'model' | 'est' | 'done'
29 todosDone: number
30 todosTotal: number
31 /** To-dos (TodoWrite) in progress. */
32 todosDoing?: number
33 /** The todo in progress, when it keeps a list. */
34 doing: string
35 /** When it took on its current goal: the window's clock for a Claude without a to-do list. */
36 since: number
37 /** The node it last moved from, and when: the map zips it across. */
38 from: string
39 movedAt: number
40 /** Tokens its finished turns used (input, output and cache), from the engine's own count. */
41 tokens: number
42 /** What those tokens would cost at Anthropic's API list prices, in dollars. */
43 cost: number
44 spawnId?: string
45 /** Why it looks stuck ('' when it does not): the same command failing again and again, and the like. */
46 stuck: string
47 /** Its last tool calls, newest last: what the stuck check reads. */
48 recent: { sig: string; ok: boolean; at: number }[]
49 /** The Claude that started it (a helper's parent), when known. */
50 parent?: string
51 /** What it is waiting on the person for (a permission, an answer, a plan to approve), and for which tool. */
52 needs?: { what: string; tool: string; at: number }
53 /** Its context window as of its last model request: tokens in it and the window's size. */
54 ctx?: { tokens: number; window: number; at: number }
55 /** How many times its conversation was compacted. */
56 compactions?: number
57}
58
59/** A compiler or test error that names a file and line. */
60export type Problem = {
61 path: string
62 line: number
63 message: string
64 /** The command whose output named it, tidied. */
65 source: string
66 by: string
67 hue: number
68 at: number
69}
70
71/** One step of the session, as the replay shows it. */
72export type Frame = {
73 at: number
74 agentId: string
75 name: string
76 hue: number
77 node: string
78 tool: string
79 detail: string
80 /** Lines this step added and removed in `node`, when it edited it. */
81 added: number
82 removed: number
83}
84
85/** What one finished task came to: the card the pane shows and copies as Markdown. */
86export type Summary = {
87 at: number
88 goal: string
89 ms: number
90 files: { path: string; added: number; removed: number }[]
91 read: number
92 tests: { runs: number; failed: number; last: string }
93 tokens: number
94 cost: number
95 claudes: string[]
96 codex: number
97 problems: number
98}
99
100/** What the crew did to one file this session. */
101export type Change = { added: number; removed: number; edits: number; reads: number; by: string; hue: number; at: number }
102
103export type MapNode = {
104 /** A normalised file path, or '$terminal', '@web', '?search'. */
105 id: string
106 label: string
107 dir: string
108 kind: 'file' | 'ghost' | 'special'
109 /** Layout position, 0-1 each way. */
110 x: number
111 y: number
112 heat: number
113 /** Node ids this file imports. */
114 imports: string[]
115 scanned: boolean
116 touchedAt: number
117 demo?: boolean
118}
119
120export type Trail = { a: string; b: string; w: number }
121
122export type LogLine = { at: number; who: string; hue: number; kind: 'act' | 'think' | 'info'; text: string }
123
124/** One task on Claude Code's task list, as the crew last saw it. */
125export type PlanTask = {
126 subject: string
127 status: 'pending' | 'in_progress' | 'completed'
128 /** The Claude that made it: its work bar counts it. */
129 by: string
130 /** What it says while on it ("Running tests"). */
131 active?: string
132 /** Finished before the person's latest message: not part of the work now. */
133 old?: boolean
134}
135
136export type Crew = {
137 agents: Agent[]
138 nodes: Record<string, MapNode>
139 trails: Trail[]
140 log: LogLine[]
141 calls: number
142 startedAt: number
143 /** Per file path: lines added and removed, reads. */
144 changes: Record<string, Change>
145 /** The map's size, a multiple of the width the pane's columns suggest. */
146 zoom: number
147 /** Whether the worked-files list is pinned open under the map. */
148 showFiles: boolean
149 /** A pretend Codex run the demo shows; empty outside the demo. */
150 demoCodex: CodexRun[]
151 /** Where a clicked file opens: 'code' (VS Code), 'cursor' or 'system'. Kept in the store across sessions. */
152 editor: string
153 /** Shell commands (not test runs) running, and those that just ended, newest last. */
154 shells?: ShellRun[]
155 /** Claude Code's task list (TaskCreate, TaskUpdate, TaskList), by task id. */
156 tasks?: Record<string, PlanTask>
157 /** Test runs the crew started, newest last. */
158 tests: TestRun[]
159 /** Errors from the last failing build or test, by file and line. */
160 problems: Problem[]
161 /** Every step, newest last, for the replay. */
162 timeline: Frame[]
163 /** Tokens and dollars the whole session has used: kept here, since old helpers drop off the agent list. */
164 spent: { tokens: number; cost: number }
165 /** Spending limit in dollars (0: none), and the highest share of it already announced. */
166 budget: number
167 budgetWarned: number
168 /** Where the current task started from: its goal, time, and totals then. */
169 turnBase: { at: number; goal: string; changes: Record<string, { added: number; removed: number }>; tokens: number; cost: number; tests: number; failed: number; codex: number }
170 /** The last finished task's summary, until dismissed. */
171 summary: Summary | null
172 /** Folders the person folded or unfolded on the map, by folder (relative to the project, '.', or '~outside'). */
173 folds?: Record<string, 'open' | 'closed'>
174 /** Whether files outside the project are hidden from the map. */
175 hideOutside?: boolean
176 /** Finished helpers whose windows the person opened again (true) or folded at once (false). */
177 unfolded?: Record<string, boolean>
178 /** Which picture of the work the pane shows: desks and folder cards (absent: desks), or the tree map. */
179 view?: 'desks' | 'map'
180 /** Folder cards the person opened to show every file (by folder). */
181 cardsOpen?: Record<string, boolean>
182}
183
184/**
185 * The map's window on the desktop: its top-left in map units, its scale (screen
186 * pixels per map unit), whether it follows the fit, a stamp that changes when
187 * the hooks module moves it, and whether the map has the wheel (clicked).
188 */
189export type MapView = { x: number; y: number; k: number; auto: boolean; rev: string; sel: boolean; /** The search stamp the window follows to its hit; 0 when it does not. */ follow?: number }
190
191/** The replay's position: off, or a step of the timeline, playing or paused. */
192export type Replay = { on: boolean; at: number; playing: boolean }
193
194/** One test command a Claude ran, and how it came out. */
195/** A shell command a Claude ran: shown while it runs, in words (Claude's description, else what it runs). */
196export type ShellRun = {
197 id: string
198 agentId: string
199 who: string
200 hue: number
201 label: string
202 startedAt: number
203 endedAt: number
204 status: 'running' | 'done' | 'failed' | 'stopped' | 'unknown'
205 /** Started with run_in_background: it runs on after the call returns, until read, stopped or reported. */
206 background: boolean
207 /** The background shell's id, to match a later read, stop or task notice to this command. */
208 shellId?: string
209}
210
211export type TestRun = {
212 id: string
213 agentId: string
214 who: string
215 hue: number
216 /** The command as shown (shell noise dropped). */
217 command: string
218 /** What the run is for: Claude's own description of the command, else its kind and targets. */
219 purpose?: string
220 startedAt: number
221 endedAt: number
222 status: 'running' | 'passed' | 'failed' | 'unknown'
223 /** Counts read from the output, when it gave them. */
224 passed: number
225 failed: number
226 /** Started with run_in_background: the Claude is not blocked on it. */
227 background: boolean
228 /** The background shell's id, to match later output reads to this run. */
229 shellId?: string
230}
231
232/**
233 * A Codex run as the codex-bridge mod publishes it (its state, read only here):
234 * the fields the crew map draws. The bridge owns and writes it.
235 */
236export type CodexRun = {
237 id: string
238 task: string
239 mode: 'read-only' | 'write'
240 root?: string
241 cwd?: string
242 model: string
243 effort: string
244 status: 'running' | 'done' | 'failed' | 'stopped'
245 startedAt: number
246 endedAt: number
247 thread: string
248 resumed: boolean
249 caller: string
250 activity: string
251 focus: string
252 touched: { path: string; kind: 'look' | 'edit'; at: number }[]
253 events: { at: number; kind: string; text: string; ok?: boolean }[]
254 files: string[]
255 commands: number
256 failedCommands: number
257 answer: string
258 notesUpdated: boolean
259}
260
261declare module 'claude-code' {
262 interface PluginState {
263 'crew-map': {
264 crew: Crew
265 /** Bumped once a second while a test runs, so its timer counts up. */
266 tick: number
267 replay: Replay
268 /** While the intro plays: when it ends (ms since the epoch), 0 when it is not playing. */
269 intro: { until: number; played?: boolean }
270 view: MapView
271 /** The map search: the text, which hit is shown, and a stamp per change. */
272 search: { q: string; i: number; rev: number }
273 /** What the pointer rests on, on the map: a click target's id, or ''. */
274 hover: string
275 /** The map's pan module has had the mouse here (so the surface takes its input). */
276 mapLive: boolean
277 }
278 }
279}
280