Live progress panel above the prompt: Claude's task list as steps, subagents as a roster with an Agents pane, tool rows condensed to one line, and a collapse…

A Claude Code mod that replaces the stream of tool output with a live progress panel above the prompt.
▶ Working, ‖ Needs you, ◼ Ended, ✔ All done, ⊘ Stopped, ✗ Failed), the step Claude is on, and the clock. While agents from this turn run it counts them by role instead (Scouting: 2 of 5 scouts done). A background agent still running after the turn ends keeps the band working and the clock counting; Waiting for 1 agent shows only for agents left over from an earlier turn. A live agent the engine stops listing for over 10 s is taken as finished.steps tool, or TaskCreate / TaskUpdate / TodoWrite) as rows with a bar and a word: Done, Working, Next, Up next; in the Ended state (below) every open row reads Not marked instead. When the band runs out of rows, finished steps fold into one line and the window keeps the step in progress in view (+N more steps marks steps cut at the tail). While Claude works, a dim ↳ Tool: label line under the active step names its latest tool call; it is the first thing dropped when rows are short. The list holds at most 50 steps, titles cut to 120 characters./orchestrate waves): description led by a dim role (scout, implementer, reviewer, fixer, scribe, read from the description, else the type and model tier), type and model tier (general/opus), status, elapsed time, tokens (4.2k tok: uncached input plus output, added as each of the agent's model requests lands, so the figure climbs while it runs), tool-call count. Live agents first, clustered by role, finished ones dimmed below. Up to 12 rows; when the band is short of rows the last one reads +N more · /progress agents. Widths are the band's usable width (body columns minus 2): under 70 the type/model and calls columns drop; under 50, the time too; the role prefix drops when the description gets narrow. The section header adds the roster's token total.🔊 speech line, ends in ?, or, failing that, a small classifier call on the answer's tail says it is a question; a classifier answer that arrives after the next turn started is dropped).Turn ended; N steps not marked done, the open rows read Not marked, and the progress line shows done-of-total. Nothing is auto-completed: the panel only shows what the model recorded.Show button that expands it. To press it, focus the band (ctrl+x then tab, or click it) and press s or Enter on the button; /progress show expands it too. The next turn expands it again.✓ Bash: Run the test suite). Errors keep the full row. Ctrl+O still shows everything. The mod's own steps calls draw no row at all.*KEY*/*TOKEN*/*SECRET*/*PASSWORD*/*AUTH*=… values and --token-style flags are masked as •••, and control characters are stripped.Agent failed: <description>, spoken "Sir, an agent has failed.") and Claude asking you a question through AskUserQuestion (spoken "Sir, Claude has a question for you."). Speech is fixed phrases only. At most one alert per kind every 10 s; later ones are dropped. Nothing is spoken at turn end.The mod registers its own tool, mcp__progress-band__steps, which Claude calls to plan the steps of a multi-step request and mark each one in_progress and completed. It answers with the list as plain text (Steps: then 1. [in_progress] Check the balance, one per line). A short system-prompt section asks Claude to use it. Each new prompt the person sends while steps are open (not a slash command, a message delivered into a running turn, or an agent or task notification) carries a hidden reminder listing them, so the model closes or replaces the list. The panel also reads TaskCreate / TaskUpdate / TodoWrite when a session offers those tools.
Running the orchestrate skill seeds a nine-step wave (Scout the code and facts, Get the owner's rulings, Brief the implementer, Re-verify the implementation, Run the review gate, Adjudicate the findings, Batch-fix the findings, Finders re-verify the fixes, Bank the wave and hand off) when no unfinished step list is open.
| Command | Effect |
|---|---|
/progress | toggle the panel (any other word prints the usage line) |
/progress hide / show | hide or show it |
/progress quiet / verbose | condense tool rows, or draw them in full |
/progress clear | forget the current steps and agents |
/progress agents | open the Agents pane |
The Agents pane lists every agent in the roster, live first. Per agent: the description with its role, then type and full model id (the short tier when the id does not fit), then status, elapsed time, calls and tokens, then ↳ its latest tool call, then the first line of its last reply in quotes. Escape closes it.
Every session, with no flag: name the folder in the env block of ~/.claude/settings.json (several folders are colon-separated):
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/progress-band" }
One session only:
claude --plugin-dir /path/to/progress-band
Either way the folder is watched: saving a file hot-reloads the mod.
Check and test:
claude plugin validate /path/to/progress-band
claude plugin test /path/to/progress-band
.claude-plugin/plugin.json: manifesthooks/hooks.json: names the hooks modulehooks/register.tsx: the hookstypes/index.d.ts: the $.state contracttests/progress-band.test.tsx: kit testshooks/register.tsx 1329 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { AgentRow, AgentState, Step, View } from '../types'
5
6const steps = atom({ plugin: 'progress-band', key: 'steps' } as const, [])
7const agents = atom({ plugin: 'progress-band', key: 'agents' } as const, [])
8const turn = atom({ plugin: 'progress-band', key: 'turn' } as const, null)
9const VIEW0: View = { isHidden: false, isCondensed: true, isCollapsed: false }
10const view = atom({ plugin: 'progress-band', key: 'view' } as const, VIEW0)
11
12// Keep the roster short: live agents plus the most recent finished ones.
13const MAX_AGENTS = 12
14// How often the band redraws while a turn runs (elapsed time, agent status).
15const TICK_MS = 2000
16
17const STEPS_TOOL = 'mcp__progress-band__steps'
18const COLLAPSE_MS = 20_000
19const MAX_STEPS = 50
20const LABEL_MAX = 120
21// At most one toast and speech per alert kind in this window.
22const ALERT_MS = 10_000
23// A live agent the engine stops listing for this long is taken as finished.
24const MISSING_MS = 10_000
25
26const WAVE: ReadonlyArray<readonly [string, string]> = [
27 ['Scout the code and facts', 'Scouting the code and facts'],
28 ["Get the owner's rulings", "Getting the owner's rulings"],
29 ['Brief the implementer', 'Briefing the implementer'],
30 ['Re-verify the implementation', 'Re-verifying the implementation'],
31 ['Run the review gate', 'Running the review gate'],
32 ['Adjudicate the findings', 'Adjudicating the findings'],
33 ['Batch-fix the findings', 'Batch-fixing the findings'],
34 ['Finders re-verify the fixes', 'Finders re-verifying the fixes'],
35 ['Bank the wave and hand off', 'Banking the wave and handing off'],
36]
37
38type Role = 'scout' | 'reviewer' | 'implementer' | 'fixer' | 'scribe' | 'agent'
39
40const ROLE_ORDER: readonly Role[] = ['scout', 'implementer', 'reviewer', 'fixer', 'scribe', 'agent']
41
42const ROLE_LABEL: Record<Role, readonly [string, string]> = {
43 scout: ['Scouting', 'scouts'],
44 reviewer: ['Review gate', 'reviewers'],
45 implementer: ['Implementing', 'implementers'],
46 fixer: ['Fixing', 'fixers'],
47 scribe: ['Scribing', 'scribes'],
48 agent: ['Agents', 'agents'],
49}
50
51// Row and spinner hooks redraw every tick: they read these, not the atoms. Undefined until hydrated after a load.
52let viewCache: View | undefined
53let stepsCache: Step[] | undefined
54let hydrating: Promise<void> | null = null
55let ticker: { cancel: () => void } | null = null
56let collapseTimer: { cancel: () => void } | null = null
57// The turn's end work runs once, when the turn is over and no agent is live.
58let isSettled = false
59// Bumped by each turn.start; a settle begun under an older value is stale.
60let turnGen = 0
61// The answer last sent to the classifier, by its turn's endedAt.
62let classifiedAt: number | undefined
63const lastAlertAt: Record<'fail' | 'ask', number> = { fail: -Infinity, ask: -Infinity }
64
65const NUDGE = `## Progress panel
66The person sees a live progress panel instead of tool output. It is built from the step list you keep with the ${STEPS_TOOL} tool, so for any request that takes more than two or three steps:
67- Before starting, call it with \`plan\`: one entry per step, in order. Titles are short, plain, user-facing phrases ("Check the credit balance"), not implementation detail; activeForm is the present-continuous form ("Checking the credit balance").
68- Right before you start a step call it with \`step\` (its 1-based number) and \`status: "in_progress"\`; right after, \`status: "completed"\`. One step in progress at a time, in order.
69- Use \`add\` when the plan grows. Never leave a finished step in_progress.
70- Before your final answer, mark every finished step completed. A step the person does themselves counts as finished once they report it.
71A one-step request needs no step list. If TaskCreate/TaskUpdate are offered you may use those instead; the panel reads both.`
72
73const STEPS_SCHEMA = {
74 type: 'object',
75 properties: {
76 plan: {
77 type: 'array',
78 description: 'Replace the step list with these steps, all pending, in order.',
79 items: {
80 type: 'object',
81 properties: {
82 title: { type: 'string', description: 'Short, user-facing step title' },
83 activeForm: { type: 'string', description: 'Present-continuous form shown while working' },
84 },
85 required: ['title'],
86 },
87 },
88 step: { type: 'integer', minimum: 1, description: '1-based number of the step to update' },
89 status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
90 add: {
91 type: 'object',
92 description: 'Append one step to the list',
93 properties: {
94 title: { type: 'string' },
95 activeForm: { type: 'string' },
96 },
97 required: ['title'],
98 },
99 },
100}
101
102type Dict = Record<string, unknown>
103
104const asDict = (value: unknown): Dict =>
105 typeof value === 'object' && value !== null ? (value as Dict) : {}
106
107const str = (value: unknown): string | undefined =>
108 typeof value === 'string' && value.length > 0 ? value : undefined
109
110const tail = (path: string): string => path.split('/').filter(Boolean).slice(-2).join('/')
111
112const clip = (text: string, width: number): string => {
113 const one = text
114 .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
115 .replace(/[\x00-\x1f\x7f]/g, ' ')
116 .replace(/\s+/g, ' ')
117 .trim()
118 if (width <= 1) return one.slice(0, Math.max(0, width))
119 return one.length > width ? `${one.slice(0, width - 1)}…` : one
120}
121
122const fit = (text: string | undefined): string | undefined =>
123 text === undefined ? undefined : clip(text, LABEL_MAX)
124
125/** Mask credentials a label might carry. */
126const redact = (text: string): string =>
127 text
128 .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\/\s:@]+:[^@\s]+@/gi, '$1•••@')
129 .replace(/((?:^|\s)(?:-u|-p|--user|--password|--passwd)\s*)(?:"[^"]*"|'[^']*'|\S+)/g, '$1•••')
130 .replace(/((?:proxy-)?authorization\s*[=:]\s*["']?)(?:[A-Za-z]+\s+)?[^\s"']+/gi, '$1•••')
131 .replace(/((?:set-)?cookie\s*[=:]\s*["']?)[^"']+/gi, '$1•••')
132 .replace(/Bearer\s+\S+/g, 'Bearer •••')
133 .replace(
134 /\b([A-Za-z_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|AUTH)[A-Za-z_]*)\s*[=:]\s*(?:"[^"]*"|'[^']*'|\S+)/gi,
135 '$1=•••',
136 )
137 .replace(/(--?(?:token|password|secret|api-?key|key)[= ])\S+/g, '$1•••')
138
139/** Scheme, host and path only: no userinfo, query or fragment. */
140const bareUrl = (raw: string | undefined): string => {
141 if (raw === undefined) return ''
142 try {
143 const u = new URL(raw)
144 return `${u.protocol}//${u.host}${u.pathname}`
145 } catch {
146 return ''
147 }
148}
149
150const elapsed = (ms: number): string => {
151 const s = Math.max(0, Math.round(ms / 1000))
152 if (s < 60) return `${s}s`
153 const m = Math.floor(s / 60)
154 const rest = s % 60
155 if (m < 60) return rest === 0 ? `${m}m` : `${m}m ${rest}s`
156 return `${Math.floor(m / 60)}h ${m % 60}m`
157}
158
159const bar = (ratio: number, width: number): string => {
160 if (width <= 0) return ''
161 const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
162 return '█'.repeat(filled) + '░'.repeat(width - filled)
163}
164
165/** One short, human label for a tool call, from its input; unknown tools get none. */
166const labelOf = (tool: string, input: unknown): string => redact(rawLabel(tool, asDict(input)))
167
168const rawLabel = (tool: string, i: Dict): string => {
169 switch (tool) {
170 case 'Bash':
171 return str(i.description) ?? redact(str(i.command) ?? '').split('\n')[0]?.slice(0, 60) ?? ''
172 case 'Read':
173 case 'Edit':
174 case 'Write':
175 case 'NotebookEdit':
176 return str(i.file_path) ? tail(String(i.file_path)) : ''
177 case 'Grep':
178 case 'Glob':
179 return str(i.pattern) ?? ''
180 case 'Agent':
181 return str(i.description) ?? str(i.subagent_type) ?? ''
182 case 'Skill':
183 return `/${str(i.skill) ?? ''}`
184 case 'WebFetch':
185 return bareUrl(str(i.url))
186 case 'WebSearch':
187 return str(i.query) ?? ''
188 case 'TaskCreate':
189 return str(i.subject) ?? ''
190 case 'TaskUpdate':
191 return [str(i.status), str(i.subject)].filter(Boolean).join(' · ')
192 case 'TodoWrite':
193 return 'updated the task list'
194 case STEPS_TOOL: {
195 if (Array.isArray(i.plan)) return `planned ${i.plan.length} steps`
196 if (i.add !== undefined) return `added ${str(asDict(i.add).title) ?? 'a step'}`
197 if (i.step !== undefined) return `step ${String(i.step)} ${str(i.status) ?? ''}`.trim()
198 return ''
199 }
200 case 'AskUserQuestion':
201 return 'asked you a question'
202 case 'SendMessage':
203 return str(i.to) ? `to ${String(i.to)}` : ''
204 default:
205 return ''
206 }
207}
208
209// Fits the 9-cell tokens column: one decimal below 100k, none from there to 1M.
210const tokensOf = (n: number): string =>
211 n < 1000
212 ? `${n} tok`
213 : n < 99_950
214 ? `${(n / 1000).toFixed(1)}k tok`
215 : n < 999_500
216 ? `${Math.round(n / 1000)}k tok`
217 : `${(n / 1_000_000).toFixed(1)}M tok`
218
219const shortType = (type: string): string =>
220 type === 'general-purpose'
221 ? 'general'
222 : type === 'claude-code-guide'
223 ? 'guide'
224 : type === 'statusline-setup'
225 ? 'statusline'
226 : type
227
228/** The tool call as one `Tool: label` line; reserved envelope keys are not the tool's input. */
229const activityOf = (tool: string, e: unknown): string => {
230 const { tool: _tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...input } = asDict(e)
231 const label = labelOf(tool, input)
232 return clip(label ? `${tool}: ${label}` : tool, LABEL_MAX)
233}
234
235const firstLine = (text: string): string | undefined => {
236 const line = text.split('\n').find(l => l.trim().length > 0)
237 return line === undefined ? undefined : clip(line, 120)
238}
239
240/** The answer's last line ends in a question mark, the TTS `🔊` line aside. */
241const endsInQuestion = (text: string): boolean => {
242 const lines = text.trimEnd().split('\n')
243 if (lines[lines.length - 1]?.trimStart().startsWith('🔊')) lines.pop()
244 const last = [...lines].reverse().find(l => l.trim().length > 0)
245 return last?.trimEnd().endsWith('?') ?? false
246}
247
248const isStepStatus = (value: unknown): value is Step['status'] =>
249 value === 'pending' || value === 'in_progress' || value === 'completed'
250
251const stepLine = (s: Step, i: number): string => `${i + 1}. [${s.status}] ${s.title}`
252
253const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk', 'unclassified'])
254
255const openStepsReminder = (list: Step[]): string =>
256 [
257 'Progress panel: the step list from the last request still has open steps:',
258 // Numbered over the full list, so each number stays a valid `step`.
259 ...list.flatMap((s, i) => (s.status === 'completed' ? [] : [stepLine(s, i)])),
260 `If this request continues that work, mark each step completed with the ${STEPS_TOOL} tool as it finishes, and before your final answer mark every finished step completed; leave a step that is genuinely unfinished open and say so. If this is a new request, call it with plan to replace the list (an empty plan clears it).`,
261 ].join('\n')
262
263const stateOfReason = (reason: string): AgentState =>
264 reason === 'answer' ? 'completed' : reason === 'aborted' ? 'killed' : 'failed'
265
266const isLive = (status: AgentState): boolean =>
267 status === 'pending' || status === 'running' || status === 'waiting' || status === 'idle'
268
269const agentWord = (status: AgentState): string => {
270 switch (status) {
271 case 'pending':
272 return 'Queued'
273 case 'running':
274 return 'Working'
275 case 'waiting':
276 return 'Waiting'
277 case 'idle':
278 return 'Idle'
279 case 'completed':
280 return 'Done'
281 case 'failed':
282 return 'Failed'
283 case 'killed':
284 return 'Stopped'
285 }
286}
287
288export const register: Register = on => {
289 on('session.start', async ($, e, next) => {
290 await $.command.register({
291 name: 'progress',
292 description: 'Progress panel: toggle it, or hide, show, quiet, verbose, clear or agents',
293 argumentHint: '[hide|show|quiet|verbose|clear|agents]',
294 })
295 await $.tool.register({
296 name: 'steps',
297 description:
298 'Keep the progress panel the person sees: plan the steps of a multi-step request, then mark each in_progress and completed as you go.',
299 inputSchema: STEPS_SCHEMA,
300 isDeferred: false,
301 })
302 await hydrate($)
303
304 return next(e)
305 })
306
307 on('tool.call', { tool: STEPS_TOOL }, async ($, e) => {
308 if (e.agentId !== undefined) {
309 return { result: 'The progress panel belongs to the main conversation.' }
310 }
311
312 const input = asDict(e)
313 let isCapped = false
314 const list = await setSteps($, cur => {
315 let out = cur
316 isCapped = false
317 if (Array.isArray(input.plan)) {
318 isCapped = input.plan.length > MAX_STEPS
319 out = input.plan.slice(0, MAX_STEPS).map((raw, i) => {
320 const p = asDict(raw)
321 return {
322 id: `step-${i + 1}`,
323 title: clip(str(p.title) ?? `Step ${i + 1}`, LABEL_MAX),
324 activeForm: fit(str(p.activeForm)),
325 status: 'pending' as const,
326 }
327 })
328 }
329 if (input.add !== undefined && out.length >= MAX_STEPS) {
330 isCapped = true
331 } else if (input.add !== undefined) {
332 const p = asDict(input.add)
333 out = [
334 ...out,
335 {
336 id: `step-${out.length + 1}`,
337 title: clip(str(p.title) ?? `Step ${out.length + 1}`, LABEL_MAX),
338 activeForm: fit(str(p.activeForm)),
339 status: 'pending' as const,
340 },
341 ]
342 }
343 const index = typeof input.step === 'number' ? Math.floor(input.step) - 1 : -1
344 const status = str(input.status)
345 if (index >= 0 && index < out.length && isStepStatus(status)) {
346 out = out.map((s, i) =>
347 i === index
348 ? { ...s, status }
349 : status === 'in_progress' && s.status === 'in_progress'
350 ? { ...s, status: 'completed' as const }
351 : s,
352 )
353 }
354 return out
355 })
356
357 return {
358 result: [
359 'Steps:',
360 ...list.map(stepLine),
361 ...(isCapped ? [`The panel keeps at most ${MAX_STEPS} steps; the rest were not added.`] : []),
362 ].join('\n'),
363 }
364 }).catch(() => ({ result: 'The progress panel could not record that.' }))
365
366 on('skill.prompt', { skill: 'orchestrate' }, async ($, e, next) => {
367 const out = await next(e)
368 const list = await read($, steps)
369 if (list.every(s => s.status === 'completed')) {
370 await setSteps($, () =>
371 WAVE.map(([title, activeForm], i) => ({
372 id: `step-${i + 1}`,
373 title,
374 activeForm,
375 status: 'pending' as const,
376 })),
377 )
378 }
379 return out
380 }).catch(($, e, next) => next(e))
381
382 on('command.run', { command: 'progress' }, async ($, e) => {
383 const arg = e.args.trim().toLowerCase()
384 const v = await read($, view)
385 switch (arg) {
386 case 'hide':
387 case 'show':
388 await setView($, cur => ({
389 ...cur,
390 isHidden: arg === 'hide',
391 isCollapsed: arg === 'show' ? false : cur.isCollapsed,
392 }))
393 return { text: `Progress panel ${arg === 'hide' ? 'hidden' : 'shown'}.` }
394 case 'quiet':
395 case 'verbose':
396 await setView($, cur => ({ ...cur, isCondensed: arg === 'quiet' }))
397 $.ui.invalidate('ui.render')
398 return {
399 text:
400 arg === 'quiet'
401 ? 'Tool rows condensed to one line each.'
402 : 'Tool rows drawn in full again.',
403 }
404 case 'clear':
405 await setSteps($, () => [])
406 await update($, agents, () => [])
407 return { text: 'Progress panel cleared.' }
408 case 'agents':
409 await $.ui.open({ id: 'progress-agents', title: 'Agents', closeOnEscape: true })
410 return { text: 'Agents pane opened.' }
411 case '':
412 await setView($, cur => ({ ...cur, isHidden: !cur.isHidden }))
413 return { text: `Progress panel ${v.isHidden ? 'shown' : 'hidden'}.` }
414 default:
415 return { text: 'Usage: /progress [hide|show|quiet|verbose|clear|agents]' }
416 }
417 })
418
419 // Ask the model to keep a task list, so the panel has steps to show.
420 on('prompt.compose', async ($, e, next) => {
421 const composed = await next(e)
422 if (!e.tools.includes(STEPS_TOOL)) return composed
423
424 return {
425 sections: [
426 ...composed.sections,
427 { id: 'progress-band:tasks', text: NUDGE, scope: 'session' as const },
428 ],
429 }
430 })
431
432 // Remind the model of open steps, so the list stops drifting across requests.
433 on('prompt.submit', async ($, e, next) => {
434 if (e.turnId !== undefined || e.text.trimStart().startsWith('/')) return next(e)
435 if (e.origin !== undefined && !PERSON_ORIGINS.has(e.origin.kind)) return next(e)
436 const list = await read($, steps)
437 if (list.every(s => s.status === 'completed')) return next(e)
438 return next({ ...e, context: [...(e.context ?? []), openStepsReminder(list)] })
439 }).catch(($, e, next) => next(e))
440
441 on('turn.start', async ($, e, next) => {
442 try {
443 const now = await $.clock.now()
444 // Written first: a settle racing this hook must read the new turn as working.
445 await update($, turn, () => ({ startedAt: now, isWorking: true, calls: 0 }))
446 const list = await read($, steps)
447 // A list that finished last turn belongs to the last request.
448 if (list.length > 0 && list.every(s => s.status === 'completed')) {
449 await setSteps($, () => [])
450 }
451 // Finished agents belong to the last request; live ones carry over.
452 await update($, agents, cur => cur.filter(a => isLive(a.status)))
453 await setView($, v => ({ ...v, isCollapsed: false }))
454 collapseTimer?.cancel()
455 collapseTimer = null
456 turnGen += 1
457 isSettled = false
458 startTicker($)
459 } catch {
460 // The turn runs whether or not the band could record it.
461 }
462
463 return next(e)
464 })
465
466 on('turn.complete', async ($, e, next) => {
467 try {
468 const now = await $.clock.now()
469 if (e.agentId !== undefined) {
470 const id = e.agentId
471 const usage = e.usage
472 const lastText = firstLine(e.answer)
473 const list = await update($, agents, cur =>
474 cur.map(a => {
475 if (a.id !== id) return a
476 // The steps of this turn already added their share as they landed.
477 const counted = a.turnId === e.turnId ? (a.turnTokens ?? 0) : 0
478 const total = usage ? usage.input_tokens + usage.output_tokens : 0
479 return {
480 ...a,
481 status: stateOfReason(e.reason),
482 endedAt: now,
483 tokens: (a.tokens ?? 0) + Math.max(0, total - counted),
484 turnId: undefined,
485 turnTokens: undefined,
486 modelId: usage?.model ?? a.modelId,
487 lastText: lastText ?? a.lastText,
488 }
489 }),
490 )
491 if (e.reason === 'error') {
492 const description = list.find(a => a.id === id)?.description ?? id
493 notify($, 'fail', `Agent failed: ${clip(redact(description), 60)}`, 'Sir, an agent has failed.', now)
494 }
495 await settleIfIdle($)
496 } else {
497 await update($, turn, cur => ({
498 startedAt: cur?.startedAt ?? now,
499 calls: cur?.calls ?? 0,
500 isWorking: false,
501 isAsking: false,
502 endedAt: now,
503 reason: e.reason,
504 finalText: e.answer.slice(-4000),
505 }))
506 await refreshAgents($)
507 await settleIfIdle($)
508 }
509 } catch {
510 // The turn ends whether or not the band could record it.
511 }
512
513 return next(e)
514 })
515
516 // Each model request a subagent makes: add its cost to the row as it lands,
517 // so the band's token column moves while the agent runs, not just at its end.
518 on('turn.step', async function* ($, e, next) {
519 const response = yield* next(e)
520 if (e.agentId !== undefined && response.usage) {
521 void recordAgentStep($, e.agentId, e.turnId, response.usage).catch(() => {})
522 }
523 return response
524 })
525
526 on('agent.spawn', async ($, e, next) => {
527 const started = await next(e)
528 if (started.agentId === undefined) return started
529
530 const id = started.agentId
531 const now = await $.clock.now()
532 await update($, agents, list => {
533 const known = list.find(a => a.id === id)
534 const row: AgentRow = {
535 calls: 0,
536 tokens: 0,
537 ...known,
538 id,
539 description: e.description,
540 type: e.subagentType,
541 model: started.model,
542 status: 'running',
543 startedAt: known?.startedAt ?? now,
544 endedAt: undefined,
545 missingSince: undefined,
546 }
547 return trimAgents([...list.filter(a => a.id !== id), row])
548 })
549 if (isSettled) {
550 turnGen += 1
551 isSettled = false
552 startTicker($)
553 }
554
555 return started
556 }).catch(($, e, next) => next(e))
557
558 // Every tool call: count it for its loop (the turn's, or a subagent's).
559 on('tool.call', async ($, e, next) => {
560 if (e.agentId !== undefined) {
561 void recordAgentCall($, e.agentId, activityOf(e.tool, e)).catch(() => {})
562 } else {
563 const isTracked = e.tool !== STEPS_TOOL && e.tool !== 'AskUserQuestion' && e.tool !== 'Skill'
564 const lastTool = isTracked ? activityOf(e.tool, e) : undefined
565 void update($, turn, cur =>
566 cur
567 ? lastTool === undefined || lastTool === cur.lastTool
568 ? { ...cur, calls: cur.calls + 1 }
569 : { ...cur, calls: cur.calls + 1, lastTool }
570 : cur,
571 ).catch(() => {})
572 }
573
574 return next(e)
575 }).catch(($, e, next) => next(e))
576
577 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
578 if (e.agentId !== undefined) return next(e)
579 await update($, turn, cur => (cur ? { ...cur, isAsking: true } : cur))
580 notify($, 'ask', 'Claude has a question for you', 'Sir, Claude has a question for you.', await $.clock.now())
581 try {
582 return await next(e)
583 } finally {
584 await update($, turn, cur => (cur ? { ...cur, isAsking: false } : cur))
585 }
586 }).catch(($, e, next) => next(e))
587
588 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
589 const ran = await next(e)
590 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError) return ran
591
592 const task = asDict(asDict(ran.result).task)
593 const id = str(task.id)
594 if (id === undefined) return ran
595
596 const step: Step = {
597 id,
598 title: clip(str(task.subject) ?? e.subject, LABEL_MAX),
599 activeForm: fit(e.activeForm),
600 status: 'pending',
601 }
602 await setSteps($, list => {
603 const rest = list.filter(s => s.id !== id)
604 return rest.length >= MAX_STEPS ? list : [...rest, step]
605 })
606
607 return ran
608 }).catch(($, e, next) => next(e))
609
610 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
611 const ran = await next(e)
612 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError) return ran
613 if (asDict(ran.result).success === false) return ran
614
615 const id = e.taskId
616 await setSteps($, list => {
617 if (e.status === 'deleted') return list.filter(s => s.id !== id)
618 const known = list.find(s => s.id === id)
619 if (known === undefined && list.length >= MAX_STEPS) return list
620 const base: Step = known ?? { id, title: `Task ${id}`, status: 'pending' }
621 const changed: Step = {
622 ...base,
623 title: fit(e.subject) ?? base.title,
624 activeForm: fit(e.activeForm) ?? base.activeForm,
625 status: e.status ?? base.status,
626 }
627 return known ? list.map(s => (s.id === id ? changed : s)) : [...list, changed]
628 })
629
630 return ran
631 }).catch(($, e, next) => next(e))
632
633 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
634 const ran = await next(e)
635 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError) return ran
636
637 const list: Step[] = e.todos.slice(0, MAX_STEPS).map((t, i) => ({
638 id: `todo-${i}`,
639 title: clip(t.content, LABEL_MAX),
640 activeForm: fit(t.activeForm),
641 status: t.status,
642 }))
643 await setSteps($, () => list)
644
645 return ran
646 }).catch(($, e, next) => next(e))
647
648 // The spinner says which step Claude is on.
649 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
650 if (stepsCache === undefined) {
651 void hydrate($)
652 return next(e)
653 }
654 const list = stepsCache
655 const working = list.find(s => s.status === 'in_progress')
656 if (working === undefined || e.props.message !== null) return next(e)
657
658 return next({
659 ...e,
660 props: { ...e.props, message: working.activeForm ?? working.title },
661 })
662 })
663
664 // Tool rows: one quiet line each; errors keep the engine's full row.
665 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
666 const { Box, Text } = $.ui.resolve(e)
667 if (e.props.tool === STEPS_TOOL) return <Box />
668 if (viewCache === undefined) {
669 void hydrate($)
670 return next(e)
671 }
672 if (!viewCache.isCondensed || e.props.isErrored) return next(e)
673
674 const p = e.props
675 const label = labelOf(p.tool, p.input)
676 const mark = p.isInterrupted ? '⊘' : p.isRunning ? '…' : '✓'
677 const markColor = p.isInterrupted ? 'warning' : p.isRunning ? 'claude' : 'success'
678
679 return (
680 <Box flexDirection="row" gap={1}>
681 <Text color={markColor}>{mark}</Text>
682 <Text dimColor wrap="truncate-end">
683 {p.tool}
684 {label ? `: ${label}` : ''}
685 {p.isInterrupted ? ' (interrupted)' : ''}
686 </Text>
687 </Box>
688 )
689 })
690
691 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
692 const { Box } = $.ui.resolve(e)
693 if (e.props.tool === STEPS_TOOL) return <Box />
694 if (viewCache === undefined) {
695 void hydrate($)
696 return next(e)
697 }
698 if (!viewCache.isCondensed || e.props.isErrored) return next(e)
699
700 return <Box />
701 })
702
703 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
704 const [v, list, roster, t, now] = await Promise.all([
705 read($, view),
706 read($, steps),
707 read($, agents),
708 read($, turn),
709 $.clock.now(),
710 ])
711 viewCache = v
712 stepsCache = list
713 if (ticker === null && (t?.isWorking || roster.some(a => isLive(a.status)))) {
714 isSettled = false
715 startTicker($)
716 }
717
718 const hasSteps = list.length > 0
719 const hasAgents = roster.length > 0
720 if (v.isHidden || e.props.hasSurvey || (!hasSteps && !hasAgents)) return next(e)
721
722 const { Box, Text, Button } = $.ui.resolve(e)
723 const width = Math.max(30, e.props.bodyColumns - 2)
724 const working = list.find(s => s.status === 'in_progress')
725 const done = list.filter(s => s.status === 'completed').length
726 const total = list.length
727 const ratio = total === 0 ? 0 : done / total
728 const liveAgents = roster.filter(a => isLive(a.status)).length
729 const isTurnOver = !e.props.isWorking && !(t?.isWorking ?? false)
730 const isWorking = !isTurnOver || liveAgents > 0
731 const since = t ? (isWorking ? now : (t.endedAt ?? now)) - t.startedAt : 0
732
733 const left = total - done
734 const summary = [
735 hasSteps ? `${total} step${total === 1 ? '' : 's'}` : '',
736 hasAgents ? `${roster.length} agent${roster.length === 1 ? '' : 's'}` : '',
737 t && t.calls > 0 ? `${t.calls} tool call${t.calls === 1 ? '' : 's'}` : '',
738 ]
739 .filter(Boolean)
740 .join(' · ')
741 let badge: string
742 let badgeColor: string | undefined
743 let message: string
744 let isEnded = false
745 if (t?.isAsking) {
746 badge = '‖ Needs you'
747 badgeColor = 'warning'
748 message = 'Claude is asking you a question'
749 } else if (isWorking) {
750 badge = working ? '▶ Working' : '▶ Thinking'
751 badgeColor = 'claude'
752 message =
753 (t ? waveMessage(roster, t.startedAt) : undefined) ??
754 (isTurnOver
755 ? `Waiting for ${liveAgents} agent${liveAgents === 1 ? '' : 's'}`
756 : working
757 ? (working.activeForm ?? working.title)
758 : 'Working out the next step')
759 } else if (t?.reason === 'error') {
760 badge = '✗ Failed'
761 badgeColor = 'error'
762 message = 'The turn ended on an error'
763 } else if (t?.reason === 'aborted') {
764 badge = '⊘ Stopped'
765 badgeColor = 'warning'
766 message = 'You interrupted the turn'
767 } else if (t?.needsReply) {
768 badge = '‖ Needs you'
769 badgeColor = 'warning'
770 message = 'Claude asked you something'
771 } else if (hasSteps && left > 0) {
772 badge = '◼ Ended'
773 isEnded = true
774 message = `Turn ended; ${left} step${left === 1 ? '' : 's'} not marked done`
775 } else {
776 badge = hasSteps ? '✔ All done' : '✔ Done'
777 badgeColor = 'success'
778 message = summary || 'Finished'
779 }
780 const clock = t ? (isWorking ? elapsed(since) : `took ${elapsed(since)}`) : ''
781 const headRoom = width - 2 - badge.length - 2 - clock.length - 3
782
783 if (v.isCollapsed && !isWorking) {
784 return (
785 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
786 <Box flexDirection="row" gap={1}>
787 <Text color={badgeColor} inverse={!isEnded} dimColor={isEnded} bold>
788 {` ${badge} `}
789 </Text>
790 <Text bold wrap="truncate-end">
791 {clip(message, Math.max(4, headRoom - 9))}
792 </Text>
793 <Box flexGrow={1} />
794 {width < 45 ? null : <Text dimColor>{clock}</Text>}
795 <Button
796 key="expand"
797 hotkey="s"
798 label="Show"
799 onPress={() => {
800 void setView($, cur => ({ ...cur, isCollapsed: false }))
801 }}
802 />
803 </Box>
804 </Box>
805 )
806 }
807
808 // Border 2, header 1, progress line; the agents header carries a one-row margin under steps.
809 const chromeRows = 3 + (hasSteps ? 1 : 0) + (hasAgents ? (hasSteps ? 2 : 1) : 0)
810 const guaranteedSteps = hasSteps ? 1 : 0
811 const wantedAgents = Math.min(roster.length, MAX_AGENTS)
812 // The activity line goes first when the band is short of rows.
813 const showActivity =
814 isWorking &&
815 working !== undefined &&
816 t?.lastTool !== undefined &&
817 chromeRows + 1 + guaranteedSteps + wantedAgents <= e.props.maxRows
818 const fixedRows = chromeRows + (showActivity ? 1 : 0)
819 const agentRoom = Math.max(0, e.props.maxRows - fixedRows - guaranteedSteps)
820 const shownAgents = Math.min(roster.length, MAX_AGENTS, agentRoom)
821 const isAgentsCut = shownAgents < roster.length
822 const stepRoom = Math.max(1, e.props.maxRows - (fixedRows + shownAgents))
823 let shown: Step[] = list
824 let folded = 0
825 let tailHidden = 0
826 if (list.length > stepRoom) {
827 const nowIndex = list.findIndex(s => s.status === 'in_progress')
828 const firstOpen = Math.max(
829 0,
830 list.findIndex(s => s.status !== 'completed'),
831 )
832 const anchor = nowIndex >= 0 ? nowIndex : firstOpen
833 if (stepRoom === 1) {
834 shown = list.slice(anchor, anchor + 1)
835 } else {
836 const keepFrom = Math.max(0, Math.min(anchor, list.length - stepRoom + 1))
837 folded = keepFrom
838 let room = stepRoom - (folded > 0 ? 1 : 0)
839 if (keepFrom + room < list.length) room -= 1
840 // No room for both notes: the tail note stays, the fold note goes.
841 if (room < 1) {
842 room = 1
843 folded = 0
844 }
845 shown = list.slice(keepFrom, keepFrom + room)
846 tailHidden = list.length - (keepFrom + shown.length)
847 }
848 }
849
850 // fits 'Not marked'
851 const wordWidth = 10
852 const barWidth = Math.max(6, Math.min(20, Math.floor(width / 4)))
853 const titleWidth = Math.max(7, width - 2 - barWidth - wordWidth - 6)
854
855 const firstPending = list.findIndex(x => x.status === 'pending')
856 const stepRow = (s: Step, index: number) => {
857 const isDone = s.status === 'completed'
858 const isActive = s.status === 'in_progress'
859 const mark = isDone ? '✔' : isActive ? '‖' : '○'
860 const isNow = isActive && !isEnded
861 const isUnmarked = isEnded && !isDone
862 const word = isDone
863 ? 'Done'
864 : isUnmarked
865 ? 'Not marked'
866 : isNow
867 ? 'Working'
868 : index === firstPending
869 ? 'Next'
870 : 'Up next'
871 const fill = isDone ? 1 : isNow ? 0.5 : 0
872 const color = isDone ? 'success' : isNow ? 'claude' : 'subtle'
873
874 return (
875 <Box key={s.id} flexDirection="row" gap={1} height={1} overflow="hidden">
876 <Box width={1}>
877 <Text color={color}>{mark}</Text>
878 </Box>
879 <Box flexGrow={1} flexShrink={1} overflow="hidden">
880 <Text bold={isNow} dimColor={!isNow && !isDone} wrap="truncate-end">
881 {clip(s.title, titleWidth)}
882 </Text>
883 </Box>
884 <Box width={barWidth} flexShrink={0}>
885 <Text color={color} dimColor={!isDone && !isNow}>
886 {bar(fill, barWidth)}
887 </Text>
888 </Box>
889 <Box width={wordWidth} flexShrink={0}>
890 <Text color={color} dimColor={!isNow && !isDone}>
891 {word}
892 </Text>
893 </Box>
894 </Box>
895 )
896 }
897
898 const agentRows = sortAgents(roster).slice(0, isAgentsCut ? Math.max(0, shownAgents - 1) : shownAgents)
899 const live = liveAgents
900 const tokenTotal = roster.reduce((n, a) => n + (a.tokens ?? 0), 0)
901 const showKind = width >= 70
902 const showTime = width >= 50
903 const agentTitleWidth = Math.max(
904 8,
905 width - 2 - 8 - 9 - 8 - (showKind ? 20 + 9 : 0) - (showTime ? 7 : 0),
906 )
907
908 return (
909 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
910 <Box flexDirection="row" gap={1}>
911 <Text color={badgeColor} inverse={!isEnded} dimColor={isEnded} bold>
912 {` ${badge} `}
913 </Text>
914 <Text bold wrap="truncate-end">
915 {clip(message, Math.max(4, headRoom))}
916 </Text>
917 <Box flexGrow={1} />
918 <Text dimColor>{clock}</Text>
919 </Box>
920 {hasSteps && (
921 <Box flexDirection="row" gap={1}>
922 <Text dimColor wrap="truncate-end">
923 {working && !isEnded ? `Step ${list.indexOf(working) + 1} of ${total}` : `${done} of ${total} steps`}
924 </Text>
925 <Text color={done === total ? 'success' : 'claude'}>{bar(ratio, Math.max(6, width - 18 - 6 - 6))}</Text>
926 <Text color={done === total ? 'success' : 'claude'} bold>
927 {`${Math.round(ratio * 100)}%`.padStart(4)}
928 </Text>
929 </Box>
930 )}
931 {folded > 0 && (
932 <Text dimColor wrap="truncate-end">
933 {` ${folded} earlier step${folded === 1 ? '' : 's'}`}
934 </Text>
935 )}
936 {shown.flatMap(s =>
937 showActivity && s === working
938 ? [
939 stepRow(s, list.indexOf(s)),
940 <Text key={`${s.id}:activity`} dimColor wrap="truncate-end">
941 {` ↳ ${clip(t?.lastTool ?? '', width - 7)}`}
942 </Text>,
943 ]
944 : [stepRow(s, list.indexOf(s))],
945 )}
946 {tailHidden > 0 && (
947 <Text dimColor wrap="truncate-end">
948 {` +${tailHidden} more step${tailHidden === 1 ? '' : 's'}`}
949 </Text>
950 )}
951 {hasAgents && (
952 <Box flexDirection="row" gap={1} marginTop={hasSteps ? 1 : 0}>
953 <Text bold>Agents</Text>
954 <Text dimColor wrap="truncate-end">
955 {live > 0 ? `${live} running · ${roster.length - live} finished` : `${roster.length} finished`}
956 {tokenTotal > 0 ? ` · ${tokensOf(tokenTotal)}` : ''}
957 </Text>
958 </Box>
959 )}
960 {agentRows.map(a => {
961 const alive = isLive(a.status)
962 const color =
963 a.status === 'failed' || a.status === 'killed'
964 ? 'error'
965 : alive
966 ? 'claude'
967 : 'success'
968 const mark = alive ? '‖' : a.status === 'completed' ? '✔' : '✗'
969 const model = a.modelId ?? a.model
970 const kind = clip(`${shortType(a.type)}${model ? `/${shortModel(model)}` : ''}`, 20)
971 const time = elapsed((a.endedAt ?? now) - a.startedAt)
972 const role = roleOf(a)
973 const prefix = role === 'agent' || agentTitleWidth < 24 ? '' : `${role} · `
974
975 return (
976 <Box key={a.id} flexDirection="row" gap={1} height={1} overflow="hidden">
977 <Box width={1}>
978 <Text color={color}>{mark}</Text>
979 </Box>
980 <Box flexDirection="row" flexGrow={1} flexShrink={1} overflow="hidden">
981 {prefix ? <Text dimColor>{prefix}</Text> : null}
982 <Text bold={alive} dimColor={!alive} wrap="truncate-end">
983 {clip(a.description, Math.max(4, agentTitleWidth - prefix.length))}
984 </Text>
985 </Box>
986 {showKind ? (
987 <Box width={20}>
988 <Text dimColor>{kind}</Text>
989 </Box>
990 ) : null}
991 <Box width={8}>
992 <Text color={color} dimColor={!alive}>
993 {agentWord(a.status)}
994 </Text>
995 </Box>
996 {showTime ? (
997 <Box width={7}>
998 <Text dimColor>{time}</Text>
999 </Box>
1000 ) : null}
1001 <Box width={9}>
1002 <Text dimColor>{tokensOf(a.tokens ?? 0)}</Text>
1003 </Box>
1004 {showKind ? (
1005 <Box width={9}>
1006 <Text dimColor>{`${a.calls} call${a.calls === 1 ? '' : 's'}`}</Text>
1007 </Box>
1008 ) : null}
1009 </Box>
1010 )
1011 })}
1012 {isAgentsCut && shownAgents > 0 && (
1013 <Text dimColor wrap="truncate-end">
1014 {` +${roster.length - agentRows.length} more · /progress agents`}
1015 </Text>
1016 )}
1017 </Box>
1018 )
1019 })
1020
1021 on('ui.render', { component: 'Pane', requestId: 'progress-agents' }, async ($, e) => {
1022 const roster = await read($, agents)
1023 const now = await $.clock.now()
1024 const { Box, Text } = $.ui.resolve(e)
1025 const width = Math.max(10, e.props.bodyColumns)
1026 if (roster.length === 0) return <Text dimColor>No agents yet.</Text>
1027
1028 return (
1029 <Box flexDirection="column">
1030 {sortAgents(roster).map((a, i) => {
1031 const role = roleOf(a)
1032 const head = role === 'agent' ? a.description : `${role} · ${a.description}`
1033 const model = a.modelId ?? a.model
1034 const fullKind = [a.type, model].filter(Boolean).join(' · ')
1035 const kind =
1036 fullKind.length <= width ? fullKind : [a.type, model ? shortModel(model) : ''].filter(Boolean).join(' · ')
1037 const meta = [
1038 agentWord(a.status),
1039 elapsed((a.endedAt ?? now) - a.startedAt),
1040 `${a.calls} call${a.calls === 1 ? '' : 's'}`,
1041 tokensOf(a.tokens ?? 0),
1042 ].join(' · ')
1043
1044 return (
1045 <Box key={a.id} flexDirection="column" marginTop={i > 0 ? 1 : 0}>
1046 <Text bold wrap="truncate-end">
1047 {clip(head, width)}
1048 </Text>
1049 <Text dimColor wrap="truncate-end">
1050 {clip(kind, width)}
1051 </Text>
1052 <Text dimColor wrap="truncate-end">
1053 {clip(meta, width)}
1054 </Text>
1055 {a.activity ? (
1056 <Text dimColor wrap="truncate-end">
1057 {clip(`↳ ${a.activity}`, width)}
1058 </Text>
1059 ) : null}
1060 {a.lastText ? (
1061 <Text dimColor wrap="truncate-end">
1062 {clip(`“${a.lastText}”`, width)}
1063 </Text>
1064 ) : null}
1065 </Box>
1066 )
1067 })}
1068 </Box>
1069 )
1070 })
1071}
1072
1073/** Write the view and keep the render cache in step. */
1074async function setView($: EngineInterface, fn: (v: View) => View) {
1075 viewCache = await update($, view, fn)
1076 return viewCache
1077}
1078
1079/** Write the steps and keep the render cache in step. */
1080async function setSteps($: EngineInterface, fn: (list: Step[]) => Step[]) {
1081 stepsCache = await update($, steps, fn)
1082 return stepsCache
1083}
1084
1085/** Fill the render caches from the atoms, once per load. */
1086async function hydrate($: EngineInterface) {
1087 hydrating ??= Promise.all([read($, view), read($, steps)]).then(
1088 ([v, list]) => {
1089 viewCache ??= v
1090 stepsCache ??= list
1091 $.ui.invalidate('ui.render')
1092 },
1093 () => {
1094 hydrating = null
1095 },
1096 )
1097 return hydrating
1098}
1099
1100function startTicker($: EngineInterface) {
1101 stopTicker()
1102 ticker = $.clock.every(TICK_MS, () => {
1103 void tick($)
1104 })
1105}
1106
1107function stopTicker() {
1108 ticker?.cancel()
1109 ticker = null
1110}
1111
1112/** Toast and speak, at most once per kind per ALERT_MS. */
1113function notify($: EngineInterface, kind: 'fail' | 'ask', toast: string, speech: string, now: number) {
1114 if (now - lastAlertAt[kind] < ALERT_MS) return
1115 lastAlertAt[kind] = now
1116 $.ui.toast(toast)
1117 void $.audio.speak(speech).catch(() => {})
1118}
1119
1120/** One tool call by a subagent; a call from an agent with no row yet adds one. */
1121async function recordAgentCall($: EngineInterface, id: string, activity: string) {
1122 const now = await $.clock.now()
1123 await update($, agents, list =>
1124 list.some(a => a.id === id)
1125 ? list.map(a =>
1126 a.id !== id
1127 ? a
1128 : isLive(a.status)
1129 ? { ...a, calls: a.calls + 1, activity }
1130 : // A resumed agent fires no agent.spawn: its first tool call revives the row.
1131 { ...a, calls: a.calls + 1, activity, status: 'running', endedAt: undefined, missingSince: undefined },
1132 )
1133 : trimAgents([
1134 ...list,
1135 { id, description: id, type: 'agent', status: 'running', startedAt: now, calls: 1, tokens: 0, activity },
1136 ]),
1137 )
1138}
1139
1140/** One model request of a subagent's turn: its tokens land on the row now. */
1141async function recordAgentStep($: EngineInterface, id: string, turnId: string, usage: TurnUsage) {
1142 const added = usage.input_tokens + usage.output_tokens
1143 await update($, agents, list =>
1144 list.map(a => {
1145 if (a.id !== id) return a
1146 const sameTurn = a.turnId === turnId
1147 return {
1148 ...a,
1149 tokens: (a.tokens ?? 0) + added,
1150 turnId,
1151 turnTokens: (sameTurn ? (a.turnTokens ?? 0) : 0) + added,
1152 modelId: usage.model || a.modelId,
1153 }
1154 }),
1155 )
1156}
1157
1158async function tick($: EngineInterface) {
1159 await refreshAgents($)
1160 $.ui.invalidate('ui.render')
1161 await settleIfIdle($)
1162}
1163
1164async function settleIfIdle($: EngineInterface) {
1165 if (isSettled) return
1166 // Claimed before the first await, so concurrent callers never settle twice.
1167 isSettled = true
1168 const gen = turnGen
1169 let isIdle = false
1170 try {
1171 const [t, roster] = await Promise.all([read($, turn), read($, agents)])
1172 isIdle = t !== null && !t.isWorking && !roster.some(a => isLive(a.status)) && gen === turnGen
1173 } finally {
1174 if (!isIdle) isSettled = false
1175 }
1176 if (isIdle) await settle($, gen)
1177}
1178
1179/** The turn and its agents are done: stop the clock, arm the collapse, look for a question. */
1180async function settle($: EngineInterface, gen: number) {
1181 if (gen !== turnGen) return
1182 stopTicker()
1183 collapseTimer?.cancel()
1184 collapseTimer = $.clock.after(COLLAPSE_MS, () => {
1185 void setView($, v => ({ ...v, isCollapsed: true }))
1186 })
1187 const t = await read($, turn)
1188 if (gen !== turnGen || t?.reason !== 'answer' || t.endedAt === undefined) return
1189 const endedAt = t.endedAt
1190 const text = t.finalText ?? ''
1191 if (endsInQuestion(text)) {
1192 await update($, turn, cur => (cur?.endedAt === endedAt ? { ...cur, needsReply: true } : cur))
1193 } else if (text.trim().length > 0) {
1194 void classifyReply($, text, endedAt)
1195 }
1196}
1197
1198/** Ask the small classifier whether a finished answer asks the person something. */
1199async function classifyReply($: EngineInterface, text: string, endedAt: number) {
1200 if (classifiedAt === endedAt) returntypes/index.d.ts 67 lines1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Step = {
4 id: string
5 title: string
6 activeForm?: string
7 status: StepStatus
8}
9
10export type AgentState =
11 | 'pending'
12 | 'running'
13 | 'waiting'
14 | 'idle'
15 | 'completed'
16 | 'failed'
17 | 'killed'
18
19export type AgentRow = {
20 id: string
21 description: string
22 type: string
23 model?: string
24 status: AgentState
25 startedAt: number
26 endedAt?: number
27 missingSince?: number
28 calls: number
29 tokens: number
30 /** The turn whose steps are being counted into `tokens` as they land. */
31 turnId?: string
32 /** What that turn's steps have added so far; its end tops up the rest. */
33 turnTokens?: number
34 modelId?: string
35 activity?: string
36 lastText?: string
37}
38
39export type Turn = {
40 startedAt: number
41 endedAt?: number
42 isWorking: boolean
43 reason?: string
44 calls: number
45 isAsking?: boolean
46 needsReply?: boolean
47 lastTool?: string
48 finalText?: string
49}
50
51export type View = {
52 isHidden: boolean
53 isCondensed: boolean
54 isCollapsed: boolean
55}
56
57declare module 'claude-code' {
58 interface PluginState {
59 'progress-band': {
60 steps: Step[]
61 agents: AgentRow[]
62 turn: Turn | null
63 view: View
64 }
65 }
66}
67