Live view of long-running work: a /board pane with a flow diagram and summary, a band above the prompt while subagents run, and a status tool Claude can call.

A Claude Code mod: one live view of long-running work in a session.

It tracks each top-level subagent (started, tool calls, last activity, finished, failed), groups agents that run together into a batch for an ETA, notices test commands run through Bash or PowerShell, and reads context fill, plan limits and cost. Each surface shows a different slice of that.
| Surface | What it shows |
|---|---|
/board pane | A flow diagram of the job, a ↻ button that reruns it now, and a short summary of where things stand. |
| Band above the prompt | Only while agents run and the pane is closed: agents done out of total, failures, ETA, elapsed time, and on wide terminals context % and the last test result. |
open_board tool | Claude calls it when asked for status. It opens the pane and returns the full board as text to Claude: agents, main turn, tests, context, plan limits and cost. |
Plan limits and cost appear only in the tool's text, since the status line already shows them.
The flow diagram comes from a Sonnet call and the summary from a Haiku call. Both run only while the pane is open, are rate limited, and are skipped when nothing changed. hooks/flow.ts draws the diagram as Raster cells in the terminal, SVG in the desktop app, or ASCII elsewhere.
State lives in $.state, so a hot reload keeps agents, the batch, the flow and the summary.
Clone the repo, then point Claude Code at it with the CLAUDE_CODE_PLUGIN_DIRS environment variable, for example in ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/workboard" }
}
| Path | Role |
|---|---|
.claude-plugin/plugin.json | Manifest |
hooks/hooks.json | Lists the hook modules to load |
hooks/register.tsx | Main module: events, state, pane, band and tool |
hooks/flow.ts | Parses and lays out the flow diagram |
types/index.d.ts | Types for the mod's saved state |
tests/board.test.ts | Tests |
docs/board.png | Screenshot used in this README |
.claude-plugin/types/ is generated by Claude Code and is not committed.
hooks/register.tsx 1178 lines1// Workboard: one live view of long-running work in this session.
2//
3// agent.spawn / tool.call / turn.complete: track each subagent (started,
4// tool calls, last activity, finished, failed) and group the ones that run
5// together into a batch, so the board can say "3/5 done, ~4m left". Only
6// top-level subagents are tracked; one a subagent spawns counts as its parent's.
7// tool.call on Bash / PowerShell: notice test commands and keep their result.
8// session.measure: context fill, plan limits and cost, free to read; in the
9// board text only, since the status line already shows them.
10// prompt.submit / turn.start: the person's own prompts make the job; agent
11// hand-backs, notifications and other sessions' messages do not.
12// turn.step: keep each agent's last few model steps (what it said, which tools
13// it called, what failed), and the main turn's too.
14// Summaries, the one part that spends usage, and only while the pane is open:
15// one Sonnet call at most every FLOW_MS describes the whole job as steps and
16// edges, which flow.ts lays out and draws (Raster, Svg or ASCII); one Haiku
17// call at most every SUMMARY_MS writes the summary at the bottom of the pane.
18// Both read the conversation, the board and every agent's task and steps; each
19// is skipped when nothing changed, and both rerun as soon as a batch finishes.
20// The pane's one control, ↻, reruns both now and counts down the last minute.
21// $.state keeps agents, the batch, the job, the flow, the summary and the
22// rounds' times (types/index.d.ts), so a hot reload loses none of them.
23// ui.render: a band above the prompt while agents run, and the /board pane.
24// open_board: a tool Claude calls when asked for status; it opens the pane and
25// answers with the same board as text, so Claude does not have to poll.
26//
27// The host reads on(...) and $.noun.method(...) from source, so they are
28// spelled literally, and helpers that take $ are top-level functions.
29
30import type { EngineInterface, ModelCompleteResult, PromptOrigin, Register } from 'claude-code'
31import type { WorkboardAgent, WorkboardStatus, WorkboardSummary } from '../types'
32import { flowRaster, flowSvg, flowText, parseFlow, type Flow } from './flow'
33
34const PANE = 'workboard'
35const TITLE = 'Workboard'
36const TOOL_FULL = 'mcp__workboard__open_board'
37const TICK_MS = 1000
38const STEPS_KEPT = 6
39const FLOW_MS = 15 * 60_000
40const SUMMARY_MS = 15 * 60_000
41const FLOW_SYSTEM = [
42 'You map the whole job a team of coding agents is doing for one person, for a diagram they read at a glance.',
43 'You get the conversation so far (what the person asked and what was done), the board (every agent, tests, recent events) and each piece of work in detail (its task and latest steps).',
44 'Reply with only a JSON object: {"steps":[{"id":"s1","label":"Adapter","state":"done","detail":"Mapped the new fields in adapter.ts."}],"edges":[{"from":"s1","to":"s2"}]}.',
45 'steps: 3 to 6 pieces of work toward what the person asked, in the order they happen, as one system; never more than 8: merge or summarize smaller pieces into one step rather than going past it. label: a noun phrase in the person\'s terms (the feature, fix, file or check), at most 4 words and 24 characters; no agent ids, numbers or sentences.',
46 'state: done, running, failed, waiting (not started, or blocked) or check (a gate the work must pass, its label a question ending in ?). progress: only on a running step, and only when the input shows how far it is (3 of 5 files), from 0 to 1. detail: one plain sentence on what happened or is happening, for a tooltip.',
47 'edges: what feeds what, top to bottom, at most 8, never back up the flow. Leave an edge unlabelled unless it leaves a check; then label it with its outcome (pass, fail). A failure is a failed step named by its outcome.',
48 'Show only what the input shows. No other text.',
49].join(' ')
50const SUMMARY_SYSTEM = [
51 'You write the summary a person reads at a glance about the whole chat: where the job they asked for stands, and what needs them.',
52 'You get the conversation so far, the board (every agent, tests, recent events), the current flow and each piece of work in detail.',
53 'Lead with the answer: where the job stands against what they asked, naming the work in their terms (the feature, file, fix or test). Mention what subagents are doing only as far as it explains where the job stands, one clause per stream of work, grouping agents that do the same kind of work; never one agent at a time for its own sake, and never counts for their own sake.',
54 "End with 'next: ' and the one action for the person, if something needs them (a failed agent or test, work stalled for a long while, a question waiting on them), otherwise with exactly 'nothing needs you'.",
55 'State only what the input shows. Plain words, no filler, no hedging, no praise, no labels or bullets. Never mention tokens, context, cost, usage, limits or percentages. Keep to the budget you are given.',
56].join(' ')
57const TEST_COMMAND =
58 /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bpytest\b|\bvitest\b|\bjest\b|\bplaywright\s+test\b|\bgo\s+test\b|\bcargo\s+test\b|\bdotnet\s+test\b|\bInvoke-Pester\b|\bunittest\b|\bnode\s+--test\b|\bplugin\s+test\b/i
59// Turn texts that are not the person speaking: subagent hand-backs, task and
60// system notices, other sessions' messages.
61const NOT_THE_PERSON =
62 /^\s*(<(agent-message|task-notification|system-reminder|teammate-message|channel-message|cross-session|peer-message|local-command-stdout)\b|\[MESSAGE FROM NON-USER|Another Claude session|The coordinator sent|The user sent a new message while you were working|This is how Claude Code surfaces)/i
63
64type Status = WorkboardStatus
65
66// What the summarizers read, for a subagent and for the main turn.
67type Track = { task: string; steps: string[] }
68
69// A top-level subagent: its task and steps (a Track), its time over every run.
70type Agent = WorkboardAgent
71
72type TestRun = { command: string; ok: boolean; at: number; durationMs: number; summary: string }
73
74type Limit = { kind: string; percentUsed: number; resetsAt?: string }
75
76type Usage = { tokens?: number; window: number; percent?: number; limits: Limit[]; usd?: number }
77
78type LogLine = { at: number; status: Status; text: string }
79
80// The summary as the pane draws it: one paragraph, then the next clause (if
81// Haiku ended with one) on a row of its own, in italics.
82type Summary = WorkboardSummary
83
84type Progress = { done: number; failed: number; running: number; total: number; startedAt: number; etaMs?: number }
85
86let agents: Agent[] = []
87let batch: string[] = []
88let main = { isWorking: false, turnStartedAt: 0, tools: 0, last: '', lastAt: 0, lastTurnMs: 0, hadAgents: false }
89let tests: TestRun[] = []
90let usage: Usage | null = null
91let log: LogLine[] = []
92let mainTrack: Track = newTrack('')
93let requests: string[] = []
94// The last prompt that entered and whether the person wrote it, from its
95// origin; turn.start reads it for the turn that prompt starts.
96let lastSubmit: { text: string; isPerson: boolean } | undefined
97let otherTools = 0
98let isPaneOpen = false
99let ticks = 0
100// Counts spawns, finishes, steps and test runs: a summarizer reruns only
101// after it moved.
102let changes = 0
103// Haiku's summary (its text, and the next line split off it) and Sonnet's
104// description of the whole job as steps and edges.
105let summary: Summary | undefined
106let flow: Flow | undefined
107// Subagents spawned by a subagent, by id, to the top-level agent they work
108// for: their tool calls count as its activity; no row or steps of their own.
109let nested = new Map<string, string>()
110let summaries = {
111 isRunning: false,
112 isForced: false,
113 // A batch finished while the pane was closed: the next open reruns both.
114 isStale: false,
115 flowAt: -Infinity,
116 flowChanges: -1,
117 summaryAt: -Infinity,
118 summaryChanges: -1,
119 refreshedAt: -Infinity,
120 failures: 0,
121}
122
123// The records that survive a hot reload, in $.state (types/index.d.ts): the
124// module keeps its working copy, loads them once on its first hook after a
125// load, and writes them back on the tick after anything changed.
126const AGENTS = { plugin: 'workboard', key: 'agents' } as const
127const BATCH = { plugin: 'workboard', key: 'batch' } as const
128const JOB = { plugin: 'workboard', key: 'job' } as const
129const FLOW = { plugin: 'workboard', key: 'flow' } as const
130const SUMMARY = { plugin: 'workboard', key: 'summary' } as const
131const ROUNDS = { plugin: 'workboard', key: 'rounds' } as const
132let restoring: Promise<void> | undefined
133let isDirty = false
134
135export const register: Register = on => {
136 on('session.start', async ($, e, next) => {
137 await restore($)
138 const result = await next(e)
139 await $.command.register({
140 name: 'board',
141 description: 'Open the Workboard: live progress of subagents, tests, context and plan limits',
142 })
143 await $.tool.register({
144 name: 'open_board',
145 description:
146 'Open the Workboard pane for the user and get the same board as text: every subagent of this session (running, done, failed, elapsed, tool calls, last activity), batch progress with a time estimate, the main turn, recent test runs, context fill, plan limits, cost, a flow diagram of the whole job and a summary of where it stands. Call it when the user asks for status, progress, what agents are doing, how long is left, or to open the board or dashboard.',
147 inputSchema: { type: 'object', properties: {}, additionalProperties: false },
148 })
149 await takeUsage($)
150 await reconcile($)
151 $.clock.every(TICK_MS, () => {
152 void tick($)
153 })
154
155 return result
156 })
157
158 on('command.run', { command: 'board' }, async $ => {
159 await restore($)
160 const opened = await openPane($)
161
162 return { text: opened ? 'Workboard open.' : 'Workboard could not open here.' }
163 })
164
165 on('tool.call', { tool: TOOL_FULL }, async $ => {
166 await restore($)
167 const opened = await openPane($)
168 const now = await $.clock.now()
169 const head = opened
170 ? 'The Workboard pane is open for the user.'
171 : 'The pane did not open here (terminal too narrow for a pane opened by a tool); the user can type /board.'
172
173 // A registered tool answers text (not an object): the board itself.
174 return { result: `${head}\n\n${boardText(now)}` }
175 })
176
177 on('agent.spawn', async ($, e, next) => {
178 await restore($)
179 if (e.parentAgentId) {
180 const result = await next(e)
181 if (result.deny === undefined && result.agentId) {
182 nested.set(result.agentId, topOf(e.parentAgentId))
183 }
184
185 return result
186 }
187 const now = await $.clock.now()
188 if (!agents.some(a => a.status === 'running')) {
189 batch = []
190 }
191 const description = e.name ? `${e.name}: ${e.description}` : e.description
192 const agent: Agent = {
193 ...newTrack(`${description}\n${e.prompt.slice(0, 1500)}`),
194 id: '',
195 key: e.tool_use_id,
196 description,
197 type: e.subagentType,
198 model: shortModel(e.model ?? e.parentModel),
199 status: 'running',
200 startedAt: now,
201 runStartedAt: now,
202 activeMs: 0,
203 runs: 1,
204 tools: 0,
205 last: '',
206 lastAt: now,
207 }
208 agents = [...agents, agent].slice(-40)
209 batch = [...batch, agent.key]
210 main.hadAgents = true
211 isDirty = true
212 addLog(now, 'running', `started ${agent.description}`)
213 $.ui.invalidate('ui.render')
214
215 const result = await next(e)
216 if (result.deny !== undefined) {
217 finish(agent, 'failed', await $.clock.now())
218 } else {
219 agent.id = result.agentId ?? agent.id
220 agent.model = shortModel(result.model)
221 }
222 $.ui.invalidate('ui.render')
223
224 return result
225 })
226
227 on('tool.call', async ($, e, next) => {
228 if (String(e.tool) === TOOL_FULL) {
229 return next(e)
230 }
231 await restore($)
232 isDirty = true
233 const now = await $.clock.now()
234 const args = e as unknown as Record<string, unknown>
235 const activity = `${String(e.tool)} ${describe(args)}`.trim()
236
237 let track: Track | undefined
238 if (!e.agentId) {
239 main.tools += 1
240 main.last = activity
241 main.lastAt = now
242 track = mainTrack
243 } else {
244 const isNested = nested.has(e.agentId)
245 const agent = agentFor(topOf(e.agentId))
246 if (agent) {
247 if (agent.status !== 'running' && !isNested) {
248 resume(agent, now)
249 }
250 agent.tools += 1
251 agent.last = activity
252 agent.lastAt = now
253 track = isNested ? undefined : agent
254 } else {
255 otherTools += 1
256 }
257 }
258
259 const ran = await next(e)
260 if (track && ran.deny === undefined && ran.isError === true) {
261 noteOnLastStep(track, `${activity} failed`)
262 }
263
264 const command = typeof args.command === 'string' ? args.command : ''
265 const isShell = e.tool === 'Bash' || String(e.tool) === 'PowerShell'
266 if (!isShell || !TEST_COMMAND.test(command)) {
267 return ran
268 }
269
270 const ended = await $.clock.now()
271 const output = `${ran.text ?? ''}\n${safeJson(ran.result)}`.slice(-6000)
272 const ok = ran.deny === undefined && ran.isError !== true && !looksFailed(output)
273 const run: TestRun = { command: testInvocation(command), ok, at: ended, durationMs: ended - now, summary: testSummary(output) }
274 tests = [run, ...tests].slice(0, 6)
275 addLog(ended, ok ? 'done' : 'failed', `tests ${ok ? 'passed' : 'failed'}: ${run.command}`)
276 $.ui.invalidate('ui.render')
277
278 return ran
279 })
280
281 on('turn.step', async function* ($, e, next) {
282 await restore($)
283 const result = yield* next(e)
284 const agent = e.agentId && !nested.has(e.agentId) ? agentFor(e.agentId) : undefined
285 // A continued agent speaks before it calls a tool: that is its resume.
286 if (agent && agent.status !== 'running') resume(agent, await $.clock.now())
287 const track = !e.agentId ? mainTrack : agent
288 if (track) {
289 pushStep(track, stepLine(result.answer, result.toolUses))
290 isDirty = true
291 }
292
293 return result
294 })
295
296 on('prompt.submit', async ($, e, next) => {
297 await restore($)
298 const result = await next(e)
299 if (result.drop === undefined) {
300 lastSubmit = { text: oneLine(e.text), isPerson: isPersonOrigin(e.origin) && !NOT_THE_PERSON.test(e.text) }
301 }
302
303 return result
304 })
305
306 on('turn.start', async ($, e, next) => {
307 await restore($)
308 const now = await $.clock.now()
309 main.isWorking = true
310 main.turnStartedAt = now
311 main.tools = 0
312 main.hadAgents = false
313 // A continuation, a hand-back or a notice keeps the task it continues;
314 // the origin prompt.submit saw decides, else the text's own look.
315 const asked = oneLine(e.text)
316 const submitted = lastSubmit && lastSubmit.text === asked ? lastSubmit.isPerson : undefined
317 const isPerson = asked !== '' && (submitted ?? !NOT_THE_PERSON.test(e.text))
318 if (isPerson && requests.at(-1) !== asked.slice(0, 300)) requests = [...requests, asked.slice(0, 300)].slice(-3)
319 mainTrack = newTrack(isPerson ? `The person asked: ${asked.slice(0, 600)}` : mainTrack.task)
320 isDirty = true
321 $.ui.invalidate('ui.render')
322
323 return next(e)
324 })
325
326 on('turn.complete', async ($, e, next) => {
327 await restore($)
328 const result = await next(e)
329 const now = await $.clock.now()
330 isDirty = true
331
332 if (!e.agentId) {
333 main.isWorking = false
334 main.lastTurnMs = e.durationMs
335 changes += 1
336 if (main.hadAgents) refreshSoon()
337 $.ui.invalidate('ui.render')
338
339 return result
340 }
341
342 const agent = nested.has(e.agentId) ? undefined : agentFor(e.agentId)
343 if (agent && agent.status === 'running') {
344 agent.result = firstSentence(e.answer)
345 finish(agent, e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed', now)
346 const p = progress(now)
347 if (p.running === 0 && p.total > 0) {
348 const failed = p.failed > 0 ? `, ${p.failed} failed` : ''
349 $.ui.toast(`Workboard: all ${p.total} agents finished${failed} · ${duration(now - p.startedAt)}`)
350 refreshSoon()
351 }
352 }
353 $.ui.invalidate('ui.render')
354
355 return result
356 })
357
358 on('session.measure', async ($, e, next) => {
359 usage = {
360 tokens: e.context.tokens,
361 window: e.context.window,
362 percent: e.context.percent,
363 limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
364 usd: e.cost?.usd,
365 }
366 $.ui.invalidate('ui.render')
367
368 return next(e)
369 })
370
371 on('ui.close', async (_$, e, next) => {
372 if (e.id === PANE) {
373 isPaneOpen = false
374 }
375
376 return next(e)
377 })
378
379 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
380 await restore($)
381 const now = await $.clock.now()
382 const p = progress(now)
383 if (e.props.hasSurvey || isPaneOpen || p.running === 0) {
384 return next(e)
385 }
386 const { Box, Text, Button } = $.ui.resolve(e)
387 const columns = e.props.bodyColumns
388 const parts = [`${p.running} running`]
389 if (p.failed > 0) parts.push(`${p.failed} failed`)
390 if (p.etaMs !== undefined) parts.push(eta(p.etaMs))
391 parts.push(duration(now - p.startedAt))
392 if (columns >= 90 && usage?.percent !== undefined) parts.push(`ctx ${Math.round(usage.percent)}%`)
393 const lastTest = tests[0]
394 if (columns >= 90 && lastTest && now - lastTest.at < 30 * 60_000) parts.push(`tests ${lastTest.ok ? '✓' : '✗'}`)
395
396 return (
397 <Box flexDirection="row" paddingX={1} gap={1}>
398 <Text color="cyan">{bar(p.done + p.failed, p.total, columns >= 80 ? 10 : 6)}</Text>
399 <Text bold>{`${p.done + p.failed}/${p.total} agents`}</Text>
400 <Text dimColor wrap="truncate-end">{parts.join(' · ')}</Text>
401 <Button key="open" label="board" hotkey="b" plain onPress={() => void openPane($)} />
402 </Box>
403 )
404 })
405
406 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
407 const elements = $.ui.resolve(e)
408 const { Box, Text, Button, Code } = elements
409 // Drawn means open, also for a pane still showing across a hot reload.
410 isPaneOpen = true
411 await restore($)
412 const now = await $.clock.now()
413 const columns = Math.max(32, e.props.bodyColumns)
414 const width = columns - 2
415 const shown = flow
416 // The terminal draws cells, the desktop vectors, the rest plain ASCII.
417 const Raster = e.surface === 'terminal' && 'Raster' in elements ? elements.Raster : undefined
418 const Svg = e.surface === 'desktop' && 'Svg' in elements ? elements.Svg : undefined
419 const grid = shown && Raster ? flowRaster(shown, width) : undefined
420 const vector = shown && !grid && Svg ? flowSvg(shown, width) : undefined
421 const ascii = shown && !grid && !vector ? flowText(shown, width) : undefined
422
423 return (
424 <Box flexDirection="column" width={columns} paddingX={1}>
425 {shown && <Text bold>Flow</Text>}
426 {grid && Raster && <Raster key="flow" columns={grid.columns} rows={grid.rows} cells={grid.cells} />}
427 {vector && Svg && <Svg source={vector.source} alt={vector.alt} width={vector.width} height={vector.height} isInteractive />}
428 {ascii && <Code source={ascii} wrap="truncate-end" />}
429 {shown && <Text> </Text>}
430 <Button key="refresh" label={refreshLabel(now)} hotkey="r" plain onPress={() => refreshSummaries($)} />
431 <Text> </Text>
432 <Text dimColor={!summary} wrap="wrap">{summaryText()}</Text>
433 {summary && summary.next && <Text italic wrap="wrap">{summary.next}</Text>}
434 </Box>
435 )
436 })
437}
438
439async function openPane($: EngineInterface): Promise<boolean> {
440 const placed = await $.ui.open({ id: PANE, title: TITLE })
441 isPaneOpen = isPaneOpen || placed.isPlaced
442
443 return placed.isPlaced
444}
445
446// The ↻ button asks the next tick for a round that ignores the timers; a
447// round already running is left to finish instead of being doubled.
448function refreshSummaries($: EngineInterface) {
449 if (summaries.isRunning) return
450 summaries.isForced = true
451 $.ui.invalidate('ui.render')
452}
453
454// A batch finished or a turn that ran agents ended: what the pane says is
455// stale now, so the next tick reruns both (after the round in flight, if
456// any); with the pane closed, the next open does.
457function refreshSoon() {
458 if (isPaneOpen) summaries.isForced = true
459 else summaries.isStale = true
460}
461
462// One round, started from the tick while the pane is open: the flow first
463// when it is due, so the summary Haiku writes next can read it. Each is due
464// once its interval has passed and something changed since it last ran.
465async function summarize($: EngineInterface) {
466 if (summaries.isRunning || (!isPaneOpen && !summaries.isForced)) return
467 summaries.isRunning = true
468 const isForced = summaries.isForced || summaries.isStale
469 summaries.isForced = false
470 summaries.isStale = false
471 try {
472 const now = await $.clock.now()
473 const isFlowDue = isForced || (now - summaries.flowAt >= FLOW_MS && changes !== summaries.flowChanges)
474 const isSummaryDue = isForced || (now - summaries.summaryAt >= SUMMARY_MS && changes !== summaries.summaryChanges)
475 if (hasBoard() && isFlowDue) await describeFlow($)
476 if (hasBoard() && isSummaryDue) await writeSummary($)
477 } finally {
478 summaries.isRunning = false
479 isDirty = true
480 $.ui.invalidate('ui.render')
481 }
482}
483
484// Sonnet: the whole job as steps and edges, from the conversation, the board
485// and every piece of work in detail. The mod lays it out and draws it.
486async function describeFlow($: EngineInterface) {
487 const now = await $.clock.now()
488 summaries.flowAt = now
489 summaries.flowChanges = changes
490 const conversation = await conversationText($, 14_000)
491 const prompt = [
492 'The conversation so far, oldest first:',
493 conversation || '(not readable)',
494 '',
495 'The board right now:',
496 boardText(now, true),
497 '',
498 'The work in detail:',
499 workText(now),
500 '',
501 'Reply with the JSON object.',
502 ].join('\n')
503
504 let reply: ModelCompleteResult
505 try {
506 reply = await $.model.complete({ model: 'sonnet', system: FLOW_SYSTEM, prompt, effort: 'low', maxTokens: 2000, timeoutMs: 90_000 })
507 } catch {
508 summaries.failures += 1
509
510 return
511 }
512 const described = answered(reply) && reply.isAnswered ? parseFlow(reply.text) : undefined
513 if (described) {
514 flow = described
515 summaries.refreshedAt = await $.clock.now()
516 } else if (reply.isAnswered) {
517 summaries.failures += 1
518 }
519}
520
521// Haiku: the summary of the whole chat at the bottom of the pane, from the
522// same input plus the current flow, as long as the parallel work needs.
523async function writeSummary($: EngineInterface) {
524 const now = await $.clock.now()
525 summaries.summaryAt = now
526 summaries.summaryChanges = changes
527 const budget = summaryBudget()
528 const conversation = await conversationText($, 8000)
529 const prompt = [
530 'The conversation so far, oldest first:',
531 conversation || '(not readable)',
532 '',
533 'The board right now:',
534 boardText(now, true),
535 '',
536 'The work in detail:',
537 workText(now),
538 '',
539 'The current flow, top to bottom:',
540 flow ? flowText(flow, 72) : '(none yet)',
541 '',
542 `Budget: ${budget.running} subagent${budget.running === 1 ? '' : 's'} running, so at most ${budget.lines} line${budget.lines === 1 ? '' : 's'} and ${budget.words} words of summary${budget.running >= 5 ? ', grouping the agents by stream of work' : ''}, then the next line.`,
543 ].join('\n')
544
545 let reply: ModelCompleteResult
546 try {
547 reply = await $.model.complete({ model: 'haiku', system: SUMMARY_SYSTEM, prompt, effort: 'low', maxTokens: 400, timeoutMs: 60_000 })
548 } catch {
549 summaries.failures += 1
550
551 return
552 }
553 const written = answered(reply) && reply.isAnswered ? summaryOf(reply.text, budget.words) : undefined
554 if (written) {
555 summary = written
556 summaries.refreshedAt = await $.clock.now()
557 } else if (reply.isAnswered) {
558 summaries.failures += 1
559 }
560}
561
562// How long the summary may run: one short line for one stream of work, a
563// clause per stream for a few, grouped streams for many.
564function summaryBudget(): { running: number; lines: number; words: number } {
565 const running = agents.filter(a => a.status === 'running').length
566 if (running <= 1) return { running, lines: 1, words: 30 }
567 if (running <= 4) return { running, lines: 3, words: 70 }
568
569 return { running, lines: 4, words: 90 }
570}
571
572// A call that did not answer counts as failed and leaves what it would have
573// replaced as it was.
574function answered(reply: ModelCompleteResult): boolean {
575 if (!reply.isAnswered) summaries.failures += 1
576
577 return reply.isAnswered
578}
579
580// The main conversation, newest kept first when it runs long: what the person
581// asked in full, what Claude said and which tools it ran, without tool output,
582// system reminders, hand-backs or notices.
583async function conversationText($: EngineInterface, budget: number): Promise<string> {
584 let messages
585 try {
586 messages = await $.session.messages()
587 } catch {
588 return ''
589 }
590 const rows: string[] = []
591 let used = 0
592 for (let i = messages.length - 1; i >= 0 && used < budget; i -= 1) {
593 const m = messages[i]
594 if (!m) continue
595 const text = oneLine(m.text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ''))
596 let row = ''
597 if (m.role === 'user') {
598 const isPerson = text !== '' && !(m.toolResults && m.toolResults.length > 0) && !NOT_THE_PERSON.test(text)
599 if (isPerson) row = `person: ${cut(text, 1200)}`
600 } else {
601 const tools = m.toolUses.slice(0, 6).map(t => {
602 const brief = describe(t.input).slice(0, 50)
603
604 return brief ? `${t.tool} (${brief})` : t.tool
605 })
606 const said = text ? cut(text, 500) : ''
607 if (said || tools.length > 0) row = `claude: ${said}${tools.length > 0 ? `${said ? ' ' : ''}[ran ${tools.join(', ')}]` : ''}`
608 }
609 if (row) {
610 rows.push(row)
611 used += row.length
612 }
613 }
614
615 return rows.reverse().join('\n')
616}
617
618// Every top-level agent of the batch and the main turn: its task and its
619// latest steps, the finished ones with their result.
620function workText(now: number): string {
621 const parts: string[] = []
622 for (const a of orderedAgents().filter(a => a.status === 'running' || batch.includes(a.key)).slice(0, 12)) {
623 parts.push(
624 [
625 `[${a.status}] ${a.description}, ${agentTime(a, now)}, ${a.tools} tools`,
626 `Task: ${a.task}`,
627 ...a.steps.map((step, i) => `${i + 1}. ${step}`),
628 a.result ? `Result: ${a.result}` : '',
629 ]
630 .filter(Boolean)
631 .join('\n'),
632 )
633 }
634 if (main.isWorking || mainTrack.steps.length > 0) {
635 parts.push(
636 [`[${main.isWorking ? 'running' : 'idle'}] Main turn (the session the person talks to)`, `Task: ${mainTrack.task}`, ...mainTrack.steps.map((step, i) => `${i + 1}. ${step}`)].join('\n'),
637 )
638 }
639
640 return parts.join('\n\n') || 'nothing yet'
641}
642
643function hasBoard(): boolean {
644 return agents.length > 0 || tests.length > 0 || log.length > 0 || main.isWorking || main.lastTurnMs > 0
645}
646
647// Loads the records once per module load; every hook awaits the one load.
648function restore($: EngineInterface): Promise<void> {
649 restoring ??= load($)
650
651 return restoring
652}
653
654async function load($: EngineInterface) {
655 try {
656 const kept = await $.state.get(AGENTS)
657 if (kept.value) {
658 agents = kept.value.map(a => ({ ...a, runStartedAt: a.runStartedAt ?? a.startedAt, activeMs: a.activeMs ?? 0, runs: a.runs ?? 1 }))
659 }
660 const order = await $.state.get(BATCH)
661 if (order.value) batch = order.value
662 const job = await $.state.get(JOB)
663 if (job.value) {
664 requests = job.value.requests
665 mainTrack = newTrack(job.value.task)
666 }
667 const described = await $.state.get(FLOW)
668 if (described.value) flow = described.value
669 const written = await $.state.get(SUMMARY)
670 if (written.value) summary = written.value
671 const rounds = await $.state.get(ROUNDS)
672 if (rounds.value) {
673 const r = rounds.value
674 summaries.flowAt = r.flowAt ?? -Infinity
675 summaries.flowChanges = r.flowChanges
676 summaries.summaryAt = r.summaryAt ?? -Infinity
677 summaries.summaryChanges = r.summaryChanges
678 summaries.refreshedAt = r.refreshedAt ?? -Infinity
679 changes = Math.max(changes, r.changes)
680 }
681 } catch {
682 // Nothing kept yet, or unreadable: start from the module's own copy.
683 }
684}
685
686// Writes the records back (JSON only: a time never set is null).
687async function save($: EngineInterface) {
688 const time = (t: number) => (Number.isFinite(t) ? t : null)
689 try {
690 await $.state.set(AGENTS, agents)
691 await $.state.set(BATCH, batch)
692 await $.state.set(JOB, { requests, task: mainTrack.task })
693 await $.state.set(FLOW, flow ?? null)
694 await $.state.set(SUMMARY, summary ?? null)
695 await $.state.set(ROUNDS, {
696 flowAt: time(summaries.flowAt),
697 flowChanges: summaries.flowChanges,
698 summaryAt: time(summaries.summaryAt),
699 summaryChanges: summaries.summaryChanges,
700 changes,
701 refreshedAt: time(summaries.refreshedAt),
702 })
703 } catch {
704 isDirty = true
705 }
706}
707
708async function takeUsage($: EngineInterface) {
709 try {
710 const u = await $.session.usage()
711 usage = {
712 tokens: u.context.tokens,
713 window: u.context.window,
714 percent: u.context.percent,
715 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
716 usd: u.cost?.usd,
717 }
718 } catch {
719 // No reading yet; session.measure fills it after the first response.
720 }
721}
722
723// Every tick redraws the elapsed clocks while something runs and the ↻
724// countdown while it shows, and every few ticks checks the engine's own list
725// of agents, which also covers agents started before this module (re)loaded.
726async function tick($: EngineInterface) {
727 ticks += 1
728 await restore($)
729 if (ticks % 6 === 0) {
730 await reconcile($)
731 }
732 if (isDirty) {
733 isDirty = false
734 await save($)
735 }
736 const now = await $.clock.now()
737 const untilNext = nextRefreshAt() - now
738 const isCounting = isPaneOpen && untilNext > 0 && untilNext < 60_000
739 if (main.isWorking || agents.some(a => a.status === 'running') || isCounting) {
740 $.ui.invalidate('ui.render')
741 }
742 void summarize($)
743}
744
745async function reconcile($: EngineInterface) {
746 let listed
747 try {
748 listed = await $.agent.list()
749 } catch {
750 return
751 }
752 const now = await $.clock.now()
753 for (const info of listed) {
754 if (info.parentId) {
755 if (!nested.has(info.id)) nested.set(info.id, topOf(info.parentId))
756 continue
757 }
758 const known = agents.find(a => a.id === info.id)
759 const status = statusOf(info.status)
760 if (!known) {
761 if (status === 'running') {
762 if (!agents.some(a => a.status === 'running')) batch = []
763 const description = info.name ? `${info.name}: ${info.description}` : info.description
764 const agent: Agent = {
765 ...newTrack(description),
766 id: info.id,
767 key: info.id,
768 description,
769 type: info.type,
770 model: '',
771 status: 'running',
772 startedAt: now,
773 runStartedAt: now,
774 activeMs: 0,
775 runs: 1,
776 tools: 0,
777 last: '',
778 lastAt: now,
779 }
780 agents = [...agents, agent].slice(-40)
781 batch = [...batch, agent.key]
782 isDirty = true
783 }
784 continue
785 }
786 if (known.status === 'running' && status !== 'running' && status !== undefined) {
787 finish(known, status, now)
788 isDirty = true
789 }
790 }
791}
792
793function statusOf(raw: string): Status | undefined {
794 const s = raw.toLowerCase()
795 if (/run|pend|start|progress|active/.test(s)) return 'running'
796 if (/fail|error/.test(s)) return 'failed'
797 if (/kill|stop|abort|cancel/.test(s)) return 'stopped'
798 if (/complet|done|finish|success/.test(s)) return 'done'
799
800 return undefined
801}
802
803// The top-level agent a loop works for: itself, or the one that spawned it.
804function topOf(agentId: string): string {
805 return nested.get(agentId) ?? agentId
806}
807
808function agentFor(agentId: string): Agent | undefined {
809 const known = agents.find(a => a.id === agentId)
810 if (known) return known
811 if (nested.has(agentId)) return undefined
812 const pending = agents.filter(a => a.id === '' && a.status === 'running')
813 const only = pending.length === 1 ? pending[0] : undefined
814 if (only) {
815 only.id = agentId
816
817 return only
818 }
819
820 return undefined
821}
822
823function finish(agent: Agent, status: Status, now: number) {
824 agent.status = status
825 agent.endedAt = now
826 agent.activeMs += Math.max(0, now - agent.runStartedAt)
827 main.hadAgents = true
828 const verb = status === 'done' ? 'finished' : status === 'stopped' ? 'stopped' : 'failed'
829 addLog(now, status, `${verb} ${agent.description} · ${duration(now - agent.startedAt)}`)
830}
831
832function resume(agent: Agent, now: number) {
833 if (!agents.some(a => a.status === 'running')) batch = []
834 agent.status = 'running'
835 agent.endedAt = undefined
836 agent.runStartedAt = now
837 agent.runs += 1
838 if (!batch.includes(agent.key)) batch = [...batch, agent.key]
839 addLog(now, 'running', `continued ${agent.description}`)
840}
841
842function progress(now: number): Progress {
843 const inBatch = agents.filter(a => batch.includes(a.key))
844 const done = inBatch.filter(a => a.status === 'done' || a.status === 'stopped').length
845 const failed = inBatch.filter(a => a.status === 'failed').length
846 const running = inBatch.filter(a => a.status === 'running')
847 const startedAt = inBatch.reduce((min, a) => Math.min(min, a.startedAt), now)
848 const finished = inBatch.filter(a => a.endedAt !== undefined)
849 let etaMs: number | undefined
850 if (finished.length > 0 && running.length > 0) {
851 const mean = finished.reduce((sum, a) => sum + ((a.endedAt ?? now) - a.startedAt), 0) / finished.length
852 etaMs = Math.max(0, ...running.map(a => mean - (now - a.startedAt)))
853 }
854
855 return { done, failed, running: running.length, total: inBatch.length, startedAt, etaMs }
856}
857
858function orderedAgents(): Agent[] {
859 const rank = (a: Agent) => (a.status === 'running' ? 0 : batch.includes(a.key) ? 1 : 2)
860
861 return [...agents].sort((x, y) => rank(x) - rank(y) || y.startedAt - x.startedAt)
862}
863
864function mainLine(now: number): string {
865 if (main.isWorking) {
866 const last = main.last ? ` · ${main.last}` : ''
867
868 return `working ${duration(now - main.turnStartedAt)} · ${main.tools} tools${last}`
869 }
870
871 return main.lastTurnMs > 0 ? `idle · last turn ${duration(main.lastTurnMs)}` : 'idle'
872}
873
874function limitRows(now: number): { label: string; percent: number; detail: string }[] {
875 if (!usage) return [{ label: 'ctx', percent: 0, detail: 'no reading yet' }]
876 const rows: { label: string; percent: number; detail: string }[] = []
877 const ctxPercent = usage.percent ?? (usage.tokens !== undefined ? (usage.tokens / usage.window) * 100 : 0)
878 const tokens = usage.tokens !== undefined ? `${short(usage.tokens)} / ` : ''
879 rows.push({ label: 'ctx', percent: ctxPercent, detail: `${Math.round(ctxPercent)}% · ${tokens}${short(usage.window)}` })
880 for (const limit of usage.limits) {
881 const label = limit.kind === 'five_hour' ? '5h' : limit.kind === 'seven_day' ? '7d' : limit.kind.replace(/_/g, ' ')
882 const resets = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
883 const when = Number.isFinite(resets) && resets > now ? ` · resets in ${duration(resets - now)}` : ''
884 rows.push({ label, percent: limit.percentUsed, detail: `${Math.round(limit.percentUsed)}%${when}` })
885 }
886 if (usage.usd !== undefined) {
887 rows.push({ label: 'cost', percent: 0, detail: `$${usage.usd.toFixed(2)} this session` })
888 }
889
890 return rows
891}
892
893// The board as plain text: what the open_board tool hands back to Claude, and
894// for a prompt what the summarizers read: without the flow and the summary,
895// which are written from it, and without usage.
896function boardText(now: number, isForPrompt = false): string {
897 const p = progress(now)
898 const lines: string[] = []
899 // The latest prompt alone is often a follow-up ("now fix the test"), so
900 // the job is the last few requests together.
901 if (requests.length > 0) {
902 lines.push("Job (the person's latest requests, newest last):", ...requests.map(r => `- ${r}`))
903 }
904 lines.push(
905 p.total === 0
906 ? 'Agents: none spawned yet in this session.'
907 : `Agents (current batch): ${p.done + p.failed}/${p.total} finished, ${p.running} running, ${p.failed} failed, batch ${duration(now - p.startedAt)}${p.etaMs !== undefined ? `, ${eta(p.etaMs)} (estimate from finished agents)` : ''}`,
908 )
909 for (const a of orderedAgents().slice(0, 15)) {
910 const isRunning = a.status === 'running'
911 const last = isRunning && a.last ? ` | last: ${a.last} (${ago(now - a.lastAt)})` : ''
912 const result = !isRunning && a.result ? ` | result: ${a.result}` : ''
913 lines.push(`- [${a.status}] ${a.description} | ${a.model || a.type} | ${agentTime(a, now)} | ${a.tools} tools${last}${result}`)
914 }
915 lines.push(`Main: ${mainLine(now)}`)
916 if (tests.length > 0) {
917 lines.push('Tests:')
918 for (const t of tests.slice(0, 3)) {
919 lines.push(`- ${t.ok ? 'PASS' : 'FAIL'} ${t.command}${t.summary ? ` (${t.summary})` : ''}, ${ago(now - t.at)}`)
920 }
921 }
922 // Usage and limits are for Claude asking for status; the summarizers leave
923 // them to the status line.
924 if (!isForPrompt) {
925 lines.push(`Limits: ${limitRows(now).map(r => `${r.label} ${r.detail}`).join('; ')}`)
926 }
927 if (log.length > 0) {
928 lines.push('Recent:')
929 for (const line of log.slice(0, 6)) {
930 lines.push(`- ${ago(now - line.at)}: ${line.text}`)
931 }
932 }
933 if (!isForPrompt && flow) {
934 lines.push('Flow:', flowText(flow, 72))
935 }
936 if (!isForPrompt && summary) {
937 lines.push('Summary:', summary.text)
938 if (summary.next) lines.push(summary.next)
939 }
940
941 return lines.join('\n')
942}
943
944// When the next automatic round runs: the earlier of the two that something
945// changed for; never (Infinity) when nothing did, or there is no board yet.
946function nextRefreshAt(): number {
947 if (!hasBoard()) return Infinity
948 const flowAt = changes !== summaries.flowChanges ? summaries.flowAt + FLOW_MS : Infinity
949 const summaryAt = changes !== summaries.summaryChanges ? summaries.summaryAt + SUMMARY_MS : Infinity
950
951 return Math.min(flowAt, summaryAt)
952}
953
954// The footer: ↻ alone, ↻ … while a round runs, ↻ 42s in the last minute
955// before an automatic one that would actually run.
956function refreshLabel(now: number): string {
957 if (summaries.isRunning || summaries.isForced) return '↻ …'
958 const left = nextRefreshAt() - now
959
960 return left > 0 && left < 60_000 ? `↻ ${Math.ceil(left / 1000)}s` : '↻'
961}
962
963// The summary paragraph as the pane draws it, whatever its state; wrapped in
964// full, never cut, and the next clause goes under it on its own.
965function summaryText(): string {
966 if (!summary) return summaries.isRunning ? 'writing the summary…' : 'no summary yet: it comes once there is work on the board'
967
968 return summary.text
969}
970
971// Elapsed from the first start; with continuations, the time spent running too.
972function agentTime(a: Agent, now: number): string {
973 const elapsed = duration((a.endedAt ?? now) - a.startedAt)
974 if (a.runs <= 1) return elapsed
975 const active = a.activeMs + (a.status === 'running' ? Math.max(0, now - a.runStartedAt) : 0)
976
977 return `${elapsed} elapsed, ${duration(active)} active over ${a.runs} runs`
978}
979
980// The person typed it: at the terminal, through the bridge or the SDK, a Slack
981// ping of their own, or a plugin submitting it as their words.
982function isPersonOrigin(origin: PromptOrigin): boolean {
983 const kind = origin.kind
984
985 return kind === 'composer' || kind === 'bridge' || kind === 'sdk' || kind === 'slack-ping' || (kind === 'plugin' && origin.asUser === true)
986}
987
988// The test invocation alone: the shell segment that runs the tests, without
989// the cd, env setup or pipes around it, cut to 60 characters.
990function testInvocation(command: string): string {
991 const segments = command.split(/&&|\|\||;|\||\r?\n/).map(part => oneLine(part).replace(/^(\w+=\S*\s+)+/, ''))
992 const found = segments.find(part => TEST_COMMAND.test(part)) ?? oneLine(command)
993 if (found.length <= 60) return found
994 const head = found.slice(0, 59)
995 const space = head.lastIndexOf(' ')
996
997 return `${space > 40 ? head.slice(0, space) : head}…`
998}
999
1000function newTrack(task: string): Track {
1001 return { task, steps: [] }
1002}
1003
1004function pushStep(track: Track, line: string) {
1005 if (line === '') return
1006 track.steps = [...track.steps, line].slice(-STEPS_KEPT)
1007 changes += 1
1008}
1009
1010// A failed tool call lands after the step that made it, so it is told there.
1011function noteOnLastStep(track: Track, note: string) {
1012 const last = track.steps[track.steps.length - 1]
1013 if (last === undefined) {
1014 pushStep(track, note)
1015
1016 return
1017 }
1018 track.steps = [...track.steps.slice(0, -1), `${last} → ${note}`.slice(0, 600)]
1019 changes += 1
1020}
1021
1022// One model step as the summarizer reads it: what it said, cut short, and the
1023// tools it called with a brief input each.
1024function stepLine(answer: string, toolUses: readonly { name: string; input: unknown }[]): string {
1025 const said = oneLine(answer)
1026 const parts: string[] = []
1027 if (said) parts.push(`said: ${cut(said, 400)}`)
1028 if (toolUses.length > 0) {
1029 const calls = toolUses.slice(0, 8).map(t => {
1030 const input = typeof t.input === 'object' && t.input !== null ? (t.input as Record<string, unknown>) : {}
1031 const brief = describe(input).slice(0, 60)
1032
1033 return brief ? `${t.name} (${brief})` : t.name
1034 })
1035 parts.push(`called ${calls.join(', ')}`)
1036 }
1037
1038 return parts.join('; ')
1039}
1040
1041function firstSentence(text: string): string {
1042 const clean = oneLine(text.replace(/^[#>*\s-]+/, ''))
1043 const end = clean.search(/[.!?](\s|$)/)
1044 const sentence = end >= 0 ? clean.slice(0, end + 1) : clean
1045
1046 return sentence.length > 160 ? `${sentence.slice(0, 159)}…` : sentence
1047}
1048
1049// Haiku's reply as one paragraph and, only when it ends with one, the next
1050// clause ('next: ...' or 'nothing needs you' after the start, a period, a
1051// semicolon, a dash or a line break), split here rather than trusting its line
1052// breaks. The paragraph keeps to the word cap by cutting at a sentence or
1053// clause boundary; nothing is ever cut mid-sentence with an ellipsis.
1054function summaryOf(text: string, words: number): Summary | undefined {
1055 const joined = text
1056 .split(/\r?\n/)
1057 .map(row => row.replace(/^\s*(summary\s*:|[-*•]|\d+[.)])\s*/i, '').trim())
1058 .filter(Boolean)
1059 .join('\n')
1060 let head = joined
1061 let next = ''
1062 const clause = /(^|[.;!?\n]|\s[—–-])\s*(next\s*:\s*|nothing (else )?needs you)/gi
1063 for (const found of joined.matchAll(clause)) {
1064 const at = (found.index ?? 0) + (found[1]?.length ?? 0)
1065 const rest = joined.slice(at).trim()
1066 const isNext = /^next\s*:/i.test(rest)
1067 if (!isNext && !/^nothing (else )?needs you[\s.!]*$/i.test(rest)) continue
1068 head = joined.slice(0, at)
1069 next = isNext ? `next: ${capWords(unquote(oneLine(rest.replace(/^next\s*:\s*/i, ''))), 24)}` : 'nothing needs you'
1070 }
1071 const body = capWords(unquote(oneLine(head)).replace(/[\s;,:—–-]+$/, ''), words)
1072 if (!body && !next) return undefined
1073
1074 return { text: body, next }
1075}
1076
1077// At most `words` words, cut at the last sentence end that fits, else the
1078// last clause boundary, and closed with a period; never an ellipsis.
1079function capWords(text: string, words: number): string {
1080 const all = text.split(' ').filter(Boolean)
1081 if (all.length <= words) return text
1082 const kept = all.slice(0, words).join(' ')
1083 const sentence = Math.max(kept.lastIndexOf('. '), kept.lastIndexOf('! '), kept.lastIndexOf('? '), /[.!?]$/.test(kept) ? kept.length - 1 : -1)
1084 if (sentence > kept.length / 3) return kept.slice(0, sentence + 1)
1085 const clause = Math.max(kept.lastIndexOf('; '), kept.lastIndexOf(', '), kept.lastIndexOf(' — '), kept.lastIndexOf(': '))
1086 const cutAt = clause > kept.length / 3 ? clause : kept.length
1087
1088 return `${kept.slice(0, cutAt).replace(/[\s;,:—–-]+$/, '')}.`
1089}
1090
1091function unquote(text: string): string {
1092 return text.replace(/^["'`*_]+|["'`*_]+$/g, '').trim()
1093}
1094
1095function cut(text: string, length: number): string {
1096 return text.length > length ? `${text.slice(0, length - 1)}…` : text
1097}
1098
1099function describe(args: Record<string, unknown>): string {
1100 for (const field of ['description', 'command', 'file_path', 'pattern', 'query', 'url', 'subject', 'prompt']) {
1101 const value = args[field]
1102 if (typeof value === 'string' && value.trim() !== '') {
1103 const text = field === 'file_path' ? value.split(/[\\/]/).pop() ?? value : value
1104
1105 return oneLine(text).slice(0, 80)
1106 }
1107 }
1108
1109 return ''
1110}
1111
1112function looksFailed(output: string): boolean {
1113 return /\b[1-9]\d*\s+(failed|failing|errors?)\b|\bFAILED\b|\bTests? failed\b/i.test(output)
1114}
1115
1116function testSummary(output: string): string {
1117 const passed = output.match(/(\d+)\s+(passed|passing)/i)
1118 const failed = output.match(/(\d+)\s+(failed|failing)/i)
1119 const ran = output.match(/Ran\s+(\d+)\s+tests?/i)
1120 const parts: string[] = []
1121 if (passed) parts.push(`${passed[1]} passed`)
1122 if (failed && failed[1] !== '0') parts.push(`${failed[1]} failed`)
1123 if (parts.length === 0 && ran) parts.push(`${ran[1]} tests`)
1124
1125 return parts.join(', ')
1126}
1127
1128function addLog(at: number, status: Status, text: string) {
1129 log = [{ at, status, text }, ...log].slice(0, 12)
1130 changes += 1
1131}
1132
1133function bar(value: number, total: number, width: number): string {
1134 const filled = total > 0 ? Math.min(width, Math.round((value / total) * width)) : 0
1135
1136 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
1137}
1138
1139function eta(ms: number): string {
1140 return ms < 30_000 ? 'finishing any moment' : `~${duration(ms)} left`
1141}
1142
1143function duration(ms: number): string {
1144 const s = Math.max(0, Math.round(ms / 1000))
1145 if (s < 60) return `${s}s`
1146 const m = Math.floor(s / 60)
1147 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
1148
1149 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
1150}
1151
1152function ago(ms: number): string {
1153 return ms < 5000 ? 'now' : `${duration(ms)} ago`
1154}
1155
1156function short(n: number): string {
1157 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
1158 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
1159
1160 return String(n)
1161}
1162
1163function shortModel(model: string): string {
1164 return model.replace(/^claude-/, '').replace(/-(\d+)-(\d+)(-\d{8})?$/, ' $1.$2')
1165}
1166
1167function oneLine(text: string): string {
1168 return text.replace(/\s+/g, ' ').trim()
1169}
1170
1171function safeJson(value: unknown): string {
1172 try {
1173 return typeof value === 'string' ? value : JSON.stringify(value ?? '')
1174 } catch {
1175 return ''
1176 }
1177}
1178hooks/flow.ts 525 lines1// The flow diagram: Sonnet describes the job as steps and edges, and this file
2// lays them out the same way every time and draws them three ways: Raster
3// cells for the terminal, an Svg for the desktop, plain ASCII elsewhere and in
4// the board text. No $ here: everything is a pure function of the flow.
5//
6// Layout, compact: layers top to bottom by longest path, an edge that skips a
7// layer routed through a dummy slot, each layer ordered by its parents'
8// positions to keep crossings down. A plain chain (one step feeding the next,
9// nothing else in or out) shares a row, left to right. Cards are one row tall
10// and as wide as their label; a connector is one row, two where it bends.
11
12import type { WorkboardFlow, WorkboardFlowEdge, WorkboardFlowState, WorkboardFlowStep } from '../types'
13
14export type FlowState = WorkboardFlowState
15
16export type FlowStep = WorkboardFlowStep
17
18export type FlowEdge = WorkboardFlowEdge
19
20export type Flow = WorkboardFlow
21
22export type RasterGrid = { columns: number; rows: number; cells: string }
23
24export type SvgDrawing = { source: string; alt: string; width: number; height: number }
25
26export const MAX_STEPS = 8
27const MAX_EDGES = 10
28const LABEL_CHARS = 24
29const DETAIL_CHARS = 200
30const EDGE_LABEL_CHARS = 8
31const CARD_MAX = 28
32const GAP = 2
33const CHAIN_GAP = 3
34const MAX_WIDTH = 60
35const MAX_ROWS = 256
36const DEFAULT = 0x01000000
37const STATES: readonly FlowState[] = ['done', 'running', 'failed', 'waiting', 'check']
38
39// One fill and one text colour per state, differing in lightness as well as
40// hue, and a glyph per state so colour never carries the meaning alone. A
41// running step's progress fills its card from the left; the rest is `rest`.
42export const STATE_STYLE: Record<FlowState, { fill: number; text: number; glyph: string; tag: string; rest?: number }> = {
43 done: { fill: 0x2e7d32, text: 0xffffff, glyph: '✓', tag: 'done' },
44 running: { fill: 0x26c6da, text: 0x0b1f24, glyph: '●', tag: 'run', rest: 0xb2ebf2 },
45 failed: { fill: 0xc62828, text: 0xffffff, glyph: '✗', tag: 'FAIL' },
46 waiting: { fill: 0x546e7a, text: 0xffffff, glyph: '○', tag: 'wait' },
47 check: { fill: 0xffb300, text: 0x1a1a1a, glyph: '◆', tag: 'check' },
48}
49const CONNECTOR = 0x8a8f98
50const EDGE_TEXT = 0xb0b6be
51
52// --- reading Sonnet's reply -------------------------------------------------
53
54// The reply's first balanced {...} as a flow: unknown states become waiting,
55// edges to unknown steps, self edges and edges that close a loop are dropped,
56// and every string is cut to what a card holds.
57export function parseFlow(text: string): Flow | undefined {
58 const raw = firstObject(text)
59 if (raw === undefined) return undefined
60 let parsed: unknown
61 try {
62 parsed = JSON.parse(raw)
63 } catch {
64 return undefined
65 }
66 if (!isRecord(parsed) || !Array.isArray(parsed.steps)) return undefined
67
68 const steps: FlowStep[] = []
69 for (const item of parsed.steps) {
70 if (!isRecord(item) || steps.length >= MAX_STEPS) continue
71 const id = typeof item.id === 'string' || typeof item.id === 'number' ? String(item.id) : ''
72 const label = typeof item.label === 'string' ? cutText(oneLine(item.label), LABEL_CHARS) : ''
73 if (!id || !label || steps.some(s => s.id === id)) continue
74 const state = STATES.find(s => s === item.state) ?? 'waiting'
75 const step: FlowStep = { id, label, state }
76 if (state === 'running' && typeof item.progress === 'number' && Number.isFinite(item.progress)) {
77 step.progress = Math.max(0, Math.min(1, item.progress > 1 ? item.progress / 100 : item.progress))
78 }
79 if (typeof item.detail === 'string' && item.detail.trim() !== '') step.detail = cutText(oneLine(item.detail), DETAIL_CHARS)
80 steps.push(step)
81 }
82 if (steps.length === 0) return undefined
83
84 const edges: FlowEdge[] = []
85 for (const item of Array.isArray(parsed.edges) ? parsed.edges : []) {
86 if (!isRecord(item) || edges.length >= MAX_EDGES) continue
87 const from = String(item.from ?? '')
88 const to = String(item.to ?? '')
89 if (from === to || !steps.some(s => s.id === from) || !steps.some(s => s.id === to)) continue
90 if (edges.some(e => e.from === from && e.to === to) || reaches(edges, to, from)) continue
91 const edge: FlowEdge = { from, to }
92 if (typeof item.label === 'string' && item.label.trim() !== '') edge.label = cutText(oneLine(item.label), EDGE_LABEL_CHARS)
93 edges.push(edge)
94 }
95
96 return { steps, edges }
97}
98
99function reaches(edges: readonly FlowEdge[], from: string, to: string): boolean {
100 const seen = new Set<string>()
101 const stack = [from]
102 while (stack.length > 0) {
103 const at = stack.pop() as string
104 if (at === to) return true
105 if (seen.has(at)) continue
106 seen.add(at)
107 for (const e of edges) if (e.from === at) stack.push(e.to)
108 }
109
110 return false
111}
112
113// --- layout -------------------------------------------------------------------
114
115type Mode = 'cells' | 'ascii'
116
117type Item = { key: string; step?: FlowStep; width: number; x: number; center: number }
118
119type Row = { y: number; items: Item[]; isChain: boolean; gap: number }
120
121type Link = { from: string; to: string; label?: string }
122
123type Layout = { columns: number; rows: number; lines: Row[]; links: Link[] }
124
125// What a card shows: the glyph and label in the terminal and on the desktop;
126// in ASCII the state rides the shape (a check is <...>) and a tag.
127function cardText(step: FlowStep, mode: Mode): string {
128 const style = STATE_STYLE[step.state]
129 if (mode === 'cells') return ` ${style.glyph} ${cutText(step.label, CARD_MAX - 4)} `
130 if (step.state === 'check') return `< ${step.label} >`
131 const tag = step.state === 'running' && step.progress !== undefined ? `${style.tag} ${Math.round(step.progress * 100)}%` : style.tag
132
133 return `[ ${step.label} (${tag}) ]`
134}
135
136function layoutFlow(flow: Flow, columns: number, mode: Mode): Layout {
137 const order = new Map(flow.steps.map((s, i) => [s.id, i]))
138 const linked = new Set(flow.edges.flatMap(e => [e.from, e.to]))
139
140 // Longest path from the roots, in the steps' own order; steps no edge
141 // touches each get a layer of their own below, in order.
142 const layer = new Map<string, number>()
143 for (const s of flow.steps) if (linked.has(s.id)) layer.set(s.id, 0)
144 for (let pass = 0; pass < flow.steps.length; pass += 1) {
145 for (const e of flow.edges) {
146 const next = (layer.get(e.from) ?? 0) + 1
147 if (next > (layer.get(e.to) ?? 0)) layer.set(e.to, next)
148 }
149 }
150 let bottom = Math.max(-1, ...layer.values())
151 for (const s of flow.steps) if (!linked.has(s.id)) layer.set(s.id, (bottom += 1))
152
153 // Slots per layer, with a dummy slot wherever an edge passes a layer by.
154 const layers: { step?: FlowStep; key: string }[][] = Array.from({ length: bottom + 1 }, () => [])
155 for (const s of flow.steps) layers[layer.get(s.id) ?? 0]?.push({ step: s, key: s.id })
156 const links: Link[] = []
157 for (const e of flow.edges) {
158 const top = layer.get(e.from) ?? 0
159 const end = layer.get(e.to) ?? 0
160 let previous = e.from
161 for (let l = top + 1; l < end; l += 1) {
162 const key = `${e.from}>${e.to}@${l}`
163 layers[l]?.push({ key })
164 links.push({ from: previous, to: key })
165 previous = key
166 }
167 links.push({ from: previous, to: e.to, label: e.label })
168 }
169
170 // Order each layer by where its parents sit (two sweeps), steps in their
171 // own order when nothing above decides.
172 const position = new Map<string, number>()
173 layers.forEach(row => row.forEach((slot, i) => position.set(slot.key, i)))
174 for (let sweep = 0; sweep < 2; sweep += 1) {
175 for (const row of layers.slice(1)) {
176 const weight = (key: string) => {
177 const parents = links.filter(s => s.to === key).map(s => position.get(s.from) ?? 0)
178
179 return parents.length > 0 ? parents.reduce((a, b) => a + b, 0) / parents.length : order.get(key) ?? 0
180 }
181 row.sort((a, b) => weight(a.key) - weight(b.key))
182 row.forEach((slot, i) => position.set(slot.key, i))
183 }
184 }
185
186 const width = Math.max(1, Math.min(mode === 'ascii' ? 72 : MAX_WIDTH, columns))
187 const widthOf = (slot: { step?: FlowStep }) => (slot.step ? Math.min(mode === 'ascii' ? 34 : CARD_MAX, cardText(slot.step, mode).length) : 1)
188
189 // Rows: a layer, or a plain chain of one-step layers packed left to right
190 // while it fits (an unlabelled edge, nothing else into or out of either).
191 const groups: { slots: { step?: FlowStep; key: string }[]; isChain: boolean }[] = []
192 for (const row of layers) {
193 const last = groups.at(-1)
194 const tail = last?.slots.at(-1)
195 const only = row.length === 1 ? row[0] : undefined
196 const out = tail ? links.filter(k => k.from === tail.key) : []
197 const into = only ? links.filter(k => k.to === only.key) : []
198 const isPlainChain =
199 last !== undefined &&
200 (last.isChain || last.slots.length === 1) &&
201 tail?.step !== undefined &&
202 only?.step !== undefined &&
203 out.length === 1 &&
204 into.length === 1 &&
205 out[0] === into[0] &&
206 out[0]?.label === undefined
207 const used = last ? last.slots.reduce((sum, s) => sum + widthOf(s), 0) + (last.slots.length - 1) * CHAIN_GAP : 0
208 if (isPlainChain && only && last && used + CHAIN_GAP + widthOf(only) <= width) {
209 last.slots.push(only)
210 last.isChain = true
211 } else {
212 groups.push({ slots: [...row], isChain: false })
213 }
214 }
215
216 // Positions: each item under its parents where it can be, packed left to
217 // right without overlap, inside the width.
218 const centers = new Map<string, number>()
219 const lines: Row[] = []
220 for (const group of groups) {
221 const gap = group.isChain ? CHAIN_GAP : GAP
222 const widths = group.slots.map(widthOf)
223 const total = widths.reduce((a, b) => a + b, 0) + gap * (widths.length - 1)
224 const lefts: number[] = []
225 let start = Math.floor((width - total) / 2)
226 widths.forEach(w => {
227 lefts.push(start)
228 start += w + gap
229 })
230 const wanted = group.slots.map((slot, i) => {
231 const parents = links.filter(k => k.to === slot.key).map(k => centers.get(k.from)).filter((c): c is number => c !== undefined)
232 const w = widths[i] ?? 1
233
234 return parents.length > 0 ? Math.round(parents.reduce((a, b) => a + b, 0) / parents.length) - Math.floor(w / 2) : undefined
235 })
236 if (group.isChain) {
237 const shift = wanted[0] !== undefined ? wanted[0] - (lefts[0] ?? 0) : 0
238 for (let i = 0; i < lefts.length; i += 1) lefts[i] = (lefts[i] ?? 0) + shift
239 } else {
240 let right = -GAP
241 for (let i = 0; i < lefts.length; i += 1) {
242 const x = Math.max(wanted[i] ?? lefts[i] ?? 0, right + GAP)
243 lefts[i] = x
244 right = x + (widths[i] ?? 1)
245 }
246 // Packing pushes siblings right of their parent; shift the row back so
247 // it sits centred under the parents it hangs from.
248 const off = wanted.map((w, i) => (w === undefined ? undefined : w - (lefts[i] ?? 0))).filter((d): d is number => d !== undefined)
249 const shift = off.length > 0 ? Math.round(off.reduce((a, b) => a + b, 0) / off.length) : 0
250 const room = Math.max(0, width - right)
251 const moved = Math.max(-(lefts[0] ?? 0), Math.min(room, shift))
252 for (let i = 0; i < lefts.length; i += 1) lefts[i] = (lefts[i] ?? 0) + moved
253 }
254 const overflow = (lefts.at(-1) ?? 0) + (widths.at(-1) ?? 1) - width
255 const back = Math.min(lefts[0] ?? 0, Math.max(0, overflow))
256 const items = group.slots.map((slot, i) => {
257 const x = Math.max(0, (lefts[i] ?? 0) - back)
258 const w = widths[i] ?? 1
259
260 return { ...slot, width: w, x, center: x + (slot.step ? Math.floor(w / 2) : 0) }
261 })
262 for (const item of items) centers.set(item.key, item.center)
263 lines.push({ y: 0, items, isChain: group.isChain, gap: 0 })
264 }
265
266 // Rows between: one where every connector runs straight down, two where
267 // one bends; the y of each row follows.
268 const rowOf = new Map<string, number>()
269 lines.forEach((line, r) => line.items.forEach(item => rowOf.set(item.key, r)))
270 let y = 0
271 lines.forEach((line, r) => {
272 line.y = y
273 const down = links.filter(k => rowOf.get(k.from) === r && rowOf.get(k.to) === r + 1)
274 line.gap = r === lines.length - 1 ? 0 : down.every(k => centers.get(k.from) === centers.get(k.to)) ? 1 : 2
275 y += 1 + line.gap
276 })
277
278 return { columns: width, rows: Math.min(MAX_ROWS, Math.max(1, y)), lines, links }
279}
280
281// --- a grid of cells, shared by the Raster and the ASCII drawing ------------
282
283type Cell = { char: string; fg: number; bg: number }
284
285const UP = 1
286const DOWN = 2
287const LEFT = 4
288const RIGHT = 8
289
290function connectorChar(bits: number, isAscii: boolean): string {
291 if (isAscii) {
292 if (bits === UP || bits === DOWN || bits === (UP | DOWN)) return '|'
293 if (bits === LEFT || bits === RIGHT || bits === (LEFT | RIGHT)) return '-'
294
295 return '+'
296 }
297 const table: Record<number, string> = {
298 [UP]: '│', [DOWN]: '│', [UP | DOWN]: '│', [LEFT]: '─', [RIGHT]: '─', [LEFT | RIGHT]: '─',
299 [DOWN | RIGHT]: '┌', [DOWN | LEFT]: '┐', [UP | RIGHT]: '└', [UP | LEFT]: '┘',
300 [UP | DOWN | RIGHT]: '├', [UP | DOWN | LEFT]: '┤', [DOWN | LEFT | RIGHT]: '┬', [UP | LEFT | RIGHT]: '┴',
301 [UP | DOWN | LEFT | RIGHT]: '┼',
302 }
303
304 return table[bits] ?? '┼'
305}
306
307function drawGrid(flow: Flow, columns: number, mode: Mode): Cell[][] {
308 const isAscii = mode === 'ascii'
309 const layout = layoutFlow(flow, columns, mode)
310 const grid: Cell[][] = Array.from({ length: layout.rows }, () =>
311 Array.from({ length: layout.columns }, () => ({ char: ' ', fg: DEFAULT, bg: DEFAULT })),
312 )
313 const line = isAscii ? DEFAULT : CONNECTOR
314 const put = (row: number, col: number, char: string, fg = DEFAULT, bg = DEFAULT) => {
315 const cell = grid[row]?.[col]
316 if (cell) Object.assign(cell, { char, fg, bg })
317 }
318 const bits = new Map<string, number>()
319 const addBits = (row: number, col: number, b: number) => bits.set(`${row},${col}`, (bits.get(`${row},${col}`) ?? 0) | b)
320 const marks: [number, number, string, number][] = []
321 const where = new Map<string, { row: Row; item: Item }>()
322 for (const row of layout.lines) for (const item of row.items) where.set(item.key, { row, item })
323
324 // Down: straight into the card below (one row), or across and down (two).
325 for (const link of layout.links) {
326 const a = where.get(link.from)
327 const b = where.get(link.to)
328 if (!a || !b || a.row === b.row) continue
329 const first = a.row.y + 1
330 const tip = a.row.gap === 1 ? first : first + 1
331 if (a.row.gap === 2) {
332 if (a.item.center === b.item.center) {
333 addBits(first, a.item.center, UP | DOWN)
334 } else {
335 const toRight = b.item.center > a.item.center
336 addBits(first, a.item.center, UP | (toRight ? RIGHT : LEFT))
337 addBits(first, b.item.center, DOWN | (toRight ? LEFT : RIGHT))
338 for (let c = Math.min(a.item.center, b.item.center) + 1; c < Math.max(a.item.center, b.item.center); c += 1) addBits(first, c, LEFT | RIGHT)
339 }
340 }
341 if (b.item.step) {
342 marks.push([tip, b.item.center, isAscii ? 'v' : '▼', line])
343 if (link.label) {
344 for (let i = 0; i < link.label.length; i += 1) marks.push([tip, b.item.center + 2 + i, link.label[i] ?? ' ', isAscii ? DEFAULT : EDGE_TEXT])
345 }
346 } else {
347 addBits(tip, b.item.center, UP | DOWN)
348 }
349 }
350 for (const row of layout.lines) for (const item of row.items) if (!item.step) addBits(row.y, item.center, UP | DOWN)
351 for (const [key, b] of bits) {
352 const [row, col] = key.split(',').map(Number) as [number, number]
353 put(row, col, connectorChar(b, isAscii), line)
354 }
355 for (const [row, col, char, fg] of marks) put(row, col, char, fg)
356
357 // Cards, one row each, and the arrow between the cards of a chain.
358 for (const row of layout.lines) {
359 row.items.forEach((item, i) => {
360 if (!item.step) return
361 const text = cutText(cardText(item.step, mode), item.width)
362 if (isAscii) {
363 for (let c = 0; c < item.width; c += 1) put(row.y, item.x + c, text[c] ?? ' ')
364 } else {
365 const style = STATE_STYLE[item.step.state]
366 const filled = item.step.progress !== undefined ? Math.round(item.step.progress * item.width) : item.width
367 for (let c = 0; c < item.width; c += 1) {
368 put(row.y, item.x + c, text[c] ?? ' ', style.text, c < filled ? style.fill : style.rest ?? style.fill)
369 }
370 }
371 const next = row.items[i + 1]
372 if (row.isChain && next) put(row.y, item.x + item.width + 1, isAscii ? '>' : '▶', line)
373 })
374 }
375
376 return grid
377}
378
379// The terminal's drawing: the grid as base64 little-endian u32 triplets.
380export function flowRaster(flow: Flow, columns: number): RasterGrid {
381 const grid = drawGrid(flow, columns, 'cells')
382 const rows = grid.length
383 const width = grid[0]?.length ?? 1
384 const words = new Uint32Array(width * rows * 3)
385 let i = 0
386 for (const row of grid) {
387 for (const cell of row) {
388 words[i] = cell.char.codePointAt(0) ?? 0x20
389 words[i + 1] = cell.fg
390 words[i + 2] = cell.bg
391 i += 3
392 }
393 }
394
395 return { columns: width, rows, cells: base64(new Uint8Array(words.buffer)) }
396}
397
398// Plain ASCII, for surfaces without a drawing element and for the board text.
399export function flowText(flow: Flow, columns: number): string {
400 return drawGrid(flow, columns, 'ascii')
401 .map(row => row.map(c => c.char).join('').trimEnd())
402 .join('\n')
403}
404
405// The desktop's drawing: the same layout in pixels (6 per column), small
406// rounded cards, tight gaps, arrows, each step's detail as a tooltip.
407export function flowSvg(flow: Flow, columns: number): SvgDrawing {
408 const layout = layoutFlow(flow, columns, 'cells')
409 const cw = 6
410 const cardH = 18
411 const gapH = 10
412 const top = (row: Row) => layout.lines.slice(0, layout.lines.indexOf(row)).reduce((sum, r) => sum + cardH + r.gap * gapH, 0)
413 const width = layout.columns * cw
414 const height = layout.lines.reduce((sum, r) => sum + cardH + r.gap * gapH, 0)
415 const where = new Map<string, { row: Row; item: Item }>()
416 for (const row of layout.lines) for (const item of row.items) where.set(item.key, { row, item })
417 const mid = (item: Item) => item.center * cw + cw / 2
418
419 const parts: string[] = []
420 for (const link of layout.links) {
421 const a = where.get(link.from)
422 const b = where.get(link.to)
423 if (!a || !b || a.row === b.row) continue
424 const start = top(a.row) + (a.item.step ? cardH - 2 : cardH)
425 const end = top(b.row) + (b.item.step ? 0 : 0)
426 const bend = top(a.row) + cardH + gapH / 2
427 const path = a.item.center === b.item.center ? `M${mid(a.item)},${start} V${end}` : `M${mid(a.item)},${start} V${bend} H${mid(b.item)} V${end}`
428 parts.push(`<path d="${path}" fill="none" stroke="#8a8f98" stroke-width="1"${b.item.step ? ' marker-end="url(#arrow)"' : ''}/>`)
429 if (link.label && b.item.step) parts.push(`<text x="${mid(b.item) + 5}" y="${end - 2}" font-size="9" fill="#8a8f98">${escapeXml(link.label)}</text>`)
430 }
431 for (const row of layout.lines) {
432 const y = top(row)
433 row.items.forEach((item, i) => {
434 if (!item.step) {
435 parts.push(`<line x1="${mid(item)}" y1="${y}" x2="${mid(item)}" y2="${y + cardH}" stroke="#8a8f98" stroke-width="1"/>`)
436 return
437 }
438 const style = STATE_STYLE[item.step.state]
439 const x = item.x * cw
440 const w = item.width * cw
441 const tip = `${item.step.label}${item.step.detail ? `: ${item.step.detail}` : ''} (${item.step.state}${item.step.progress !== undefined ? `, ${Math.round(item.step.progress * 100)}%` : ''})`
442 const fill =
443 item.step.progress !== undefined
444 ? `<rect x="${x}" y="${y + 1}" width="${w}" height="${cardH - 3}" rx="4" fill="#${hex(style.rest ?? style.fill)}"/><rect x="${x}" y="${y + 1}" width="${Math.round(w * item.step.progress)}" height="${cardH - 3}" rx="4" fill="#${hex(style.fill)}"/>`
445 : `<rect x="${x}" y="${y + 1}" width="${w}" height="${cardH - 3}" rx="4" fill="#${hex(style.fill)}"/>`
446 parts.push(
447 `<g><title>${escapeXml(tip)}</title>${fill}<rect x="${x}" y="${y + 1}" width="${w}" height="${cardH - 3}" rx="4" fill="none" stroke="#8a8f98" stroke-width="1"/><text x="${x + w / 2}" y="${y + cardH / 2 + 3}" text-anchor="middle" font-size="10" fill="#${hex(style.text)}">${escapeXml(cardText(item.step, 'cells').trim())}</text></g>`,
448 )
449 const next = row.items[i + 1]
450 if (row.isChain && next) {
451 parts.push(`<path d="M${x + w + 2},${y + cardH / 2} H${next.x * cw - 3}" stroke="#8a8f98" stroke-width="1" marker-end="url(#arrow)"/>`)
452 }
453 })
454 }
455 const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" font-family="ui-sans-serif, system-ui, sans-serif"><defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="4" markerHeight="4" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#8a8f98"/></marker></defs>${parts.join('')}</svg>`
456 const alt = `Flow of the job, top to bottom: ${flow.steps.map(s => `${s.label} (${s.state})`).join(', ')}`
457
458 return { source, alt, width, height }
459}
460
461// --- small helpers ------------------------------------------------------------
462
463function cutText(text: string, length: number): string {
464 if (length <= 0) return ''
465
466 return text.length > length ? `${text.slice(0, Math.max(0, length - 1))}…` : text
467}
468
469function oneLine(text: string): string {
470 return text.replace(/\s+/g, ' ').trim()
471}
472
473function isRecord(value: unknown): value is Record<string, unknown> {
474 return typeof value === 'object' && value !== null && !Array.isArray(value)
475}
476
477function hex(color: number): string {
478 return color.toString(16).padStart(6, '0')
479}
480
481function escapeXml(text: string): string {
482 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
483}
484
485const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
486
487function base64(bytes: Uint8Array): string {
488 let out = ''
489 for (let i = 0; i < bytes.length; i += 3) {
490 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
491 out += ALPHABET[(n >> 18) & 63]
492 out += ALPHABET[(n >> 12) & 63]
493 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63] : '='
494 out += i + 2 < bytes.length ? ALPHABET[n & 63] : '='
495 }
496
497 return out
498}
499
500// The first balanced {...} in a reply; whatever surrounds it is ignored.
501export function firstObject(text: string): string | undefined {
502 const start = text.indexOf('{')
503 if (start < 0) return undefined
504 let depth = 0
505 let isInString = false
506 let isEscaped = false
507 for (let i = start; i < text.length; i += 1) {
508 const c = text[i]
509 if (isInString) {
510 if (isEscaped) isEscaped = false
511 else if (c === '\\') isEscaped = true
512 else if (c === '"') isInString = false
513 } else if (c === '"') {
514 isInString = true
515 } else if (c === '{') {
516 depth += 1
517 } else if (c === '}') {
518 depth -= 1
519 if (depth === 0) return text.slice(start, i + 1)
520 }
521 }
522
523 return undefined
524}
525types/index.d.ts 66 lines1// The Workboard's contract: the records it keeps in $.state so a hot reload
2// of its code does not lose them. Self-contained: no import or reference.
3
4export type WorkboardStatus = 'running' | 'done' | 'failed' | 'stopped'
5
6// One top-level subagent: what it was asked, its last steps, and its time and
7// tool count over every run (a continued agent keeps its first start).
8export type WorkboardAgent = {
9 id: string
10 key: string
11 description: string
12 type: string
13 model: string
14 status: WorkboardStatus
15 task: string
16 steps: string[]
17 startedAt: number
18 runStartedAt: number
19 activeMs: number
20 runs: number
21 endedAt?: number
22 tools: number
23 last: string
24 lastAt: number
25 result?: string
26}
27
28// The person's own latest requests, and the task the main turn works on.
29export type WorkboardJob = { requests: string[]; task: string }
30
31export type WorkboardFlowState = 'done' | 'running' | 'failed' | 'waiting' | 'check'
32
33export type WorkboardFlowStep = { id: string; label: string; state: WorkboardFlowState; progress?: number; detail?: string }
34
35export type WorkboardFlowEdge = { from: string; to: string; label?: string }
36
37// Sonnet's description of the whole job, which the pane lays out and draws.
38export type WorkboardFlow = { steps: WorkboardFlowStep[]; edges: WorkboardFlowEdge[] }
39
40// Haiku's summary: one paragraph, and the next clause when it ended with one.
41export type WorkboardSummary = { text: string; next: string }
42
43// When each summarizer last ran and at which change count (null: never), the
44// change count itself, and when either last answered.
45export type WorkboardRounds = {
46 flowAt: number | null
47 flowChanges: number
48 summaryAt: number | null
49 summaryChanges: number
50 changes: number
51 refreshedAt: number | null
52}
53
54declare module 'claude-code' {
55 interface PluginState {
56 workboard: {
57 agents: WorkboardAgent[]
58 batch: string[]
59 job: WorkboardJob
60 flow: WorkboardFlow | null
61 summary: WorkboardSummary | null
62 rounds: WorkboardRounds
63 }
64 }
65}
66