SLOPSHOPPER

crew-graph

Live agent dashboard with Clawd crabs: model vitals, permission gate, architect, subagent cards, the agent tree and plan/doc graph, and a three.js 3D view of…

newpaneguardcommandtoaststatus
★ 2v0.13.0MITupdated 2026-10-15JSungMin/crew-graph/plugins/crew-graph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew-graph
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ CREW · — IDLE │ ┃ ■ main ■ agents ■ gate ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ — · main ⏺ Update(src/auth.ts) │ ┃ │ effort ▯▯▯▯ — 0 req ⎿ Added 2 lines, removed 1 line │ ┃ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k ⏺ Bash(bun test) │ ┃ │ $0.42 5h ▰▰▱▱▱ 31% ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ crew 0 running │ ┃ │ ▣ client module ./clawd-anim.tsx — · done ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ 10 calls › /crew │ ┃ │ · code 3: cache.ts ◄─ main:w audit.ts ◄─ ⎿ crew-graph: Crew opened. Focus it with ctrl+x tab; 1-6 expand ca │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ✓ last turn 42s · 0 agents · 3 edits · 1 e │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ session log │ ┃ │ 08:53:20 main Edit → src/auth.ts │ ┃ │ 08:53:20 main Write → src/audit.ts │ ┃ │ 08:53:20 main Write → src/cache.ts │ ┃ │ 08:53:20 main Bash → bun test ✗ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ crew-graph: ctx 49%

Draws

Pane · Crew
CREW · — IDLE ■ main ■ agents ■ gate ╭─────────────────────────────────────────────────────╮ │ — · main ○ idle │ │ effort ▯▯▯▯ — 0 req │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ $0.42 5h ▰▰▱▱▱ 31% │ ╰─────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────╮ │ crew 0 running · 3 files │ │ ▣ client module ./clawd-anim.tsx — · done │ │ │ │ 10 calls · 0 req │ │ · code 3: cache.ts ◄─ main:w audit.ts ◄─ main:w … │ ╰─────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────╮ │ ✓ last turn 42s · 0 agents · 3 edits · 1 error │ ╰─────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────╮ │ session log │ │ 08:53:20 main Edit → src/auth.ts │ │ 08:53:20 main Write → src/audit.ts │ │ 08:53:20 main Write → src/cache.ts │ │ 08:53:20 main Bash → bun test ✗ │ ╰─────────────────────────────────────────────────────╯
README

🦀 crew-graph

Watch your Claude Code agents work — as little 3D characters on your desktop, in your terminal or in a browser.

version Claude Code license local only

English · 한국어

<img src="docs/screenshots/pet.png" width="640" alt="The desktop pet: agents as voxel characters with what they are doing, their files and the flow between them, with no background">

⚡ Quick start

/plugin marketplace add JSungMin/crew-graph
/plugin install crew-graph@crew-graph
/reload-plugins
/crew pet

The first /crew pet installs Electron (~100 MB) once, in the background, and opens the pet when it is done. /crew pet and /crew 3d need Node.js on PATH (22+ for /crew 3d). Already using Flightdeck? Turn it off: crew-graph includes all of it.

👀 Four ways to watch

CommandWhat you see
🖥️ Desktop pet/crew petA transparent, always-on-top window per session. Each agent is a character that acts out its work; its files float beside it.
📟 Terminal pane/crewA dashboard: the agent tree, the plans and docs they touch, cost, context, permission checks and a log.
🌐 Browser/crew webThe full 3D view with session vitals and a live event log.
🧊 3D in the pane/crew 3dThe same 3D view, drawn in the terminal with block glyphs.

<table> <tr> <td width="50%"><img src="docs/screenshots/sessions.png" alt="Three pet windows, one per session, each with its nameplate; the last one has ended and sleeps"><br><sub>One window per session, each with a nameplate</sub></td> <td width="50%"><img src="docs/screenshots/web.png" alt="The full 3D web view with session vitals, gate counters and the event log"><br><sub>The browser view</sub></td> </tr> </table>

🐾 Play with the pet

DoIt does
Drag a character or the windowCarries the window; the character dangles and swings
Shake while carryingIt gets dizzy: spiral eyes, stars, a stagger
Click a characterPinch: it squashes and hops
Stroke it (hover back and forth)Pet: happy eyes and hearts
Click a fileOpens it
Hover a character or a fileA card: status and steps, or a file summary
Watch the tasksEach to-do item is food: on a plate while it waits, in hand while it is worked on, eaten when it is done. Hover one for a card
Watch the work queueReports that reach main pile up on its plate as envelopes (with a count), a teammate holds the jobs it was given, and an agent takes a small bite for each tool call while it works
Right-clickCharacter ▸, Ask Claude…, Settings…, open, copy path
Right-click a finished agentDismiss (it waves and pops away); a running one: Ask Claude to stop it
Wheel · Ctrl+wheel · right-dragZoom · resize · rotate

Clicks on the transparent parts go through to your desktop.

<img src="docs/screenshots/tasks.png" width="640" alt="Agents with their tasks as food: carrots, fish, treat sticks and a battery on plates and in hands, a lock on a blocked task, and below, the crab eating a fish with crumbs flying">

<table> <tr> <td width="58%"><img src="docs/screenshots/dizzy.png" alt="A dizzy bunny with spiral eyes and stars after a shake"><br><sub>Shake it: dizzy</sub></td> <td width="42%"><img src="docs/screenshots/settings.png" alt="The pet settings window with character tiles and per-agent-type pickers"><br><sub>Settings… : characters, what to show, motion, window</sub></td> </tr> </table>

🎭 Characters

<img src="docs/screenshots/characters.png" alt="Crab, bunny, cat, hamster and robot doing think, edit and run">

IdPicked by auto for
🦀crab (default)lead, main, generalist
🐰bunnydocs, write, summarize, web
🐱catexplore, search, review
🐹mochiplan, architect, design
🤖robotcode, build, fix, test

Pick them in the pet's Settings…, or with the characters option:

main=crab, Explore=cat, *=auto

auto matches each agent's role to a character; random gives each agent a stable random one. Bring your own as voxel JSON or GLB in ~/.claude/crew-graph/characters/ — see the guide. A pack that is wrong or misses something gets a red or amber ! badge, listed with how to fix it in Settings… and in /crew characters (pack problems).

⌨️ Commands

CommandDoes
/crew · /crew close · /crew resetOpen, close or clear the pane
/crew pet · /crew pet off [all]Start the pet · close this window (or quit)
`/crew web [noopen\off]`The browser view
/crew 3d [off]3D inside the pane
/crew characters [reload]List characters and who gets which
/crew setupShow which task tools this session has and what feeds the characters (changes nothing)
`/crew layout auto\compact\wide\mini`Pane layout

Options (in /config): petOnStart, webOnStart, characters, layout, panels and the rest of Flightdeck's.

🔒 Local only

  • Watches, never changes. Every hook passes its event on unchanged.
  • Writes only under ~/.claude/crew-graph/, and only after /crew web, /crew pet or /crew characters reload.
  • No network. three.js is bundled. The one exception: installing Electron from npm, once.

Details: What it can reach.

📖 More

The guide has everything else: every panel, the pet's settings file, the character format, the 3D renderer, and how to develop the mod.

Credits

MIT, see LICENSE.

  • Flightdeck © 2026 Stephen Casella, MIT (third_party/flightdeck-LICENSE.txt). crew-graph absorbs it; the ported files carry a header note.
  • three.js © three.js authors, MIT (plugins/crew-graph/web/THREE_LICENSE.txt, third_party/three-LICENSE.txt).
  • Clawd is Claude Code's mascot. The crabs here are drawn from scratch as fan art; the other characters are original designs.
