SLOPSHOPPER

progress-band

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…

newpanebandspinnerrowsguard
★ 1v0.3.0no licenseupdated 2026-10-22abandisch/claude-code-mods/progress-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress-band
│ ┃ progress-agents ✕ › fix the failing auth test and add an audit log call │ ┃ No agents yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ✓ Edit: src/auth.ts │ ⏺ 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 │ │ › /progress │ ⎿ progress-band: Progress panel hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · progress-agents
No agents yet.
Tool row
✓ Edit: src/auth.ts
README

progress-band

A Claude Code mod that replaces the stream of tool output with a live progress panel above the prompt.

What it shows

  • Header: a state badge (▶ 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: Claude's step list (the mod's 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.
  • Agents: one row per subagent (the /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.
  • Needs you when Claude's answer asks you something (its last line, ignoring a final 🔊 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).
  • Ended: the turn finished on a plain answer with steps still open; the header reads 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.
  • Collapse: 20 s after the turn and its agents finish, the band shrinks to one row (badge, summary, clock) with a 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.
  • Tool rows in the transcript condense to one dim line each (✓ 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.
  • Labels are redacted: Bash shows its description, else the first 60 characters of the command; WebFetch shows the URL without credentials or query; tools the mod does not know show their name only. Bearer tokens, *KEY*/*TOKEN*/*SECRET*/*PASSWORD*/*AUTH*=… values and --token-style flags are masked as •••, and control characters are stripped.
  • Spinner says the active step's present-continuous form.
  • Toast and voice, for two events only: an agent failing (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.

Commands

CommandEffect
/progresstoggle the panel (any other word prints the usage line)
/progress hide / showhide or show it
/progress quiet / verbosecondense tool rows, or draw them in full
/progress clearforget the current steps and agents
/progress agentsopen 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.

Loading 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

Files

  • .claude-plugin/plugin.json: manifest
  • hooks/hooks.json: names the hooks module
  • hooks/register.tsx: the hooks
  • types/index.d.ts: the $.state contract
  • tests/progress-band.test.tsx: kit tests
Source 2 files
hooks/register.tsx 1329 lines
1import { 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) return
types/index.d.ts 67 lines
1export 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