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…

Watch your Claude Code agents work — as little 3D characters on your desktop, in your terminal or in a browser.
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">
/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.
| Command | What you see | |
|---|---|---|
| 🖥️ Desktop pet | /crew pet | A transparent, always-on-top window per session. Each agent is a character that acts out its work; its files float beside it. |
| 📟 Terminal pane | /crew | A dashboard: the agent tree, the plans and docs they touch, cost, context, permission checks and a log. |
| 🌐 Browser | /crew web | The full 3D view with session vitals and a live event log. |
| 🧊 3D in the pane | /crew 3d | The 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>
| Do | It does |
|---|---|
| Drag a character or the window | Carries the window; the character dangles and swings |
| Shake while carrying | It gets dizzy: spiral eyes, stars, a stagger |
| Click a character | Pinch: it squashes and hops |
| Stroke it (hover back and forth) | Pet: happy eyes and hearts |
| Click a file | Opens it |
| Hover a character or a file | A card: status and steps, or a file summary |
| Watch the tasks | Each 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 queue | Reports 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-click | Character ▸, Ask Claude…, Settings…, open, copy path |
| Right-click a finished agent | Dismiss (it waves and pops away); a running one: Ask Claude to stop it |
| Wheel · Ctrl+wheel · right-drag | Zoom · 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>
<img src="docs/screenshots/characters.png" alt="Crab, bunny, cat, hamster and robot doing think, edit and run">
| Id | Picked by auto for | |
|---|---|---|
| 🦀 | crab (default) | lead, main, generalist |
| 🐰 | bunny | docs, write, summarize, web |
| 🐱 | cat | explore, search, review |
| 🐹 | mochi | plan, architect, design |
| 🤖 | robot | code, 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).
| Command | Does | |||
|---|---|---|---|---|
/crew · /crew close · /crew reset | Open, 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 setup | Show 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.
~/.claude/crew-graph/, and only after /crew web, /crew pet or /crew characters reload.Details: What it can reach.
The guide has everything else: every panel, the pet's settings file, the character format, the 3D renderer, and how to develop the mod.
MIT, see LICENSE.
third_party/flightdeck-LICENSE.txt). crew-graph absorbs it; the ported files carry a header note.plugins/crew-graph/web/THREE_LICENSE.txt, third_party/three-LICENSE.txt).hooks/register.tsx 2554 lines1// 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') continuehooks/deck/core.ts 575 lines1// 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
575hooks/clawd.ts 68 lines1// 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] ?? ''
68hooks/characters.ts 601 lines1// 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}
601hooks/pet-ask.ts 192 lines1// 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}
192hooks/crew.ts 263 lines1// 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}
263hooks/tasks.ts 445 lines1// 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}
445hooks/setup.ts 66 lines1// 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}
66hooks/rail.tsx 76 lines1// 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
76hooks/elapsed.tsx 39 lines1// 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
39hooks/clawd-anim.tsx 49 lines1// 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
49hooks/input3d.tsx 53 lines1// 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