Source 13 files
hooks/register.tsx 2554 lines
1// crew-graph = Flightdeck (ported, MIT, Stephen Casella; see third_party/flightdeck-LICENSE.txt)
2// + the crew: Clawd crabs per loop, the agent tree, the plan/doc graph, and the browser view export.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { AgentCard, Architect, Bucket, Check, CrewEvent, FileNode, Gate, Layout, LogLine, Loop, LoopAct, Main, Roster, Turn, Usage, Verb, View, View3d, Web, PetFocus, LastReply } from '../types'
7import {
8  DEFAULT_ARCHITECT,
9  DEFAULT_GATE,
10  DEFAULT_MAIN,
11  DEFAULT_ROSTER,
12  DEFAULT_TURN,
13  DEFAULT_USAGE,
14  DEFAULT_VIEW,
15  EDIT_TOOLS,
16  SCHEMA_VERSION,
17  afterCall,
18  applyStep,
19  bucketOf,
20  cardTitle,
21  titleLines,
22  consultTimeline,
23  describeInput,
24  endConsult,
25  fitLegend,
26  fmtClock,
27  fmtDuration,
28  fmtTimer,
29  fmtUsd,
30  plural,
31  gateSummary,
32  gauge,
33  isAdvising,
34  isLoopActive,
35  kTokens,
36  lanes,
37  limitLabel,
38  limitOf,
39  fmtResetIn,
40  fmtSpeed,
41  listOf,
42  logRows,
43  momentOf,
44  normalize,
45  normalizeCard,
46  normalizeGate,
47  normalizeLog,
48  noteTool,
49  PALETTES,
50  SVG_COLORS,
51  parseConfig,
52  prettyModel,
53  promptLine,
54  redact,
55  handbackOf,
56  adviceLine,
57  receiptOf,
58  recordCheck,
59  settleCheck,
60  shorten,
61  startConsult,
62  stepSpeed,
63  stepLoop,
64} from './deck/core'
65import type { Config, Panel } from './deck/core'
66import { bigFrame, miniFrame, propFrame } from './clawd'
67import type { CharFs, Scan } from './characters'
68import { BUILTIN_CHARACTERS, agentsFromDataJs, describeAgentPicks, describeCharacters, describePetSettings, ensureCharacterFolder, scanCharacters, writeCharacters } from './characters'
69import { ASK_MAX, aboutOf, autoDismissed, autoDismissOf, checkPetMsg, cleanAsk, composeAsk, composeStop, dismiss, dismissedOf, hiddenIds, isFinished, replyOf, withoutHidden } from './pet-ask'
70import type { AskAbout, PetMsg } from './pet-ask'
71import { absPath, clip, dataJs, describeCall, titleOf, pushEvent, shortPath, snapshotOf, touchFile, verbOfCard, scrollbar } from './crew'
72import {
73  TASK_TOOLS,
74  addresseeOf,
75  applyAssign,
76  applyAssignDone,
77  applyMailDrop,
78  applyMailEnd,
79  applyMailIn,
80  applyMailStart,
81  applyTaskCreate,
82  applyTaskUpdate,
83  applyTodoWrite,
84  applyTurnEnd,
85  applyTurnQueued,
86  applyTurnStart,
87  assignTitle,
88  bumpEaten,
89  createdId,
90  isProtocolText,
91  looksLikeUser,
92  mailFromOf,
93  mailForTurn,
94  mailKey,
95  mailParse,
96  mailTitle,
97  noteMailFrom,
98  normalizeBook,
99  tasksOf,
100} from './tasks'
101import type { MailFrom, MailWait } from './tasks'
102import { USER_ORIGINS, foodFromOf, hasNativeTaskTools, isMailOrigin, proxyFoodOn, setupReport } from './setup'
103import type { FoodFrom, SetupState } from './setup'
104
105const PANE = 'crew-graph'
106const TITLE = 'Crew'
107const PANE_COLUMNS = 70
108const WEB_FILES = [
109  'index.html',
110  'app.js',
111  'three.min.js',
112  'THREE_LICENSE.txt',
113  'gltf-loader.classic.js',
114  'characters-core.js',
115  'characters.js',
116  'tasks.js',
117  'view-core.js',
118  'pet-settings-core.js',
119  'input-diag-core.js',
120  'settings.html',
121  'settings.js',
122]
123const WEB_THROTTLE_MS = 450
124
125
126// ---------------------------------------------------------------- state
127
128const meta = atom({ plugin: 'crew-graph', key: 'meta' } as const, { schemaVersion: SCHEMA_VERSION })
129const main = atom({ plugin: 'crew-graph', key: 'main' } as const, DEFAULT_MAIN)
130const usage = atom({ plugin: 'crew-graph', key: 'usage' } as const, DEFAULT_USAGE)
131const architect = atom({ plugin: 'crew-graph', key: 'architect' } as const, DEFAULT_ARCHITECT)
132const gate = atom({ plugin: 'crew-graph', key: 'gate' } as const, DEFAULT_GATE)
133const agents = atom({ plugin: 'crew-graph', key: 'agents' } as const, [])
134const loops = atom({ plugin: 'crew-graph', key: 'loops' } as const, [])
135const log = atom({ plugin: 'crew-graph', key: 'log' } as const, [])
136const turn = atom({ plugin: 'crew-graph', key: 'turn' } as const, DEFAULT_TURN)
137const receipt = atom({ plugin: 'crew-graph', key: 'receipt' } as const, null)
138const view = atom({ plugin: 'crew-graph', key: 'view' } as const, DEFAULT_VIEW)
139const roster = atom({ plugin: 'crew-graph', key: 'roster' } as const, DEFAULT_ROSTER)
140const acts = atom({ plugin: 'crew-graph', key: 'acts' } as const, {})
141const files = atom({ plugin: 'crew-graph', key: 'files' } as const, [])
142const events = atom({ plugin: 'crew-graph', key: 'events' } as const, [])
143const parents = atom({ plugin: 'crew-graph', key: 'parents' } as const, {})
144const tasks = atom({ plugin: 'crew-graph', key: 'tasks' } as const, { todo: [], task: [], turn: [], mail: [], assign: [], eaten: {} })
145const setup = atom({ plugin: 'crew-graph', key: 'setup' } as const, { native: null })
146const autoOpened = atom({ plugin: 'crew-graph', key: 'autoOpened' } as const, false)
147const web = atom({ plugin: 'crew-graph', key: 'web' } as const, { isOn: false, dir: '', lastWriteAt: 0 })
148const view3d = atom({ plugin: 'crew-graph', key: 'view3d' } as const, { isOn: false, cols: 0, rows: 0, status: '' })
149const petFocus = atom({ plugin: 'crew-graph', key: 'petFocus' } as const, null)
150const sessionTitle = atom({ plugin: 'crew-graph', key: 'sessionTitle' } as const, '')
151const dismissed = atom({ plugin: 'crew-graph', key: 'dismissed' } as const, {})
152const lastReply = atom({ plugin: 'crew-graph', key: 'lastReply' } as const, null)
153const petOn = atom({ plugin: 'crew-graph', key: 'petOn' } as const, false)
154const paneRows = atom({ plugin: 'crew-graph', key: 'paneRows' } as const, 0)
155
156type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
157
158// Every read goes through these, so a value saved under an older shape still reads.
159async function getMain($: EngineInterface): Promise<Main> {
160  return normalize(DEFAULT_MAIN, await read($, main))
161}
162async function getUsage($: EngineInterface): Promise<Usage> {
163  return normalize(DEFAULT_USAGE, await read($, usage))
164}
165async function getArchitect($: EngineInterface): Promise<Architect> {
166  const a = normalize(DEFAULT_ARCHITECT, await read($, architect))
167  return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
168}
169async function getGate($: EngineInterface): Promise<Gate> {
170  return normalizeGate(await read($, gate))
171}
172async function getCards($: EngineInterface): Promise<AgentCard[]> {
173  return listOf<unknown>(await read($, agents)).map(normalizeCard)
174}
175async function getLoops($: EngineInterface): Promise<Loop[]> {
176  return listOf<Loop>(await read($, loops))
177}
178async function getLog($: EngineInterface): Promise<LogLine[]> {
179  return normalizeLog(await read($, log))
180}
181async function getTurn($: EngineInterface): Promise<Turn> {
182  return normalize(DEFAULT_TURN, await read($, turn))
183}
184async function getView($: EngineInterface): Promise<View> {
185  return normalize(DEFAULT_VIEW, await read($, view))
186}
187async function getRoster($: EngineInterface): Promise<Roster> {
188  const r = normalize(DEFAULT_ROSTER, await read($, roster))
189  return { architectTypes: listOf(r.architectTypes) }
190}
191
192/** A stored shape older than this build's: drop what cannot be read, keep the rest. */
193async function migrate($: EngineInterface) {
194  const m = await read($, meta)
195  if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
196  await update($, log, list => normalizeLog(list))
197  await update($, agents, list => listOf<unknown>(list).map(normalizeCard))
198  await update($, gate, g => normalizeGate(g))
199  await update($, meta, () => ({ schemaVersion: SCHEMA_VERSION }))
200}
201
202async function say($: EngineInterface, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null) {
203  const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId }
204  await update($, log, list => [...normalizeLog(list), line].slice(-60))
205}
206
207async function refreshStatus($: EngineInterface, cfg: Config) {
208  if (!cfg.statusLine) return $.ui.status(undefined)
209  const [u, a, g, allCards, dis, ac] = await Promise.all([getUsage($), getArchitect($), getGate($), getCards($), getDismissed($), getActs($)])
210  const hidden = hiddenIds(allCards, dis, ac)
211  const cards = allCards.filter(c => !hidden.has(c.id))
212  const running = cards.filter(c => c.status === 'running').length
213  const s = gateSummary(g)
214  const parts = [
215    u.pct !== null ? `ctx ${Math.round(u.pct)}%` : null,
216    cards.length > 0 ? `agents ${running}/${cards.length}` : null,
217    a.consults.length > 0 || a.ids.length > 0 ? `${cfg.architectLabel.toLowerCase()} ${isAdvising(a) ? 'advising' : a.consults.length}` : null,
218    s.deny > 0 ? `denied ${s.deny}` : null,
219  ]
220  // Only fields with something to say; with none, no status entry at all.
221  const shown = parts.filter(Boolean)
222  $.ui.status(shown.length > 0 ? shown.join(' · ') : undefined)
223}
224
225async function whoIs($: EngineInterface, agentId: string | undefined) {
226  if (!agentId) return 'main'
227  const card = (await getCards($)).find(c => c.id === agentId)
228  return card ? shorten(cardTitle(card), 14) : 'agent'
229}
230
231async function consultStarted($: EngineInterface, cfg: Config, id: string, via: string) {
232  const t = await getTurn($)
233  const moment = momentOf(t)
234  const at = await $.clock.now()
235  await update($, architect, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
236  if (moment === 'before done') await update($, turn, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
237  await say($, cfg.architectLabel.toLowerCase(), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
238  await refreshStatus($, cfg)
239}
240
241async function consultEnded($: EngineInterface, cfg: Config, advice: string | null, id?: string) {
242  const at = await $.clock.now()
243  const first = advice?.split('\n').find(l => l.trim()) ?? null
244  const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
245  await update($, architect, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
246  await update($, turn, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
247  await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
248  await refreshStatus($, cfg)
249}
250
251async function noteAdvice($: EngineInterface, cfg: Config, advice: string) {
252  await update($, architect, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
253  await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
254}
255
256async function isArchitectType($: EngineInterface, cfg: Config, type: string) {
257  return cfg.architect.test(type) || (await getRoster($)).architectTypes.includes(type)
258}
259
260async function openPane($: EngineInterface) {
261  // columns apply when docked beside the transcript, rows when seated inline above the prompt.
262  return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
263}
264
265async function resetAll($: EngineInterface) {
266  await update($, main, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
267  await update($, architect, () => DEFAULT_ARCHITECT)
268  await update($, gate, () => DEFAULT_GATE)
269  await update($, agents, () => [])
270  await update($, loops, () => [])
271  await update($, log, () => [])
272  await update($, turn, () => DEFAULT_TURN)
273  await update($, receipt, () => null)
274  await update($, view, () => DEFAULT_VIEW)
275  await update($, acts, () => ({}))
276  await update($, files, () => [])
277  await update($, events, () => [])
278  await update($, parents, () => ({}))
279  await update($, tasks, () => ({ todo: [], task: [], turn: [], mail: [], assign: [], eaten: {} }))
280  turnQueue.length = 0
281  mailWaits.length = 0
282  mailFroms = []
283  await update($, sessionTitle, () => '')
284  // The dismissed set belongs to the agents that were just cleared; a pending question to the pet is not answered any more.
285  await update($, dismissed, () => ({}))
286  await update($, lastReply, () => null)
287  askWaits.length = 0
288  askTurns.clear()
289  // The context gauge waits for the next measurement rather than showing the pre-clear fill.
290  await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
291}
292
293/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
294async function costNow($: EngineInterface): Promise<number | null> {
295  const u = await $.session.usage().catch(() => null)
296  return u?.cost?.usd ?? null
297}
298
299async function noteMode($: EngineInterface, mode: string | undefined) {
300  if (mode) await update($, main, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
301}
302
303// ---------------------------------------------------------------- food from: which task tools the session has, and the prompt turns as proxy food
304// A model without TodoWrite / TaskCreate leaves the agents' to-dos out of the food; each prompt the user sends (main's turn) is food instead.
305// The subagent tickets are in every session. hooks/setup.ts has the rules, hooks/tasks.ts the items.
306
307/** The `foodFrom` option. */
308let foodFrom: FoodFrom = 'auto'
309/** Prompts typed while a turn runs, in line: the id of the pending item and the head of the text, to match the turn that starts for it. */
310const turnQueue: { qid: string; key: string }[] = []
311let queueSeq = 0
312const QUEUE_KEY_LEN = 80
313const QUEUE_MAX = 20
314
315async function getSetup($: EngineInterface): Promise<SetupState> {
316  return normalize({ native: null } as SetupState, await read($, setup))
317}
318
319/** Does the session list a native task tool? true / false, null when the list cannot be read. */
320async function detectNative($: EngineInterface): Promise<boolean | null> {
321  try {
322    return hasNativeTaskTools((await $.tool.list()).map(t => t.name))
323  } catch {
324    return null
325  }
326}
327
328/** The state, reading the tool list when that has not worked yet. */
329async function knownSetup($: EngineInterface): Promise<SetupState> {
330  const s = await getSetup($)
331  if (s.native !== null) return s
332  const native = await detectNative($)
333  if (native === null) return s
334  await update($, setup, () => ({ native }))
335  return { native }
336}
337
338/** Are the prompt turns food now? */
339async function proxyOn($: EngineInterface) {
340  // only `auto` depends on the tool list
341  if (foodFrom !== 'auto') return proxyFoodOn(foodFrom, null)
342  return proxyFoodOn(foodFrom, (await knownSetup($)).native)
343}
344
345/** A native task tool was used: the session has them, whatever the tool list said. */
346async function noteNativeSeen($: EngineInterface) {
347  if ((await getSetup($)).native !== true) await update($, setup, () => ({ native: true }))
348}
349
350/** A prompt typed over a running turn waits behind it: a pending item on main's plate, until its own turn starts. */
351async function noteTurnQueued($: EngineInterface, text: string) {
352  const now = await $.clock.now()
353  queueSeq += 1
354  const qid = `q${queueSeq}`
355  turnQueue.push({ qid, key: text.slice(0, QUEUE_KEY_LEN) })
356  if (turnQueue.length > QUEUE_MAX) turnQueue.shift()
357  await update($, tasks, b => applyTurnQueued(normalizeBook(b), qid, text, now))
358  await queueWeb($)
359}
360
361/**
362 * Main's turn starts: a prompt of the user's (or a question typed into the pet, titled by the user's words, not the frame) is an item in
363 * progress; a turn the engine opened with a tagged message (a hand-back, a task notification) is not. A queued prompt's pending item becomes it.
364 * A turn opened by a letter never gets here (noteMailStart took it, by the origin its prompt carried); the text check is the fallback for a
365 * turn whose origin was not seen.
366 */
367async function noteTurnStart($: EngineInterface, turnId: string, text: string, askKey: string) {
368  if (!(askKey || (text.trim() && looksLikeUser(text)))) return
369  const now = await $.clock.now()
370  const title = askKey || text
371  let at = turnQueue.findIndex(q => text.includes(q.key) || title.includes(q.key))
372  // The line runs in order: a user turn that matches no text is the head of it (a pet question never came from the line).
373  if (at < 0 && !askKey && turnQueue.length > 0) at = 0
374  const queued = at >= 0 ? turnQueue.splice(at, 1)[0] : undefined
375  await update($, tasks, b => applyTurnStart(normalizeBook(b), turnId, title, now, queued?.qid))
376}
377
378async function noteTurnEnd($: EngineInterface, turnId: string, reason: string) {
379  const now = await $.clock.now()
380  await update($, tasks, b => applyTurnEnd(normalizeBook(b), turnId, reason, now))
381}
382
383// ---------------------------------------------------------------- mail and assignments (the work queue; on whatever the food setting is)
384// A letter is a message that reaches main from a teammate, a task or another session (the prompt's origin says so). It waits on main's plate
385// and is eaten when the main turn that reads it ends. An assignment is a job main gave a teammate; the teammate holds it until it reports.
386
387/** Letters that reached main and have not opened a turn yet, and the sightings of who sent what (hooks/tasks.ts). */
388const mailWaits: MailWait[] = []
389let mailFroms: MailFrom[] = []
390let mailSeq = 0
391const MAIL_WAIT_MAX = 20
392const MAIL_WAIT_TTL_MS = 5 * 60_000
393
394/** A letter reached main. Returns its id. Registered before the prompt goes on: the turn it opens starts inside that call. */
395async function noteMailIn($: EngineInterface, text: string, kind: string, turnId: string | undefined): Promise<string> {
396  const now = await $.clock.now()
397  mailSeq += 1
398  const mid = `${now.toString(36)}-${mailSeq}`
399  const { from, summary } = mailParse(text, kind, mailFromOf(mailFroms, text, now))
400  if (turnId === undefined) {
401    mailWaits.push({ mid, key: mailKey(text), at: now })
402    if (mailWaits.length > MAIL_WAIT_MAX) mailWaits.shift()
403  }
404  await update($, tasks, b => applyMailIn(normalizeBook(b), mid, mailTitle(from, summary), now, turnId))
405  await queueWeb($)
406  return mid
407}
408
409/** The prompt of a letter was dropped by another hook: the letter never reached main. */
410async function noteMailDrop($: EngineInterface, mid: string) {
411  const at = mailWaits.findIndex(w => w.mid === mid)
412  if (at >= 0) mailWaits.splice(at, 1)
413  await update($, tasks, b => applyMailDrop(normalizeBook(b), mid))
414  await queueWeb($)
415}
416
417/** Main's turn starts: the letter that opened it (if one did) is its work. Returns whether a letter opened it. */
418async function noteMailStart($: EngineInterface, turnId: string, text: string): Promise<boolean> {
419  const now = await $.clock.now()
420  for (let i = mailWaits.length - 1; i >= 0; i -= 1) if (now - (mailWaits[i]?.at ?? now) > MAIL_WAIT_TTL_MS) mailWaits.splice(i, 1)
421  const wait = mailForTurn(mailWaits, text)
422  if (wait) mailWaits.splice(mailWaits.indexOf(wait), 1)
423  await update($, tasks, b => applyMailStart(normalizeBook(b), turnId, now, wait?.mid))
424  return wait !== undefined
425}
426
427async function noteMailEnd($: EngineInterface, turnId: string, reason: string) {
428  const now = await $.clock.now()
429  await update($, tasks, b => applyMailEnd(normalizeBook(b), turnId, reason, now))
430}
431
432/** Cards already checked against the engine's agent list this load, so the list is asked for once per card. */
433const matesChecked = new Set<string>()
434
435/**
436 * A card made before this version has no `isTeammate`; the engine's agent list knows (`teammateId` is there for a teammate and for no other agent).
437 * The flag is written back, so the card stops being a ticket and its idle flips stop being eaten. Returns the cards, flags settled.
438 */
439async function settleTeammates($: EngineInterface, rows?: readonly { id: string; teammateId?: string }[]): Promise<AgentCard[]> {
440  let cards = await getCards($)
441  const todo = cards.filter(c => !c.isTeammate && !matesChecked.has(c.id))
442  if (todo.length === 0) return cards
443  const list = rows ?? (await $.agent.list().catch(() => null))
444  if (!list) return cards
445  const mates = new Set(todo.filter(c => list.some(r => r.id === c.id && r.teammateId)).map(c => c.id))
446  for (const c of todo) matesChecked.add(c.id)
447  if (mates.size === 0) return cards
448  await update($, agents, l => listOf<unknown>(l).map(normalizeCard).map(c => (mates.has(c.id) ? { ...c, isTeammate: true } : c)))
449  cards = await getCards($)
450  return cards
451}
452
453/** A message left an agent for another: main giving a teammate a job, or a teammate reporting to the lead. */
454async function noteSend($: EngineInterface, from: string | undefined, to: unknown, text: string) {
455  if (typeof to !== 'string') return
456  const listed = await $.agent.list().catch(() => null)
457  const rows = listed ?? []
458  const cards = await settleTeammates($, listed ?? undefined)
459  const target = addresseeOf(to, [...rows.map(r => ({ id: r.id, name: r.name, teammateId: r.teammateId })), ...cards.map(c => ({ id: c.id, name: c.type }))])
460  const mate = (id: string) => cards.find(c => c.id === id)?.isTeammate === true
461  const now = await $.clock.now()
462  if (from === undefined) {
463    if (target && mate(target)) await update($, tasks, b => applyAssign(normalizeBook(b), target, assignTitle(undefined, text), 'main', now))
464  } else if (mate(from) && !target) {
465    // a teammate's message to the lead is its report: the oldest job it holds is handed back
466    mailFroms = noteMailFrom(mailFroms, text, cards.find(c => c.id === from)?.type ?? '', now)
467    await update($, tasks, b => applyAssignDone(normalizeBook(b), from, false, now))
468  } else return
469  await queueWeb($)
470}
471
472// ---------------------------------------------------------------- crew
473
474let cwd = ''
475/** The `characters` option as [who, character] pairs, for the snapshot. */
476let charRules: [string, string][] = []
477let webPending = false
478const WEB_OFF: Web = { isOn: false, dir: '', lastWriteAt: 0 }
479
480async function getActs($: EngineInterface): Promise<Record<string, LoopAct>> {
481  const a = await read($, acts)
482  return a && typeof a === 'object' ? a : {}
483}
484async function getFiles($: EngineInterface): Promise<FileNode[]> {
485  return listOf<FileNode>(await read($, files))
486}
487async function getEvents($: EngineInterface): Promise<CrewEvent[]> {
488  return listOf<CrewEvent>(await read($, events))
489}
490async function getParents($: EngineInterface): Promise<Record<string, string>> {
491  const p = await read($, parents)
492  return p && typeof p === 'object' ? p : {}
493}
494async function getDismissed($: EngineInterface): Promise<Record<string, string>> {
495  return dismissedOf(await read($, dismissed))
496}
497async function getWeb($: EngineInterface): Promise<Web> {
498  return normalize(WEB_OFF, await read($, web))
499}
500
501/** TodoWrite, TaskCreate and TaskUpdate: what the agent `key` just said about its tasks (hooks/tasks.ts keeps the book). */
502async function noteTasks($: EngineInterface, e: Record<string, unknown>, ran: { result?: unknown; text?: unknown }, key: string) {
503  const now = await $.clock.now()
504  const tool = String(e.tool)
505  if (tool === 'TodoWrite') await update($, tasks, b => applyTodoWrite(normalizeBook(b), key, e, now))
506  else if (tool === 'TaskCreate') {
507    const id = createdId(ran.result, ran.text)
508    if (id) await update($, tasks, b => applyTaskCreate(normalizeBook(b), id, e, now))
509  } else {
510    const known = [{ id: 'main', type: 'main' }, ...(await getCards($)).map(c => ({ id: c.id, type: c.type }))]
511    await update($, tasks, b => applyTaskUpdate(normalizeBook(b), e, key, known, now))
512  }
513  await queueWeb($)
514}
515
516async function act($: EngineInterface, key: string, verb: Verb, tool: string, target: string) {
517  const now = await $.clock.now()
518  await update($, acts, all => {
519    const prev = all?.[key]
520    return { ...all, [key]: { key, verb, tool, target, since: now, calls: (prev?.calls ?? 0) + (tool ? 1 : 0) } }
521  })
522}
523
524async function emit($: EngineInterface, e: Omit<CrewEvent, 'id' | 't'>) {
525  const t = await $.clock.now()
526  await update($, events, list => pushEvent(listOf<CrewEvent>(list), { ...e, t }))
527  await queueWeb($)
528}
529
530/** The browser view's data, written at most every WEB_THROTTLE_MS while the export is on. */
531async function queueWeb($: EngineInterface) {
532  const w = await getWeb($)
533  if (!w.isOn || webPending) return
534  const now = await $.clock.now()
535  const wait = Math.max(0, WEB_THROTTLE_MS - (now - w.lastWriteAt))
536  if (wait === 0) return writeWeb($)
537  webPending = true
538  $.clock.after(wait, () => void flushWeb($))
539}
540
541async function flushWeb($: EngineInterface) {
542  webPending = false
543  await writeWeb($)
544}
545
546async function writeWeb($: EngineInterface) {
547  const w = await getWeb($)
548  if (!w.isOn || !w.dir) return
549  const [m, u, a, g, allCards, t, r, allActs, allFiles, allEvents, allParents, now, sid, title, shown, reply, book] = await Promise.all([
550    getMain($),
551    getUsage($),
552    getArchitect($),
553    getGate($),
554    settleTeammates($),
555    getTurn($),
556    read($, receipt),
557    getActs($),
558    getFiles($),
559    getEvents($),
560    getParents($),
561    $.clock.now(),
562    $.session.id().catch(() => ''),
563    read($, sessionTitle),
564    getDismissed($),
565    read($, lastReply),
566    read($, tasks),
567  ])
568  // Agents the pet dismissed are not in what the pet (or the browser) is shown.
569  const hidden = hiddenIds(allCards, shown, allActs)
570  const { cards, acts: ac, files: fl, events: ev, parents: pa } = withoutHidden(
571    { cards: allCards, files: allFiles, acts: allActs, events: allEvents, parents: allParents },
572    hidden,
573  )
574  // The prompt turns are food only while the proxies are on (see food from); the to-dos, tasks and tickets always are.
575  const tk0 = normalizeBook(book)
576  const tk = (await proxyOn($)) ? tk0 : { ...tk0, turn: [] }
577  const snap = snapshotOf({
578    now,
579    sessionId: String(sid),
580    title,
581    cwd,
582    main: m,
583    usage: u,
584    architect: a,
585    gate: g,
586    cards,
587    architectIds: a.ids,
588    acts: ac,
589    files: fl,
590    events: ev,
591    parents: pa,
592    turn: t,
593    receipt: r,
594    chars: charRules,
595    lastReply: reply,
596    ack: petAck,
597    // a dismissed agent takes its tasks and its count with it
598    tasks: tasksOf(tk, cards, pa, now, hidden),
599    eaten: Object.fromEntries(Object.entries(tk.eaten).filter(([id]) => !hidden.has(id))),
600  })
601  const text = dataJs(snap)
602  await $.fs.write(`${w.dir}/data.js`, text).catch(() => undefined)
603  // The same snapshot under the session's own name: every desktop pet window reads its session's file, data.js is whoever wrote last.
604  if (safeId(String(sid))) await $.fs.write(`${w.dir}/sessions/${sid}.js`, text).catch(() => undefined)
605  await update($, web, x => ({ ...normalize(WEB_OFF, x), lastWriteAt: now }))
606}
607
608/** A session id that is fine as a file name (the usual uuid is). */
609const safeId = (id: string) => /^[A-Za-z0-9._-]{1,80}$/.test(id)
610
611async function homeDir($: EngineInterface) {
612  return ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
613}
614
615/** Copies the viewer next to its data and turns the export on; opens it in the browser when asked. */
616async function startWeb($: EngineInterface, shouldOpen: boolean): Promise<string> {
617  const home = await homeDir($)
618  if (!home) return 'crew-graph: no home directory to write the browser view into.'
619  const dir = `${home}/.claude/crew-graph`
620  for (const f of WEB_FILES) {
621    const text = await $.fs.read(`${$.plugin.root}/web/${f}`).catch(() => null)
622    if (typeof text === 'string') await $.fs.write(`${dir}/${f}`, text)
623  }
624  await syncCharacters($, dir).catch(() => undefined)
625  await update($, web, () => ({ isOn: true, dir, lastWriteAt: 0 }))
626  await writeWeb($)
627  const page = `${dir}/index.html`
628  const where = shouldOpen ? await openInBrowser($, page) : ''
629  return `Crew web view: ${page}${where}`
630}
631
632// ---------------------------------------------------------------- characters
633
634const charFs = ($: EngineInterface): CharFs => ({
635  list: p => $.fs.list(p),
636  read: p => $.fs.read(p),
637  readBytes: async p => (await $.fs.read(p, { as: 'bytes' })).base64,
638  write: (p, t) => $.fs.write(p, t),
639  exists: p => $.fs.exists(p),
640})
641
642/** Scans ~/.claude/crew-graph/characters/ and writes the manifest (and base64 parts of any GLB) the page loads. */
643async function syncCharacters($: EngineInterface, dir: string): Promise<Scan> {
644  const fs = charFs($)
645  const folder = `${dir}/characters`
646  await ensureCharacterFolder(fs, folder).catch(() => undefined)
647  const scan = await scanCharacters(fs, folder, true)
648  scan.at = await $.clock.now()
649  await writeCharacters(fs, dir, scan)
650  return scan
651}
652
653/** `/crew characters` lists; `/crew characters reload` rescans and rewrites what the page reads. */
654async function charactersCommand($: EngineInterface, spec: string, reload: boolean): Promise<string> {
655  const home = await homeDir($)
656  if (!home) return 'crew-graph: no home directory to keep characters in.'
657  const dir = `${home}/.claude/crew-graph`
658  const scan = reload ? await syncCharacters($, dir) : await scanCharacters(charFs($), `${dir}/characters`, false)
659  // What the pet's Settings window says (a missing or broken file is only reported) and what `auto` gives the agents of the latest snapshot.
660  const petText = await $.fs.read(`${dir}/overlay/pet-settings.json`).catch(() => null)
661  const sid = await petSession($)
662  const snapText = (sid ? await $.fs.read(`${dir}/sessions/${sid}.js`).catch(() => null) : null) ?? (await $.fs.read(`${dir}/data.js`).catch(() => null))
663  const pool = [...BUILTIN_CHARACTERS, ...scan.chars.map(c => ({ id: c.id, traits: c.kind === 'voxel' ? c.traits : [] }))]
664  const extra = [
665    ...describePetSettings(typeof petText === 'string' ? petText : null),
666    ...describeAgentPicks(agentsFromDataJs(typeof snapText === 'string' ? snapText : null), pool),
667  ]
668  return describeCharacters(scan, spec, `${dir}/characters`, charRules, reload, extra)
669}
670
671/** Inside Orca, a browser tab beside the terminal (full pixels, real mouse); else the system browser. */
672async function openInBrowser($: EngineInterface, page: string): Promise<string> {
673  if ((await $.env.get('TERM_PROGRAM')) === 'Orca') {
674    const url = `file://${page.startsWith('/') ? '' : '/'}${page}`
675    const res = await $.process.run(['orca', 'tab', 'create', '--url', url, '--json'], { timeoutMs: 15_000 }).catch(() => null)
676    if (res && res.exitCode === 0) return ' (opened in an Orca browser tab)'
677  }
678  const isWin = (await $.env.get('OS')) === 'Windows_NT'
679  const argv = isWin ? ['explorer', page.replace(/\//g, '\\')] : [(await $.env.get('XDG_SESSION_TYPE')) ? 'xdg-open' : 'open', page]
680  await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => undefined)
681  return ''
682}
683
684// ---------------------------------------------------------------- desktop pet
685// overlay/main.js shows the web view in a transparent, always-on-top Electron window on the desktop. The mod
686// cannot hold that process itself ($.process.spawn kills its child with the session), so overlay/launch.mjs starts
687// it detached and returns. Electron lives in ~/.claude/crew-graph/overlay-runtime, not in the plugin folder
688// (an update replaces that), and is installed with npm the first time.
689
690const ELECTRON_VERSION = '44.4.5'
691const PET_POLL_MS = 1000
692/** One install at a time across sessions: a lock older than this is a crashed install, not a running one. */
693const INSTALL_STALE_MS = 15 * 60_000
694const INSTALL_WAIT_MS = 2000
695
696const PET_HELP = [
697  'Crew pet: the crabs on your desktop, with no background.',
698  '  press a crab and pull to carry the window · shake it while carrying and it gets dizzy · click to pinch · stroke it (button up) to pet',
699  '  right-drag to rotate · wheel to zoom · Ctrl+wheel or the corner grip to resize',
700  '  click a file to open it · Shift+click shows it in the folder · right-click for the menu: Character (who is drawn as what), Settings…',
701  '  the tray icon has Settings… too: characters per agent type or per agent, bubbles, ticker, papers, nameplate, reduced motion, opacity, size',
702  '  the pill on top names this session (folder · first prompt); drag it to carry the window',
703  '/crew pet off closes the window of this session, /crew pet off all every window.',
704].join('\n')
705
706type PetPaths = { dir: string; runtime: string; state: string; page: string; main: string; launch: string; isWin: boolean }
707
708let petInstalling = false
709// The poll of the pet's inbox runs while its generation is the current one; /crew pet off ends it.
710let petGen = 0
711let petSeen = 0
712/**
713 * Asked of the pet and the pet asks back with it: the secret this session gave the window it launched. Held in memory only (never in state
714 * or the snapshot); the window repeats it in every request, and a request without it is ignored. A new one is made each time the pet is
715 * started, which also hands it to a window that is already running.
716 */
717let petToken = ''
718/** The newest request (its `seq`) this mod accepted; in the snapshot as `petAck`. Zero again after a reload: the requests of the old mod are not acknowledged. */
719let petAck = 0
720/** Questions sent from the pet that have not started their turn yet (the session may be busy): the head of the user's text. */
721const askWaits: { key: string; at: number }[] = []
722/** The ids of the main-loop turns that came from a pet question; the answer of such a turn is the pet's `lastReply`. */
723const askTurns = new Set<string>()
724const ASK_WAIT_MS = 30 * 60_000
725
726async function petPaths($: EngineInterface): Promise<PetPaths | null> {
727  const home = await homeDir($)
728  if (!home) return null
729  const dir = `${home}/.claude/crew-graph`
730  // Test only: CREW_PET_RUNTIME names another folder to install Electron into (the default is overlay-runtime next to the viewer).
731  const runtimeEnv = String((await $.env.get('CREW_PET_RUNTIME').catch(() => '')) ?? '').replace(/\\/g, '/')
732  return {
733    dir,
734    runtime: runtimeEnv || `${dir}/overlay-runtime`,
735    state: `${dir}/overlay`,
736    page: `${dir}/index.html`,
737    main: `${$.plugin.root}/overlay/main.js`,
738    launch: `${$.plugin.root}/overlay/launch.mjs`,
739    isWin: (await $.env.get('OS')) === 'Windows_NT',
740  }
741}
742
743/** The Electron binary npm unpacked, if it is there: electron.exe, Electron.app on a Mac, else the bare binary. */
744async function petElectron($: EngineInterface, p: PetPaths): Promise<string | null> {
745  const base = `${p.runtime}/node_modules/electron/dist`
746  const found = p.isWin
747    ? [`${base}/electron.exe`]
748    : [`${base}/Electron.app/Contents/MacOS/Electron`, `${base}/electron`]
749  for (const f of found) if (await $.fs.exists(f).catch(() => false)) return f
750  return null
751}
752
753const tailOf = (text: string) => text.trim().split('\n').slice(-6).join('\n').slice(-400)
754
755type RunResult = { exitCode: number; stdout: string; stderr: string }
756async function runOrFail($: EngineInterface, argv: readonly string[], cwd: string | undefined, timeoutMs: number): Promise<RunResult> {
757  try {
758    const r = await $.process.run(argv, cwd ? { cwd, timeoutMs } : { timeoutMs })
759    return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
760  } catch (e) {
761    return { exitCode: 1, stdout: '', stderr: e instanceof Error ? e.message : String(e) }
762  }
763}
764
765/** npm install electron@<pinned> into the runtime folder, then its own binary download (npm leaves that out here). '' when done. */
766async function petInstall($: EngineInterface, p: PetPaths): Promise<string> {
767  // Writing the folder's note makes the folder; npm makes the package.json itself.
768  await $.fs.write(`${p.runtime}/README.txt`, 'Electron for the crew-graph desktop pet (/crew pet). Safe to delete: /crew pet installs it again.\n')
769  // process.run has no shell, and npm is npm.cmd on Windows: go through cmd. --prefix . keeps it in this folder
770  // (without it npm would look for a package.json in the folders above).
771  const npm = p.isWin ? ['cmd', '/c', 'npm'] : ['npm']
772  const got = await runOrFail($, [...npm, 'install', '--no-audit', '--no-fund', '--prefix', '.', `electron@${ELECTRON_VERSION}`], p.runtime, 600_000)
773  if (got.exitCode !== 0) return `npm install electron@${ELECTRON_VERSION} failed: ${tailOf(got.stderr || got.stdout)}`
774  const bin = await runOrFail($, ['node', 'node_modules/electron/install.js'], p.runtime, 600_000)
775  if (bin.exitCode !== 0) return `the Electron binary download failed: ${tailOf(bin.stderr || bin.stdout)}`
776  return ''
777}
778
779/** This session's id when it is usable in a file name, else '' (the pet then reads data.js, whoever wrote last). */
780async function petSession($: EngineInterface): Promise<string> {
781  const id = String(await $.session.id().catch(() => ''))
782  return safeId(id) ? id : ''
783}
784
785async function petLaunch($: EngineInterface, p: PetPaths, bin: string): Promise<string> {
786  const isOrca = (await $.env.get('TERM_PROGRAM')) === 'Orca'
787  const sid = await petSession($)
788  petToken = crypto.randomUUID().replace(/-/g, '')
789  const argv = ['node', p.launch, '--electron', bin, '--main', p.main, '--page', p.page, '--state', p.state, ...(sid ? ['--session', sid, '--token', petToken] : []), ...(isOrca ? ['--orca'] : [])]
790  const res = await runOrFail($, argv, undefined, 20_000)
791  if (res.exitCode !== 0) return `crew pet: could not start it: ${tailOf(res.stdout || res.stderr)}`
792  petWatch($, p, sid)
793  return PET_HELP
794}
795
796async function toast($: EngineInterface, text: string) {
797  try {
798    await $.ui.toast(text)
799  } catch {
800    // a host without toasts just goes without
801  }
802}
803
804async function petNotify($: EngineInterface, text: string) {
805  await toast($, text)
806  await say($, 'crew pet', text).catch(() => undefined)
807}
808
809const sleepFor = ($: EngineInterface, ms: number) => new Promise<void>(resolve => $.clock.after(ms, () => resolve()))
810
811// The install lock is a file in the runtime folder: "held:<token>:<ms>" while an install runs, "free:<ms>" after. A session that starts
812// while another installs waits for it (instead of running npm in the same folder), then opens its own window. The host has no
813// delete, so a finished install writes "free"; a "held" older than INSTALL_STALE_MS is taken over.
814const petLockFile = (p: PetPaths) => `${p.runtime}/install.lock`
815
816async function petLockTake($: EngineInterface, p: PetPaths, token: string): Promise<boolean> {
817  const now = await $.clock.now()
818  const cur = await $.fs.read(petLockFile(p)).catch(() => null)
819  const m = typeof cur === 'string' ? /^held:([^:]*):(\d+)/.exec(cur.trim()) : null
820  if (m && now - Number(m[2]) < INSTALL_STALE_MS && m[1] !== token) return false
821  await $.fs.write(petLockFile(p), `held:${token}:${now}`)
822  // Two sessions can write at the same moment: the one whose token is still there after the write owns it.
823  const back = await $.fs.read(petLockFile(p)).catch(() => null)
824  return typeof back === 'string' && back.startsWith(`held:${token}:`)
825}
826
827async function petLockFree($: EngineInterface, p: PetPaths, token: string) {
828  const cur = await $.fs.read(petLockFile(p)).catch(() => null)
829  if (typeof cur === 'string' && !cur.startsWith(`held:${token}:`)) return // taken over after a long install: not ours to free
830  await $.fs.write(petLockFile(p), `free:${await $.clock.now()}`).catch(() => undefined)
831}
832
833/** Installs Electron (one session at a time), then opens this session's window. Toasts: installing, then "ready" or the error. */
834async function petInstallThenLaunch($: EngineInterface, p: PetPaths) {
835  const token = `${await petSession($)}-${(await $.clock.now()).toString(36)}`
836  let waited = false
837  while (!(await petElectron($, p))) {
838    if (await petLockTake($, p, token)) {
839      await toast($, 'crew pet: installing Electron (~100 MB)… the pet opens by itself when it is done.')
840      let err = ''
841      try {
842        err = await petInstall($, p)
843      } finally {
844        await petLockFree($, p, token)
845      }
846      if (err) return petNotify($, `crew pet: ${err}`)
847      break
848    }
849    if (!waited) {
850      waited = true
851      await toast($, 'crew pet: another session is installing Electron; this one opens when it is done.')
852    }
853    await sleepFor($, INSTALL_WAIT_MS)
854  }
855  const bin = await petElectron($, p)
856  if (!bin) return petNotify($, 'crew pet: npm finished but no Electron binary was found')
857  const text = await petLaunch($, p, bin)
858  await petNotify($, text === PET_HELP ? 'crew pet: ready' : text)
859}
860
861/** /crew pet: the viewer and its data, Electron, then the window. A first run installs Electron in the background. */
862async function petStart($: EngineInterface): Promise<string> {
863  await startWeb($, false)
864  const p = await petPaths($)
865  if (!p) return 'crew pet: no home directory to keep Electron in.'
866  // The pet is on for this session: state outlives a reload, so session.start can start it again (new token, inbox poll) when it comes back.
867  await update($, petOn, () => true)
868  const bin = await petElectron($, p)
869  if (bin) return petLaunch($, p, bin)
870  if (petInstalling) return 'Electron is still downloading for the pet; it starts when that is done.'
871  petInstalling = true
872  void petInstallThenLaunch($, p)
873    .catch(e => petNotify($, `crew pet: ${e instanceof Error ? e.message : String(e)}`))
874    .finally(() => {
875      petInstalling = false
876    })
877  return `Installing Electron ${ELECTRON_VERSION} (about 100 MB) for the pet, once, in ${p.runtime}. The pet opens by itself when that is done (a toast says so).`
878}
879
880/** /crew pet off closes this session's window, /crew pet off all quits the pet with every window. */
881async function petStop($: EngineInterface, all: boolean): Promise<string> {
882  petGen += 1
883  petToken = ''
884  await update($, petOn, () => false)
885  const p = await petPaths($)
886  if (!p) return 'crew pet: no home directory.'
887  const sid = await petSession($)
888  const which = all || !sid ? ['--quit'] : ['--close', sid]
889  const res = await runOrFail($, ['node', p.launch, '--state', p.state, ...which], undefined, 10_000)
890  if (res.exitCode !== 0) return `crew pet: could not ask it to close: ${tailOf(res.stdout || res.stderr)}`
891  return which[0] === '--quit' ? 'Crew pet closed, with every window.' : "Crew pet: this session's window closed."
892}
893
894// The pet's right-click menu can ask the Crew pane to show a file or an agent: it writes <state>/pet-inbox.json
895// and the mod looks at it about once a second while a pet it started is running.
896type PetInbox = PetMsg & {
897  abs?: string
898  path?: string
899  summary?: { title?: string; lead?: string; heads?: string[]; decls?: string[]; error?: string }
900}
901
902function petWatch($: EngineInterface, p: PetPaths, sid: string) {
903  petGen += 1
904  const gen = petGen
905  const inbox = `${p.state}/${sid ? `pet-inbox-${sid}.json` : 'pet-inbox.json'}`
906  void $.clock
907    .now()
908    .then(startedAt => petPoll($, gen, inbox, startedAt, 0))
909    .catch(() => undefined)
910}
911
912/** Every this many polls the pet's settings are read again (auto-dismiss). */
913const SETTINGS_EVERY = 10
914
915async function petPoll($: EngineInterface, gen: number, inbox: string, startedAt: number, n: number) {
916  if (gen !== petGen) return
917  if (n % SETTINGS_EVERY === 0) await petAutoDismiss($, inbox.slice(0, inbox.lastIndexOf('/'))).catch(() => undefined)
918  const text = await $.fs.read(inbox).catch(() => null)
919  if (typeof text === 'string') {
920    let msg: PetInbox | null = null
921    try {
922      msg = JSON.parse(text) as PetInbox
923    } catch {
924      msg = null
925    }
926    // Requests from before this pet started are not ours; one seq is acted on once.
927    if (msg && typeof msg.seq === 'number' && msg.seq !== petSeen && (msg.at ?? 0) >= startedAt) {
928      petSeen = msg.seq
929      await petInbox($, msg).catch(() => undefined)
930    }
931  }
932  if (gen === petGen) $.clock.after(PET_POLL_MS, () => void petPoll($, gen, inbox, startedAt, n + 1))
933}
934
935/** The pet's "auto-dismiss finished agents after N minutes": read from pet-settings.json (off when missing or unreadable), applied as a dismissal. */
936async function petAutoDismiss($: EngineInterface, stateDir: string) {
937  const minutes = autoDismissOf(await $.fs.read(`${stateDir}/pet-settings.json`).catch(() => null))
938  if (minutes === 0) return
939  const [cards, set, acts, now] = await Promise.all([getCards($), getDismissed($), getActs($), $.clock.now()])
940  const next = autoDismissed(cards, set, acts, now, minutes)
941  if (!next) return
942  await update($, dismissed, () => next)
943  await queueWeb($)
944}
945
946const FOCUS_LINES = 12
947
948// ---------------------------------------------------------------- asking Claude from the pet, dismissing agents
949
950async function petNote($: EngineInterface, text: string) {
951  await say($, 'crew pet', text).catch(() => undefined)
952}
953
954/** Who a question is about: the card of an agent, or the main loop; null for a question with no agent or an id this session does not have. */
955async function askAbout($: EngineInterface, id: string | undefined): Promise<AskAbout | null> {
956  if (!id) return null
957  const [cards, events, files, m] = await Promise.all([getCards($), getEvents($), getFiles($), getMain($)])
958  if (id === 'main') return aboutOf({ id: 'main', type: 'main', status: m.isRunning ? 'running' : 'idle', description: 'the main loop of this session' }, events, files)
959  const card = cards.find(c => c.id === id)
960  return card ? aboutOf(card, events, files) : null
961}
962
963/**
964 * An ask, a dismiss or a stop from the pet. It is acted on only with this session's id and the token the mod gave the pet. What reaches
965 * the model is submitted framed (as a message of this plugin), never as the person's own words.
966 */
967async function petRequest($: EngineInterface, msg: PetInbox) {
968  const checked = checkPetMsg(msg, await petSession($), petToken)
969  if (!checked.ok) return petNote($, `ignored a pet ${String(msg.kind).slice(0, 12)} request: ${checked.why}`)
970  // Received: the snapshot says so (`petAck`), which is how the pet knows a request got to a live mod. A request that is refused above gets none.
971  if (typeof msg.seq === 'number') {
972    petAck = Math.max(petAck, msg.seq)
973    await queueWeb($)
974  }
975  if (msg.kind === 'ask') return petAsk($, msg)
976  if (msg.kind === 'rescan') {
977    // The Settings window's Rescan: the same as `/crew characters reload`; a running pet picks the new manifest up within a few seconds.
978    const home = await homeDir($)
979    if (home) await syncCharacters($, `${home}/.claude/crew-graph`).catch(() => undefined)
980    return
981  }
982  if (msg.kind === 'dismiss') {
983    const ids = Array.isArray(msg.ids) ? msg.ids.filter((x): x is string => typeof x === 'string' && x.length <= 200).slice(0, 100) : []
984    const next = dismiss(await getDismissed($), ids, await getCards($), await getActs($))
985    if (next.taken.length === 0) return
986    await update($, dismissed, () => next.set)
987    await queueWeb($)
988    return
989  }
990  // stop: a running agent of this session, by id
991  const card = typeof msg.id === 'string' ? (await getCards($)).find(c => c.id === msg.id) : undefined
992  if (!card || isFinished(card)) return petNote($, 'ignored a pet stop request: that agent is not running')
993  // $.agent has spawn, list and register only and $.turn.abort ends the main loop's turn: no call stops an agent, so the mod asks Claude to
994  // (running TaskStop through $.tool.call would skip the person's chance to see the request; not done).
995  void $.prompt.submit({ text: composeStop(card) }).catch(() => undefined)
996}
997
998async function petAsk($: EngineInterface, msg: PetInbox) {
999  const text = cleanAsk(msg.text)
1000  if (!text) return petNote($, `ignored a pet question: empty or over ${ASK_MAX} characters`)
1001  if (msg.mode !== 'send' && msg.mode !== 'draft') return petNote($, 'ignored a pet question: no mode')
1002  const composed = composeAsk(text, await askAbout($, typeof msg.agentId === 'string' ? msg.agentId : undefined))
1003  if (msg.mode === 'draft') {
1004    // fill replaces the draft; a draft the person has typed already is kept, the question goes after it
1005    const box = await $.prompt.read().catch(() => null)
1006    const has = !!box && box.text.trim().length > 0
1007    await $.prompt.fill(has ? { text: `\n\n${composed}`, mode: 'append' } : { text: composed, mode: 'replace' }).catch(() => undefined)
1008    return
1009  }
1010  const now = await $.clock.now()
1011  const key = text.slice(0, 80)
1012  while (askWaits.length > 0 && now - (askWaits[0]?.at ?? now) > ASK_WAIT_MS) askWaits.shift()
1013  askWaits.push({ key, at: now })
1014  if (askWaits.length > 8) askWaits.shift()
1015  const forget = () => {
1016    const i = askWaits.findIndex(w => w.key === key && w.at === now)
1017    if (i >= 0) askWaits.splice(i, 1)
1018  }
1019  // Not awaited: the call returns when the turn has started, which may be after the current one. No `asUser`: the model reads it as this plugin's message.
1020  void $.prompt
1021    .submit({ text: composed })
1022    .then(r => {
1023      if ((r as { drop?: string }).drop !== undefined) forget()
1024    })
1025    .catch(forget)
1026}
1027
1028async function petInbox($: EngineInterface, msg: PetInbox) {
1029  if (msg.kind === 'ask' || msg.kind === 'dismiss' || msg.kind === 'stop' || msg.kind === 'rescan') return petRequest($, msg)
1030  if (msg.kind === 'focus-agent' && typeof msg.id === 'string') {
1031    const id = msg.id
1032    if ((await getCards($)).some(c => c.id === id)) await update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: id }))
1033    await openPane($)
1034    return
1035  }
1036  if (msg.kind !== 'focus-file' || typeof msg.abs !== 'string') return
1037  const abs = msg.abs
1038  // Only a file the crew itself touched: the inbox is a file anyone could write.
1039  const touched = (await getFiles($)).find(n => n.abs === abs)
1040  if (!touched) return
1041  const s = msg.summary
1042  const hasSummary = !!s && !s.error && (!!s.title || !!s.lead || !!s.heads?.length || !!s.decls?.length)
1043  let head: string[] = []
1044  if (!hasSummary) {
1045    const text = await $.fs.read(abs).catch(() => null)
1046    if (typeof text === 'string') head = text.split('\n').slice(0, FOCUS_LINES).map(l => clip(l.replace(/[\r\t]+/g, ' '), 100))
1047  }
1048  await update($, petFocus, () => ({
1049    kind: 'file' as const,
1050    abs,
1051    path: touched.path,
1052    summary: hasSummary && s ? { title: s.title ?? '', lead: s.lead ?? '', heads: s.heads ?? [], decls: s.decls ?? [] } : null,
1053    head,
1054    at: msg.at ?? 0,
1055  }))
1056  await openPane($)
1057}
1058
1059// ---------------------------------------------------------------- 3d view in the pane
1060// bin/crew-3d.mjs renders the web view in a headless Chrome/Edge and streams it as Raster
1061// cells (each cell the block glyph and two colours that best fit its 4x8 pixels, bin/cells.mjs)
1062// or, where the terminal draws pictures, as Image frames; each is blitted into the pane's `crew3d`.
1063
1064const RASTER_KEY = 'crew3d'
1065const VIEW3D_OFF: View3d = { isOn: false, cols: 0, rows: 0, status: '' }
1066let view3dGen = 0
1067let last3d: { cols: number; rows: number; cells: string } | null = null
1068let lastImage: { path: string; width: number; height: number; gen: number } | null = null
1069let helper: { port: number; token: string } | null = null
1070let pointerIn3d = false
1071let pointerAt = { x: 0.5, y: 0.5 }
1072let mode3d: 'cells' | 'image' = 'cells'
1073let isRestarting3d = false
1074
1075async function getView3d($: EngineInterface): Promise<View3d> {
1076  return normalize(VIEW3D_OFF, await read($, view3d))
1077}
1078
1079const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
1080
1081/** Standard padded base64, without relying on the host having toBase64 or btoa. */
1082function base64(bytes: Uint8Array): string {
1083  let out = ''
1084  for (let i = 0; i < bytes.length; i += 3) {
1085    const a = bytes[i] ?? 0
1086    const b = bytes[i + 1] ?? 0
1087    const c = bytes[i + 2] ?? 0
1088    const n = (a << 16) | (b << 8) | c
1089    out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
1090    out += i + 1 < bytes.length ? B64.charAt((n >> 6) & 63) : '='
1091    out += i + 2 < bytes.length ? B64.charAt(n & 63) : '='
1092  }
1093  return out
1094}
1095
1096let blankCache: { key: string; cells: string } | null = null
1097
1098/** Blank cells for a raster not yet painted: spaces on the terminal's own colours. */
1099function blankCells(cols: number, rows: number): string {
1100  const key = `${cols}x${rows}`
1101  if (blankCache?.key === key) return blankCache.cells
1102  const words = new Uint32Array(cols * rows * 3)
1103  for (let i = 0; i < cols * rows; i += 1) {
1104    words[i * 3] = 0x20
1105    words[i * 3 + 1] = 0x01000000
1106    words[i * 3 + 2] = 0x01000000
1107  }
1108  blankCache = { key, cells: base64(new Uint8Array(words.buffer)) }
1109  return blankCache.cells
1110}
1111
1112/** Pixels where the terminal draws them (kitty, Ghostty); colour cells everywhere else. */
1113async function pickMode($: EngineInterface, want: string): Promise<'cells' | 'image'> {
1114  if (want === 'cells' || want === 'image') return want
1115  const [term, prog, kitty, ghostty, forced] = await Promise.all([
1116    $.env.get('TERM'),
1117    $.env.get('TERM_PROGRAM'),
1118    $.env.get('KITTY_WINDOW_ID'),
1119    $.env.get('GHOSTTY_RESOURCES_DIR'),
1120    $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
1121  ])
1122  // Orca draws kitty Unicode placeholders once its terminal has them; Orca then sets the
1123  // engine's image flag for the Claude it launches, so the flag is what tells.
1124  const isOrcaPixels = prog === 'Orca' && Boolean(forced) && forced !== '0'
1125  const isPixel = /kitty/i.test(term ?? '') || /ghostty|kitty/i.test(prog ?? '') || Boolean(kitty) || Boolean(ghostty) || isOrcaPixels
1126  return isPixel ? 'image' : 'cells'
1127}
1128
1129async function start3d($: EngineInterface, cols: number, rows: number, fps: number, want = 'auto'): Promise<string> {
1130  const started = await startWeb($, false)
1131  const w = await getWeb($)
1132  if (!w.isOn || !w.dir) return started
1133  mode3d = await pickMode($, want)
1134  view3dGen += 1
1135  const gen = view3dGen
1136  last3d = null
1137  lastImage = null
1138  helper = null
1139  await update($, view3d, () => ({ isOn: true, cols, rows, status: 'starting the renderer…' }))
1140  void run3d($, gen, cols, rows, fps, w.dir)
1141  const inOrca = (await $.env.get('TERM_PROGRAM')) === 'Orca'
1142  const full = inOrca && mode3d === 'cells' ? ' Full size: /crew web opens it in an Orca browser tab.' : ''
1143  return `Crew 3D view: ${cols}x${rows} cells in the pane, ${mode3d === 'image' ? 'pixels' : 'block glyphs fitted to 4x8 pixels a cell'}, ${fps} fps. Drag to rotate, wheel to zoom, click a crab to pinch it, stroke it to pet it.${full}`
1144}
1145
1146async function stop3d($: EngineInterface, status = '') {
1147  view3dGen += 1
1148  last3d = null
1149  lastImage = null
1150  helper = null
1151  pointerIn3d = false
1152  await update($, view3d, () => ({ ...VIEW3D_OFF, status }))
1153}
1154
1155async function restart3d($: EngineInterface, cols: number, rows: number, fps: number, want: string) {
1156  isRestarting3d = false
1157  if ((await getView3d($)).isOn) await start3d($, cols, rows, fps, want)
1158}
1159
1160// ---------------------------------------------------------------- pane scrollbar
1161// The engine scrolls a pane's body (wheel, arrows, page keys) but draws no scrollbar, and the
1162// render props carry the window, not the tree's height. A plugin scroll raises `ui.scroll` with
1163// `contentRows`: the scroll hook keeps it and cancels that move, so measuring moves nothing.
1164let isMeasuring = false
1165let paneSig = ''
1166let paneOffset = 0
1167/** Where the 3D picture sits in the pane's tree (rows), while it is the first panel. */
1168let region3d: { top: number; rows: number; cols: number } | null = null
1169
1170async function measurePane($: EngineInterface) {
1171  isMeasuring = true
1172  try {
1173    await $.ui.scroll({ in: PANE, to: 'start' })
1174  } catch {
1175    // A pane not drawn now: measured the next time it is.
1176  } finally {
1177    isMeasuring = false
1178  }
1179}
1180
1181/** The pane's pointer events, forwarded to the renderer's page. */
1182async function sendInput($: EngineInterface, events: unknown[]) {
1183  if (!helper || events.length === 0) return
1184  await $.http
1185    .fetch(`http://127.0.0.1:${helper.port}/input?t=${helper.token}`, { method: 'POST', body: JSON.stringify({ events }) })
1186    .catch(() => undefined)
1187}
1188
1189async function run3d($: EngineInterface, gen: number, cols: number, rows: number, fps: number, dir: string) {
1190  const page = `file:///${dir.replace(/^\/+/, '')}/index.html`
1191  const argv = ['node', `${$.plugin.root}/bin/crew-3d.mjs`, '--page', page, '--cols', String(cols), '--rows', String(rows), '--fps', String(fps), '--mode', mode3d]
1192  if (mode3d === 'image') argv.push('--out', `${dir}/frames`)
1193  const child = $.process.spawn({ argv })
1194  let buf = ''
1195  let denies = 0
1196  let isDone = false
1197  try {
1198    for await (const piece of child) {
1199      if (gen !== view3dGen) break
1200      if (piece.stream !== 'stdout') continue
hooks/deck/core.ts 575 lines
1// Ported from Flightdeck by Stephen Casella (MIT, https://github.com/scasella/claude-flightdeck),
2// see third_party/flightdeck-LICENSE.txt. Adapted for crew-graph.
3// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
4// behaviour is testable directly (the test kit cannot raise a subagent's tool call or a
5// permission check inside a call; these functions are what the hooks apply).
6import type {
7  AgentCard,
8  Architect,
9  Bucket,
10  Check,
11  Gate,
12  Layout,
13  LogLine,
14  Loop,
15  Main,
16  Moment,
17  Receipt,
18  Roster,
19  Tally,
20  ToolNote,
21  Turn,
22  Usage,
23  View,
24} from '../../types'
25
26export const SCHEMA_VERSION = 2
27
28// ---------------------------------------------------------------- defaults
29
30export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false, speed: null }
31export const DEFAULT_USAGE: Usage = {
32  pct: null,
33  tokens: null,
34  window: 0,
35  costUsd: null,
36  limits: [],
37  compactions: 0,
38  lastCompactAt: null,
39}
40export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' }
41const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
42export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
43export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null }
44export const DEFAULT_VIEW: View = { expanded: null, gateOpen: null, layout: null }
45export const DEFAULT_ROSTER: Roster = { architectTypes: [] }
46
47const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
48
49/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
50export const normalize = <T extends object>(def: T, stored: unknown): T =>
51  isObject(stored) ? ({ ...def, ...stored } as T) : def
52
53/** A stored list, or empty when what is stored is not a list. */
54export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
55
56export const normalizeGate = (stored: unknown): Gate => {
57  const g = normalize(DEFAULT_GATE, stored)
58  const totals = normalize(DEFAULT_GATE.totals, g.totals)
59  return {
60    recent: listOf<Check>(g.recent),
61    totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) },
62  }
63}
64
65export const normalizeCard = (stored: unknown): AgentCard =>
66  normalize<AgentCard>(
67    {
68      id: '',
69      type: 'agent',
70      model: '',
71      description: '',
72      status: 'running',
73      spawnedAt: 0,
74      endedAt: null,
75      ctx: 0,
76      out: 0,
77      steps: 0,
78      lastStop: null,
79      tools: [],
80      answer: '',
81    },
82    stored,
83  )
84
85export const normalizeLog = (stored: unknown): LogLine[] =>
86  listOf<Record<string, unknown>>(stored).map(l => ({
87    at: typeof l.at === 'number' ? l.at : 0,
88    who: String(l.who ?? ''),
89    text: String(l.text ?? ''),
90    agentId: typeof l.agentId === 'string' ? l.agentId : null,
91    kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
92  }))
93
94// ---------------------------------------------------------------- config
95
96export type Panel = '3d' | 'main' | 'crew' | 'architect' | 'gate' | 'agents' | 'loops' | 'receipt' | 'log'
97const PANELS: readonly Panel[] = ['3d', 'main', 'crew', 'architect', 'gate', 'agents', 'loops', 'receipt', 'log']
98
99export type Config = {
100  architect: RegExp
101  architectLabel: string
102  gateLabel: string
103  panels: Panel[]
104  motion: boolean
105  moments: boolean
106  matchDescriptions: boolean
107  maxCards: number
108  layout: Layout
109  palette: Palette
110  openOnStart: boolean
111  statusLine: boolean
112  /** crew-graph: start the browser view's data export when a session starts. */
113  webOnStart: boolean
114  /** crew-graph: start the desktop pet when a session starts. */
115  petOnStart: boolean
116  /** crew-graph: the in-pane 3D view's frame rate and height in rows. */
117  view3dFps: number
118  view3dRows: number
119  view3dColumns: number
120  view3dMode: 'auto' | 'cells' | 'image'
121  /** crew-graph: who is drawn as which character, as written: "main=crab, Explore=bunny, *=random". Empty: every agent is a crab. */
122  characters: string
123  characterRules: CharacterRule[]
124}
125
126const safeRegExp = (source: string, fallback: string) => {
127  try {
128    return new RegExp(source || fallback, 'i')
129  } catch {
130    return new RegExp(fallback, 'i')
131  }
132}
133
134/**
135 * One `who=character` rule of the `characters` option. `character` is a character id, `random` (a stable pick by hash of the agent id)
136 * or `auto` (the character whose traits fit the agent's role best).
137 */
138export type CharacterRule = [who: string, character: string]
139
140/**
141 * "main=crab, Explore=bunny; sonnet-coder=robot, *=random" -> [['main','crab'], ['Explore','bunny'], ['sonnet-coder','robot'], ['*','random']].
142 * Parts are split by `,` `;` or a line break. A part without `=` is `*=<it>`, so `random`, `auto` or `bunny` alone work, and so does
143 * `main=crab, random`; one with an empty side (`=x`, `y=`) is dropped. `random` and `auto` are kept apart (they are not the same).
144 * The viewer (web/characters-core.js) reads the same text the same way; a test holds the two together.
145 */
146export const parseCharacterRules = (spec: unknown): CharacterRule[] => {
147  const out: CharacterRule[] = []
148  if (typeof spec !== 'string') return out
149  for (const part of spec.split(/[,;\n]+/)) {
150    const i = part.indexOf('=')
151    if (i === 0) continue
152    const who = i < 0 ? '*' : part.slice(0, i).trim()
153    const ch = (i < 0 ? part : part.slice(i + 1)).trim()
154    if (!who || !ch || who.length > 80 || ch.length > 80) continue
155    out.push([who, /^random$/i.test(ch) ? 'random' : /^auto$/i.test(ch) ? 'auto' : ch])
156  }
157  return out.slice(0, 64)
158}
159
160/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
161export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
162  const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
163  const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
164  const panels = str('panels', PANELS.join(','))
165    .split(',')
166    .map(s => s.trim())
167    .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
168  const layout = str('layout', 'auto')
169  const max = typeof o.maxCards === 'number' ? Math.round(o.maxCards) : 3
170  return {
171    architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'),
172    architectLabel: str('architectLabel', 'ARCHITECT'),
173    gateLabel: str('gateLabel', 'GATE'),
174    panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS],
175    motion: str('motion', 'while-active') !== 'off',
176    moments: bool('moments', true),
177    matchDescriptions: bool('matchDescriptions', false),
178    maxCards: Math.min(6, Math.max(1, max)),
179    layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
180    palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
181    openOnStart: bool('openOnStart', true),
182    statusLine: bool('statusLine', true),
183    webOnStart: bool('webOnStart', false),
184    petOnStart: bool('petOnStart', false),
185    view3dFps: typeof o.view3dFps === 'number' ? Math.min(20, Math.max(1, Math.round(o.view3dFps))) : 10,
186    view3dRows: typeof o.view3dRows === 'number' ? Math.min(60, Math.max(8, Math.round(o.view3dRows))) : 28,
187    view3dColumns: typeof o.view3dColumns === 'number' ? Math.min(240, Math.max(40, Math.round(o.view3dColumns))) : 110,
188    view3dMode: o.view3dMode === 'cells' || o.view3dMode === 'image' ? o.view3dMode : 'auto',
189    characters: str('characters', ''),
190    characterRules: parseCharacterRules(o.characters),
191  }
192}
193
194// ---------------------------------------------------------------- palette
195
196export type Palette = 'theme' | 'pastel'
197export type Colors = Record<'main' | 'agent' | 'gate' | 'cleared' | 'arch' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
198
199/**
200 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
201 * `pastel` is fixed hex tuned for dark terminals.
202 */
203export const PALETTES: Record<Palette, Colors> = {
204  theme: {
205    main: 'claude',
206    agent: 'suggestion',
207    gate: 'success',
208    cleared: 'permission',
209    arch: 'merged',
210    amber: 'warning',
211    warn: 'error',
212    dim: 'inactive',
213    faint: 'subtle',
214    text: 'text',
215  },
216  pastel: {
217    main: '#7dd3fc',
218    agent: '#93c5fd',
219    gate: '#86efac',
220    cleared: '#5eead4',
221    arch: '#c4b5fd',
222    amber: '#fcd34d',
223    warn: '#fca5a5',
224    dim: '#6b7280',
225    faint: '#3f4654',
226    text: '#e5e7eb',
227  },
228}
229
230/** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */
231export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
232
233// ---------------------------------------------------------------- formatting
234
235/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
236/**
237 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
238 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
239 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
240 */
241export const prettyModel = (id: string) => {
242  if (!id) return '—'
243  const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
244  const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
245  const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
246  if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
247  const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
248  if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
249  return shorten(id, 22)
250}
251
252export const shorten = (s: string, n: number) => {
253  const one = s.replace(/\s+/g, ' ').trim()
254  return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
255}
256
257export const kTokens = (n: number) =>
258  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
259
260export const fmtDuration = (ms: number) => {
261  const s = Math.max(0, Math.round(ms / 1000))
262  if (s < 60) return `${s}s`
263  const m = Math.floor(s / 60)
264  return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
265}
266
267/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
268export const fmtTimer = (ms: number) => {
269  const s = Math.max(0, Math.floor(ms / 1000))
270  const m = Math.floor(s / 60)
271  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
272}
273
274export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
275
276/** `1 error`, `2 errors`. */
277export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
278
279export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
280
281/**
282 * How fast a request answered, from three readings of the clock: when it was sent, when its first
283 * piece of response arrived, and when it stopped. Null when no response came. `tokPerS` is null
284 * when there is no usage or no time between the first piece and the stop to divide by.
285 */
286export const stepSpeed = (sentAt: number, firstAt: number | null, stopAt: number, outTokens: number | null) => {
287  if (firstAt === null) return null
288  const decodeMs = stopAt - firstAt
289  return {
290    ttftMs: Math.max(0, firstAt - sentAt),
291    tokPerS: outTokens !== null && outTokens > 0 && decodeMs > 0 ? (outTokens * 1000) / decodeMs : null,
292  }
293}
294
295/** `ttft 0.8s · 42 tok/s`; the rate is left off when it is unknown. */
296export const fmtSpeed = (s: { ttftMs: number; tokPerS: number | null }) =>
297  `ttft ${(s.ttftMs / 1000).toFixed(1)}s` + (s.tokPerS !== null ? ` · ${Math.round(s.tokPerS)} tok/s` : '')
298
299/** A gauge of `width` cells: ▰ filled, ▱ empty. */
300export const gauge = (pct: number, width: number) => {
301  const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
302  return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
303}
304
305/** A rate-limit reading as the pane keeps it, its ISO reset time turned into ms since the epoch. */
306export const limitOf = (r: { kind: string; percentUsed: number; resetsAt?: string }) => {
307  const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN
308  return { kind: r.kind, pct: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null }
309}
310
311/** Time left until a window resets, its two largest units: `25m`, `3h10m`, `4d2h`; empty when unknown or past. */
312export const fmtResetIn = (resetsAt: number | null | undefined, now: number) => {
313  if (resetsAt === null || resetsAt === undefined || resetsAt <= now) return ''
314  const m = Math.max(1, Math.floor((resetsAt - now) / 60_000))
315  const d = Math.floor(m / 1440)
316  const h = Math.floor((m % 1440) / 60)
317  if (d > 0) return `${d}d${h}h`
318  return h > 0 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
319}
320
321/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
322export const limitLabel = (kind: string) =>
323  kind
324    .replace(/five[_ -]?hours?/i, '5h')
325    .replace(/seven[_ -]?days?/i, '7d')
326    .replace(/[_-]+/g, ' ')
327    .trim()
328
329// ---------------------------------------------------------------- redaction
330
331const SECRETS: [RegExp, string][] = [
332  [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
333  [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
334  [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
335  [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
336  [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
337  [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
338  [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
339]
340
341/** What the gate drill-down may store: credentials masked before anything is written to state. */
342export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
343
344// ---------------------------------------------------------------- tools and gate
345
346const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
347const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
348export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
349
350export const bucketOf = (tool: string): Bucket =>
351  FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other'
352
353/** One line saying what a call was about: its command, path or pattern; redacted. */
354export const describeInput = (tool: string, input: unknown) => {
355  const i = isObject(input) ? input : {}
356  const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
357  const what =
358    typeof i.command === 'string'
359      ? i.command
360      : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
361  return redact(what ? `${tool} → ${what}` : tool)
362}
363
364/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
365export const trimRecent = (list: Check[], max: number): Check[] => {
366  let extra = list.length - max
367  if (extra <= 0) return list
368  const out: Check[] = []
369  for (const c of list) {
370    if (extra > 0 && c.verdict !== 'ask') {
371      extra -= 1
372      continue
373    }
374    out.push(c)
375  }
376  return out.slice(-max * 2)
377}
378
379export const recordCheck = (g: Gate, c: Check): Gate => {
380  const t = g.totals[c.bucket]
381  return {
382    recent: trimRecent([...g.recent, c], 80),
383    totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
384  }
385}
386
387/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
388export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => {
389  const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
390  if (!c) return g
391  const verdict = didRun ? 'cleared' : 'deny'
392  const t = g.totals[c.bucket]
393  return {
394    recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
395    totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
396  }
397}
398
399export const gateSummary = (g: Gate) => {
400  const all = (['file', 'shell', 'other'] as const).reduce(
401    (s, k) => ({
402      rule: s.rule + g.totals[k].rule,
403      ask: s.ask + g.totals[k].ask,
404      cleared: s.cleared + g.totals[k].cleared,
405      deny: s.deny + g.totals[k].deny,
406    }),
407    { ...ZERO },
408  )
409  return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
410}
411
412// ---------------------------------------------------------------- turn and architect
413
414/**
415 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
416 * own); edits count from every loop, so delegated work still reaches "before done".
417 */
418export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
419  ...t,
420  errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
421  errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
422  edits: t.edits + (c.isEdit ? 1 : 0),
423})
424
425/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
426export const momentOf = (t: Pick<Turn, 'edits' | 'errorStreak'>): Moment =>
427  t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
428
429export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect =>
430  a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
431
432/** Ends the open consult (the latest without an end), or the one named. */
433export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => {
434  const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
435  return {
436    ...a,
437    consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
438    lastAdvice: advice ?? a.lastAdvice,
439  }
440}
441
442export const isAdvising = (a: Architect) => a.consults.some(c => c.endAt === null)
443
444/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
445export const consultTimeline = (a: Architect, now: number, width: number) => {
446  if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
447  const first = a.consults[0]?.at ?? now
448  const span = Math.max(1, now - first)
449  const cells = Array.from({ length: width }, () => '─')
450  for (const c of a.consults) {
451    const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
452    const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
453    for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
454    cells[from] = '◆'
455  }
456  return cells.join('')
457}
458
459export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): Receipt => ({
460  durationMs: o.durationMs,
461  agents: o.agentsSince,
462  edits: t.edits,
463  errors: t.errors,
464  costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
465  reason: o.reason,
466})
467
468// ---------------------------------------------------------------- agents and loops
469
470type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
471
472/** A card after one of its model requests: its context is the latest step's whole input; output adds up. */
473export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => {
474  const u = s.usage ?? {}
475  const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
476  return {
477    ...c,
478    model: c.model || s.model,
479    steps: c.steps + 1,
480    ctx: ctx > 0 ? ctx : c.ctx,
481    out: c.out + (u.output_tokens ?? 0),
482    lastStop: s.stopReason,
483  }
484}
485
486export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) })
487
488export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => {
489  const found = loops.find(l => l.id === id)
490  const next = found
491    ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l))
492    : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
493  return next.slice(-60)
494}
495
496export const LOOP_ACTIVE_MS = 15_000
497export const isLoopActive = (l: Loop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
498
499/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
500export const lanes = (cards: AgentCard[], now: number, width: number) => {
501  const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
502  const span = Math.max(1, now - start)
503  return cards.map(c => {
504    const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
505    const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
506    return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
507  })
508}
509
510// ---------------------------------------------------------------- layout
511
512/** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */
513export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3))
514
515/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
516export const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
517  const out: T[] = []
518  let used = 0
519  for (const it of items) {
520    const w = it.label.length + 4
521    if (used + w > width) break
522    out.push(it)
523    used += w
524  }
525  return out
526}
527
528/** A card's title row: the task in the agent's own words, the type only when there is none. */
529export const cardTitle = (c: AgentCard) => c.description || c.type
530
531/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
532export const titleLines = (title: string, first: number, rest: number): [string, string] => {
533  const t = title.replace(/\s+/g, ' ').trim()
534  if (t.length <= first) return [t, '']
535  const cut = t.lastIndexOf(' ', first)
536  const at = cut > 0 ? cut : first
537  return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
538}
539
540/**
541 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
542 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
543 */
544export const promptLine = (text: string): { who: string; text: string } => {
545  const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
546  if (!tag) return { who: 'you', text: shorten(text, 70) }
547  const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
548  return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
549}
550
551/**
552 * A subagent's hand-back message: who sent it and the first line of what it said. The report
553 * follows a framing header in the message; without one, the first line after the opening tag.
554 */
555export const handbackOf = (text: string): { from: string; body: string } | null => {
556  const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
557  if (!from) return null
558  const afterHeader = text.split(/The report follows:\s*\n/)[1]
559  const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
560  const body =
561    rest
562      .split('\n')
563      .map(l => l.trim())
564      .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
565  return body ? { from, body } : null
566}
567
568/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
569export const adviceLine = (report: string) => {
570  const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
571  return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
572}
573
574export const elapsedOf = (c: AgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt
575
hooks/clawd.ts 68 lines
1// Clawd, Claude Code's crab, as terminal block art: one big 3-row pose for a loop's header and
2// a 1-row mini for tree rows. Two frames per verb; every glyph is one cell wide.
3import type { Verb } from '../types'
4
5type Frames = [string[], string[]]
6
7const EYES_UP = ' ▐▛███▜▌ '
8const EYES_DOWN = ' ▐▙███▟▌ '
9const BLINK = ' ▐█████▌ '
10const X_EYES = ' ▐▚███▞▌ '
11const ARMS_DOWN = '▝▜█████▛▘'
12const ARMS_UP = '▗▟█████▙▖'
13const LEGS_A = '  ▘▘ ▝▝  '
14const LEGS_B = '  ▝▝ ▘▘  '
15const HOP = '         '
16
17const shift = (rows: string[]) => rows.map(r => ` ${r.slice(0, -1)}`)
18
19export const BIG: Record<Verb, Frames> = {
20  think: [[EYES_UP, ARMS_DOWN, LEGS_A], [BLINK, ARMS_DOWN, LEGS_A]],
21  read: [[EYES_DOWN, ARMS_DOWN, LEGS_A], [EYES_UP, ARMS_DOWN, LEGS_A]],
22  edit: [[EYES_DOWN, ARMS_UP, LEGS_A], [EYES_DOWN, ARMS_DOWN, LEGS_A]],
23  search: [[EYES_UP, ARMS_DOWN, LEGS_A], shift([EYES_UP, ARMS_DOWN, LEGS_B])],
24  run: [[EYES_UP, ARMS_DOWN, LEGS_A], [EYES_UP, ARMS_DOWN, LEGS_B]],
25  web: [[EYES_UP, ARMS_UP, LEGS_A], [BLINK, ARMS_UP, LEGS_A]],
26  spawn: [[EYES_UP, ARMS_UP, LEGS_A], [EYES_UP, ARMS_UP, HOP]],
27  plan: [[EYES_DOWN, ARMS_DOWN, LEGS_A], [EYES_DOWN, ARMS_UP, LEGS_A]],
28  done: [[EYES_UP, ARMS_UP, LEGS_A], [EYES_UP, ARMS_UP, HOP]],
29  failed: [[X_EYES, ARMS_DOWN, LEGS_A], [X_EYES, ARMS_DOWN, LEGS_A]],
30  wait: [[EYES_UP, ARMS_DOWN, LEGS_A], [EYES_UP, ARMS_DOWN, LEGS_A]],
31}
32
33export const MINI: Record<Verb, [string, string]> = {
34  think: ['▝▜█▛▘', '▝▜▀▛▘'],
35  read: ['▝▜█▛▘', '▝▙█▟▘'],
36  edit: ['▗▟█▙▖', '▝▜█▛▘'],
37  search: ['▝▜█▛▘', ' ▝▜█▛'],
38  run: ['▝▜█▛▘', '▘▜█▛▝'],
39  web: ['▗▟█▙▖', '▗▟▀▙▖'],
40  spawn: ['▗▟█▙▖', '▗▟█▙▖'],
41  plan: ['▝▙█▟▘', '▗▙█▟▖'],
42  done: ['▗▟█▙▖', '▗▟█▙▖'],
43  failed: ['▝▚█▞▘', '▝▚█▞▘'],
44  wait: ['▝▜█▛▘', '▝▜█▛▘'],
45}
46
47/** What the crab holds or says beside its body, per verb and frame. */
48export const PROP: Record<Verb, [string, string]> = {
49  think: ['z ', 'zZ'],
50  read: ['▤ ', ' ▤'],
51  edit: ['✎ ', '✎·'],
52  search: ['? ', '!?'],
53  run: ['» ', '»»'],
54  web: ['@ ', ' @'],
55  spawn: ['+ ', '++'],
56  plan: ['… ', '..'],
57  done: ['✓ ', '✓ '],
58  failed: ['✗ ', '✗ '],
59  wait: ['· ', '··'],
60}
61
62/** Verbs whose crab moves; the rest hold still and need no frame clock. */
63export const isMoving = (v: Verb) => v !== 'done' && v !== 'failed' && v !== 'wait'
64
65export const bigFrame = (v: Verb, f: number) => BIG[v][f % 2] ?? BIG.think[0]
66export const miniFrame = (v: Verb, f: number) => MINI[v][f % 2] ?? MINI.think[0]
67export const propFrame = (v: Verb, f: number) => PROP[v][f % 2] ?? ''
68
hooks/characters.ts 601 lines
1// The user's own characters: a folder of voxel packs (*.json) and skinned models (*.glb) that the viewer cannot list
2// itself (a page on file:// cannot read a folder), so the mod scans it and writes what the page needs beside the page:
3//   characters-user.js          the manifest; JSON packs are inlined, a GLB is listed by name
4//   characters-glb/<id>.<n>.js  a GLB as base64 script parts (a page on file:// may not fetch a file, a script it may load)
5// Pure apart from the `CharFs` it is handed, so the tests run it on a map.
6
7export type CharFs = {
8  list(path: string): Promise<{ name: string; kind: string; size: number; mtimeMs: number }[]>
9  read(path: string): Promise<string>
10  readBytes(path: string): Promise<string> // base64
11  write(path: string, text: string): Promise<void>
12  exists(path: string): Promise<boolean>
13}
14
15/** The characters that ship with the viewer (web/characters.js); a test keeps this list and that file in step. `traits`: the role words `auto` matches. */
16export const BUILTIN_CHARACTERS: readonly { id: string; name: string; traits: readonly string[] }[] = [
17  { id: 'crab', name: 'Clawd crab', traits: ['lead', 'main', 'generalist'] },
18  { id: 'bunny', name: 'Mochi bunny', traits: ['docs', 'write', 'summarize', 'quick', 'web'] },
19  { id: 'cat', name: 'Loaf cat', traits: ['explore', 'search', 'investigate', 'review', 'locate'] },
20  { id: 'mochi', name: 'Mochi hamster', traits: ['plan', 'architect', 'design', 'think', 'advisor'] },
21  { id: 'robot', name: 'Boxbot', traits: ['code', 'build', 'fix', 'test', 'implement'] },
22]
23
24/** "traits": ["Code", "test", ...] -> lower-case words, at most 16 of 1-24 letters, digits, - _ (web/characters-core.js reads them the same way). */
25export const traitsOf = (list: unknown): string[] => {
26  const out: string[] = []
27  if (!Array.isArray(list)) return out
28  for (const x of list) {
29    const w = typeof x === 'string' ? x.trim().toLowerCase() : ''
30    if (/^[a-z0-9_-]{1,24}$/.test(w) && !out.includes(w)) out.push(w)
31    if (out.length >= 16) break
32  }
33  return out
34}
35
36/** `$.fs.read` and `$.fs.write` refuse over 4 MiB: a GLB that big cannot be embedded. */
37export const HOST_FILE_MAX = 4 * 1024 * 1024
38const PART_CHARS = 3_000_000
39const MANIFEST_MAX = 3 * 1024 * 1024
40
41// ---------------------------------------------------------------- pack problems
42// What is wrong or missing in a user's pack, as { level, code, text, fix }: an error means the character is not drawn as the pack meant (a
43// crab, nothing, a food it does not eat), a warning means it works but misses something. This is a twin of PROBLEM_TABLE / lintVoxel in
44// web/characters-core.js (the page runs that one for GLBs and as a fallback); a test feeds both the same packs and holds them together.
45
46export type PackProblem = { level: 'error' | 'warn'; code: string; text: string; fix: string }
47
48/** The rig bones (web/characters-core.js RIG) and the built-in foods (web/tasks.js FOODS); a test keeps these lists and those in step. */
49export const RIG_BONES: readonly string[] = ['root', 'body', 'head', 'earL', 'earR', 'eyeL', 'eyeR', 'mouth', 'armL', 'handL', 'armR', 'handR', 'legL1', 'legL2', 'legL3', 'legR1', 'legR2', 'legR3', 'tail']
50export const FOOD_IDS: readonly string[] = ['fish', 'carrot', 'treat', 'seed', 'battery', 'ticket']
51const EYE_VARIANTS = ['x', 'squint', 'happy', 'sleepy', 'dizzy']
52const BOX_LIMIT = 400
53const FOOD_BOX_LIMIT = 40
54
55type Row = [level: 'error' | 'warn', text: (d?: string) => string, fix: (d?: string) => string]
56const listOf = (a: string[]): string => a.slice(0, 4).map(x => `"${x}"`).join(', ') + (a.length > 4 ? ` and ${a.length - 4} more` : '')
57const PROBLEM_TABLE: Record<string, Row> = {
58  // errors
59  unreadable: ['error', () => 'The file could not be read.', () => 'Check that it exists and is not locked by another program, then rescan.'],
60  'bad-json': ['error', d => `Not valid JSON${d ? ` (${d})` : ''}, so it is skipped.`, () => 'Fix the syntax (a trailing comma or a missing quote is the usual cause) and rescan.'],
61  'too-big': ['error', d => `Too big (${d || 'over 512 KiB'}), so it is skipped.`, () => `Use fewer boxes: a pack is meant to be small, up to ${BOX_LIMIT} boxes.`],
62  'no-bones': ['error', () => 'There is no "bones" object, so nothing can be drawn.', () => 'Add "bones": { "body": { "boxes": [ { "size": [5,3,4], "at": [0,2,0], "color": "#88aaff" } ] } }.'],
63  'nothing-drawn': ['error', () => 'No box is left to draw, so the character is invisible.', () => 'Give "body" (or another bone) at least one box: { "size": [w,h,d], "at": [x,y,z], "color": "#rrggbb" }.'],
64  'food-bad': ['error', () => '"food" is not a built-in food or a list of boxes, so it eats the seed.', () => `Use one of ${FOOD_IDS.join(', ')}, or boxes: "food": { "boxes": [ ... ] }.`],
65  'glb-failed': ['error', d => `The model could not be loaded${d ? ` (${d})` : ''}, so a crab is drawn instead.`, () => 'Export it again as a plain glTF binary (.glb) without Draco, Meshopt or KTX2 compression, then rescan.'],
66  'glb-too-big': ['error', () => 'The model is over 4 MiB and this page cannot read it from the folder, so a crab is drawn instead.', () => 'Make it smaller than 4 MiB (fewer polygons, smaller textures), or use the desktop pet (/crew pet), which can read it.'],
67  'glb-empty': ['error', () => 'The model has no geometry, so a crab is drawn instead.', () => 'Export the meshes together with the skeleton, not the armature alone.'],
68  // warnings
69  'food-none': ['warn', () => 'No "food", so it eats the seed.', () => 'Add "food": "seed" (or fish, carrot, treat, battery, ticket), or a list of boxes.'],
70  'no-hands': ['warn', d => `No handL / handR ${d === 'glb' ? 'nodes' : 'bones'}, so items are held at the arm.`, d => (d === 'glb' ? 'Name the hand nodes handL and handR (Blender: Hand.L, Hand.R).' : 'Add "handL": { "parent": "armL", ... } and "handR": { "mirror": "handL" }.')],
71  'no-mouth': ['warn', d => `No mouth ${d === 'glb' ? 'node' : 'bone'}, so it eats at the head.`, d => (d === 'glb' ? 'Name a node on the face "mouth".' : 'Add a "mouth" bone (parent "head") with a small box on the face.')],
72  'no-traits': ['warn', () => 'No "traits", so auto never picks it by role.', () => 'Add "traits": ["code", "test"]; README.txt in the characters folder lists the words that count.'],
73  'bone-unknown': ['warn', d => (/,| and /.test(d ?? '') ? `${d} are not rig bones, so they are skipped.` : `${d} is not a rig bone, so it is skipped.`), () => `Rename it to a rig bone (${RIG_BONES.join(' ')}) or remove it.`],
74  'mirror-missing': ['warn', d => (/,| and /.test(d ?? '') ? `${d} mirror bones that are not defined, so they are skipped.` : `${d} mirrors a bone that is not defined, so it is skipped.`), () => 'Define the bone it mirrors, or give this one boxes of its own.'],
75  'box-color': ['warn', d => `${d} no #rrggbb color and ${/^1 /.test(d ?? '') ? 'is' : 'are'} drawn light grey.`, () => 'Give each box "color": "#rrggbb" (or "@base" for the body color).'],
76  'box-limit': ['warn', () => `More than ${BOX_LIMIT} boxes, so the rest are ignored.`, () => `Merge boxes or make them bigger; ${BOX_LIMIT} is the limit.`],
77  'glb-large': ['warn', () => 'Over 4 MiB: the mod does not embed it, so the pet reads it from the folder and a browser may refuse.', () => 'Make it smaller than 4 MiB to work everywhere.'],
78  'glb-unread': ['warn', () => 'The mod could not read the file, so the page will try the folder itself.', () => 'Check that it is not locked by another program, then rescan.'],
79  'glb-no-rig': ['warn', () => 'No node is named like a rig bone, so only the whole model moves.', () => 'Name the nodes head, armL, handL, legL1 and so on (case and _ . - are ignored).'],
80}
81export const PACK_CODES: readonly string[] = Object.keys(PROBLEM_TABLE)
82
83export function problem(code: string, detail?: string): PackProblem {
84  const row = PROBLEM_TABLE[code]
85  if (!row) throw new Error(`unknown pack problem code ${code}`)
86  return { level: row[0], code, text: row[1](detail), fix: row[2](detail) }
87}
88
89/** 'error' if any problem is an error, 'warn' if there are only warnings, else 'ok'. */
90export const statusOf = (list: readonly PackProblem[]): 'ok' | 'warn' | 'error' => (list.some(p => p.level === 'error') ? 'error' : list.length > 0 ? 'warn' : 'ok')
91
92const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
93const colorOk = (c: unknown): boolean => typeof c === 'string' && (/^#[0-9a-f]{6}$/i.test(c) || c === '@base')
94
95/** A voxel pack as parsed JSON -> its problems; follows normalizeDef in web/characters-core.js step by step (same loops, same limits). */
96export function lintVoxel(def: unknown): PackProblem[] {
97  if (!isObj(def) || !isObj(def.bones)) return [problem('no-bones')]
98  const bones = def.bones
99  const errors: PackProblem[] = []
100  const warns: PackProblem[] = []
101  const unknown: string[] = []
102  const missing: string[] = []
103  const defined: Record<string, number> = {}
104  let total = 0
105  let over = false
106  let drawn = 0
107  let grey = 0
108  const lintBox = (b: unknown): boolean => {
109    if (!isObj(b)) return false
110    if (!colorOk(b.color)) grey++
111    return true
112  }
113  for (const name of Object.keys(bones)) {
114    if (!RIG_BONES.includes(name)) {
115      unknown.push(name)
116      continue
117    }
118    const src = bones[name]
119    if (!isObj(src) || typeof src.mirror === 'string') continue
120    let n = 0
121    for (const bx of Array.isArray(src.boxes) ? (src.boxes as unknown[]) : []) {
122      if (total >= BOX_LIMIT) {
123        over = true
124        break
125      }
126      if (lintBox(bx)) {
127        total++
128        n++
129      }
130    }
131    defined[name] = n
132    drawn += n
133  }
134  for (const name of Object.keys(bones)) {
135    const src = bones[name]
136    if (!RIG_BONES.includes(name) || !isObj(src) || typeof src.mirror !== 'string') continue
137    const from = Object.hasOwn(defined, src.mirror) ? defined[src.mirror] : undefined
138    if (from == null) {
139      missing.push(name)
140      continue
141    }
142    defined[name] = from
143    drawn += from
144  }
145  const eyes = def.eyes
146  for (const v of EYE_VARIANTS) {
147    if (!isObj(eyes) || !Array.isArray(eyes[v])) continue
148    ;(eyes[v] as unknown[]).slice(0, 12).forEach(lintBox)
149  }
150  let food: 'none' | 'ok' | 'bad' = 'none'
151  const f = def.food
152  if (f != null) {
153    food = 'bad'
154    if (typeof f === 'string') {
155      if (/^[a-z0-9_-]{1,24}$/.test(f.toLowerCase()) && FOOD_IDS.includes(f.toLowerCase())) food = 'ok'
156    } else {
157      const fl = Array.isArray(f) ? (f as unknown[]) : isObj(f) && Array.isArray(f.boxes) ? (f.boxes as unknown[]) : null
158      if (fl) {
159        let n = 0
160        fl.slice(0, FOOD_BOX_LIMIT).forEach(bx => {
161          if (lintBox(bx)) n++
162        })
163        if (n) food = 'ok'
164      }
165    }
166  }
167  if (drawn === 0) errors.push(problem('nothing-drawn'))
168  if (food === 'bad') errors.push(problem('food-bad'))
169  if (unknown.length) warns.push(problem('bone-unknown', listOf(unknown)))
170  if (missing.length) warns.push(problem('mirror-missing', listOf(missing)))
171  if (grey) warns.push(problem('box-color', `${grey} ${grey === 1 ? 'box has' : 'boxes have'}`))
172  if (over) warns.push(problem('box-limit'))
173  if (food === 'none') warns.push(problem('food-none'))
174  if (defined.handL == null && defined.handR == null) warns.push(problem('no-hands'))
175  if (defined.mouth == null) warns.push(problem('no-mouth'))
176  if (traitsOf(def.traits).length === 0) warns.push(problem('no-traits'))
177  return [...errors, ...warns]
178}
179
180export type UserChar =
181  | { kind: 'voxel'; file: string; id: string; name: string; json: unknown; traits: string[] }
182  | { kind: 'glb'; file: string; id: string; name: string; bytes: number; rev: string; parts: string[] | null }
183
184/** One file of the folder: what it is, the id it has (or would have) and what is wrong with it. A pack that did not load has problems too. */
185export type PackInfo = { file: string; id: string; kind: 'voxel' | 'glb'; problems: PackProblem[] }
186
187/** `notes`: what is wrong with the folder itself (it cannot be read); a file's own problems are in `packs`. `at`: when it was scanned (ms). */
188export type Scan = { chars: UserChar[]; notes: string[]; rev: string; packs: PackInfo[]; at?: number }
189
190export const safeCharId = (s: string): string => {
191  const id = s.replace(/\.[^.]*$/, '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40)
192  return id || 'character'
193}
194
195/**
196 * Reads the folder. With `withData` a GLB is read and cut into base64 parts; without it only its size is looked at
197 * (`/crew characters` just lists). Every .json and .glb gets an entry in `packs`, loaded or not, with its problems.
198 */
199export async function scanCharacters(fs: CharFs, folder: string, withData: boolean): Promise<Scan> {
200  const notes: string[] = []
201  const chars: UserChar[] = []
202  const packs: PackInfo[] = []
203  let entries: Awaited<ReturnType<CharFs['list']>> = []
204  try {
205    entries = await fs.list(folder)
206  } catch {
207    return { chars, notes: [`cannot read ${folder}`], rev: '0', packs }
208  }
209  const taken = new Set<string>(BUILTIN_CHARACTERS.map(c => c.id.toLowerCase()))
210  const unique = (id: string) => {
211    let out = id
212    for (let n = 2; taken.has(out.toLowerCase()); n++) out = `${id.slice(0, 36)}-${n}`
213    taken.add(out.toLowerCase())
214    return out
215  }
216  let manifestBytes = 0
217  for (const e of [...entries].sort((a, b) => (a.name < b.name ? -1 : 1))) {
218    if (e.kind !== 'file') continue
219    const low = e.name.toLowerCase()
220    const path = `${folder}/${e.name}`
221    if (low.endsWith('.json')) {
222      // A pack that does not load keeps the id it would have had, so a rule that names it can be traced back to this file.
223      const failed = (id: string, ...problems: PackProblem[]) => void packs.push({ file: e.name, id, kind: 'voxel', problems })
224      if (e.size > 512 * 1024) {
225        failed(safeCharId(e.name), problem('too-big', `${Math.round(e.size / 1024)} KiB, the limit is 512 KiB`))
226        continue
227      }
228      let text: string
229      try {
230        text = await fs.read(path)
231      } catch {
232        failed(safeCharId(e.name), problem('unreadable'))
233        continue
234      }
235      let json: unknown
236      try {
237        json = JSON.parse(text.replace(/^/, ''))
238      } catch (err) {
239        failed(safeCharId(e.name), problem('bad-json', String((err as Error)?.message ?? '').replace(/ is not valid JSON$/, '').replace(/\s+/g, ' ').slice(0, 60)))
240        continue
241      }
242      const j = json as { id?: unknown; name?: unknown; bones?: unknown } | null
243      // A pack names itself ("id"); a file without a usable id takes its file name. A taken id (a built-in's, another pack's) gets a suffix.
244      const wanted = j && typeof j === 'object' && typeof j.id === 'string' && /^[A-Za-z0-9._-]{1,40}$/.test(j.id) ? j.id : safeCharId(e.name)
245      if (!j || typeof j !== 'object' || !j.bones || typeof j.bones !== 'object') {
246        failed(wanted, problem('no-bones'))
247        continue
248      }
249      const size = JSON.stringify(json).length
250      if (manifestBytes + size > MANIFEST_MAX) {
251        failed(wanted, problem('too-big', `the manifest would pass ${MANIFEST_MAX >> 20} MiB`))
252        continue
253      }
254      const id = unique(wanted)
255      manifestBytes += size
256      chars.push({ kind: 'voxel', file: e.name, id, name: typeof j.name === 'string' && j.name ? j.name.slice(0, 60) : id, json: { ...(json as object), id }, traits: traitsOf((j as { traits?: unknown }).traits) })
257      packs.push({ file: e.name, id, kind: 'voxel', problems: lintVoxel(json) })
258    } else if (low.endsWith('.glb')) {
259      const id = unique(safeCharId(e.name))
260      const rev = `${e.size}-${Math.round(e.mtimeMs)}`
261      const problems: PackProblem[] = []
262      let parts: string[] | null = null
263      if (e.size > HOST_FILE_MAX) {
264        // not embedded: the page loads the file itself where it may (the desktop pet); a browser on file:// may refuse
265        problems.push(problem('glb-large'))
266      } else if (withData) {
267        try {
268          const b64 = await fs.readBytes(path)
269          parts = []
270          for (let i = 0; i < b64.length; i += PART_CHARS) parts.push(b64.slice(i, i + PART_CHARS))
271        } catch {
272          problems.push(problem('glb-unread'))
273        }
274      }
275      chars.push({ kind: 'glb', file: e.name, id, name: id, bytes: e.size, rev, parts })
276      packs.push({ file: e.name, id, kind: 'glb', problems })
277    }
278  }
279  const rev = chars.map(c => `${c.id}:${c.kind === 'glb' ? c.rev : JSON.stringify(c.json).length}`).join('|') + `#${JSON.stringify(packs)}`
280  return { chars, notes, rev: String(hash32(rev)), packs }
281}
282
283const hash32 = (s: string): number => {
284  let h = 2166136261
285  for (let i = 0; i < s.length; i++) {
286    h ^= s.charCodeAt(i)
287    h = Math.imul(h, 16777619)
288  }
289  return h >>> 0
290}
291
292/**
293 * The manifest the page loads: `window.__crewUserCharacters` (what can be drawn), `window.__crewUserPacks` (every file with its problems,
294 * also those that did not load), a revision it compares to know that something changed, and when the folder was scanned.
295 */
296export function manifestOf(scan: Scan): string {
297  const list = scan.chars.map(c =>
298    c.kind === 'voxel'
299      ? { file: c.file, id: c.id, name: c.name, json: c.json }
300      : { id: c.id, name: c.name, kind: 'glb', rev: c.rev, glb: { file: `characters/${c.file}`, parts: c.parts ? c.parts.length : 0, bytes: c.bytes } },
301  )
302  const at = typeof scan.at === 'number' ? `window.__crewUserScanned = ${Math.round(scan.at)};\n` : ''
303  return `window.__crewUserCharacters = ${JSON.stringify(list)};\nwindow.__crewUserPacks = ${JSON.stringify(scan.packs)};\nwindow.__crewUserRev = ${JSON.stringify(scan.rev)};\n${at}`
304}
305
306/** Writes the manifest and the GLB parts into `dir` (the viewer's folder). */
307export async function writeCharacters(fs: CharFs, dir: string, scan: Scan): Promise<void> {
308  for (const c of scan.chars) {
309    if (c.kind !== 'glb' || !c.parts) continue
310    for (let i = 0; i < c.parts.length; i++) {
311      await fs.write(`${dir}/characters-glb/${c.id}.${i}.js`, `window.__crewGlbPart(${JSON.stringify(c.id)}, ${i}, ${JSON.stringify(c.parts[i])});\n`)
312    }
313  }
314  await fs.write(`${dir}/characters-user.js`, manifestOf(scan))
315}
316
317/** Creates the folder and its README.txt when they are not there. */
318export async function ensureCharacterFolder(fs: CharFs, folder: string): Promise<void> {
319  if (!(await fs.exists(`${folder}/README.txt`).catch(() => false))) await fs.write(`${folder}/README.txt`, CHAR_README)
320}
321
322export const CHAR_README = `crew-graph characters
323=====================
324
325Drop your own characters in this folder. The mod scans it when a session starts (/crew web, /crew pet)
326and on  /crew characters reload ; a running pet or browser view picks the change up within a few seconds.
327Then choose who is who with the "characters" option, for example
328    main=crab, Explore=bunny, sonnet-coder=mymodel, *=auto
329(left side: an agent id or type, "architect", or a prefix with * ; right side: a character id, "random" (a stable pick per agent)
330or "auto" (the character whose traits fit the agent's role)). A part without "=" means "*=": "auto" alone is fine.
331The pet's Settings window (tray icon or right-click menu, "Settings...") sets characters too: a default, a character per agent type,
332or one agent, and it wins over the option. /crew characters lists what was found and what each agent would get.
333
334Two kinds of file
335-----------------
336
3371. NAME.json   a voxel character: boxes on a named rig. Small, editable by hand.
338
339   {
340     "id": "mybot", "name": "My bot", "scale": 1,
341     "bones": {
342       "body":  { "boxes": [ { "size": [5,3,4], "at": [0,2,0], "color": "#88aaff" } ] },
343       "head":  { "at": [0,3.5,0], "boxes": [ { "size": [4,3,4], "at": [0,1.5,0], "color": "#99bbff" } ] },
344       "eyeL":  { "parent": "head", "at": [-1,1.6,2.1], "boxes": [ { "size": [0.8,1,0.3], "at": [0,0,0], "color": "#10131c" } ] },
345       "eyeR":  { "mirror": "eyeL" },
346       "armL":  { "at": [-2.8,2.5,0], "boxes": [ { "size": [1.4,1,1], "at": [-0.7,0,0], "color": "#88aaff" } ] },
347       "armR":  { "mirror": "armL" },
348       "legL1": { "at": [-1.2,0.5,0], "gait": 0, "boxes": [ { "size": [1.2,1,1.4], "at": [0,0,0], "color": "#5570c0" } ] },
349       "legR1": { "mirror": "legL1", "gait": 2 }
350     }
351   }
352
353   Units: the crab is 7 wide. x is to the right, y up, z toward the viewer (the character faces +z).
354   "at" is relative to the bone's parent ("parent": a rig name, default "body"). A bone moves about its own origin,
355   so put the origin where it should bend (an arm at the shoulder, an ear at its base). "mirror": "armL" copies a
356   bone with x flipped. A box is { size, at, color } plus optional "rot": [rx,ry,rz] in radians, "unlit": true
357   (an LED that ignores light) and "fixed": true (not tinted when the agent fails or is done).
358   Colors are "#rrggbb"; "@base" is the body colour from "base": { "h":15, "s":0.7, "l":0.56, "spread":15 }.
359   Optional: "eyes": { "x": [...], "squint": [...], "happy": [...], "sleepy": [...], "dizzy": [...] } = boxes for the
360   LEFT eye in each expression (the right one is mirrored); without them the viewer draws default ones.
361   Also optional: "props": { "page": {"at":[x,y,z]}, "hat": {"at":[x,y,z],"s":1}, "anten": {"at":[x,y,z],"spread":1.5} },
362   "hit": { "y": 3, "r": 1 }, "shadow": [8,6], "travel": 1 (how far the eyes slide).
363   "traits": ["code", "test", "cheerful"]: role words for "auto". An agent's role comes from its type (Explore, Plan, sonnet-coder,
364   code-reviewer, general-purpose, ...), the words of its task and what it is doing; the character whose traits overlap the most is
365   chosen (siblings are spread over equally good characters, and an agent keeps the character it was first given). Words that
366   count: lead main generalist code build fix test implement run explore search investigate locate review docs write summarize
367   quick web plan architect design think advisor. Other words are fine, they just never match.
368   "food": "carrot": what this character works on and eats when the pet shows its tasks. A built-in (fish carrot treat seed battery
369   ticket), or boxes like a bone: "food": { "boxes": [ { "size":[2.4,0.5,0.5], "at":[0,0,0], "color":"#e8e0d0" }, ... ] }, centred on 0,
370   about 2.5 voxels long with the long side along x, at most 40 boxes. Without one (or with one that is not valid), the crab gets a fish,
371   the bunny a carrot, the cat a treat stick, the hamster a sunflower seed, the robot a battery, and your own characters a sunflower seed
372   (a .glb has no food of its own: it eats the seed).
373
374   The rig (every bone is optional; an animation skips the bones a character lacks):
375     root body head earL earR eyeL eyeR mouth armL handL armR handR legL1 legL2 legL3 legR1 legR2 legR3 tail
376
3772. NAME.glb   a skinned model (glTF binary) whose bones (nodes) have those names. Case does not matter and _ . space
378   - are ignored: "Head", "arm_L", "Arm.L", "mixamorig:Head" -> head, armL ... "left arm" and "L_Leg" work too.
379   Bones the animation drives: head, ears, eyes, mouth, arms, hands, legs, tail (the model as a whole bobs, leans and squashes).
380   Rotate the bones in the model's own rest pose: the viewer moves them relative to it.
381   It is scaled to fit the crab's footprint (about 7 wide, 6.5 tall) and stood on the ground facing +z (a model whose eyes are
382   at its back is turned around). Optional extra nodes next to the eye bones, named eyeL_happy, eyeL_x, eyeL_squint,
383   eyeL_sleepy, eyeL_dizzy (and the same for eyeR), are shown instead of the eye for that expression.
384   Draco, Meshopt and KTX2 compression are not supported. Files up to 4 MiB are embedded; a larger one is read from this
385   folder by the page itself, which the desktop pet can do and a browser on file:// may refuse.
386
387Problems
388--------
389
390A file that is wrong or misses something is not silently ignored: a character whose pack has problems wears a small badge over its head
391(red "!" for an error, amber "!" for warnings only), its hover card lists the first three, and the pet's Settings window has a
392"Character packs" section with every file, its status and what to change. /crew characters prints the same list. Errors: the file cannot be read
393or parsed, a model fails to load (a crab is drawn), nothing is left to draw, an invalid "food". Warnings: no "food" (it eats the seed), no
394hand bones or no mouth (items are held at the arm, it eats at the head), no "traits", boxes or bones that were dropped.
395`
396
397/** What `/crew characters` prints: the built-in and user characters, every pack with its problems and what to change, and the current assignment. `extra`: more lines (the pet's own settings, the agents' picks). */
398export function describeCharacters(
399  scan: Scan,
400  spec: string,
401  folder: string,
402  rules: readonly (readonly [string, string])[],
403  reloaded: boolean,
404  extra: readonly string[] = [],
405): string {
406  const lines: string[] = []
407  lines.push(`Built-in: ${BUILTIN_CHARACTERS.map(c => `${c.id} (${c.name}: ${c.traits.join(', ')})`).join('; ')}`)
408  if (scan.chars.length === 0 && scan.packs.length === 0) lines.push(`Your characters: none yet. Put *.json (voxel) or *.glb (skinned) files in ${folder}; README.txt there says how.`)
409  else {
410    lines.push(`Your characters (${folder}):`)
411    for (const c of scan.chars) {
412      lines.push(
413        c.kind === 'voxel'
414          ? `  ${c.id}  voxel  ${c.file}${c.traits.length ? `  traits: ${c.traits.join(', ')}` : ''}`
415          : `  ${c.id}  glb    ${c.file}  ${(c.bytes / 1048576).toFixed(1)} MiB  ${c.parts ? 'embedded' : 'read from the file'}`,
416      )
417    }
418    // Every file with a problem, grouped by pack, worst first; a file that did not load is listed here and nowhere above.
419    const bad = scan.packs.filter(p => p.problems.length > 0).sort((a, b) => Number(statusOf(b.problems) === 'error') - Number(statusOf(a.problems) === 'error'))
420    const loaded = new Set(scan.chars.map(c => c.file))
421    const sick = scan.packs.filter(p => !loaded.has(p.file))
422    const oks = scan.packs.length - bad.length
423    lines.push(bad.length === 0 ? `Pack problems: none (${oks} pack${oks === 1 ? '' : 's'} ok).` : `Pack problems (${bad.length} of ${scan.packs.length} pack${scan.packs.length === 1 ? '' : 's'}${oks > 0 ? `, ${oks} ok` : ''}):`)
424    for (const p of bad) {
425      lines.push(`  ${p.file}  ${statusOf(p.problems) === 'error' ? 'ERROR' : 'warnings'}${sick.includes(p) ? '  (not loaded)' : ''}  as "${p.id}"`)
426      for (const pr of p.problems) {
427        lines.push(`    ${pr.level === 'error' ? 'error' : 'warn '}  ${pr.text}`)
428        lines.push(`           fix: ${pr.fix}`)
429      }
430    }
431  }
432  for (const n of scan.notes) lines.push(`  ! ${n}`)
433  if (spec.trim() === '') lines.push('Assignment: no "characters" option set, so every agent is a crab (unless the pet\'s Settings say otherwise). Set it in /config, e.g. main=crab, Explore=bunny, *=auto')
434  else {
435    const ids = new Set([...BUILTIN_CHARACTERS.map(c => c.id.toLowerCase()), ...scan.chars.map(c => c.id.toLowerCase())])
436    lines.push(`Assignment: ${spec.trim()}`)
437    for (const [who, ch] of rules) {
438      lines.push(`  ${who} -> ${ch === 'random' ? 'a stable pick by agent id' : ch === 'auto' ? 'the character whose traits fit the agent\'s role' : ids.has(ch.toLowerCase()) ? ch : `${ch} (not found: a crab)`}`)
439    }
440    lines.push('  anyone else -> a stable pick by agent id among all of the above')
441  }
442  lines.push(...extra)
443  lines.push(reloaded ? 'Rescanned: a running pet or browser view picks the change up within a few seconds.' : '/crew characters reload rescans the folder.')
444  return lines.join('\n')
445}
446
447// ---------------------------------------------------------------- `auto`: the character whose traits fit the agent's role
448// A twin of roleOf / autoPick in web/characters-core.js (the pet runs that one); a test feeds both the same agents and holds them together.
449
450export type RoleAgent = { id: string; type?: string; description?: string; verb?: string; isArchitect?: boolean; parentId?: string | null }
451export type Traited = { id: string; traits: readonly string[] }
452export type AutoPick = { id: string; why: string[]; parent: string }
453export type AutoState = { locks: Map<string, AutoPick> }
454
455const TYPE_ROLES: [RegExp, string[]][] = [
456  [/plan|architect|advisor|design|strateg/, ['plan', 'architect', 'design', 'think', 'advisor']],
457  [/explor|search|locat|scout|find|investig|research|survey/, ['explore', 'search', 'investigate', 'locate']],
458  [/review|audit|critic|verif|inspect|lint/, ['review', 'investigate']],
459  [/doc|writer|write|summar|readme|wiki|translat|scribe|changelog/, ['docs', 'write', 'summarize']],
460  [/web|fetch|browse|crawl|scrape/, ['web', 'quick']],
461  [/cod(e|er|ing)|implement|build|develop|\bdev\b|engineer|fix|debug|refactor|test|tdd|resolver/, ['code', 'build', 'fix', 'test', 'implement']],
462  [/quick|fast|haiku|mini|lite/, ['quick']],
463  [/general|purpose|orchestr|coordinat|manager|lead/, ['generalist', 'lead']],
464]
465const TEXT_ROLES: [RegExp, string[]][] = [
466  [/\b(implement\w*|code|coding|refactor\w*|build|compile|migrat\w*)\b|구현|코드/, ['code', 'build', 'implement']],
467  [/\b(fix\w*|bugs?|debug\w*|patch\w*|repair\w*)\b|수정|버그/, ['fix', 'code']],
468  [/\b(tests?|testing|specs?)\b|테스트/, ['test']],
469  [/\b(run|execute|npm|script)\b|실행/, ['run']],
470  [/\b(review\w*|audit\w*|critique|verify|check\w*)\b|리뷰|검토/, ['review']],
471  [/\b(search\w*|find|locate|grep|look ?up|map|trace|survey)\b|검색|탐색|찾/, ['search', 'locate', 'explore']],
472  [/\b(investigat\w*|analy[sz]\w*|research\w*|explor\w*|explain\w*)\b|조사|분석/, ['investigate', 'explore']],
473  [/\b(docs?|document\w*|readme|write|writing|changelog|guide|translat\w*)\b|문서|작성/, ['docs', 'write']],
474  [/\b(summari[sz]\w*|summary|tl;?dr)\b|요약/, ['summarize']],
475  [/\b(web|fetch|url|https?|browse)\b/, ['web']],
476  [/\b(plan\w*|design\w*|architect\w*|strategy|propose)\b|계획|설계/, ['plan', 'design']],
477]
478const VERB_ROLES: Record<string, string[]> = {
479  edit: ['code', 'fix', 'implement'],
480  run: ['run', 'build', 'test'],
481  read: ['explore', 'search'],
482  search: ['search', 'explore', 'locate'],
483  web: ['web'],
484  plan: ['plan', 'design'],
485  think: ['think'],
486  spawn: ['lead'],
487}
488const W_TYPE = 3
489const W_TEXT = 1.2
490const W_VERB = 0.8
491const W_REPEAT = 0.75
492
493/** An agent's role as { word: weight }, from its type, its task text and its verb. */
494export function roleOf(agent: RoleAgent): Record<string, number> {
495  const w: Record<string, number> = {}
496  const add = (words: string[], k: number) => words.forEach(x => (w[x] = (w[x] ?? 0) + k))
497  const type = String(agent.type ?? '').toLowerCase()
498  if (agent.id === 'main' || type === 'main') add(['lead', 'main', 'generalist'], W_TYPE)
499  if (agent.isArchitect) add(['architect', 'plan', 'design', 'think', 'advisor'], W_TYPE)
500  for (const [re, words] of TYPE_ROLES) {
501    if (re.test(type)) {
502      add(words, W_TYPE)
503      break
504    }
505  }
506  const text = String(agent.description ?? '').toLowerCase().slice(0, 300)
507  for (const [re, words] of TEXT_ROLES) if (re.test(text)) add(words, W_TEXT)
508  const v = agent.verb ? VERB_ROLES[agent.verb] : undefined
509  if (v) add(v, W_VERB)
510  return w
511}
512
513/** The best character for the role; the first pick of an agent is kept (state.locks); siblings that took a character make it a little less attractive. */
514export function autoPick(chars: readonly Traited[], agent: RoleAgent, state: AutoState): AutoPick | null {
515  if (chars.length === 0) return null
516  const key = String(agent.id)
517  const had = state.locks.get(key)
518  if (had && chars.some(c => c.id === had.id)) return had
519  const role = roleOf(agent)
520  const parent = agent.parentId != null ? String(agent.parentId) : ''
521  const taken: Record<string, number> = {}
522  state.locks.forEach((v, k) => {
523    if (k !== key && v.parent === parent) taken[v.id] = (taken[v.id] ?? 0) + 1
524  })
525  const rows = chars.map(c => {
526    const hit = c.traits.filter(x => (role[x] ?? 0) > 0).sort((x, y) => (role[y] ?? 0) - (role[x] ?? 0))
527    return { c, hit, fit: hit.reduce((s, x) => s + (role[x] ?? 0), 0) }
528  })
529  const top = Math.max(...rows.map(r => r.fit))
530  const unit = top > 0 ? 0.2 * top : W_REPEAT
531  let best: { id: string; why: string[]; score: number; tie: number } | null = null
532  for (const { c, hit, fit } of rows) {
533    const score = fit - unit * (taken[c.id] ?? 0)
534    const tie = hash32(`${key}|${c.id}`)
535    if (!best || score > best.score + 1e-9 || (Math.abs(score - best.score) <= 1e-9 && tie < best.tie)) best = { id: c.id, why: hit.slice(0, 3), score, tie }
536  }
537  const pick = { id: best!.id, why: best!.why, parent }
538  state.locks.set(key, pick)
539  return pick
540}
541
542/** The agents of a snapshot file (data.js / sessions/<id>.js); [] when the text is missing or not a snapshot. */
543export function agentsFromDataJs(text: string | null | undefined): RoleAgent[] {
544  const mark = '&& window.__crewUpdate('
545  const at = typeof text === 'string' ? text.indexOf(mark) : -1
546  if (!text || at < 0) return []
547  try {
548    const snap = JSON.parse(text.slice(at + mark.length, text.lastIndexOf(')'))) as { agents?: unknown }
549    if (!Array.isArray(snap.agents)) return []
550    return snap.agents
551      .filter((a): a is Record<string, unknown> => !!a && typeof a === 'object' && typeof (a as { id?: unknown }).id === 'string')
552      .map(a => ({
553        id: String(a.id),
554        type: typeof a.type === 'string' ? a.type : '',
555        description: typeof a.description === 'string' ? a.description : '',
556        verb: typeof a.verb === 'string' ? a.verb : '',
557        isArchitect: a.isArchitect === true,
558        parentId: typeof a.parentId === 'string' ? a.parentId : null,
559      }))
560  } catch {
561    return []
562  }
563}
564
565/** What `auto` would give each agent of the latest snapshot, with the reason (the pet keeps the first pick it made for an agent). */
566export function describeAgentPicks(agents: readonly RoleAgent[], chars: readonly Traited[]): string[] {
567  if (agents.length === 0) return []
568  const state: AutoState = { locks: new Map() }
569  const lines = ['Auto-match for the agents of the latest snapshot (the pet keeps the first pick it made):']
570  for (const a of agents) {
571    const p = autoPick(chars, a, state)
572    if (!p) continue
573    const label = a.id === 'main' ? 'main' : a.type || a.id
574    lines.push(`  ${label}${a.description && a.id !== 'main' ? ` (${a.description.slice(0, 40)})` : ''} -> ${p.id}${p.why.length ? ` (matched: ${p.why.join(', ')})` : ' (no trait fits: a stable pick)'}`)
575  }
576  return lines
577}
578
579/** What the pet's own settings file says about characters, for `/crew characters`; tolerant of a missing or broken file. */
580export function describePetSettings(raw: string | null | undefined): string[] {
581  if (raw == null) {
582    return ["Pet settings: none yet. The pet's Settings window (tray icon or right-click menu: Settings...) sets a default, a character per agent type, or one agent; it wins over the option."]
583  }
584  let j: unknown
585  try {
586    j = JSON.parse(raw.replace(/^\uFEFF/, ''))
587  } catch {
588    return ["Pet settings: overlay/pet-settings.json is not valid JSON, so the pet ignores it (it writes a fresh one when you change a setting in the pet's Settings window)."]
589  }
590  const o = (j && typeof j === 'object' ? j : {}) as { default?: unknown; types?: unknown; agents?: unknown }
591  const pairs = (m: unknown): [string, string][] =>
592    m && typeof m === 'object' && !Array.isArray(m) ? Object.entries(m as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === 'string') : []
593  const types = pairs(o.types)
594  const agents = pairs(o.agents)
595  const lines = ["Pet settings (overlay/pet-settings.json, set in the pet's Settings window; they win over the option):"]
596  lines.push(`  default: ${typeof o.default === 'string' && o.default ? (o.default === 'auto' ? 'auto (match the role)' : o.default) : 'none (the option, else a crab)'}`)
597  for (const [k, v] of types) lines.push(`  ${k === '*' ? 'any other type (*)' : k} -> ${v}`)
598  if (agents.length > 0) lines.push(`  ${agents.length} agent${agents.length === 1 ? '' : 's'} with a character of their own`)
599  return lines
600}
601
hooks/pet-ask.ts 192 lines
1// What the desktop pet may ask of the mod, kept apart from `$` so it can be tested on its own: checking an inbox
2// message, composing the prompts the mod submits for it, and hiding dismissed agents. Nothing here touches the host.
3import type { AgentCard, CrewEvent, FileNode, LastReply, LoopAct } from '../types'
4import { redact } from './deck/core'
5import { clip } from './crew'
6
7export const ASK_MAX = 4000
8export const REPLY_CELLS = 200
9export const AUTO_DISMISS_MINUTES = [5, 10, 30]
10/** The dismissed set keeps at most this many agents (the card list itself keeps 24). */
11const DISMISSED_MAX = 60
12
13export type PetMsg = {
14  seq?: number
15  at?: number
16  kind?: string
17  sessionId?: string
18  token?: string
19  mode?: string
20  text?: string
21  agentId?: string
22  id?: string
23  ids?: unknown
24}
25
26const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
27
28/**
29 * An inbox message the mod may act on: an ask, a dismiss, a stop or a rescan of the characters folder that carries this session's id and the token the mod gave the
30 * pet when it launched it. Anything else is refused with a reason (never containing the token).
31 */
32export function checkPetMsg(raw: unknown, sessionId: string, token: string): { ok: true; msg: PetMsg } | { ok: false; why: string } {
33  if (!isObject(raw)) return { ok: false, why: 'not an object' }
34  const msg = raw as PetMsg
35  if (msg.kind !== 'ask' && msg.kind !== 'dismiss' && msg.kind !== 'stop' && msg.kind !== 'rescan') return { ok: false, why: 'not a request that needs a token' }
36  if (!sessionId || !token) return { ok: false, why: 'this session has no pet token' }
37  if (msg.sessionId !== sessionId) return { ok: false, why: 'another session' }
38  if (typeof msg.token !== 'string' || msg.token !== token) return { ok: false, why: 'wrong token' }
39  return { ok: true, msg }
40}
41
42/** The text of an ask, trimmed; null when it is empty or longer than ASK_MAX. */
43export function cleanAsk(text: unknown): string | null {
44  if (typeof text !== 'string') return null
45  const t = text.replace(/\0/g, '').trim()
46  return t.length > 0 && t.length <= ASK_MAX ? t : null
47}
48
49// ---------------------------------------------------------------- what the user pointed at
50
51export type AskAbout = { id: string; type: string; status: string; description: string; recent: string[]; files: string[] }
52
53/** The few facts about an agent that go along with a question about it: last 5 event texts and the files it touched. */
54export function aboutOf(card: { id: string; type: string; status: string; description: string }, events: CrewEvent[], files: FileNode[]): AskAbout {
55  const recent = events
56    .filter(e => e.from === card.id)
57    .slice(-5)
58    .map(e => clip(redact(e.text || e.tool || e.kind), 120))
59  const touched = files.filter(f => f.by && f.by[card.id]).map(f => f.path)
60  return {
61    id: card.id,
62    type: card.type,
63    status: card.status,
64    description: clip(redact(card.description), 160),
65    recent,
66    files: touched.slice(0, 8),
67  }
68}
69
70/** The user's text, then (with an agent attached) what the crew knows about that agent. The engine frames the whole as coming from the plugin. */
71export function composeAsk(text: string, about: AskAbout | null): string {
72  const head = `The user typed this into the crew-graph desktop pet:\n\n${text}`
73  if (!about) return head
74  const lines = [
75    `${head}\n\nContext from crew-graph about the agent the user pointed at:`,
76    `- agent: ${about.type} (id ${about.id}), status ${about.status}`,
77    ...(about.description ? [`- task: ${about.description}`] : []),
78    ...(about.recent.length ? ['- last events:', ...about.recent.map(r => `  - ${r}`)] : []),
79    ...(about.files.length ? [`- files touched: ${about.files.join(', ')}`] : []),
80  ]
81  return lines.join('\n')
82}
83
84/** The framed prompt that asks Claude to stop a running agent; the mod has no way to stop one itself. */
85export function composeStop(card: { id: string; type: string; description: string }): string {
86  const task = card.description ? `, task: ${clip(redact(card.description), 120)}` : ''
87  return `The user asked from the crew-graph desktop pet to stop the running ${card.type} agent (id ${card.id}${task}). Please stop it if you can (the TaskStop tool does) and say what you did. The crew-graph plugin cannot stop agents itself.`
88}
89
90/** The first characters of the answer to a pet ask, for the pet's speech bubble: one line, credentials masked. */
91export function replyOf(answer: string, reason: string, at: number): LastReply {
92  const text = answer.trim() ? clip(redact(answer), REPLY_CELLS) : `(the turn ended: ${reason})`
93  return { at, text }
94}
95
96// ---------------------------------------------------------------- dismissing
97
98const RUNNING = new Set(['running', 'pending'])
99/** An agent with no work in flight: finished, failed, stopped, or sitting idle. */
100export const isFinished = (c: Pick<AgentCard, 'status'>) => !RUNNING.has(c.status)
101
102type Acts = Record<string, LoopAct>
103
104/** What a dismissal remembers of an agent; a later step, tool call or end changes it, which brings the agent back. */
105export const stampOf = (c: Pick<AgentCard, 'id' | 'endedAt' | 'steps' | 'status'>, acts: Acts) => `${c.status}:${c.endedAt ?? 0}:${c.steps}:${acts[c.id]?.calls ?? 0}`
106
107export function dismissedOf(stored: unknown): Record<string, string> {
108  const out: Record<string, string> = {}
109  if (!isObject(stored)) return out
110  for (const k of Object.keys(stored)) if (typeof stored[k] === 'string') out[k] = stored[k] as string
111  return out
112}
113
114/** The set after dismissing these ids; running agents and unknown ids are skipped. Returns the new set and the ids taken. */
115export function dismiss(set: Record<string, string>, ids: string[], cards: AgentCard[], acts: Acts): { set: Record<string, string>; taken: string[] } {
116  const next = { ...set }
117  const taken: string[] = []
118  for (const id of ids) {
119    const c = cards.find(x => x.id === id)
120    if (!c || !isFinished(c)) continue
121    next[id] = stampOf(c, acts)
122    taken.push(id)
123  }
124  // Only agents that still have a card are worth remembering.
125  const keep = Object.keys(next).filter(k => cards.some(c => c.id === k))
126  const pruned: Record<string, string> = {}
127  for (const k of keep.slice(-DISMISSED_MAX)) pruned[k] = next[k] as string
128  return { set: pruned, taken }
129}
130
131/** The ids hidden now: dismissed, and not active or changed since. */
132export function hiddenIds(cards: AgentCard[], set: Record<string, string>, acts: Acts): Set<string> {
133  const out = new Set<string>()
134  for (const c of cards) if (set[c.id] !== undefined && set[c.id] === stampOf(c, acts) && isFinished(c)) out.add(c.id)
135  return out
136}
137
138/** Auto-dismiss: the set with every agent that has been finished for `minutes` added; null when nothing changed. */
139export function autoDismissed(cards: AgentCard[], set: Record<string, string>, acts: Acts, now: number, minutes: number): Record<string, string> | null {
140  if (!AUTO_DISMISS_MINUTES.includes(minutes)) return null
141  const old = cards.filter(c => isFinished(c) && c.endedAt !== null && now - c.endedAt >= minutes * 60_000 && set[c.id] !== stampOf(c, acts)).map(c => c.id)
142  return old.length ? dismiss(set, old, cards, acts).set : null
143}
144
145/** The `autoDismiss` of pet-settings.json (minutes) read tolerantly: a BOM, bad JSON or a value that is not 5, 10 or 30 mean off (0). */
146export function autoDismissOf(text: unknown): number {
147  if (typeof text !== 'string') return 0
148  try {
149    const v = (JSON.parse(text.replace(/^/, '')) as Record<string, unknown> | null)?.autoDismiss
150    return typeof v === 'number' && AUTO_DISMISS_MINUTES.includes(v) ? v : 0
151  } catch {
152    return 0
153  }
154}
155
156export type Shown = {
157  cards: AgentCard[]
158  files: FileNode[]
159  acts: Record<string, LoopAct>
160  events: CrewEvent[]
161  parents: Record<string, string>
162}
163
164/** What the snapshot and the Crew pane show: without the hidden agents, their events, and their name in the files' `by`. A file only they touched goes too. */
165export function withoutHidden(all: Shown, hidden: Set<string>): Shown {
166  if (hidden.size === 0) return all
167  // A hidden agent's children hang from the nearest agent above it that is shown (main at the top).
168  const up = (id: string): string => {
169    let p = all.parents[id] ?? 'main'
170    for (let i = 0; i < 24 && hidden.has(p); i += 1) p = all.parents[p] ?? 'main'
171    return p
172  }
173  const parents: Record<string, string> = {}
174  for (const k of Object.keys(all.parents)) if (!hidden.has(k)) parents[k] = up(k)
175  const acts: Record<string, LoopAct> = {}
176  for (const k of Object.keys(all.acts)) if (!hidden.has(k)) acts[k] = all.acts[k] as LoopAct
177  const files: FileNode[] = []
178  for (const f of all.files) {
179    const keys = Object.keys(f.by ?? {})
180    const by: FileNode['by'] = {}
181    for (const k of keys) if (!hidden.has(k)) by[k] = f.by[k] as 'r' | 'w' | 'rw'
182    if (keys.length === 0 || Object.keys(by).length > 0) files.push(keys.length === Object.keys(by).length ? f : { ...f, by })
183  }
184  return {
185    cards: all.cards.filter(c => !hidden.has(c.id)),
186    files,
187    acts,
188    events: all.events.filter(e => !hidden.has(e.from) && !(e.to !== undefined && hidden.has(e.to))),
189    parents,
190  }
191}
192
hooks/crew.ts 263 lines
1// crew-graph's own pure helpers: what a tool call means for the crew, file kinds, cell-width
2// clipping, and the snapshot the browser view reads. Nothing here touches `$`.
3import type { AgentCard, Architect, CrewEvent, FileNode, Gate, LastReply, LoopAct, Main, Parents, Receipt, TaskItem, Turn, Usage, Verb } from '../types'
4import { gateSummary, isAdvising, redact } from './deck/core'
5
6export const MAX_FILES = 60
7export const MAX_EVENTS = 200
8
9const PLAN_RE = /(^|\/)(plans?|specs?|\.omc\/plans)\/|(^|\/)(plan|intent|spec|prd|roadmap)[^/]*\.(md|d4a)$/i
10const DOC_RE = /\.(md|d4a|txt|rst|adoc)$|(^|\/)docs?\//i
11
12export function kindOf(path: string): FileNode['kind'] {
13  if (PLAN_RE.test(path)) return 'plan'
14  if (DOC_RE.test(path)) return 'doc'
15  return 'code'
16}
17
18export function shortPath(path: string, cwd: string): string {
19  const norm = path.replace(/\\/g, '/')
20  const root = cwd.replace(/\\/g, '/').replace(/\/$/, '')
21  const rel = root && norm.toLowerCase().startsWith(root.toLowerCase() + '/') ? norm.slice(root.length + 1) : norm
22  return rel.length > 48 ? '…' + rel.slice(-47) : rel
23}
24
25/** Hangul, CJK and full-width forms take two terminal cells. */
26export function cellWidth(ch: string): number {
27  const c = ch.codePointAt(0) ?? 0
28  const isWide =
29    (c >= 0x1100 && c <= 0x115f) ||
30    (c >= 0x2e80 && c <= 0xa4cf) ||
31    (c >= 0xac00 && c <= 0xd7a3) ||
32    (c >= 0xf900 && c <= 0xfaff) ||
33    (c >= 0xfe30 && c <= 0xfe4f) ||
34    (c >= 0xff00 && c <= 0xff60) ||
35    (c >= 0xffe0 && c <= 0xffe6)
36  return isWide ? 2 : 1
37}
38
39/** Cuts to `n` cells. Folds line breaks only; indentation and alignment spaces stay. */
40export function clip(text: string, n: number): string {
41  const one = text.replace(/[\r\n\t]+/g, ' ')
42  let used = 0
43  let out = ''
44  for (const ch of one) {
45    const w = cellWidth(ch)
46    if (used + w > n - 1) return out + '…'
47    used += w
48    out += ch
49  }
50  return out
51}
52
53export type Touch = { verb: Verb; target: string; file?: string; op?: 'r' | 'w' }
54
55const str = (v: unknown) => (typeof v === 'string' ? v : '')
56
57/** One tool call read as what the crab is doing. Input fields arrive flattened on the event. */
58export function describeCall(tool: string, e: Record<string, unknown>, cwd: string): Touch {
59  const file = str(e.file_path) || str(e.notebook_path)
60  switch (tool) {
61    case 'Read':
62      return { verb: 'read', target: shortPath(file, cwd), file, op: 'r' }
63    case 'Edit':
64    case 'Write':
65    case 'NotebookEdit':
66      return { verb: 'edit', target: shortPath(file, cwd), file, op: 'w' }
67    case 'Grep':
68    case 'Glob':
69      return { verb: 'search', target: clip(str(e.pattern), 30) }
70    case 'Bash':
71    case 'PowerShell':
72      return { verb: 'run', target: clip(redact(str(e.command)), 34) }
73    case 'WebFetch':
74      return { verb: 'web', target: clip(str(e.url), 34) }
75    case 'WebSearch':
76      return { verb: 'web', target: clip(str(e.query), 34) }
77    case 'Agent':
78      return { verb: 'spawn', target: clip(`${str(e.subagent_type) || 'agent'}: ${str(e.description)}`, 34) }
79    case 'TaskCreate':
80    case 'TaskUpdate':
81    case 'TodoWrite':
82    case 'EnterPlanMode':
83    case 'ExitPlanMode':
84      return { verb: 'plan', target: tool }
85    default:
86      return { verb: 'run', target: tool }
87  }
88}
89
90/** A tool call's file as an absolute path in forward slashes: a relative one is taken from `cwd`. */
91export function absPath(file: string, cwd: string): string | undefined {
92  if (!file) return undefined
93  const f = file.replace(/\\/g, '/')
94  if (f.startsWith('/') || /^[A-Za-z]:\//.test(f)) return f
95  const root = cwd.replace(/\\/g, '/').replace(/\/$/, '')
96  return root ? `${root}/${f.replace(/^\.\//, '')}` : undefined
97}
98
99/**
100 * A file recorded without its absolute path (by a version before 0.6, which kept only the short
101 * one) gets it back from the session's working directory, unless the short path was clipped.
102 */
103export function withAbs(f: FileNode, cwd: string): FileNode {
104  if (f.abs || !cwd || f.path.startsWith('…')) return f
105  const abs = absPath(f.path, cwd)
106  return abs ? { ...f, abs } : f
107}
108
109export function touchFile(list: FileNode[], path: string, key: string, op: 'r' | 'w', now: number, abs?: string): FileNode[] {
110  const found = list.find(n => n.path === path)
111  const prev = found?.by[key]
112  const merged: 'r' | 'w' | 'rw' = prev && prev !== op ? 'rw' : op
113  const node: FileNode = found
114    ? { ...found, by: { ...found.by, [key]: merged }, last: now, ...(abs ? { abs } : {}) }
115    : { path, kind: kindOf(path), by: { [key]: op }, last: now, ...(abs ? { abs } : {}) }
116  return [node, ...list.filter(n => n.path !== path)].slice(0, MAX_FILES)
117}
118
119export function pushEvent(list: CrewEvent[], e: Omit<CrewEvent, 'id'>): CrewEvent[] {
120  const id = (list[list.length - 1]?.id ?? 0) + 1
121  return [...list, { ...e, id }].slice(-MAX_EVENTS)
122}
123
124/** A card's status as the crab shows it. */
125export function verbOfCard(c: AgentCard, act: LoopAct | undefined): Verb {
126  if (c.status === 'done') return 'done'
127  if (c.status === 'failed' || c.status === 'stopped') return 'failed'
128  return act?.verb ?? 'think'
129}
130
131const basename = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() ?? ''
132
133/** A session's name for its window: the first prompt on one line, credentials masked, about 32 cells. */
134export function titleOf(text: string): string {
135  return clip(redact(text.replace(/\p{Cc}+/gu, ' ').replace(/\s+/g, ' ').trim()), 32)
136}
137
138export type SnapshotInput = {
139  now: number
140  sessionId: string
141  /** The session's title (its first prompt, see titleOf); empty before there is one. */
142  title?: string
143  cwd: string
144  main: Main
145  usage: Usage
146  architect: Architect
147  gate: Gate
148  cards: AgentCard[]
149  architectIds: string[]
150  acts: Record<string, LoopAct>
151  files: FileNode[]
152  events: CrewEvent[]
153  parents: Parents
154  turn: Turn
155  receipt: Receipt | null
156  /** The `characters` option parsed ([who, character] pairs); left out of the snapshot when empty. */
157  chars?: [string, string][]
158  /** The answer to the latest question asked from the pet; left out of the snapshot when null. */
159  lastReply?: LastReply | null
160  /** The newest pet request (its seq) the mod accepted; left out of the snapshot when 0. */
161  ack?: number
162  /** The tasks to list (tasksOf in ./tasks) and how many each agent has eaten (the book's counters). */
163  tasks?: TaskItem[]
164  eaten?: Record<string, number>
165}
166
167/** What the browser view reads (docs/DATA_CONTRACT.md, v1). */
168export function snapshotOf(s: SnapshotInput) {
169  const g = gateSummary(s.gate)
170  const mainAct = s.acts.main
171  const mainAgent = {
172    id: 'main',
173    parentId: null,
174    type: 'main',
175    description: 'main loop',
176    status: s.main.isRunning ? 'running' : 'idle',
177    verb: s.main.isRunning ? (mainAct?.verb ?? 'think') : 'wait',
178    target: s.main.isRunning ? (mainAct?.target ?? '') : '',
179    since: mainAct?.since ?? s.now,
180    steps: s.main.steps,
181    calls: mainAct?.calls ?? 0,
182    startedAt: s.turn.startedAt || s.now,
183  }
184  const agents = s.cards.map(c => {
185    const act = s.acts[c.id]
186    const status = c.status === 'done' ? 'completed' : c.status === 'stopped' ? 'killed' : c.status
187    return {
188      id: c.id,
189      parentId: s.parents[c.id] ?? 'main',
190      type: c.type,
191      description: c.description,
192      status,
193      verb: verbOfCard(c, act),
194      target: act?.target ?? '',
195      since: act?.since ?? c.spawnedAt,
196      steps: c.steps,
197      calls: act?.calls ?? 0,
198      ctxTokens: c.ctx,
199      outTokens: c.out,
200      startedAt: c.spawnedAt,
201      endedAt: c.endedAt ?? undefined,
202      isArchitect: s.architectIds.includes(c.id),
203    }
204  })
205  const lastConsult = s.architect.consults[s.architect.consults.length - 1]
206  return {
207    v: 1,
208    now: s.now,
209    session: {
210      id: s.sessionId,
211      cwd: basename(s.cwd),
212      title: s.title || undefined,
213      // The full working directory, for the pet's "show in folder"; the data file lives under the user's own home.
214      cwdAbs: s.cwd ? s.cwd.replace(/\\/g, '/') : undefined,
215      model: s.main.model,
216      effort: s.main.effort || undefined,
217      mode: s.main.mode || undefined,
218      requests: s.main.steps,
219      ctx: s.usage.tokens !== null && s.usage.window > 0 ? { used: s.usage.tokens, max: s.usage.window } : undefined,
220      compactions: s.usage.compactions,
221      costUsd: s.usage.costUsd ?? undefined,
222      rate: s.usage.limits.map(l => ({ label: l.kind, pct: l.pct, resetsAt: l.resetsAt ?? undefined })),
223      ttftMs: s.main.speed?.ttftMs,
224      tokPerSec: s.main.speed?.tokPerS ?? undefined,
225      turn: s.turn.startedAt
226        ? {
227            running: s.main.isRunning,
228            startedAt: s.turn.startedAt,
229            endedAt: s.main.isRunning || !s.receipt ? undefined : s.turn.startedAt + s.receipt.durationMs,
230            edits: s.turn.edits,
231            errors: s.turn.errors,
232            agents: s.receipt?.agents ?? s.cards.filter(c => c.spawnedAt >= s.turn.startedAt).length,
233          }
234        : undefined,
235    },
236    agents: [mainAgent, ...agents],
237    files: s.files.map(f => withAbs(f, s.cwd)),
238    events: s.events,
239    gate: { allowed: g.rule, asked: g.cleared, denied: g.deny, pending: g.ask },
240    ...(s.chars && s.chars.length > 0 ? { chars: s.chars } : {}),
241    ...(s.lastReply ? { lastReply: s.lastReply } : {}),
242    ...(s.ack ? { petAck: s.ack } : {}),
243    tasks: s.tasks ?? [],
244    ...(s.eaten && Object.keys(s.eaten).length > 0 ? { eaten: s.eaten } : {}),
245    architect: {
246      consults: s.architect.consults.length,
247      running: isAdvising(s.architect),
248      lastMs: lastConsult?.endAt ? lastConsult.endAt - lastConsult.at : undefined,
249    },
250  }
251}
252
253export const dataJs = (snapshot: unknown) => `window.__crewUpdate && window.__crewUpdate(${JSON.stringify(snapshot)});\n`
254
255/** The scrollbar column for a window of `bodyRows` at `offset` over `total` rows; null when it all fits. */
256export function scrollbar(offset: number, bodyRows: number, total: number): { top: number; thumbTop: number; thumbLen: number } | null {
257  if (bodyRows <= 0 || total <= bodyRows) return null
258  const top = Math.max(0, Math.min(offset, total - bodyRows))
259  const thumbLen = Math.max(1, Math.min(bodyRows, Math.round((bodyRows * bodyRows) / total)))
260  const thumbTop = Math.round((top * (bodyRows - thumbLen)) / (total - bodyRows))
261  return { top, thumbTop, thumbLen }
262}
263
hooks/tasks.ts 445 lines
1// crew-graph's task tracking: what the agents' to-do lists and tasks say, kept as plain data for the pet and the browser view.
2// Six sources become one list (docs/DATA_CONTRACT.md, `tasks`): TodoWrite (an agent's whole list, replaced on every call),
3// TaskCreate / TaskUpdate (one shared list, an owner per task), the subagent cards (a ticket owned by the child), the prompt turns of
4// the main loop (proxy food, for a session whose model has no task tools), the letters that reach main (a teammate's report, a task
5// notification: the work queue), and the assignments main gives a teammate. Nothing here touches `$`.
6import type { AgentCard, Parents, TaskBook, TaskItem, TaskStatus } from '../types'
7import { clip } from './crew'
8import { promptLine, redact } from './deck/core'
9
10/** Cells of a title in the snapshot; the active text (activeForm) is a little shorter. */
11export const MAX_TITLE = 80
12export const MAX_ACTIVE = 60
13/** A prompt turn's title is shorter: it is the user's own words, and it goes into data.js. */
14export const MAX_TURN_TITLE = 60
15/** What the snapshot carries: every open task up to this many, the last few closed ones, a deleted one for a moment. */
16export const MAX_OPEN = 40
17export const MAX_CLOSED = 10
18export const DELETED_MS = 5000
19/** A prompt waiting behind a running turn for longer than this is not waiting any more (its turn never came); it leaves the book. */
20export const QUEUED_STALE_MS = 30 * 60_000
21/** What the stored state keeps per list, so a long session does not grow it without bound. */
22export const MAX_STORED = 200
23
24export const EMPTY_BOOK: TaskBook = { todo: [], task: [], turn: [], mail: [], assign: [], eaten: {} }
25
26/** The tools whose calls are read (see noteTasks in register.tsx). */
27export const TASK_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
28
29const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
30const str = (v: unknown) => (typeof v === 'string' ? v : '')
31const STATUSES: readonly string[] = ['pending', 'in_progress', 'completed', 'failed', 'deleted']
32const isOpen = (s: TaskStatus) => s === 'pending' || s === 'in_progress'
33
34const hashOf = (s: string) => {
35  let h = 2166136261
36  for (let i = 0; i < s.length; i += 1) {
37    h ^= s.charCodeAt(i)
38    h = Math.imul(h, 16777619)
39  }
40  return (h >>> 0).toString(36)
41}
42
43/** A title or an active text for the snapshot: one line, credentials masked, cut to `n` cells. */
44export const taskText = (s: unknown, n: number) => clip(redact(str(s)).replace(/\p{Cc}+/gu, ' ').replace(/\s+/g, ' ').trim(), n)
45
46function normalizeItem(v: unknown): TaskItem | null {
47  if (!isObject(v) || typeof v.id !== 'string' || typeof v.title !== 'string') return null
48  const status = STATUSES.includes(str(v.status)) ? (v.status as TaskStatus) : 'pending'
49  const src = v.src === 'task' || v.src === 'agent' || v.src === 'turn' || v.src === 'mail' || v.src === 'assign' ? v.src : 'todo'
50  const item: TaskItem = { id: v.id, src, title: v.title, status, t: typeof v.t === 'number' ? v.t : 0 }
51  if (typeof v.owner === 'string' && v.owner) item.owner = v.owner
52  if (typeof v.active === 'string' && v.active) item.active = v.active
53  if (Array.isArray(v.blockedBy)) item.blockedBy = v.blockedBy.filter((x): x is string => typeof x === 'string')
54  if (typeof v.deliverTo === 'string' && v.deliverTo) item.deliverTo = v.deliverTo
55  if (typeof v.bound === 'string' && v.bound) item.bound = v.bound
56  return item
57}
58
59/** A stored book that may be missing, older or damaged, read as a book. */
60export function normalizeBook(stored: unknown): TaskBook {
61  if (!isObject(stored)) return EMPTY_BOOK
62  const list = (v: unknown) => (Array.isArray(v) ? v.map(normalizeItem).filter((x): x is TaskItem => x !== null) : [])
63  const eaten: Record<string, number> = {}
64  if (isObject(stored.eaten)) for (const [k, n] of Object.entries(stored.eaten)) if (typeof n === 'number' && n > 0) eaten[k] = Math.floor(n)
65  return { todo: list(stored.todo), task: list(stored.task), turn: list(stored.turn), mail: list(stored.mail), assign: list(stored.assign), eaten }
66}
67
68/** Deleted items older than the window go; each list keeps at most MAX_STORED (closed ones go first). */
69export function pruneBook(book: TaskBook, now: number): TaskBook {
70  const keep = (list: TaskItem[]) => {
71    const alive = list.filter(i => i.status !== 'deleted' || now - i.t <= DELETED_MS)
72    if (alive.length <= MAX_STORED) return alive
73    let excess = alive.length - MAX_STORED
74    return alive.filter(i => {
75      if (excess > 0 && !isOpen(i.status)) {
76        excess -= 1
77        return false
78      }
79      return true
80    })
81  }
82  const unqueued = (list: TaskItem[]) => list.filter(i => !(i.status === 'pending' && now - i.t > QUEUED_STALE_MS))
83  return { ...book, todo: keep(book.todo), task: keep(book.task), turn: keep(unqueued(book.turn)), mail: keep(unqueued(book.mail)), assign: keep(book.assign) }
84}
85
86/** One more thing the agent `eater` has eaten (an id that turned into completed). */
87export function bumpEaten(book: TaskBook, eater: string): TaskBook {
88  return { ...book, eaten: { ...book.eaten, [eater]: (book.eaten[eater] ?? 0) + 1 } }
89}
90
91// ---------------------------------------------------------------- TodoWrite
92
93/**
94 * A TodoWrite call: `agent`'s whole list. It replaces that agent's previous list; the ids are stable (agent + a hash of the
95 * content, a repeat of the same content numbered), so the page can see an item change. An item that was open and is missing from the
96 * new list is deleted (for a moment); one that was closed simply leaves.
97 */
98export function applyTodoWrite(book: TaskBook, agent: string, input: unknown, now: number): TaskBook {
99  const raw = isObject(input) && Array.isArray(input.todos) ? input.todos : []
100  const seen = new Map<string, number>()
101  const prev = new Map(book.todo.filter(i => i.owner === agent).map(i => [i.id, i]))
102  const next: TaskItem[] = []
103  let eaten = book.eaten
104  for (const r of raw) {
105    if (!isObject(r) || typeof r.content !== 'string' || !r.content.trim()) continue
106    const base = `todo:${agent}:${hashOf(r.content)}`
107    const n = (seen.get(base) ?? 0) + 1
108    seen.set(base, n)
109    const id = n === 1 ? base : `${base}#${n}`
110    const status: TaskStatus = r.status === 'in_progress' || r.status === 'completed' ? r.status : 'pending'
111    const was = prev.get(id)
112    const item: TaskItem = { id, src: 'todo', owner: agent, title: taskText(r.content, MAX_TITLE), status, t: was && was.status === status ? was.t : now }
113    const active = taskText(r.activeForm, MAX_ACTIVE)
114    if (active) item.active = active
115    next.push(item)
116    if (status === 'completed' && was?.status !== 'completed') eaten = { ...eaten, [agent]: (eaten[agent] ?? 0) + 1 }
117  }
118  const nextIds = new Set(next.map(i => i.id))
119  const gone: TaskItem[] = [...prev.values()].filter(i => !nextIds.has(i.id) && isOpen(i.status)).map(i => ({ ...i, status: 'deleted' as const, t: now }))
120  const others = book.todo.filter(i => i.owner !== agent)
121  return pruneBook({ ...book, todo: [...others, ...next, ...gone], eaten }, now)
122}
123
124// ---------------------------------------------------------------- TaskCreate / TaskUpdate
125
126/** The id a TaskCreate result names: `result.task.id`, else the `#<n>` of its text. Null when neither is there. */
127export function createdId(result: unknown, text?: unknown): string | null {
128  const task = isObject(result) && isObject(result.task) ? result.task : null
129  if (task && (typeof task.id === 'string' || typeof task.id === 'number') && String(task.id)) return String(task.id)
130  const m = typeof text === 'string' ? /#(\d+)/.exec(text) : null
131  return m?.[1] ?? null
132}
133
134export function applyTaskCreate(book: TaskBook, id: string, input: unknown, now: number): TaskBook {
135  const i = isObject(input) ? input : {}
136  const taskId = `task:${id}`
137  const item: TaskItem = { id: taskId, src: 'task', title: taskText(i.subject, MAX_TITLE) || `task ${id}`, status: 'pending', t: now }
138  const active = taskText(i.activeForm, MAX_ACTIVE)
139  if (active) item.active = active
140  return pruneBook({ ...book, task: [...book.task.filter(x => x.id !== taskId), item] }, now)
141}
142
143/** An agent the owner field may name: its id, and the type or name it was started under. */
144export type KnownAgent = { id: string; type: string }
145
146/** The agent an `owner` text names: an id, else a type or name (the latest of those), else null. */
147export function ownerOf(text: unknown, known: KnownAgent[]): string | null {
148  const t = str(text).trim()
149  if (!t) return null
150  const byId = known.find(k => k.id === t)
151  if (byId) return byId.id
152  const lc = t.toLowerCase()
153  for (let i = known.length - 1; i >= 0; i -= 1) if (known[i]?.type.toLowerCase() === lc) return known[i]?.id ?? null
154  return null
155}
156
157/**
158 * A TaskUpdate call by `agent`. The owner is the agent that last set the task in_progress (or the `owner` field, when it names a known
159 * agent); a task nobody took is unowned. A task this book never saw is made from the update, so a late start still shows it.
160 */
161export function applyTaskUpdate(book: TaskBook, input: unknown, agent: string, known: KnownAgent[], now: number): TaskBook {
162  const i = isObject(input) ? input : {}
163  const raw = typeof i.taskId === 'number' ? String(i.taskId) : str(i.taskId)
164  if (!raw) return book
165  const id = `task:${raw}`
166  const found = book.task.find(x => x.id === id)
167  let item: TaskItem = found ?? { id, src: 'task', title: taskText(i.subject, MAX_TITLE) || `task ${raw}`, status: 'pending', t: now }
168  if (typeof i.subject === 'string' && taskText(i.subject, MAX_TITLE)) item = { ...item, title: taskText(i.subject, MAX_TITLE) }
169  if (typeof i.activeForm === 'string') {
170    const active = taskText(i.activeForm, MAX_ACTIVE)
171    if (active) item = { ...item, active }
172    else delete item.active
173  }
174  const named = ownerOf(i.owner, known)
175  if (named) item = { ...item, owner: named }
176  const status = str(i.status)
177  let eaten = book.eaten
178  if (status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'deleted') {
179    if (status !== item.status) item = { ...item, status, t: now }
180    if (status === 'in_progress' && !named) item = { ...item, owner: agent }
181    if (status === 'completed' && found?.status !== 'completed') {
182      const eater = item.owner ?? 'main'
183      eaten = { ...eaten, [eater]: (eaten[eater] ?? 0) + 1 }
184    }
185  }
186  if (Array.isArray(i.addBlockedBy)) {
187    const add = i.addBlockedBy.map(x => (typeof x === 'number' || typeof x === 'string' ? `task:${x}` : '')).filter(Boolean)
188    const merged = [...new Set([...(item.blockedBy ?? []), ...add])].filter(x => x !== id)
189    if (merged.length > 0) item = { ...item, blockedBy: merged }
190  }
191  return pruneBook({ ...book, eaten, task: found ? book.task.map(x => (x.id === id ? item : x)) : [...book.task, item] }, now)
192}
193
194// ---------------------------------------------------------------- prompt turns (proxy food)
195
196/** What the engine puts in front of a prompt the plugin submitted ("The crew-graph plugin sent a message: ..."): not the user's words. */
197const PLUGIN_FRAME = /^\s*The [\w-]+ plugin sent a message:\s*/i
198
199/** A prompt as a turn's title: the plugin's frame dropped, one line, credentials masked, cut to MAX_TURN_TITLE cells. */
200export const turnTitle = (text: string) => taskText(text.replace(PLUGIN_FRAME, ''), MAX_TURN_TITLE) || 'prompt'
201
202const turnItem = (id: string, title: string, status: TaskStatus, now: number): TaskItem => ({ id: `turn:${id}`, src: 'turn', owner: 'main', title, status, t: now })
203
204/** A prompt typed while a turn runs waits its turn: a pending item on main's plate, under the queue id `qid`. */
205export function applyTurnQueued(book: TaskBook, qid: string, text: string, now: number): TaskBook {
206  const id = `turn:${qid}`
207  return pruneBook({ ...book, turn: [...book.turn.filter(i => i.id !== id), turnItem(qid, turnTitle(text), 'pending', now)] }, now)
208}
209
210/**
211 * Main's turn `turnId` starts with `text`: the queued item `fromQid` becomes it (in progress), else a new in-progress item. A turn that
212 * is still in progress when the next one starts lost its end (a reload, a missed event): it is closed as failed so it cannot stay in hand.
213 */
214export function applyTurnStart(book: TaskBook, turnId: string, text: string, now: number, fromQid?: string): TaskBook {
215  const id = `turn:${turnId}`
216  const from = fromQid ? `turn:${fromQid}` : ''
217  const queued = from ? book.turn.find(i => i.id === from) : undefined
218  const title = queued && !text.trim() ? queued.title : turnTitle(text)
219  const rest = book.turn.map(i => (i.status === 'in_progress' && i.id !== id ? { ...i, status: 'failed' as const, t: now } : i)).filter(i => i.id !== from && i.id !== id)
220  // the queued item's place in the line is kept: it was there first
221  const at = queued ? book.turn.indexOf(queued) : rest.length
222  const next = [...rest]
223  next.splice(Math.min(at, next.length), 0, turnItem(turnId, title, 'in_progress', now))
224  return pruneBook({ ...book, turn: next }, now)
225}
226
227/** Main's turn `turnId` ended: `answer` is eaten (completed, main's count goes up), anything else (aborted, error, refusal) fails. A turn the book never saw changes nothing. */
228export function applyTurnEnd(book: TaskBook, turnId: string, reason: string, now: number): TaskBook {
229  const id = `turn:${turnId}`
230  const found = book.turn.find(i => i.id === id)
231  if (!found) return book
232  const status: TaskStatus = reason === 'answer' ? 'completed' : 'failed'
233  const eaten = status === 'completed' && found.status !== 'completed' ? { ...book.eaten, main: (book.eaten.main ?? 0) + 1 } : book.eaten
234  return pruneBook({ ...book, eaten, turn: book.turn.map(i => (i.id === id ? { ...i, status, t: now } : i)) }, now)
235}
236
237// ---------------------------------------------------------------- mail: what reaches main from a teammate, a task or another session
238
239/** The sender's name when nothing names it: what the origin kind says. */
240const MAIL_SENDER: Record<string, string> = {
241  'task-notification': 'task',
242  peer: 'peer',
243  'peer-send-message': 'peer',
244  coordinator: 'lead',
245  'projects-relay': 'relay',
246  channel: 'channel',
247}
248
249/** The frame the engine puts in front of another session's message. */
250const PEER_FRAME = /^\s*Another Claude session sent a message:?\s*/i
251
252/** One attribute of an opening tag (`<teammate-message teammate_id="..." summary="...">`). */
253const attr = (tag: string, name: string) => new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1] ?? ''
254
255/**
256 * Who a letter is from and what it says, from the text main receives and the origin kind the engine stamped. `teammate` is a sender the
257 * caller already knows (the mailbox's stamp, or the `session.send` that carried it); it wins over what the text says.
258 */
259export function mailParse(text: string, kind: string, teammate?: string): { from: string; summary: string } {
260  const open = /<teammate-message\b[^>]*>/i.exec(text)?.[0] ?? ''
261  const body =
262    text
263      .replace(PEER_FRAME, '')
264      .replace(/<[^>]*>/g, '\n')
265      .split('\n')
266      .map(l => l.trim())
267      .find(l => l.length > 0) ?? ''
268  const from = teammate?.trim() || attr(open, 'teammate_id') || attr(text.slice(0, 200), 'from') || MAIL_SENDER[kind] || 'someone'
269  return { from: taskText(from, 24), summary: taskText(attr(open, 'summary') || body, MAX_TITLE) }
270}
271
272/** The head of a message's text, whitespace folded and the peer frame dropped: what two sightings of one message are matched by. */
273export const mailKey = (text: string) => text.replace(PEER_FRAME, '').replace(/\s+/g, ' ').trim().slice(0, 60)
274
275/** Fallback only: does a turn's opening text read as the user's own words? (A leading `<tag>` or the peer frame is not.) The origin of the prompt decides when it was seen. */
276export const looksLikeUser = (text: string) => !PEER_FRAME.test(text) && promptLine(text).who === 'you' && !isProtocolText(text)
277
278/** A letter that reached main and has not opened a turn yet: its id and the key of its text. */
279export type MailWait = { mid: string; key: string; at: number }
280
281/** The waiting letter a main turn that starts with `text` was opened by: the one whose text it holds; else, for a turn that is not the user's, the oldest. */
282export function mailForTurn(waits: readonly MailWait[], text: string): MailWait | undefined {
283  const flat = text.replace(/\s+/g, ' ')
284  return waits.find(w => w.key !== '' && flat.includes(w.key)) ?? (looksLikeUser(text) ? undefined : waits[0])
285}
286
287/** Who sent a message, as an earlier sighting of it said (a `session.send` of a teammate, a `session.receive` stamp). */
288export type MailFrom = { key: string; from: string; at: number }
289export const MAIL_FROM_TTL_MS = 10 * 60_000
290export const MAIL_FROM_MAX = 40
291
292/** `log` with one more sighting; the old and the many are dropped. A message with no usable text leaves it as it was. */
293export function noteMailFrom(log: readonly MailFrom[], text: string, from: string, now: number): MailFrom[] {
294  const key = mailKey(text)
295  if (!key || !from) return [...log]
296  return [...log.filter(x => now - x.at <= MAIL_FROM_TTL_MS && x.key !== key), { key, from, at: now }].slice(-MAIL_FROM_MAX)
297}
298
299/** The sender an earlier sighting gave this text, if one did (the delivered text holds the sent text). */
300export function mailFromOf(log: readonly MailFrom[], text: string, now: number): string | undefined {
301  const flat = text.replace(/\s+/g, ' ')
302  return [...log].reverse().find(x => now - x.at <= MAIL_FROM_TTL_MS && flat.includes(x.key))?.from
303}
304
305/** An agent a message may be addressed to: its id, the name SendMessage knows it by, its address in the team (`<name>@<team>`). */
306export type Address = { id: string; name?: string; teammateId?: string }
307
308/** The agent id a recipient text names ("scout", "scout@team", "scout [ref]", an id), or null when it names no known agent (the lead, a session). */
309export function addresseeOf(to: string, known: readonly Address[]): string | null {
310  const t = to.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
311  if (!t || t === 'main' || t === 'team-lead') return null
312  const hit = (a: Address) => a.id.toLowerCase() === t || a.name?.toLowerCase() === t || a.teammateId?.toLowerCase() === t || a.teammateId?.split('@')[0]?.toLowerCase() === t
313  return known.find(hit)?.id ?? null
314}
315
316/**
317 * A message that is a protocol object (a shutdown or plan answer, an idle notice: `{"type": "..."}`), not words: no letter, no job. It may
318 * arrive bare, after the peer frame, or inside the mailbox tag, so the frame and the tags are taken off before it is looked at.
319 */
320export const isProtocolText = (text: string) => /^\{\s*"type"\s*:\s*"[\w-]+"/.test(text.replace(PEER_FRAME, '').replace(/<[^>]*>/g, ' ').trim())
321
322/** A letter's title: "from <sender>: <summary>", cut to MAX_TITLE cells, credentials masked. */
323export const mailTitle = (from: string, summary: string) => taskText(summary ? `from ${from}: ${summary}` : `from ${from}`, MAX_TITLE)
324
325const mailItem = (mid: string, title: string, status: TaskStatus, now: number, bound?: string): TaskItem => {
326  const item: TaskItem = { id: `mail:${mid}`, src: 'mail', owner: 'main', title, status, t: now }
327  if (bound) item.bound = bound
328  return item
329}
330
331/** A letter reached main: pending on its plate. `bound`: the main turn it was delivered into (it reads it); absent when it opens the next turn. */
332export function applyMailIn(book: TaskBook, mid: string, title: string, now: number, bound?: string): TaskBook {
333  const id = `mail:${mid}`
334  return pruneBook({ ...book, mail: [...book.mail.filter(i => i.id !== id), mailItem(mid, title, 'pending', now, bound)] }, now)
335}
336
337/** The prompt was dropped (a hook refused it): the letter never reached main. */
338export function applyMailDrop(book: TaskBook, mid: string): TaskBook {
339  const id = `mail:${mid}`
340  return book.mail.some(i => i.id === id) ? { ...book, mail: book.mail.filter(i => i.id !== id) } : book
341}
342
343/**
344 * Main's turn `turnId` starts, opened by the letter `mid` or by anything else. The letter becomes the turn's work (in progress); a letter
345 * still in progress for an earlier turn lost its end (a reload, a missed event) and is closed as failed, so it cannot stay in hand.
346 */
347export function applyMailStart(book: TaskBook, turnId: string, now: number, mid?: string): TaskBook {
348  const id = mid ? `mail:${mid}` : ''
349  const stale = (i: TaskItem) => i.status === 'in_progress' && i.id !== id && i.bound !== turnId
350  if (!book.mail.some(i => i.id === id || stale(i))) return book
351  return pruneBook(
352    {
353      ...book,
354      mail: book.mail.map(i => (i.id === id ? { ...i, status: 'in_progress' as const, bound: turnId, t: now } : stale(i) ? { ...i, status: 'failed' as const, t: now } : i)),
355    },
356    now,
357  )
358}
359
360/** Main's turn `turnId` ended: every letter that turn read is eaten on `answer` (main's count goes up by one each), else it fails with the turn. */
361export function applyMailEnd(book: TaskBook, turnId: string, reason: string, now: number): TaskBook {
362  const mine = book.mail.filter(i => i.bound === turnId && isOpen(i.status))
363  if (mine.length === 0) return book
364  const status: TaskStatus = reason === 'answer' ? 'completed' : 'failed'
365  const eaten = status === 'completed' ? { ...book.eaten, main: (book.eaten.main ?? 0) + mine.length } : book.eaten
366  return pruneBook({ ...book, eaten, mail: book.mail.map(i => (mine.includes(i) ? { ...i, status, t: now } : i)) }, now)
367}
368
369// ---------------------------------------------------------------- assignments: what main gave a teammate to do
370
371/** What an assignment's title is: the message's own label when it has one, else its first words. */
372export const assignTitle = (summary: unknown, text: unknown) => {
373  const firstLine = str(text).split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
374  return taskText(str(summary) || firstLine, MAX_TITLE) || 'assignment'
375}
376
377const assignNum = (id: string) => Number(id.slice(id.lastIndexOf(':') + 1)) || 0
378
379/**
380 * `agent` (a teammate) was given a job, by its spawn or by a message from `giver`. The teammate holds the oldest open one; the
381 * rest wait on its plate in the order they came. It is handed back to `giver` when the teammate reports (applyAssignDone), whatever the
382 * teammate's card says in between: a teammate's card flips between idle and completed with every message.
383 */
384export function applyAssign(book: TaskBook, agent: string, title: string, giver: string, now: number): TaskBook {
385  const mine = book.assign.filter(i => i.owner === agent)
386  const n = mine.reduce((m, i) => Math.max(m, assignNum(i.id)), 0) + 1
387  const status: TaskStatus = mine.some(i => isOpen(i.status)) ? 'pending' : 'in_progress'
388  const item: TaskItem = { id: `assign:${agent}:${n}`, src: 'assign', owner: agent, title, status, deliverTo: giver, t: now }
389  return pruneBook({ ...book, assign: [...book.assign, item] }, now)
390}
391
392/**
393 * `agent` reported back (`all` false: it sent its report; the oldest job is delivered and the next one is taken in hand) or went idle
394 * (`all` true, with how the turn ended: nothing is left in its hands). Delivered jobs are eaten by whoever gave them (the count goes up).
395 */
396export function applyAssignDone(book: TaskBook, agent: string, all: boolean, now: number, reason = 'answer'): TaskBook {
397  const open = book.assign.filter(i => i.owner === agent && isOpen(i.status))
398  const first = open.find(i => i.status === 'in_progress') ?? open[0]
399  if (!first) return book
400  const status: TaskStatus = reason === 'answer' ? 'completed' : 'failed'
401  const closing = all || status === 'failed' ? open : [first]
402  const next = !all && status === 'completed' ? open.find(i => !closing.includes(i)) : undefined
403  let eaten = book.eaten
404  if (status === 'completed') for (const i of closing) eaten = { ...eaten, [i.deliverTo ?? 'main']: (eaten[i.deliverTo ?? 'main'] ?? 0) + 1 }
405  const assign = book.assign.map(i => (closing.includes(i) ? { ...i, status, t: now } : i === next ? { ...i, status: 'in_progress' as const, t: now } : i))
406  return pruneBook({ ...book, eaten, assign }, now)
407}
408
409// ---------------------------------------------------------------- the snapshot's list
410
411/** A subagent card as a ticket owned by the child, to be handed to its parent when it is done. */
412export function ticketOf(c: AgentCard, parents: Parents): TaskItem {
413  const status: TaskStatus = c.status === 'done' ? 'completed' : c.status === 'failed' || c.status === 'stopped' ? 'failed' : 'in_progress'
414  return {
415    id: `agent:${c.id}`,
416    src: 'agent',
417    owner: c.id,
418    title: taskText(c.description, MAX_TITLE) || taskText(c.type, MAX_TITLE) || 'agent',
419    status,
420    deliverTo: parents[c.id] ?? 'main',
421    t: c.endedAt ?? c.spawnedAt,
422  }
423}
424
425/**
426 * What the snapshot lists: every open task up to MAX_OPEN, the last MAX_CLOSED closed ones (completed or failed) and the items deleted
427 * a moment ago. The subagent tickets come from the cards, so they exist whether or not an agent uses the task tools (and come
428 * first, so the cap never cuts one). `hidden`: agents the pet dismissed; their items are left out.
429 */
430export function tasksOf(book: TaskBook, cards: AgentCard[], parents: Parents, now: number, hidden?: ReadonlySet<string>): TaskItem[] {
431  // the tickets first: a long to-do list must not push a subagent's ticket out of the cap
432  const mine = (i: TaskItem) => !(hidden && i.owner !== undefined && hidden.has(i.owner))
433  // a teammate's card flips between idle and completed with every message, so it is no ticket: its assignments are (applyAssign)
434  const tickets = cards.filter(c => !c.isTeammate).map(c => ticketOf(c, parents))
435  const letters = book.mail.map(({ bound: _bound, ...i }) => i)
436  const all = [...tickets, ...book.assign, ...letters, ...book.todo, ...book.task, ...book.turn].filter(mine)
437  const open = all.filter(i => isOpen(i.status)).slice(0, MAX_OPEN)
438  const closed = all
439    .filter(i => i.status === 'completed' || i.status === 'failed')
440    .sort((a, b) => a.t - b.t)
441    .slice(-MAX_CLOSED)
442  const gone = all.filter(i => i.status === 'deleted' && now - i.t <= DELETED_MS)
443  return [...open, ...closed, ...gone]
444}
445
hooks/setup.ts 66 lines
1// crew-graph's food sources when the model has no task tools. Claude Code leaves TodoWrite and the Task tools out on newer models unless
2// asked, so a session without them has no to-do lists to turn into food; the mod then makes food from proxies of real work (the prompt
3// turns of the main loop; the subagent tickets exist in every session). Plain data and text; nothing here touches `$`.
4// Source of the availability rules: https://code.claude.com/docs/en/tools-reference#task-tool-availability and https://code.claude.com/docs/en/env-vars
5import type { Setup } from '../types'
6
7export type SetupState = Setup
8
9/** The `foodFrom` option: `auto` uses the proxies only when the session has no native task tools; `todos` never; `work` always. */
10export type FoodFrom = 'auto' | 'todos' | 'work'
11export const FOOD_FROM_LABEL: Record<FoodFrom, string> = { auto: 'auto', todos: 'todos', work: 'todos+work' }
12
13/** The option as written in /config ("auto", "todos", "todos+work"), read leniently; anything else is `auto`. */
14export function foodFromOf(v: unknown): FoodFrom {
15  const t = typeof v === 'string' ? v.toLowerCase() : ''
16  if (t.includes('+') || t === 'work') return 'work'
17  if (t.includes('only') || t === 'todos') return 'todos'
18  return 'auto'
19}
20
21/** The native tools whose presence means the agents' own to-do lists are there. */
22const NATIVE = ['TodoWrite', 'TaskCreate']
23export const hasNativeTaskTools = (names: readonly string[]) => names.some(n => NATIVE.includes(n))
24
25/** Are the prompt turns food? `auto`: only when the session is known to have no native task tools (an unreadable tool list is not "none"). */
26export const proxyFoodOn = (foodFrom: FoodFrom, native: boolean | null) => foodFrom === 'work' || (foodFrom === 'auto' && native === false)
27
28/** A user prompt origin worth a plate: the user's own Enter, a Remote Control client, the owner's Slack ping. */
29export const USER_ORIGINS: readonly string[] = ['composer', 'bridge', 'slack-ping']
30
31/**
32 * A prompt origin that is a letter to main, not the user's words (PromptOrigin in the engine's types): a teammate's or another session's
33 * message, a background task's notification, a coordinating session's hand-off, a channel relay. `scheduled-trigger`, `sdk`,
34 * `auto-continuation` and the observer kinds are neither the user nor a report, so they are food for no one.
35 */
36export const MAIL_ORIGINS: readonly string[] = ['task-notification', 'peer', 'peer-send-message', 'coordinator', 'projects-relay', 'channel']
37export const isMailOrigin = (kind: unknown): boolean => typeof kind === 'string' && MAIL_ORIGINS.includes(kind)
38
39export const DOCS_URL = 'https://code.claude.com/docs/en/tools-reference#task-tool-availability'
40export const ENV_URL = 'https://code.claude.com/docs/en/env-vars'
41export const ENV_KEY = 'CLAUDE_CODE_ENABLE_TODO_TOOLS'
42
43/** `/crew setup`: what was found, which food sources are on, and how to get the native tools. It changes nothing and writes no file. */
44export function setupReport(s: SetupState, foodFrom: FoodFrom): string {
45  const native = s.native === true ? 'present' : s.native === false ? 'missing' : "unknown (the session's tool list could not be read)"
46  const turns = proxyFoodOn(foodFrom, s.native)
47  const why =
48    foodFrom === 'work' ? 'the setting is "todos+work"' : foodFrom === 'todos' ? 'the setting is "todos"' : s.native === false ? 'this session has no native task tools' : s.native === true ? 'this session has the native task tools' : 'not known whether it has them'
49  return [
50    'crew-graph setup: food',
51    `native task tools (TodoWrite, TaskCreate): ${native}`,
52    `food from: ${FOOD_FROM_LABEL[foodFrom]} (the foodFrom option in /config)`,
53    'sources:',
54    `  to-dos and tasks: from TodoWrite / TaskCreate, ${s.native === false ? 'none in this session' : 'as the agents write them'}`,
55    '  subagent tickets: always',
56    `  prompt turns: ${turns ? 'on' : 'off'} (${why}); each prompt you send is one item, eaten when the answer is done`,
57    '  background jobs: not tracked (the end of a job reaches the mod only as a notification prompt of untyped text, or a transcript row that cannot be read into state)',
58    s.native === true
59      ? ''
60      : `To get the native task tools, set ${ENV_KEY}=1 in the environment before starting Claude Code (or ${ENV_KEY}=1 under "env" in settings.json). They cost some context on every request. Docs: ${DOCS_URL} and ${ENV_URL}`,
61    'This command changes nothing and writes no file.',
62  ]
63    .filter(Boolean)
64    .join('\n')
65}
66
hooks/rail.tsx 76 lines
1// Ported from Flightdeck by Stephen Casella (MIT), see third_party/flightdeck-LICENSE.txt.
2// A connector drawn on the surface's own frame clock: packets travel along it while `active`,
3// and it rests as a plain dim line otherwise. Only this region redraws; the pane does not.
4import type { ClientModule } from 'claude-code'
5
6type Props = {
7  active: boolean
8  width: number
9  color: string
10  dim: string
11  /** Cells where a branch drops (┬); the rest of the line is ─. */
12  marks: number[]
13  /** Draw ┴ instead of ┬ at the marks: a merge into what is below. */
14  isMerge: boolean
15}
16
17type Ref = { phase: number; active: boolean }
18type State = { ref: Ref }
19
20const STEP_MS = 110
21
22const Rail: ClientModule<Props, State> = (props, surface) => {
23  const { Box, Text } = surface.elements
24  const ref = surface.state?.ref ?? { phase: 0, active: props.active }
25  ref.active = props.active
26  if (surface.state === undefined) {
27    surface.setState({ ref })
28    surface.every(STEP_MS, () => {
29      if (ref.active) {
30        ref.phase += 1
31        surface.setState({ ref })
32      }
33    })
34  }
35
36  const width = Math.max(1, surface.columns || props.width)
37  const cells = Array.from({ length: width }, () => '─')
38  for (const m of props.marks) if (m >= 0 && m < width) cells[m] = props.isMerge ? '┴' : '┬'
39
40  if (!props.active) {
41    return (
42      <Box>
43        <Text color={props.dim}>{cells.join('')}</Text>
44      </Box>
45    )
46  }
47
48  // Two packets per 24 cells, a bright head and a trailing dot, moving left to right.
49  const lit = new Map<number, string>()
50  for (let base = 0; base < width + 24; base += 24) {
51    const head = (base + ref.phase) % (width + 24)
52    if (head < width) lit.set(head, '●')
53    if (head - 1 >= 0 && head - 1 < width) lit.set(head - 1, '•')
54  }
55  // Runs, not cells: consecutive cells of one kind share a Text, a handful of nodes per frame.
56  const runs: { text: string; isLit: boolean }[] = []
57  cells.forEach((ch, i) => {
58    const isLit = lit.has(i)
59    const last = runs[runs.length - 1]
60    const glyph = lit.get(i) ?? ch
61    if (last && last.isLit === isLit) last.text += glyph
62    else runs.push({ text: glyph, isLit })
63  })
64  return (
65    <Box>
66      {runs.map(r => (
67        <Text color={r.isLit ? props.color : props.dim} bold={r.isLit}>
68          {r.text}
69        </Text>
70      ))}
71    </Box>
72  )
73}
74
75export default Rail
76
hooks/elapsed.tsx 39 lines
1// Ported from Flightdeck by Stephen Casella (MIT), see third_party/flightdeck-LICENSE.txt.
2// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
3// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
4import type { ClientModule } from 'claude-code'
5
6type Props = { since: number; now: number; endAt: number | null; color: string }
7type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
8type State = { ref: Ref }
9
10const fmt = (ms: number) => {
11  const s = Math.max(0, Math.floor(ms / 1000))
12  const m = Math.floor(s / 60)
13  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
14}
15
16const Elapsed: ClientModule<Props, State> = (props, surface) => {
17  const { Text } = surface.elements
18  const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
19  if (props.now !== ref.lastNow) {
20    // A fresh reading from the hooks module: restart the local count from it.
21    ref.lastNow = props.now
22    ref.base = (props.endAt ?? props.now) - props.since
23    ref.ticks = 0
24  }
25  ref.isRunning = props.endAt === null
26  if (surface.state === undefined) {
27    surface.setState({ ref })
28    surface.every(1000, () => {
29      if (ref.isRunning) {
30        ref.ticks += 1
31        surface.setState({ ref })
32      }
33    })
34  }
35  return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
36}
37
38export default Elapsed
39
hooks/clawd-anim.tsx 49 lines
1// A Clawd that animates on the surface's own frame clock, so the pane need not redraw for it.
2import type { ClientModule } from 'claude-code'
3
4import type { Verb } from '../types'
5import { bigFrame, isMoving, miniFrame, propFrame } from './clawd'
6
7type Props = { verb: Verb; size: 'big' | 'mini'; color: string; propColor: string }
8type Ref = { frame: number; verb: Verb }
9type State = { ref: Ref }
10
11const STEP_MS = 420
12
13const ClawdAnim: ClientModule<Props, State> = (props, surface) => {
14  const { Box, Text } = surface.elements
15  const ref = surface.state?.ref ?? { frame: 0, verb: props.verb }
16  ref.verb = props.verb
17  if (surface.state === undefined) {
18    surface.setState({ ref })
19    surface.every(STEP_MS, () => {
20      if (isMoving(ref.verb)) {
21        ref.frame += 1
22        surface.setState({ ref })
23      }
24    })
25  }
26  const f = ref.frame
27  if (props.size === 'mini') {
28    return (
29      <Text>
30        <Text color={props.color}>{miniFrame(props.verb, f)}</Text>
31        <Text color={props.propColor}>{propFrame(props.verb, f)}</Text>
32      </Text>
33    )
34  }
35  const rows = bigFrame(props.verb, f)
36  return (
37    <Box flexDirection="column">
38      {rows.map((r, i) => (
39        <Text>
40          <Text color={props.color}>{r}</Text>
41          <Text color={props.propColor}>{i === 0 ? propFrame(props.verb, f) : '  '}</Text>
42        </Text>
43      ))}
44    </Box>
45  )
46}
47
48export default ClawdAnim
49
hooks/input3d.tsx 53 lines
1// The 3D view's input layer: an empty region over the picture that catches the pointer and
2// posts it to the hooks module in batches, as fractions of the view (0..1), so it does not
3// matter how many pixels a cell holds. The hooks module forwards it to the renderer.
4import type { ClientPointerEvent, ClientSurface } from 'claude-code'
5
6type Props = { cols: number; rows: number }
7type State = { cols: number; rows: number }
8type Ev = { kind: 'down' | 'up' | 'move' | 'leave'; button?: string; x: number; y: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
9
10const FLUSH_MS = 30
11
12export function toEvent(e: ClientPointerEvent, cols: number, rows: number): Ev {
13  const fx = e.fine?.x ?? e.x + 0.5
14  const fy = e.fine?.y ?? e.y + 0.5
15  const kind = e.type === 'down' || e.type === 'up' ? e.type : e.type === 'leave' ? 'leave' : 'move'
16  return {
17    kind,
18    button: e.button,
19    x: Math.max(0, Math.min(1, fx / Math.max(1, cols))),
20    y: Math.max(0, Math.min(1, fy / Math.max(1, rows))),
21    shift: e.shift === true || undefined,
22    alt: e.alt === true || undefined,
23    ctrl: e.ctrl === true || undefined,
24  }
25}
26
27export default function Input3d(props: Props, surface: ClientSurface<State>) {
28  const { Box } = surface.elements
29  const queue: Ev[] = []
30  if (surface.state === undefined) {
31    surface.setState({ cols: props.cols, rows: props.rows })
32    surface.onPointer(e => {
33      const st = surface.state ?? { cols: props.cols, rows: props.rows }
34      if (e.type === 'enter') surface.post({ type: 'hover', inside: true })
35      if (e.type === 'leave') surface.post({ type: 'hover', inside: false })
36      const ev = toEvent(e, st.cols, st.rows)
37      // Hover moves only keep the latest: the page needs where the pointer is, not its path.
38      const last = queue[queue.length - 1]
39      if (ev.kind === 'move' && !e.button && last?.kind === 'move' && !last.button) queue[queue.length - 1] = ev
40      else queue.push(ev)
41      if (e.type === 'move') surface.post({ type: 'at', x: ev.x, y: ev.y })
42    })
43    surface.every(FLUSH_MS, () => {
44      if (queue.length === 0) return
45      surface.post({ type: 'input', events: queue.splice(0, queue.length) as unknown as never })
46    })
47  }
48  if (surface.state && (surface.state.cols !== props.cols || surface.state.rows !== props.rows)) {
49    surface.setState({ cols: props.cols, rows: props.rows })
50  }
51  return <Box width={props.cols} height={props.rows} />
52}
53