SLOPSHOPPER

workboard

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.

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-07lokeshrana9999/workboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workboard
│ ┃ Workboard ✕ › fix the failing auth test and add an audit log call │ ┃ r: ↻ │ ┃ ⏺ Read(src/auth.ts) │ ┃ no summary yet: it comes once there is work ⎿ Read 6 lines │ ┃ board ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ workboard: Workboard open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workboard
r: ↻ no summary yet: it comes once there is work on the board
README

workboard

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

The /board pane: a flow diagram of the job, the refresh button, and a summary

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.

surfaces

SurfaceWhat it shows
/board paneA flow diagram of the job, a ↻ button that reruns it now, and a short summary of where things stand.
Band above the promptOnly 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 toolClaude 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.

install

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" }
}

layout

PathRole
.claude-plugin/plugin.jsonManifest
hooks/hooks.jsonLists the hook modules to load
hooks/register.tsxMain module: events, state, pane, band and tool
hooks/flow.tsParses and lays out the flow diagram
types/index.d.tsTypes for the mod's saved state
tests/board.test.tsTests
docs/board.pngScreenshot used in this README

.claude-plugin/types/ is generated by Claude Code and is not committed.

Source 3 files
hooks/register.tsx 1178 lines
1// 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}
1178
hooks/flow.ts 525 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
525
types/index.d.ts 66 lines
1// 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