Side pane with a card per subagent and background process: animated crab, model, context fill, idle warning, API-equivalent cost, and expandable operations…

A side pane for Claude Code that shows every subagent and background process of the session, each subagent with a pixel crab that types while it works.
⚠ quiet.▸ opens its result, operations, files touched (with +/− lines) and a token and cost breakdown.TaskStop, the engine's task list or a Monitor's deadline. A running one has ■ Stop, which asks once more before it calls TaskStop. An ended one folds its command behind ▸.Clear finished (c) drops what has ended.
Costs are API list prices, so under a subscription they read as what the same work would cost on the API. Subagent figures are computed from each response's usage: long Haiku 5.5 prompts are billed at the long-prompt rate, cache writes count as five-minute ones until the Agent tool's record says how many were kept an hour, and fast mode doubles the price.
The pane opens with the session and again on your next prompt if it was not placed. /crabs reopens it after you close it by hand.
hooks/register.tsx 652 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, AgentCard, BgProcess, Step } from '../types'
5import { drawPane } from './lib/draw'
6import type { Kit } from './lib/draw'
7import { firstLine, fmtDuration, targetOf, toolLabel } from './lib/format'
8import { addSpend, NO_SPEND, spendOf } from './lib/models'
9import { CRAB_COLORS } from './lib/theme'
10import { addFile, fileTouchOf, kindOf, planOf } from './lib/tools'
11
12const PANE = 'agent-crabs'
13const TITLE = 'Agents & processes'
14// the dock's width in columns; a width dragged by hand still wins
15const COLUMNS = 46
16const KEEP = 20
17// per card: the operations it remembers
18const KEEP_STEPS = 40
19// how often the ticker asks the engine where the subagents stand and checks deadlines
20const RECONCILE_MS = 10_000
21// with no task list from the engine to check against, a process unheard of this long is marked stale
22const STALE_MS = 20 * 60_000
23// a subagent the engine no longer lists is over, once it had time to show up there
24const AGENT_GONE_MS = 30_000
25const MONITOR_GRACE_MS = 5_000
26
27const agents = atom({ plugin: 'agent-crabs', key: 'agents' } as const, [])
28const processes = atom({ plugin: 'agent-crabs', key: 'processes' } as const, [])
29const windowSize = atom({ plugin: 'agent-crabs', key: 'window' } as const, 0)
30const clockNow = atom({ plugin: 'agent-crabs', key: 'now' } as const, 0)
31// set when the person closes the pane by hand: their prompts stop reopening it until /crabs
32const closedByHand = atom({ plugin: 'agent-crabs', key: 'closedByHand' } as const, false)
33const expanded = atom({ plugin: 'agent-crabs', key: 'expanded' } as const, [])
34const confirmStop = atom({ plugin: 'agent-crabs', key: 'confirmStop' } as const, '')
35
36// The person's own prompt counts as asking, so a pane opened from its hook is placed at any width,
37// where the open from session.start waits undrawn below 144 columns.
38async function openOnPrompt($: EngineInterface) {
39 if (await read($, closedByHand)) return
40 const panes = await $.ui.panes().catch(() => [])
41 if (panes.some(pane => pane.id === PANE && pane.isPlaced)) return
42 await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
43}
44
45function blank(id: string, now: number, crab: number): AgentCard {
46 return {
47 ...NO_SPEND,
48 id,
49 role: 'subagent',
50 description: '',
51 model: '',
52 startedAt: now,
53 status: 'running',
54 tokensIn: 0,
55 tokensOut: 0,
56 tokensCacheWrite: 0,
57 tokensCacheRead: 0,
58 ctx: 0,
59 steps: 0,
60 tools: 0,
61 history: [],
62 files: [],
63 crab,
64 lastEventAt: now,
65 }
66}
67
68// A card kept in the session's state by an earlier version lacks the newer fields;
69// its spend is rebuilt from its token counts at its model's price.
70function normalize(card: AgentCard): AgentCard {
71 const tokens = {
72 input_tokens: card.tokensIn,
73 output_tokens: card.tokensOut,
74 cache_read_input_tokens: card.tokensCacheRead ?? 0,
75 cache_creation_input_tokens: card.tokensCacheWrite ?? 0,
76 }
77 const spend = card.costIn === undefined ? (spendOf(card.model, tokens) ?? { ...NO_SPEND, unpriced: true }) : {}
78 return {
79 ...card,
80 ...spend,
81 tokensCacheWrite: card.tokensCacheWrite ?? 0,
82 tokensCacheRead: card.tokensCacheRead ?? 0,
83 history: card.history ?? [],
84 files: card.files ?? [],
85 }
86}
87
88// Cards the person cleared or the list evicted: late events for them must not bring back a blank card.
89const dismissed = new Set<string>()
90
91async function upsert($: EngineInterface, id: string, fn: (card: AgentCard) => AgentCard) {
92 if (dismissed.has(id)) return
93 const now = await $.clock.now()
94 await update($, agents, list => {
95 const found = list.find(card => card.id === id)
96 if (found) return list.map(card => (card.id === id ? fn(normalize(card)) : card))
97 const kept = list.length >= KEEP ? dropOldestDone(list) : list
98 return [...kept, fn(blank(id, now, pickCrab(kept)))]
99 })
100}
101
102function pickCrab(list: readonly AgentCard[]): number {
103 const free = (taken: readonly AgentCard[]) => {
104 const used = new Set(taken.map(card => card.crab))
105 return CRAB_COLORS.findIndex((_, i) => !used.has(i))
106 }
107 const unused = free(list)
108 if (unused !== -1) return unused
109 const notRunning = free(list.filter(card => card.status === 'running'))
110 return notRunning !== -1 ? notRunning : list.length % CRAB_COLORS.length
111}
112
113function dropOldestDone(list: readonly AgentCard[]): readonly AgentCard[] {
114 const oldest = list.filter(card => card.status !== 'running').sort((a, b) => a.startedAt - b.startedAt)[0]
115 if (!oldest) return list
116 dismissed.add(oldest.id)
117 return list.filter(card => card !== oldest)
118}
119
120// The Agent tool's record, once a subagent returns, says how long its cache writes are kept and its speed.
121function agentRecordOf(result: unknown): { agentId: string; share?: number; fast: boolean } | undefined {
122 const record = result as {
123 agentId?: unknown
124 usage?: { cache_creation?: { ephemeral_1h_input_tokens?: unknown; ephemeral_5m_input_tokens?: unknown } | null; speed?: unknown }
125 } | null
126 if (!record || typeof record.agentId !== 'string' || !record.usage) return undefined
127 const hour = Number(record.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0)
128 const five = Number(record.usage.cache_creation?.ephemeral_5m_input_tokens ?? 0)
129 return {
130 agentId: record.agentId,
131 ...(hour + five > 0 ? { share: hour / (hour + five) } : {}),
132 fast: record.usage.speed === 'fast',
133 }
134}
135
136// what a tool call this module makes itself came to, or why it could not run
137type Ran = { deny?: string; isError?: boolean }
138
139let fallbackIds = 0
140let lastReconcileAt = 0
141// the engine has handed this module its list of background tasks at least once
142let hasTaskList = false
143
144export const register: Register = on => {
145 on('session.start', async ($, e, next) => {
146 await $.command.register({ name: 'crabs', description: 'Show the pane of subagents and background processes' })
147 void $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
148 void $.session
149 .usage()
150 .then(usage => measure($, usage))
151 .catch(() => undefined)
152 $.clock.every(1000, () => {
153 void (async () => {
154 const [list, procs] = await Promise.all([read($, agents), read($, processes)])
155 const isLive = (item: { status: string }) => item.status === 'running'
156 if (!list.some(isLive) && !procs.some(isLive)) return
157 const now = await $.clock.now()
158 if (now - lastReconcileAt >= RECONCILE_MS) {
159 lastReconcileAt = now
160 try {
161 await reconcile($, now)
162 } catch {
163 // the next tick tries again
164 }
165 }
166 await update($, clockNow, () => now)
167 })()
168 })
169
170 return next(e)
171 })
172
173 on('command.run', { command: 'crabs' }, async $ => {
174 await update($, closedByHand, () => false)
175 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
176
177 return {
178 text: opened.isPlaced ? 'Agents & processes pane is open.' : `The pane is not placed: ${opened.reason}`,
179 }
180 })
181
182 on('ui.close', { id: PANE }, async ($, e, next) => {
183 if (e.origin.kind === 'person') await update($, closedByHand, () => true)
184
185 return next(e)
186 })
187
188 // After each main-thread turn the engine measures the window: a subagent's context fill is read against it.
189 on('session.measure', async ($, e, next) => {
190 try {
191 await measure($, e)
192 } catch {
193 // the pane keeps the last figures
194 }
195
196 return next(e)
197 })
198
199 on('agent.spawn', async ($, e, next) => {
200 const started = await next(e)
201 if (started.agentId === undefined) return started
202 try {
203 const role = e.name ? `${e.subagentType} · ${e.name}` : e.subagentType
204 await upsert($, started.agentId, card => ({
205 ...card,
206 role,
207 description: e.description,
208 model: card.model || started.model,
209 ...(e.parentAgentId !== undefined ? { parentId: e.parentAgentId } : {}),
210 }))
211 } catch {
212 // the spawn already happened; the card simply stays as it is
213 }
214
215 return started
216 })
217
218 on('turn.step', async function* ($, e, next) {
219 if (e.agentId === undefined) return yield* next(e)
220 const agentId = e.agentId
221 const stepAt = await $.clock.now()
222 await upsert($, agentId, card => ({
223 ...card,
224 ...(card.status === 'running' ? {} : { startedAt: stepAt, endedAt: undefined, result: undefined }),
225 model: e.model,
226 effort: e.effort === undefined ? card.effort : String(e.effort),
227 steps: card.steps + 1,
228 status: 'running',
229 lastEventAt: stepAt,
230 }))
231 const result = yield* next(e)
232 const usage = result.usage
233 const said = firstLine(result.answer ?? '', 200)
234 const doneAt = await $.clock.now()
235 await upsert($, agentId, card => {
236 const model = usage?.model || card.model
237 const counted: AgentCard = {
238 ...card,
239 lastEventAt: doneAt,
240 ...(said !== '' ? { lastText: said } : {}),
241 ...(usage
242 ? {
243 model,
244 ctx: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
245 tokensIn: card.tokensIn + usage.input_tokens,
246 tokensOut: card.tokensOut + usage.output_tokens,
247 tokensCacheWrite: card.tokensCacheWrite + usage.cache_creation_input_tokens,
248 tokensCacheRead: card.tokensCacheRead + usage.cache_read_input_tokens,
249 }
250 : {}),
251 }
252 return usage ? addSpend(counted, spendOf(model, usage)) : counted
253 })
254
255 return result
256 })
257
258 on('tool.call', async ($, e, next) => {
259 const tool = String(e.tool)
260 const args = e as unknown as Record<string, unknown>
261 const agentId = e.agentId
262 const startedAt = await $.clock.now()
263 const activity: Activity | undefined =
264 agentId === undefined
265 ? undefined
266 : {
267 id: e.tool_use_id ?? `${tool}:${(fallbackIds += 1)}`,
268 tool: toolLabel(tool),
269 target: targetOf(args),
270 kind: kindOf(tool),
271 }
272 if (agentId !== undefined && activity) {
273 const step: Step = { ...activity, status: 'running', startedAt }
274 const plan = tool === 'TodoWrite' ? planOf(args) : undefined
275 await upsert($, agentId, card => ({
276 ...card,
277 activity,
278 tools: card.tools + 1,
279 lastEventAt: startedAt,
280 history: [...card.history, step].slice(-KEEP_STEPS),
281 ...(plan ? { plan } : {}),
282 }))
283 }
284 const ran = await next(e)
285 try {
286 if (agentId !== undefined && activity) {
287 const endedAt = await $.clock.now()
288 const failed = ran.deny !== undefined || ran.isError === true
289 const touch = failed ? undefined : fileTouchOf(tool, args)
290 const note = ran.deny !== undefined
291 ? 'denied'
292 : failed
293 ? 'failed'
294 : touch && touch.op !== 'R'
295 ? `+${touch.added} −${touch.removed}`
296 : endedAt - startedAt >= 1000
297 ? fmtDuration(endedAt - startedAt)
298 : undefined
299 await upsert($, agentId, card => ({
300 ...card,
301 lastEventAt: endedAt,
302 ...(card.activity?.id === activity.id ? { activity: undefined } : {}),
303 history: card.history.map(step =>
304 step.id === activity.id
305 ? { ...step, status: failed ? ('error' as const) : ('ok' as const), endedAt, ...(note ? { note } : {}) }
306 : step,
307 ),
308 ...(touch ? { files: addFile(card.files, touch) } : {}),
309 }))
310 }
311 if (tool === 'Agent' && ran.deny === undefined && !ran.isError) {
312 const record = agentRecordOf(ran.result)
313 if (record) {
314 await upsert($, record.agentId, card => ({
315 ...card,
316 ...(record.share !== undefined ? { cacheWrite1hShare: record.share } : {}),
317 fast: record.fast,
318 }))
319 }
320 }
321 if (ran.deny === undefined && !ran.isError) await trackProcess($, tool, args, ran.result, agentId)
322 } catch {
323 // the tool already ran; the panel just misses this update
324 }
325
326 return ran
327 })
328
329 on('prompt.submit', async ($, e, next) => {
330 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
331 try {
332 await openOnPrompt($)
333 } catch {
334 // the prompt goes on; /crabs still opens the pane
335 }
336 }
337 if (e.origin.kind === 'task-notification') {
338 try {
339 await endFromNotification($, e.text)
340 } catch {
341 // the notification still reaches the model
342 }
343 }
344
345 return next(e)
346 })
347
348 // A notification that lands while a turn runs reaches the model as an attachment, not a new prompt.
349 on('prompt.attachment', async ($, e, next) => {
350 if (e.text.includes('<task-notification')) {
351 try {
352 await endFromNotification($, e.text)
353 } catch {
354 // the attachment still reaches the model
355 }
356 }
357
358 return next(e)
359 })
360
361 // At each stop the engine lists the background work still in flight: what is not there has ended.
362 on('classic.Stop', async ($, e, next) => {
363 try {
364 if (e.background_tasks) await applyTaskList($, e.background_tasks, () => true)
365 } catch {
366 // the panel keeps what it had
367 }
368
369 return next(e)
370 })
371
372 // A subagent's list may hold its own work alone, so only its own processes are checked against it.
373 on('classic.SubagentStop', async ($, e, next) => {
374 try {
375 if (e.background_tasks) await applyTaskList($, e.background_tasks, proc => proc.owner === e.agent_id)
376 } catch {
377 // the panel keeps what it had
378 }
379
380 return next(e)
381 })
382
383 on('turn.complete', async ($, e, next) => {
384 if (e.agentId !== undefined) {
385 const agentId = e.agentId
386 const now = await $.clock.now()
387 const answer = firstLine(e.answer ?? '', 200)
388 await upsert($, agentId, card => ({
389 ...card,
390 // a TaskStop already said how it ended
391 status: card.status === 'killed' ? 'killed' : e.reason === 'answer' ? 'done' : 'error',
392 endedAt: card.endedAt ?? now,
393 activity: undefined,
394 history: card.history.map(step => (step.status === 'running' ? { ...step, status: 'error' as const, endedAt: now } : step)),
395 ...(answer !== '' ? { result: answer } : {}),
396 }))
397 // its commands the engine ends with this final response send no notification
398 await update($, processes, all =>
399 all.map(proc =>
400 proc.owner === agentId && proc.endsWithOwner && proc.status === 'running'
401 ? { ...proc, status: 'killed' as const, endedAt: now, summary: proc.summary ?? 'ended with its agent' }
402 : proc,
403 ),
404 )
405 await update($, clockNow, () => now)
406 }
407
408 return next(e)
409 })
410
411 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
412 const { Box, Text, Button } = $.ui.resolve(e)
413 const Svg = e.surface === 'terminal' ? undefined : $.ui.resolve(e).Svg
414 const list = (await read($, agents)).map(normalize)
415 const procs = await read($, processes)
416 const window = await read($, windowSize)
417 const open = new Set(await read($, expanded))
418 const confirming = await read($, confirmStop)
419 // the ticker's value only subscribes this drawing to redraw each second
420 await read($, clockNow)
421 const tick = await $.clock.now()
422 const kit: Kit = {
423 Box,
424 Text,
425 Button,
426 Svg,
427 inner: Math.max(16, e.props.bodyColumns - 4),
428 tick,
429 window: window > 0 ? window : undefined,
430 busy: new Set(list.filter(card => card.status === 'running' && card.parentId !== undefined).map(card => card.parentId as string)),
431 confirming,
432 askStop: async (id: string) => {
433 await update($, confirmStop, () => id)
434 },
435 stop: async (id: string) => {
436 await update($, confirmStop, () => '')
437 // the same TaskStop the model would make; a call of this module's own skips its tool.call hook,
438 // so the card is marked here
439 const ran: Ran = await $.tool.call({ tool: 'TaskStop', task_id: id } as never).catch((error: unknown) => ({ deny: String(error) }))
440 if (ran.deny !== undefined || ran.isError) {
441 await $.ui.toast(`Could not stop it: ${ran.deny ?? 'TaskStop failed'}`)
442 return
443 }
444 await endTask($, id, 'killed', await $.clock.now())
445 },
446 toggle: async (id: string) => {
447 await update($, expanded, ids => (ids.includes(id) ? ids.filter(other => other !== id) : [...ids, id]))
448 },
449 clear: async () => {
450 // both writes leave together, so the redraw the first one causes cannot strand the second
451 await Promise.all([
452 update($, agents, all => {
453 for (const card of all) if (card.status !== 'running') dismissed.add(card.id)
454 return all.filter(card => card.status === 'running')
455 }),
456 update($, processes, all => all.filter(proc => proc.status === 'running')),
457 ])
458 },
459 }
460
461 return drawPane(kit, { list, procs, open })
462 })
463}
464
465async function measure($: EngineInterface, { context }: { context: { window: number } }) {
466 await update($, windowSize, () => context.window)
467}
468
469async function trackProcess(
470 $: EngineInterface,
471 tool: string,
472 args: Record<string, unknown>,
473 result: unknown,
474 owner: string | undefined,
475) {
476 if (tool === 'TaskStop') {
477 const id = args.task_id ?? args.shell_id
478 if (typeof id === 'string') await endTask($, id, 'killed', await $.clock.now())
479 return
480 }
481 if (tool !== 'Bash' && tool !== 'Monitor') return
482 const out = (result ?? {}) as {
483 backgroundTaskId?: unknown
484 taskId?: unknown
485 timeoutMs?: unknown
486 persistent?: unknown
487 backgroundEndsWithFinalResponse?: unknown
488 }
489 const id = tool === 'Bash' ? out.backgroundTaskId : out.taskId
490 if (typeof id !== 'string' || id === '') return
491 const command = typeof args.command === 'string' ? args.command : ''
492 const ws = (args.ws as { url?: unknown } | undefined)?.url
493 const source = command || (typeof ws === 'string' ? ws : '')
494 const description = typeof args.description === 'string' && args.description !== '' ? args.description : source
495 const startedAt = await $.clock.now()
496 const timeoutMs = typeof out.timeoutMs === 'number' && out.persistent !== true ? out.timeoutMs : 0
497 const proc: BgProcess = {
498 id,
499 kind: tool === 'Bash' ? 'shell' : 'monitor',
500 description: firstLine(description) || 'background task',
501 command: firstLine(source, 200),
502 owner,
503 startedAt,
504 status: 'running',
505 ...(timeoutMs > 0 ? { deadlineAt: startedAt + timeoutMs } : {}),
506 ...(out.backgroundEndsWithFinalResponse === true ? { endsWithOwner: true } : {}),
507 }
508 await update($, processes, list => {
509 const rest = list.filter(item => item.id !== id)
510 const kept = rest.length >= KEEP ? dropOldestProcess(rest) : rest
511 return [...kept, proc]
512 })
513}
514
515function dropOldestProcess(list: readonly BgProcess[]): readonly BgProcess[] {
516 const oldest = list.filter(item => item.status !== 'running').sort((a, b) => a.startedAt - b.startedAt)[0]
517 return oldest ? list.filter(item => item !== oldest) : list
518}
519
520function tagOf(text: string, tag: string): string | undefined {
521 return new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text)?.[1]?.trim()
522}
523
524function tagsOf(text: string, tag: string): string[] {
525 return [...text.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))].map(m => (m[1] ?? '').trim())
526}
527
528type Ending = { id: string; status: 'done' | 'error' | 'killed'; summary?: string }
529
530// Each <task-notification> block names one task and how it ended; several can come in one text.
531function endingsOf(text: string): Ending[] {
532 const blocks = tagsOf(text, 'task-notification')
533 return (blocks.length > 0 ? blocks : [text]).flatMap(block => {
534 const word = (tagOf(block, 'status') ?? 'completed').toLowerCase()
535 const status = word === 'completed' ? 'done' : word === 'killed' || word === 'stopped' ? 'killed' : 'error'
536 const summary = tagOf(block, 'summary')
537 const ids = [...tagsOf(block, 'task-id'), ...tagsOf(block, 'shell-id')].filter(id => id !== '')
538 return [...new Set(ids)].map(id => ({ id, status, ...(summary === undefined ? {} : { summary: firstLine(summary) }) }))
539 })
540}
541
542// Ids this panel never saw are left alone.
543async function endFromNotification($: EngineInterface, text: string) {
544 const endings = endingsOf(text)
545 if (endings.length === 0) return
546 const now = await $.clock.now()
547 for (const ending of endings) await endTask($, ending.id, ending.status, now, ending.summary)
548}
549
550// One id names either a background process or a subagent: whichever card holds it, while it runs.
551async function endTask($: EngineInterface, id: string, status: Ending['status'], now: number, summary?: string) {
552 await update($, processes, list =>
553 list.map(item =>
554 item.id === id && (item.status === 'running' || item.status === 'stale')
555 ? { ...item, status, endedAt: now, summary: summary ?? item.summary }
556 : item,
557 ),
558 )
559 await endAgent($, id, status, now)
560 await update($, clockNow, () => now)
561}
562
563async function endAgent($: EngineInterface, id: string, status: AgentCard['status'], now: number) {
564 await update($, agents, list =>
565 list.map(card =>
566 card.id === id && card.status === 'running'
567 ? {
568 ...card,
569 status,
570 endedAt: now,
571 activity: undefined,
572 history: (card.history ?? []).map(step =>
573 step.status === 'running' ? { ...step, status: status === 'done' ? ('ok' as const) : ('error' as const), endedAt: now } : step,
574 ),
575 }
576 : card,
577 ),
578 )
579}
580
581type TaskSummary = { id: string; type: string; status: string; description: string; command?: string }
582
583// The engine's list of background work still in flight: a running process it leaves out has ended,
584// one it names is alive, and one it names that the panel lost (cleared, or started before a reload) comes back.
585async function applyTaskList($: EngineInterface, tasks: readonly TaskSummary[], isChecked: (proc: BgProcess) => boolean) {
586 hasTaskList = true
587 const now = await $.clock.now()
588 const live = new Map(tasks.map(task => [task.id, task]))
589 await update($, processes, list => {
590 const known = new Set(list.map(proc => proc.id))
591 const checked = list.map(proc => {
592 if (live.has(proc.id)) {
593 return proc.status === 'running' || proc.status === 'stale'
594 ? { ...proc, status: 'running' as const, endedAt: undefined, lastSeenAt: now }
595 : proc
596 }
597 if ((proc.status === 'running' || proc.status === 'stale') && isChecked(proc)) {
598 return { ...proc, status: 'done' as const, endedAt: now, summary: proc.summary ?? 'not in the engine task list' }
599 }
600 return proc
601 })
602 const back: BgProcess[] = tasks
603 .filter(task => !known.has(task.id) && (task.type === 'shell' || task.type === 'monitor'))
604 .map(task => ({
605 id: task.id,
606 kind: task.type === 'shell' ? 'shell' : 'monitor',
607 description: firstLine(task.description) || 'background task',
608 command: firstLine(task.command ?? '', 200),
609 startedAt: now,
610 lastSeenAt: now,
611 status: 'running',
612 }))
613 let kept: readonly BgProcess[] = checked
614 for (const proc of back) kept = [...(kept.length >= KEEP ? dropOldestProcess(kept) : kept), proc]
615 return [...kept]
616 })
617 await update($, clockNow, () => now)
618}
619
620// Between stops: where the engine says each subagent stands, the Monitors past their deadline,
621// and, with no task list ever given to check against, the processes unheard of too long.
622async function reconcile($: EngineInterface, now: number) {
623 const cards = await read($, agents)
624 if (cards.some(card => card.status === 'running')) {
625 const infos = await $.agent.list()
626 const byId = new Map(infos.map(info => [info.id, info]))
627 for (const card of cards) {
628 if (card.status !== 'running') continue
629 const info = byId.get(card.id)
630 if (info === undefined) {
631 if (now - card.startedAt > AGENT_GONE_MS) await endAgent($, card.id, 'done', now)
632 continue
633 }
634 if (info.status === 'completed' || info.status === 'idle') await endAgent($, card.id, 'done', now)
635 else if (info.status === 'failed') await endAgent($, card.id, 'error', now)
636 else if (info.status === 'killed') await endAgent($, card.id, 'killed', now)
637 }
638 }
639 await update($, processes, list =>
640 list.map(proc => {
641 if (proc.status !== 'running') return proc
642 if (proc.deadlineAt !== undefined && now > proc.deadlineAt + MONITOR_GRACE_MS) {
643 return { ...proc, status: 'done' as const, endedAt: proc.deadlineAt, summary: proc.summary ?? 'monitor deadline passed' }
644 }
645 if (!hasTaskList && now - (proc.lastSeenAt ?? proc.startedAt) > STALE_MS) {
646 return { ...proc, status: 'stale' as const, endedAt: now }
647 }
648 return proc
649 }),
650 )
651}
652hooks/lib/draw.tsx 522 lines1import type { BoxProps, ButtonProps, ElementConstructor, SvgProps, TextProps } from 'claude-code'
2
3import type { AgentCard, BgProcess, Step } from '../../types'
4import { fmtDuration, fmtTokens, fmtUsd, plural, shortPath } from './format'
5import { costOf, modelColor, modelLabel, windowOf } from './models'
6import type { Cost } from './models'
7import { barSvg, crabSvg } from './svg'
8import { AGENT_STATUS, CRAB_COLORS, ERR, FILE_OP, KIND_STYLE, OK, MUTED, PROCESS_END, PROCESS_STYLE, WARN } from './theme'
9
10// how many operations an open card lists
11const SHOW_STEPS = 10
12// a running subagent this long without a step or tool call of its own, and no child at work, may be stuck
13const QUIET_MS = 90_000
14// a tool's or a pause's running time shows once it passes this
15const SHOW_ELAPSED_MS = 10_000
16
17const ASCII_CRAB = [
18 [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ '],
19 [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▝▝ ▘▘ '],
20]
21const ASCII_CRAB_WIDTH = 9
22
23export type Kit = {
24 Box: ElementConstructor<BoxProps>
25 Text: ElementConstructor<TextProps>
26 Button: ElementConstructor<ButtonProps>
27 Svg?: ElementConstructor<SvgProps>
28 // the card's inside: the pane less its border and padding
29 inner: number
30 tick: number
31 // the session's context window, when the engine gave one
32 window?: number
33 // the subagents with a running child of their own
34 busy: ReadonlySet<string>
35 // the process whose Stop waits for its second press, '' for none
36 confirming: string
37 askStop: (id: string) => Promise<void>
38 stop: (id: string) => Promise<void>
39 toggle: (id: string) => Promise<void>
40 clear: () => Promise<void>
41}
42
43export type PaneData = {
44 list: readonly AgentCard[]
45 procs: readonly BgProcess[]
46 open: ReadonlySet<string>
47}
48
49export function drawPane(kit: Kit, { list, procs, open }: PaneData) {
50 const { Box, Text, Button } = kit
51 const runningAgents = list.filter(card => card.status === 'running').length
52 const runningProcs = procs.filter(proc => proc.status === 'running').length
53 const finished = list.length - runningAgents + procs.length - runningProcs
54 const costs = list.map(card => costOf(card))
55 const spent = costs.reduce((sum, cost) => sum + (cost?.total ?? 0), 0)
56 const ownerOf = (id: string | undefined) => (id === undefined ? 'main' : (list.find(card => card.id === id)?.role ?? 'subagent'))
57
58 return (
59 <Box flexDirection="column" gap={1}>
60 <Box flexDirection="row" justifyContent="space-between" gap={1}>
61 <Text weight="semibold" wrap="truncate-end">
62 {list.length + procs.length === 0
63 ? 'No subagents or background processes yet'
64 : `agents ${runningAgents}/${list.length} · bg ${runningProcs}/${procs.length}`}
65 {costs.some(Boolean) ? <Text dimColor tabular>{` ≈ ${fmtUsd(spent)}`}</Text> : ''}
66 </Text>
67 {finished > 0 && <Button key="clear" label="Clear finished" hotkey="c" onPress={() => void kit.clear()} />}
68 </Box>
69 {list.length > 0 && <Text dimColor>── Subagents ──</Text>}
70 {ordered(list).map(({ card, depth }) => (
71 <Box key={`n:${card.id}`} flexDirection="column" marginLeft={Math.min(depth, 3) * 2}>
72 {drawCard(kit, card, depth, open.has(card.id))}
73 </Box>
74 ))}
75 {procs.length > 0 && <Text dimColor>── Background processes ──</Text>}
76 {byRecency(procs).map(proc => drawProcess(kit, proc, ownerOf(proc.owner), open.has(proc.id)))}
77 </Box>
78 )
79}
80
81export function byRecency<T extends { status: string; startedAt: number }>(list: readonly T[]): T[] {
82 return [...list].sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') || b.startedAt - a.startedAt)
83}
84
85// Each subagent right under the one that spawned it, a level deeper.
86export function ordered(list: readonly AgentCard[]): { card: AgentCard; depth: number }[] {
87 const ids = new Set(list.map(card => card.id))
88 const children = new Map<string, AgentCard[]>()
89 const roots: AgentCard[] = []
90 for (const card of list) {
91 if (card.parentId !== undefined && card.parentId !== card.id && ids.has(card.parentId)) {
92 children.set(card.parentId, [...(children.get(card.parentId) ?? []), card])
93 } else {
94 roots.push(card)
95 }
96 }
97 const out: { card: AgentCard; depth: number }[] = []
98 const seen = new Set<string>()
99 const walk = (card: AgentCard, depth: number) => {
100 if (seen.has(card.id)) return
101 seen.add(card.id)
102 out.push({ card, depth })
103 for (const child of byRecency(children.get(card.id) ?? [])) walk(child, depth + 1)
104 }
105 for (const root of byRecency(roots)) walk(root, 0)
106 // parents that point at each other reach no root: they still show
107 for (const card of byRecency(list)) walk(card, 0)
108 return out
109}
110
111function statusColor(card: AgentCard): string {
112 if (card.status === 'done') return OK
113 if (card.status === 'error') return ERR
114 if (card.status === 'killed') return MUTED
115 return CRAB_COLORS[card.crab % CRAB_COLORS.length] ?? '#D97757'
116}
117
118// The context bar warns as the window fills: amber past 70%, red past 90%.
119function fillColor(fraction: number, color: string): string {
120 return fraction >= 0.9 ? ERR : fraction >= 0.7 ? WARN : color
121}
122
123function ctxLabel(ctx: number, window: number, percent?: number): string {
124 return `ctx ${fmtTokens(ctx)}/${fmtTokens(window)} · ${percent ?? Math.round((ctx / window) * 100)}%`
125}
126
127function drawCrab({ Box, Text, Svg, tick }: Kit, card: AgentCard) {
128 const color = statusColor(card)
129 const isRunning = card.status === 'running'
130 if (Svg) {
131 return <Svg source={crabSvg(color, card.status)} alt={`crab typing for ${card.role}`} width={48} height={32} isInteractive />
132 }
133 const rows = ASCII_CRAB[isRunning ? Math.floor(tick / 1000) % 2 : 0] ?? []
134 // the card's text gives way, never the crab: a narrowed crab wraps its rows apart
135 return (
136 <Box flexDirection="column" flexShrink={0} width={ASCII_CRAB_WIDTH}>
137 {rows.map((row, i) => (
138 <Text key={`crab:${i}`} color={color} dimColor={card.status === 'killed'} wrap="truncate-end">
139 {row}
140 </Text>
141 ))}
142 </Box>
143 )
144}
145
146function drawBar({ Text, Svg, inner, tick }: Kit, color: string, fraction: number | undefined, isRunning: boolean, uid: string) {
147 if (Svg) {
148 return (
149 <Svg
150 source={barSvg(color, fraction, isRunning, uid)}
151 alt={fraction === undefined ? 'working' : `${Math.round(fraction * 100)}%`}
152 height={6}
153 isInteractive={isRunning}
154 />
155 )
156 }
157 const width = inner
158 if (fraction === undefined) {
159 // a block that slides along, a little further each second
160 const seg = 6
161 const at = ((Math.floor(tick / 1000) * 3) % (width + seg)) - seg
162 const from = Math.max(0, at)
163 const to = Math.min(width, at + seg)
164 return (
165 <Text wrap="truncate-end">
166 <Text dimColor>{'─'.repeat(from)}</Text>
167 <Text color={color}>{'━'.repeat(Math.max(0, to - from))}</Text>
168 <Text dimColor>{'─'.repeat(Math.max(0, width - Math.max(to, from)))}</Text>
169 </Text>
170 )
171 }
172 const filled = Math.max(fraction > 0 ? 1 : 0, Math.round(width * Math.min(1, fraction)))
173 return (
174 <Text wrap="truncate-end">
175 <Text color={color}>{'━'.repeat(filled)}</Text>
176 <Text dimColor>{'─'.repeat(width - filled)}</Text>
177 </Text>
178 )
179}
180
181function drawNow({ Box, Text, tick, busy }: Kit, card: AgentCard) {
182 if (card.status === 'running' && card.activity) {
183 const style = KIND_STYLE[card.activity.kind]
184 const step = card.history.find(item => item.id === card.activity?.id)
185 const ran = step ? tick - step.startedAt : 0
186 return (
187 <Box flexDirection="row" gap={1}>
188 <Box flexShrink={1} minWidth={0} overflow="hidden">
189 <Text color={style.color} wrap="truncate-middle">
190 <Text weight="semibold">
191 {style.verb} · {card.activity.tool}
192 </Text>
193 {card.activity.target ? ` ${card.activity.target}` : ''}
194 </Text>
195 </Box>
196 {ran >= SHOW_ELAPSED_MS && (
197 <Text dimColor tabular>
198 {fmtDuration(ran)}
199 </Text>
200 )}
201 </Box>
202 )
203 }
204 if (card.status === 'running') {
205 const quiet = tick - (card.lastEventAt ?? card.startedAt)
206 if (quiet >= QUIET_MS && !busy.has(card.id)) {
207 return (
208 <Text color={WARN} wrap="truncate-end">
209 ⚠ quiet {fmtDuration(quiet)}: no step or tool call
210 </Text>
211 )
212 }
213 return (
214 <Text dimColor italic>
215 {quiet >= SHOW_ELAPSED_MS ? `thinking… ${fmtDuration(quiet)}` : 'thinking…'}
216 </Text>
217 )
218 }
219 if (card.status === 'error') {
220 return (
221 <Text color={ERR} wrap="truncate-end">
222 ✕ ended without an answer
223 </Text>
224 )
225 }
226 return undefined
227}
228
229function countsOf(card: AgentCard): string {
230 return `${plural(card.steps, 'step')} · ${plural(card.tools, 'tool')}`
231}
232
233// Its own todo list when it keeps one, else what it has done so far.
234function progressLabel(card: AgentCard): string {
235 if (!card.plan) return countsOf(card)
236 const current = card.status === 'running' && card.plan.current ? ` · ${card.plan.current}` : ''
237 return `${card.plan.done}/${card.plan.total} todos${current}`
238}
239
240function totalTokens(card: AgentCard): number {
241 return card.tokensIn + card.tokensOut + card.tokensCacheWrite + card.tokensCacheRead
242}
243
244function drawCard(kit: Kit, card: AgentCard, depth: number, isOpen: boolean) {
245 const { Box, Text, Button } = kit
246 const color = statusColor(card)
247 const model = modelColor(card.model)
248 const elapsed = (card.endedAt ?? Math.max(kit.tick, card.startedAt)) - card.startedAt
249 const cost = costOf(card)
250 const isRunning = card.status === 'running'
251 const window = windowOf(card.model, kit.window)
252 const fraction = card.ctx > 0 ? card.ctx / window : undefined
253
254 return (
255 <Box key={card.id} flexDirection="column" borderStyle="round" borderColor={isRunning ? model : color} paddingX={1} overflow="hidden">
256 <Box flexDirection="row" gap={1}>
257 {drawCrab(kit, card)}
258 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
259 <Box flexDirection="row" justifyContent="space-between" gap={1}>
260 <Box flexShrink={1} minWidth={0} overflow="hidden">
261 <Text weight="semibold" wrap="truncate-end">
262 {depth > 0 ? `↳ ${card.role}` : card.role}
263 </Text>
264 </Box>
265 <Text color="#FFFFFF" backgroundColor={model}>
266 {` ${modelLabel(card.model)}${card.effort !== undefined ? ` · ${card.effort}` : ''} `}
267 </Text>
268 </Box>
269 {card.description !== '' && (
270 <Text dimColor wrap="truncate-end">
271 {card.description}
272 </Text>
273 )}
274 {drawNow(kit, card)}
275 </Box>
276 </Box>
277 {(fraction !== undefined || isRunning) &&
278 drawBar(kit, fraction === undefined ? color : fillColor(fraction, color), fraction, isRunning, card.id)}
279 <Box flexDirection="row" justifyContent="space-between" gap={1}>
280 <Box flexShrink={1} minWidth={0} overflow="hidden">
281 <Text dimColor wrap="truncate-end">
282 {progressLabel(card)}
283 </Text>
284 </Box>
285 {fraction !== undefined && (
286 <Text dimColor tabular>
287 {ctxLabel(card.ctx, window)}
288 </Text>
289 )}
290 </Box>
291 <Box flexDirection="row" gap={1}>
292 <Text color={color}>{AGENT_STATUS[card.status].text}</Text>
293 <Text dimColor tabular>
294 ⏱ {fmtDuration(elapsed)}
295 </Text>
296 <Text dimColor tabular>
297 {fmtTokens(totalTokens(card))} tok
298 </Text>
299 <Box flexGrow={1} />
300 <Text weight="semibold" tabular>
301 {fmtUsd(cost?.total)}
302 </Text>
303 <Button plain key={`more:${card.id}`} label={isOpen ? '▾' : '▸'} onPress={() => void kit.toggle(card.id)} />
304 </Box>
305 {isOpen && drawDetails(kit, card, cost)}
306 </Box>
307 )
308}
309
310function heading({ Text }: Kit, title: string, count?: number) {
311 return (
312 <Text dimColor weight="semibold">
313 {title.toUpperCase()}
314 {count === undefined ? '' : ` · ${count}`}
315 </Text>
316 )
317}
318
319function priceNote(card: AgentCard): string {
320 const notes = ['API-equivalent list price']
321 if (card.fast) notes.push('fast mode ×2')
322 if (card.tokensCacheWrite > 0) {
323 notes.push(
324 card.cacheWrite1hShare === undefined
325 ? 'cache writes as 5-min until it returns'
326 : `${Math.round(card.cacheWrite1hShare * 100)}% of cache writes kept 1h`,
327 )
328 }
329 return notes.join(' · ')
330}
331
332function drawDetails(kit: Kit, card: AgentCard, cost: Cost | undefined) {
333 const { Box, Text } = kit
334 const steps = card.history.slice(-SHOW_STEPS)
335 const earlier = card.history.length - steps.length
336 const glyph = (step: Step) => (step.status === 'running' ? '◐' : step.status === 'ok' ? '✓' : '✕')
337 const glyphColor = (step: Step) => (step.status === 'running' ? statusColor(card) : step.status === 'ok' ? OK : ERR)
338 const row = (label: string, tokens: number, usd: number | undefined) => (
339 <Box flexDirection="row" justifyContent="space-between">
340 <Text dimColor>{label}</Text>
341 <Text tabular>
342 {fmtTokens(tokens)}
343 <Text dimColor>{` ${fmtUsd(usd)}`}</Text>
344 </Text>
345 </Box>
346 )
347
348 return (
349 <Box flexDirection="column" gap={1} marginTop={1}>
350 {card.status === 'running' && (
351 <Box flexDirection="column">
352 {heading(kit, 'Now')}
353 <Box flexDirection="column" borderStyle="round" borderColor={statusColor(card)} paddingX={1} overflow="hidden">
354 {card.activity ? (
355 <Text wrap="truncate-middle">
356 <Text weight="semibold">{card.activity.tool}</Text>
357 {` ${card.activity.target}`}
358 </Text>
359 ) : (
360 <Text dimColor italic>
361 thinking…
362 </Text>
363 )}
364 {card.lastText !== undefined && (
365 <Text dimColor italic wrap="truncate-end">
366 “{card.lastText}”
367 </Text>
368 )}
369 </Box>
370 </Box>
371 )}
372 {card.status === 'done' && card.result !== undefined && (
373 <Box flexDirection="column">
374 {heading(kit, 'Result')}
375 <Text color={OK} wrap="wrap">
376 ✓ {card.result}
377 </Text>
378 </Box>
379 )}
380 {(card.status === 'error' || card.status === 'killed') && (
381 <Box flexDirection="column">
382 {heading(kit, card.status === 'error' ? 'Error' : 'Stopped')}
383 <Text color={statusColor(card)} wrap="wrap">
384 {card.status === 'error' ? `✕ ${card.lastText ?? 'it ended without an answer'}` : '■ stopped by TaskStop'}
385 </Text>
386 </Box>
387 )}
388 <Box flexDirection="column">
389 {heading(kit, 'Operations', card.history.length)}
390 {earlier > 0 && <Text dimColor>{`… ${earlier} earlier`}</Text>}
391 {steps.length === 0 && <Text dimColor>No tool calls yet</Text>}
392 {steps.map(step => (
393 <Box key={`op:${step.id}`} flexDirection="row" gap={1}>
394 <Text color={glyphColor(step)}>{glyph(step)}</Text>
395 <Text weight="semibold">{step.tool}</Text>
396 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
397 <Text dimColor wrap="truncate-middle">
398 {step.target}
399 </Text>
400 </Box>
401 {step.note !== undefined && (
402 <Text dimColor={step.status !== 'error'} color={step.status === 'error' ? ERR : undefined} tabular>
403 {step.note}
404 </Text>
405 )}
406 </Box>
407 ))}
408 </Box>
409 <Box flexDirection="column">
410 {heading(kit, 'Files', card.files.length)}
411 {card.files.length === 0 && <Text dimColor>No files touched yet</Text>}
412 {card.files.map(file => (
413 <Box key={`file:${file.path}`} flexDirection="row" gap={1}>
414 <Text>
415 {file.ops.map(op => (
416 <Text color="#FFFFFF" backgroundColor={FILE_OP[op].color}>{` ${op} `}</Text>
417 ))}
418 </Text>
419 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
420 <Text wrap="truncate-start">{shortPath(file.path)}</Text>
421 </Box>
422 {file.added + file.removed > 0 && (
423 <Text tabular>
424 <Text color={OK}>{`+${file.added}`}</Text> <Text color={ERR}>{`−${file.removed}`}</Text>
425 </Text>
426 )}
427 </Box>
428 ))}
429 </Box>
430 <Box flexDirection="column">
431 {heading(kit, 'Tokens & cost')}
432 {row('↓ input', card.tokensIn, cost?.input)}
433 {row('↑ output', card.tokensOut, cost?.output)}
434 {row('✎ cache write', card.tokensCacheWrite, cost?.cacheWrite)}
435 {row('⟲ cache read', card.tokensCacheRead, cost?.cacheRead)}
436 <Box flexDirection="row" justifyContent="space-between">
437 <Text weight="semibold">{`Total · ${modelLabel(card.model)}`}</Text>
438 <Text weight="semibold" tabular>
439 {fmtUsd(cost?.total)}
440 </Text>
441 </Box>
442 <Text dimColor wrap="wrap">
443 {priceNote(card)}
444 </Text>
445 </Box>
446 </Box>
447 )
448}
449
450// A running process can be stopped, with a second press to confirm; an ended one folds its command behind ▸.
451function drawProcess(kit: Kit, proc: BgProcess, owner: string, isOpen: boolean) {
452 const { Box, Text, Button, tick } = kit
453 const style = PROCESS_STYLE[proc.kind]
454 const isRunning = proc.status === 'running'
455 const elapsed = (proc.endedAt ?? Math.max(tick, proc.startedAt)) - proc.startedAt
456 const SPINNER = ['◐', '◓', '◑', '◒']
457 const glyph = isRunning ? (SPINNER[Math.floor(tick / 1000) % SPINNER.length] ?? style.glyph) : style.glyph
458 const end = proc.status === 'running' ? undefined : PROCESS_END[proc.status]
459 const isConfirming = isRunning && kit.confirming === proc.id
460
461 return (
462 <Box key={proc.id} flexDirection="column" borderStyle="round" borderColor={end ? end.color : style.color} paddingX={1} overflow="hidden">
463 <Box flexDirection="row" gap={1}>
464 <Text color={style.color} weight="semibold" dimColor={!isRunning}>
465 {glyph}
466 </Text>
467 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
468 <Text weight="semibold" wrap="truncate-end">
469 {proc.description}
470 </Text>
471 <Box flexDirection="row" gap={1}>
472 <Text color="#FFFFFF" backgroundColor={style.color}>
473 {' '}
474 {style.label}{' '}
475 </Text>
476 <Text dimColor tabular>
477 ⏱ {fmtDuration(elapsed)}
478 </Text>
479 <Box flexShrink={1} minWidth={0} overflow="hidden">
480 <Text dimColor wrap="truncate-end">
481 · {owner}
482 </Text>
483 </Box>
484 </Box>
485 </Box>
486 </Box>
487 {proc.command !== '' && (isRunning || isOpen) && (
488 <Text dimColor wrap={isOpen ? 'wrap' : 'truncate-middle'}>
489 $ {proc.command}
490 </Text>
491 )}
492 {isRunning && drawBar(kit, style.color, undefined, true, proc.id)}
493 <Box flexDirection="row" gap={1}>
494 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
495 {end ? (
496 <Text color={end.color} wrap={isOpen ? 'wrap' : 'truncate-end'}>
497 {end.text}
498 {proc.summary ? ` ${proc.summary}` : ''}
499 </Text>
500 ) : (
501 <Text color={style.color} italic wrap="truncate-end">
502 ● running in background…
503 </Text>
504 )}
505 </Box>
506 {isRunning && !isConfirming && <Button plain key={`stop:${proc.id}`} label="■ Stop" onPress={() => void kit.askStop(proc.id)} />}
507 {!isRunning && proc.command !== '' && (
508 <Button plain key={`more:${proc.id}`} label={isOpen ? '▾' : '▸'} onPress={() => void kit.toggle(proc.id)} />
509 )}
510 </Box>
511 {isConfirming && (
512 <Box flexDirection="row" gap={1}>
513 <Text color={WARN}>Stop it?</Text>
514 <Box flexGrow={1} />
515 <Button key={`stop:${proc.id}`} label="Stop" onPress={() => void kit.stop(proc.id)} />
516 <Button plain key={`keep:${proc.id}`} label="Keep running" onPress={() => void kit.askStop('')} />
517 </Box>
518 )}
519 </Box>
520 )
521}
522hooks/lib/format.ts 57 lines1export function shortPath(path: string): string {
2 const parts = path.split('/').filter(Boolean)
3 return parts.length > 4 ? '…/' + parts.slice(-3).join('/') : path
4}
5
6export function targetOf(args: Record<string, unknown>): string {
7 const str = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
8 const path = str('file_path') ?? str('notebook_path') ?? str('path')
9 if (path !== undefined) {
10 const pattern = str('pattern')
11 return pattern !== undefined ? `${pattern} in ${shortPath(path)}` : shortPath(path)
12 }
13 const text = str('pattern') ?? str('command') ?? str('url') ?? str('query') ?? str('description') ?? str('prompt') ?? ''
14 const line = text.split('\n')[0] ?? ''
15 return line.length > 80 ? line.slice(0, 79) + '…' : line
16}
17
18export function toolLabel(tool: string): string {
19 const mcp = /^mcp__[^_]+(?:_[^_]+)*?__(.+)$/.exec(tool)
20 return mcp?.[1] ?? tool
21}
22
23export function fmtUsd(v: number | undefined): string {
24 if (v === undefined) return '$—'
25 if (v === 0) return '$0'
26 if (v < 0.01) return '$' + v.toFixed(4)
27 if (v < 1) return '$' + v.toFixed(3)
28 return '$' + v.toFixed(2)
29}
30
31export function fmtTokens(n: number): string {
32 if (n < 1000) return String(Math.round(n))
33 if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0) + 'k'
34 return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
35}
36
37export function fmtDuration(ms: number): string {
38 const s = Math.max(0, Math.floor(ms / 1000))
39 if (s < 60) return `${s}s`
40 const m = Math.floor(s / 60)
41 if (m < 60) return `${m}:${String(s % 60).padStart(2, '0')}`
42 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
43}
44
45export function plural(n: number, word: string): string {
46 return `${n} ${word}${n === 1 ? '' : 's'}`
47}
48
49export function lineCount(text: unknown): number {
50 return typeof text === 'string' && text !== '' ? text.split('\n').length : 0
51}
52
53export function firstLine(text: string, max = 120): string {
54 const line = (text.split('\n').find(part => part.trim() !== '') ?? '').trim()
55 return line.length > max ? line.slice(0, max - 1) + '…' : line
56}
57hooks/lib/models.ts 117 lines1import type { Spend } from '../../types'
2import { MODEL_COLORS, MUTED } from './theme'
3
4// API list prices per million tokens: input, output, cache read. A cache write is 1.25× input when kept
5// five minutes and 2× when kept an hour. Under a subscription nothing is billed per token, so the pane
6// calls it the API-equivalent cost.
7const PRICES: Record<string, readonly [number, number, number]> = {
8 'fable-5.1': [10, 50, 0.25],
9 'mythos-5.1': [10, 50, 0.25],
10 'fable-5': [10, 50, 1],
11 'opus-5.5': [4, 20, 0.2],
12 'opus-5': [5, 25, 0.5],
13 'opus-4.8': [5, 25, 0.5],
14 'opus-4.7': [5, 25, 0.5],
15 'opus-4.6': [5, 25, 0.5],
16 'sonnet-5.5': [2, 10, 0.2],
17 'sonnet-5': [2, 10, 0.2],
18 'sonnet-4.6': [3, 15, 0.3],
19 'haiku-5.5': [0.1, 0.5, 0.01],
20 'haiku-4.5': [1, 5, 0.1],
21}
22
23// a prompt longer than `above` tokens is billed at `factor` times the list price, the whole response
24const LONG_PROMPT: Record<string, { above: number; factor: number }> = {
25 'haiku-5.5': { above: 100_000, factor: 5 },
26}
27
28// the models whose window is smaller than the 1M the rest share
29const WINDOW: Record<string, number> = {
30 'haiku-4.5': 200_000,
31}
32
33export const NO_SPEND: Spend = { costIn: 0, costOut: 0, costCacheRead: 0, cacheWriteBase: 0 }
34
35export function modelParts(model: string): { family: string; version: string } | undefined {
36 const full = /(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(model)
37 if (full) {
38 const [, family = '', major = '', minor] = full
39 return { family: family.toLowerCase(), version: minor ? `${major}.${minor}` : major }
40 }
41 const family = /(opus|sonnet|haiku|fable|mythos)/i.exec(model)?.[1]?.toLowerCase()
42 return family ? { family, version: '' } : undefined
43}
44
45function keyOf(model: string): string {
46 const parts = modelParts(model)
47 return parts ? `${parts.family}-${parts.version}` : ''
48}
49
50export function modelLabel(model: string): string {
51 const parts = modelParts(model)
52 if (!parts) return model || '…'
53 const name = parts.family.charAt(0).toUpperCase() + parts.family.slice(1)
54 return parts.version ? `${name} ${parts.version}` : name
55}
56
57export function modelColor(model: string): string {
58 return MODEL_COLORS[modelParts(model)?.family ?? ''] ?? MUTED
59}
60
61// The session's own window when the engine gave one, unless the model's is smaller.
62export function windowOf(model: string, sessionWindow: number | undefined): number {
63 const own = WINDOW[keyOf(model)]
64 if (own !== undefined) return sessionWindow === undefined ? own : Math.min(own, sessionWindow)
65 return sessionWindow ?? 1_000_000
66}
67
68export type StepUsage = {
69 input_tokens: number
70 output_tokens: number
71 cache_read_input_tokens: number
72 cache_creation_input_tokens: number
73}
74
75// What one response cost; undefined for a model the table lacks.
76export function spendOf(model: string, usage: StepUsage): Spend | undefined {
77 const key = keyOf(model)
78 const price = PRICES[key]
79 if (!price) return undefined
80 const [pin, pout, pread] = price
81 const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
82 const long = LONG_PROMPT[key]
83 const factor = long && prompt > long.above ? long.factor : 1
84 return {
85 costIn: (usage.input_tokens * pin * factor) / 1e6,
86 costOut: (usage.output_tokens * pout * factor) / 1e6,
87 costCacheRead: (usage.cache_read_input_tokens * pread * factor) / 1e6,
88 cacheWriteBase: (usage.cache_creation_input_tokens * pin * factor) / 1e6,
89 }
90}
91
92export function addSpend<T extends Spend>(to: T, spend: Spend | undefined): T {
93 if (!spend) return { ...to, unpriced: true }
94 return {
95 ...to,
96 costIn: to.costIn + spend.costIn,
97 costOut: to.costOut + spend.costOut,
98 costCacheRead: to.costCacheRead + spend.costCacheRead,
99 cacheWriteBase: to.cacheWriteBase + spend.cacheWriteBase,
100 }
101}
102
103export type Cost = { input: number; output: number; cacheWrite: number; cacheRead: number; total: number }
104
105// Until the Agent tool's record says otherwise, cache writes count as kept five minutes; fast mode doubles all.
106export function costOf(spend: Spend & { cacheWrite1hShare?: number; fast?: boolean }): Cost | undefined {
107 const base = spend.costIn + spend.costOut + spend.costCacheRead + spend.cacheWriteBase
108 if (spend.unpriced && base === 0) return undefined
109 const speed = spend.fast ? 2 : 1
110 const share = spend.cacheWrite1hShare ?? 0
111 const input = spend.costIn * speed
112 const output = spend.costOut * speed
113 const cacheRead = spend.costCacheRead * speed
114 const cacheWrite = spend.cacheWriteBase * (1.25 * (1 - share) + 2 * share) * speed
115 return { input, output, cacheWrite, cacheRead, total: input + output + cacheWrite + cacheRead }
116}
117hooks/lib/theme.ts 51 lines1import type { ActivityKind, AgentCard, BgProcess, FileOp, ProcessKind } from '../../types'
2
3// green and red are kept for finished and failed agents
4export const CRAB_COLORS = ['#D97757', '#58A6FF', '#BC8CFF', '#F2CC60', '#39C5CF', '#FF7EB6', '#E3A15B']
5export const OK = '#3FB950'
6export const ERR = '#F85149'
7export const WARN = '#D29922'
8export const MUTED = '#8B949E'
9
10export const MODEL_COLORS: Record<string, string> = {
11 opus: '#BC8CFF',
12 sonnet: '#58A6FF',
13 haiku: '#39C5CF',
14 fable: '#D97757',
15 mythos: '#D97757',
16}
17
18export const KIND_STYLE: Record<ActivityKind, { color: string; verb: string }> = {
19 read: { color: '#58A6FF', verb: 'reading' },
20 edit: { color: '#E3A15B', verb: 'editing' },
21 run: { color: '#BC8CFF', verb: 'running' },
22 web: { color: '#39C5CF', verb: 'browsing' },
23 agent: { color: '#FF7EB6', verb: 'delegating' },
24 other: { color: MUTED, verb: 'calling' },
25}
26
27export const FILE_OP: Record<FileOp, { color: string; title: string }> = {
28 R: { color: '#58A6FF', title: 'read' },
29 E: { color: '#E3A15B', title: 'edited' },
30 W: { color: OK, title: 'written' },
31}
32
33export const PROCESS_STYLE: Record<ProcessKind, { color: string; label: string; glyph: string }> = {
34 shell: { color: '#BC8CFF', label: 'shell', glyph: '❯' },
35 monitor: { color: '#39C5CF', label: 'monitor', glyph: '◉' },
36}
37
38export const PROCESS_END: Record<Exclude<BgProcess['status'], 'running'>, { color: string; text: string }> = {
39 done: { color: OK, text: '✓ finished' },
40 error: { color: ERR, text: '✕ failed' },
41 killed: { color: MUTED, text: '■ stopped' },
42 stale: { color: WARN, text: '? no word, probably finished' },
43}
44
45export const AGENT_STATUS: Record<AgentCard['status'], { text: string }> = {
46 running: { text: '● running' },
47 done: { text: '✓ done' },
48 error: { text: '✕ failed' },
49 killed: { text: '■ stopped' },
50}
51hooks/lib/tools.ts 73 lines1import type { ActivityKind, FileOp, FileTouch, Plan } from '../../types'
2import { firstLine, lineCount } from './format'
3
4const KEEP_FILES = 30
5
6const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead'])
7const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
8const RUN_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor'])
9const WEB_TOOLS = new Set(['WebFetch', 'WebSearch'])
10const AGENT_TOOLS = new Set(['Agent', 'Task', 'SendMessage'])
11
12export function kindOf(tool: string): ActivityKind {
13 if (READ_TOOLS.has(tool)) return 'read'
14 if (EDIT_TOOLS.has(tool)) return 'edit'
15 if (RUN_TOOLS.has(tool)) return 'run'
16 if (WEB_TOOLS.has(tool)) return 'web'
17 if (AGENT_TOOLS.has(tool)) return 'agent'
18 return 'other'
19}
20
21export type Touch = { path: string; op: FileOp; added: number; removed: number }
22
23// What one call does to the files: which it touched, how, and the lines it changed.
24export function fileTouchOf(tool: string, args: Record<string, unknown>): Touch | undefined {
25 const path = typeof args.file_path === 'string' ? args.file_path : typeof args.notebook_path === 'string' ? args.notebook_path : undefined
26 if (path === undefined) return undefined
27 if (tool === 'Read' || tool === 'NotebookRead') return { path, op: 'R', added: 0, removed: 0 }
28 if (tool === 'Write') return { path, op: 'W', added: lineCount(args.content), removed: 0 }
29 if (tool === 'Edit') return { path, op: 'E', added: lineCount(args.new_string), removed: lineCount(args.old_string) }
30 if (tool === 'MultiEdit' && Array.isArray(args.edits)) {
31 const edits = args.edits as { old_string?: unknown; new_string?: unknown }[]
32 return {
33 path,
34 op: 'E',
35 added: edits.reduce((n, edit) => n + lineCount(edit.new_string), 0),
36 removed: edits.reduce((n, edit) => n + lineCount(edit.old_string), 0),
37 }
38 }
39 if (tool === 'NotebookEdit') return { path, op: 'E', added: 0, removed: 0 }
40 return undefined
41}
42
43export function addFile(files: readonly FileTouch[], touch: Touch): FileTouch[] {
44 const found = files.find(file => file.path === touch.path)
45 if (found) {
46 return files.map(file =>
47 file === found
48 ? {
49 ...file,
50 ops: file.ops.includes(touch.op) ? file.ops : [...file.ops, touch.op],
51 added: file.added + touch.added,
52 removed: file.removed + touch.removed,
53 }
54 : file,
55 )
56 }
57 const next = [...files, { path: touch.path, ops: [touch.op], added: touch.added, removed: touch.removed }]
58 return next.length > KEEP_FILES ? next.slice(-KEEP_FILES) : next
59}
60
61export function planOf(args: Record<string, unknown>): Plan | undefined {
62 if (!Array.isArray(args.todos)) return undefined
63 const todos = args.todos as { status?: unknown; content?: unknown; activeForm?: unknown }[]
64 if (todos.length === 0) return undefined
65 const current = todos.find(todo => todo.status === 'in_progress')
66 const label = current && (typeof current.activeForm === 'string' ? current.activeForm : current.content)
67 return {
68 done: todos.filter(todo => todo.status === 'completed').length,
69 total: todos.length,
70 ...(typeof label === 'string' && label !== '' ? { current: firstLine(label, 80) } : {}),
71 }
72}
73hooks/lib/svg.ts 175 lines1import type { AgentCard } from '../../types'
2import { ERR, OK } from './theme'
3
4const cells = (list: readonly (readonly [number, number])[]) =>
5 list.map(([x, y]) => `<rect x="${x}" y="${y}" width="1.05" height="1.05"/>`).join('')
6
7const loop = (attr: string, values: string, dur: string, begin = '0s', type = '') =>
8 type
9 ? `<animateTransform attributeName="${attr}" type="${type}" values="${values}" dur="${dur}" begin="${begin}" calcMode="discrete" repeatCount="indefinite"/>`
10 : `<animate attributeName="${attr}" values="${values}" dur="${dur}" begin="${begin}" calcMode="discrete" repeatCount="indefinite"/>`
11
12const svg = (inner: string) =>
13 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 24 16" width="48" height="32" shape-rendering="crispEdges">${inner}</svg>`
14
15// At work the crab sits sideways at its desk and types; once it has ended it stands facing us.
16export function crabSvg(color: string, status: AgentCard['status']): string {
17 return status === 'running' ? typingCrab(color) : standingCrab(color, status)
18}
19
20// a pixel crab at its desk, seen from the side: it faces a monitor and types on the keyboard before it
21function typingCrab(color: string): string {
22 const MAP = [
23 '.BBBBBB.',
24 'BBBBBBBB',
25 'BBBBBBBB',
26 'BBBBBBBB',
27 '.BBBBBB.',
28 '.L.L.L..',
29 ]
30 let body = ''
31 MAP.forEach((row, y) =>
32 [...row].forEach((ch, x) => {
33 if (ch !== '.') body += `<rect x="${x}" y="${y + 6}" width="1.05" height="1.05"/>`
34 }),
35 )
36 // the far claw reaches over to the keys on the right, the near one to those on the left; they tap in turn
37 const farClaw = cells([[8, 7], [9, 8], [10, 9], [11, 10], [12, 10]])
38 const nearClaw = cells([[8, 9], [9, 10], [10, 10]])
39 const tap = (begin: string) => loop('transform', '0 0;0 -0.8;0 0;0 0', '0.36s', begin, 'translate')
40 const eye = (x: number) => `<rect x="${x}" y="7" width="1.05" height="2"/>`
41 const bob = loop('transform', '0 0;0 -0.3', '0.72s', '0s', 'translate')
42
43 // the screen: lines of code typed out one after another
44 const lines: readonly (readonly [number, number, string])[] = [
45 [3, 5, color],
46 [5, 4, '#C9D1D9'],
47 [7, 6, '#8B949E'],
48 ]
49 // each line grows a cell at a time in its own third of the loop, all wiped together at the end
50 const steps = 18
51 const screen =
52 lines
53 .map(([y, width, fill], line) => {
54 const values = Array.from({ length: steps }, (_, i) => {
55 const typed = i - line * 6
56 return typed < 0 ? 0 : Math.min(width, typed)
57 }).join(';')
58 return `<rect x="15" y="${y}" width="0" height="0.8" fill="${fill}">${loop('width', values, '3.6s')}</rect>`
59 })
60 .join('') + `<rect x="21" y="7" width="0.6" height="0.8" fill="${color}">${loop('opacity', '1;0', '0.6s')}</rect>`
61 // the screen's light on the crab's face while it types
62 const glow = `<rect x="14" y="2" width="8" height="7" fill="${color}" opacity="0.08">${loop('opacity', '0.06;0.14;0.08;0.18', '1.2s')}</rect>`
63 const desk = deskSvg(glow + screen)
64
65 return svg(
66 desk +
67 `<g fill="${color}">${bob}${body}` +
68 `<g fill="#1b1b1f">${eye(4)}${eye(6)}</g>` +
69 `<g opacity="0.8">${tap('0.18s')}${farClaw}</g><g>${tap('0s')}${nearClaw}</g>` +
70 `</g>`,
71 )
72}
73
74// The desk: a monitor showing `screen`, its stand, the keyboard and the desk's edge.
75function deskSvg(screen: string): string {
76 const keys = Array.from({ length: 6 }, (_, i) => `<rect x="${8 + i}" y="11.2" width="0.8" height="0.8"/>`).join('')
77 return (
78 `<rect x="0" y="12" width="24" height="0.5" fill="#6E7681"/>` +
79 `<rect x="13" y="1" width="10" height="9" rx="0.5" fill="#6E7681"/>` +
80 `<rect x="14" y="2" width="8" height="7" fill="#0D1117"/>` +
81 screen +
82 `<rect x="17" y="10" width="2" height="2" fill="#6E7681"/>` +
83 `<g fill="#8B949E">${keys}</g>`
84 )
85}
86
87// Once it has ended the crab gets up from the keys and stands at the desk facing us, drawn as Claude's crab
88// is: done, it hops and blinks with a check on the screen; failed, its claws droop and the screen shows a
89// cross; stopped, it stands dimmed with its eyes shut before a dark screen.
90function standingCrab(color: string, status: Exclude<AgentCard['status'], 'running'>): string {
91 // Claude's crab in half-cell pixels: 18 across, 5 down, tall and narrow as a terminal's half cells are
92 const MAP = [
93 '...BBBBBBBBBBBB...',
94 '...BBEBBBBBBEBB...',
95 '.AABBBBBBBBBBBBAA.',
96 '...BBBBBBBBBBBB...',
97 '....L.L....L.L....',
98 ]
99 // a touch narrower than the terminal's cells, so its legs stop short of the keyboard
100 const W = 0.55
101 const H = 1.2
102 const X = 0.1
103 const Y = 6
104 const px = (x: number, y: number, h = H) => `<rect x="${(X + x * W).toFixed(2)}" y="${(Y + y * H).toFixed(2)}" width="${W + 0.05}" height="${h + 0.05}"/>`
105 let body = ''
106 let eyes = ''
107 MAP.forEach((row, y) =>
108 [...row].forEach((ch, x) => {
109 if (ch === 'B' || ch === 'L') body += px(x, y)
110 // the claws droop a row when it failed
111 if (ch === 'A') body += px(x, status === 'error' ? y + 1 : y)
112 // an eye sits on the body, so a half-shut one leaves no hole above it
113 if (ch === 'E') {
114 body += px(x, y)
115 eyes += px(x, y)
116 }
117 }),
118 )
119 if (status === 'done') {
120 // a blink now and then
121 eyes = eyes.replace(
122 /height="([\d.]+)"\/>/g,
123 `height="$1"><animate attributeName="height" values="${H + 0.05};${H + 0.05};0.2;${H + 0.05}" keyTimes="0;0.9;0.94;1" dur="3.6s" repeatCount="indefinite"/></rect>`,
124 )
125 } else {
126 // half shut when it failed, closed when it was stopped
127 eyes = eyes.replace(/y="([\d.]+)" width="([\d.]+)" height="[\d.]+"/g, (_, y: string, w: string) =>
128 `y="${(Number(y) + (status === 'error' ? 0.5 : 0.8)).toFixed(2)}" width="${w}" height="${status === 'error' ? 0.7 : 0.35}"`,
129 )
130 }
131 const hop =
132 status === 'done'
133 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 0;0 -1.2;0 0" keyTimes="0;0.7;0.75;0.8" dur="3.6s" repeatCount="indefinite"/>`
134 : ''
135 const screen =
136 status === 'done'
137 ? `<g fill="${OK}">${cells([[15, 5], [16, 6], [17, 7], [18, 6], [19, 5], [20, 4]])}</g>`
138 : status === 'error'
139 ? `<g fill="${ERR}">${cells([[16, 3], [20, 3], [17, 4], [19, 4], [18, 5], [17, 6], [19, 6], [16, 7], [20, 7]])}</g>`
140 : ''
141
142 return svg(
143 deskSvg(screen) +
144 `<g${status === 'killed' ? ' opacity="0.55"' : ''}><g fill="${color}">${hop}${body}` +
145 `<g fill="#1b1b1f">${eyes}</g>` +
146 `</g></g>`,
147 )
148}
149
150// The bar's own stripes move while it runs; with no known total a striped block slides along instead.
151// `uid` keeps each bar's pattern and clip ids apart, where several bars share one document.
152export function barSvg(color: string, fraction: number | undefined, isRunning: boolean, uid: string): string {
153 const W = 300
154 const H = 6
155 const id = uid.replace(/[^A-Za-z0-9_-]/g, '_')
156 const stripes =
157 `<defs><pattern id="s-${id}" width="8" height="${H}" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">` +
158 `<rect width="4" height="${H}" fill="rgba(255,255,255,0.28)"/>` +
159 `<animateTransform attributeName="patternTransform" type="translate" values="0 0;8 0" dur="0.8s" additive="sum" repeatCount="indefinite"/>` +
160 `</pattern><clipPath id="c-${id}"><rect width="${W}" height="${H}" rx="3"/></clipPath></defs>`
161 const track = `<rect width="${W}" height="${H}" rx="3" fill="${color}" opacity="0.18"/>`
162 let fill: string
163 if (fraction === undefined && isRunning) {
164 const seg = 80
165 const slide = `<animate attributeName="x" values="${-seg};${W}" dur="1.8s" repeatCount="indefinite"/>`
166 fill =
167 `<rect y="0" width="${seg}" height="${H}" rx="3" fill="${color}">${slide}</rect>` +
168 `<rect y="0" width="${seg}" height="${H}" fill="url(#s-${id})">${slide}</rect>`
169 } else {
170 const w = Math.max(H, Math.round(W * Math.min(1, fraction ?? 1)))
171 fill = `<rect width="${w}" height="${H}" rx="3" fill="${color}"/>` + (isRunning ? `<rect width="${w}" height="${H}" fill="url(#s-${id})"/>` : '')
172 }
173 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none">${stripes}<g clip-path="url(#c-${id})">${track}${fill}</g></svg>`
174}
175types/index.d.ts 109 lines1export type ActivityKind = 'read' | 'edit' | 'run' | 'web' | 'agent' | 'other'
2
3export type Activity = { id: string; tool: string; target: string; kind: ActivityKind }
4
5// one tool call of a subagent, as the Operations list shows it
6export type Step = {
7 id: string
8 tool: string
9 target: string
10 kind: ActivityKind
11 status: 'running' | 'ok' | 'error'
12 startedAt: number
13 endedAt?: number
14 // what came of it: a diff for edits, why it failed
15 note?: string
16}
17
18// R read, E edited, W written whole
19export type FileOp = 'R' | 'E' | 'W'
20
21export type FileTouch = { path: string; ops: FileOp[]; added: number; removed: number }
22
23// the subagent's own todo list, when it keeps one
24export type Plan = { done: number; total: number; current?: string }
25
26// What the responses cost so far at list price, in US dollars. A cache write is kept at the
27// base input price until its lifetime is known: 1.25× for five minutes, 2× for an hour.
28export type Spend = {
29 costIn: number
30 costOut: number
31 costCacheRead: number
32 cacheWriteBase: number
33 // a response came from a model the price table lacks
34 unpriced?: boolean
35}
36
37export type AgentCard = Spend & {
38 id: string
39 // the subagent that spawned it; absent when the main loop did
40 parentId?: string
41 role: string
42 description: string
43 model: string
44 effort?: string
45 startedAt: number
46 endedAt?: number
47 status: 'running' | 'done' | 'error' | 'killed'
48 // uncached input only: the cache's two sides are counted apart, each at its own price
49 tokensIn: number
50 tokensOut: number
51 tokensCacheWrite: number
52 tokensCacheRead: number
53 ctx: number
54 steps: number
55 tools: number
56 activity?: Activity
57 history: Step[]
58 files: FileTouch[]
59 plan?: Plan
60 // the first line of what it said last, and of its final answer
61 lastText?: string
62 result?: string
63 crab: number
64 // last time a step or tool call of its own began or ended
65 lastEventAt?: number
66 // from the Agent tool's record once it returns: the share of cache writes kept an hour, and fast mode
67 cacheWrite1hShare?: number
68 fast?: boolean
69}
70
71export type ProcessKind = 'shell' | 'monitor'
72
73export type BgProcess = {
74 id: string
75 kind: ProcessKind
76 description: string
77 // its first line, as the card shows it
78 command: string
79 owner?: string
80 startedAt: number
81 endedAt?: number
82 // 'stale': no word of it for STALE_MS while the engine gave no task list to check against
83 status: 'running' | 'done' | 'error' | 'killed' | 'stale'
84 summary?: string
85 // last time the engine's list of background tasks named it
86 lastSeenAt?: number
87 // a Monitor's deadline: past it the watch is over
88 deadlineAt?: number
89 // the engine ends it with its owner's final response, no notification follows
90 endsWithOwner?: boolean
91}
92
93declare module 'claude-code' {
94 interface PluginState {
95 'agent-crabs': {
96 agents: AgentCard[]
97 processes: BgProcess[]
98 // the session's context window as the engine last measured it; 0 before the first measure
99 window: number
100 now: number
101 closedByHand: boolean
102 // ids of the cards opened to their details
103 expanded: string[]
104 // the process whose Stop was pressed once and waits for the second press; '' for none
105 confirmStop: string
106 }
107 }
108}
109