SLOPSHOPPER

crew-map

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…

newpaneguardcommandtoastprocess
v1.3.6MITupdated 2026-10-26D3K1D/claude-crew-mods/crew-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew-map
│ ┃ Claude Crew ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ✻ CLAUDE CREW v1.3.6 1 claude · 0 busy · 3 │ crew-map │ │ ┃ ⏺ Read(src/auth.ts) │ ✗ Tests failed (failed) · Run the test │ │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ suite │ │ ┃ │ ✻ Claude ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ │ your turn ⎿ Added 2 lines, removed 1 line │ ┃ │ waiting for your next message ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ before: cache.ts, audit.ts, auth.ts │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ dir:src 3 files · 3 edited │ ┃ │ file:/work/app/src/audit.ts file:/work/ap › /crew │ ┃ ╰─────────────────────────────────────────── ⎿ crew-map: Claude Crew pane opened. │ ┃ │ ┃ ⏪ replay 📁 folder ▸ files 3 +16 −1⊞ tr │ ┃ │ ┃ ○ nothing running │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ✻ Claude main ○ your turn │ │ ┃ │ goal waiting for a prompt │ │ ┃ │ work — │ │ ┃ │ cost 99k tokens · $0.08 │ │ ┃ │ ctx ██████████░░░░░░░░░░ 49% of 200k │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claude Crew
✻ CLAUDE CREW v1.3.6 1 claude · 0 busy · 3 files · 9 calls ╭─────────────────────────────────────────────────────╮ │ ✻ Claude │ │ your turn │ │ waiting for your next message │ │ │ │ before: cache.ts, audit.ts, auth.ts │ ╰─────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ dir:src 3 files · 3 edited │ file:/work/app/src/audit.ts file:/work/app/src/auth.ts f ╰─────────────────────────────────────────────────────────── ⏪ replay 📁 folder ▸ files 3 +16 −1⊞ tree map code in ○ nothing running ╭──────────────────────────────────────────────────────────╮ │ ✻ Claude main ○ your turn │ │ goal waiting for a prompt │ │ work — │ │ cost 99k tokens · $0.08 │ │ ctx ██████████░░░░░░░░░░ 49% of 200k │ │ test ✗ failed · 0s Run the test suite │ │ $ Show environment file │ │ $ Clean build output and push │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ log │ 17:16:13 Claude $ Run the test suite ✗ failed │ 17:16:13 Claude ✗ tests failed (failed) in 1s · Run the te │ 17:16:13 Claude $ Show working tree status │ 17:16:13 Claude $ Clean build output and push │ 17:16:13 Claude $ Show environment file │ 17:16:13 Claude finished ╰─────────────────────────────────────────────────────────── spend ████████ Claude $0.08
README

Claude Crew (plugin 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.

What you see

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:

  • its goal (your prompt, or the task a helper was given)
  • a progress bar
  • a cost line: the tokens its turns used and what they'd cost at Anthropic's API list prices (a subscription isn't billed per token, so treat it as a yardstick)
  • how full its context is, with how often it was compacted (amber from 60%, red with "compacts soon" from 85%)
  • what it's doing now, or its latest thought, and its last two actions

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 running line under the toolbar says what the run is for (Claude's own description of the command, or the runner and its targets, like "Rust tests (cargo): chunk") and how long it's been going. A loop that keeps checking a test log reads as "watching <file>". Other long commands show there too, in plain words.
  • the Claude's window shows the run with its timer
  • on the tree map, a bubbling flask appears beside the terminal

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:

  • it runs the same command three times and it fails each time
  • its last five tool calls all fail
  • it edits the same file eight times in five minutes

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

Controls

  • ⏻ 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.

How it works

The mod hooks Claude Code's events:

  • every tool call: which file, which tool, and for edits the lines added and removed. Shell output is read for test results and error locations.
  • every reply: summarised into "thoughts".
  • every helper spawn
  • every turn's start and end: goal, status, tokens

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.

Source 5 files
hooks/register.tsx 5173 lines
1import { 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 lines
1// 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
70
hooks/map.tsx 249 lines
1// 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}
249
hooks/pan.tsx 91 lines
1// 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
91
types/index.d.ts 280 lines
1export 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