Progress bars with stage colors, ETA, subagent strips and automatic session titles

Private collection of reusable Claude Code skills, shared across projects.
Clone once, then symlink each skill into the personal skills directory:
git clone git@github.com:Chasonnnn/agentic-engineering-skills-personal.git ~/agentic-engineering-skills-personal
ln -s ~/agentic-engineering-skills-personal/audit-skill ~/.claude/skills/audit-skill
ln -s ~/agentic-engineering-skills-personal/board-sync ~/.claude/skills/board-sync
ln -s ~/agentic-engineering-skills-personal/authmux ~/.claude/skills/authmux
ln -s ~/agentic-engineering-skills-personal/codex ~/.claude/skills/codex
ln -s ~/agentic-engineering-skills-personal/github-pr-validation-loop ~/.claude/skills/github-pr-validation-loop
ln -s ~/agentic-engineering-skills-personal/no-use-effect ~/.claude/skills/no-use-effect
ln -s ~/agentic-engineering-skills-personal/orchestrate ~/.claude/skills/orchestrate
ln -s ~/agentic-engineering-skills-personal/review-and-remediate-server-logs ~/.claude/skills/review-and-remediate-server-logs
Mods (Claude Code function-hook plugins) load from CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json:
"CLAUDE_CODE_PLUGIN_DIRS": "~/agentic-engineering-skills-personal/mods/progress"
New machines: repeat both steps. Updating: git -C ~/agentic-engineering-skills-personal pull (symlinks pick up changes automatically).
| Skill | What it does |
|---|---|
| audit-skill | Audit or improve skill instructions and helpers with source-specific evidence; includes optional comparison guidance and the Provencher article reference. |
| board-sync | Sync planning state (specs/ADRs/roadmap) to a GitHub Project board as epic issues with nested sub-issues — epics on the board, slices hidden under them — plus an optional stale-issue refresh. |
| authmux | Resolve repository-bound authentication intent, verify provider identity evidence, route supported CLI commands through process-scoped contexts, and fail closed before wrong-account or wrong-project work. |
| codex | Wraps the OpenAI Codex CLI for an independent second opinion: review (PASS/FAIL/INCOMPLETE on an explicit diff scope), challenge (adversarial break-it), consult (ask anything, with session continuity). Ported from gstack's /codex skill — see credit note below. |
| dependency-modernization | Upgrade dependencies with official release review, local feature adoption, compatibility checks, and measured validation. |
| github-pr-validation-loop | Audit open PRs against the current base branch, reimplement valid findings cleanly, close invalid or superseded noise with evidence, and monitor CI through completion. |
| no-use-effect | Prefer explicit React data flow, replace ad-hoc Effects with derived state, event handlers, query libraries, or keyed remounting, and contain legitimate external synchronization in named hooks. |
| orchestrate | Coordinates delegates, independent review, and integration; shares CLI execution with codex. (Folded in from orchestrate-skill with history; that repo is archived.) |
| review-and-remediate-server-logs | Review live server logs, prioritize actionable failures, reproduce and fix their causes, validate through CI, and verify the result after an authorized deployment. |
| Mod | What it does |
|---|---|
| progress | Progress bars above the prompt: the mcp__progress__track tool, a hairline bar with stage notches and a moving shine (heavy-rule text bar in the terminal), elapsed time, subagent strips, a done line that fades out, a status-line fallback while hidden, and session names taken from the open bar or a one-time Haiku call. Commands: /bars, /bars-clear, /bars-demo. |
SKILL.md inside, frontmatter per Claude Code skill format. Mods live under mods/, one plugin folder each.codex/ is a standalone port of the /codex skill from gstack (Garry Tan, MIT license) — the original wraps the OpenAI Codex CLI as one of ~30 skills in a much larger multi-agent toolkit. This port keeps the review/challenge/consult mechanics and drops everything wired into gstack's own infrastructure (telemetry, question-tuning auto-decide, checkpoint mode, gbrain sync, the D<N> AskUserQuestion format), so it runs standalone with no dependency on gstack being installed.
Use the approved project/host Python runtime. Helpers use the standard library.
claude plugin validate mods/progress && claude plugin test mods/progress
python -m unittest discover -s codex/tests -v
python -m unittest discover -s github-pr-validation-loop/tests -v
The PR snapshot JSON now contains coverage and pull_requests; exit 2 signals incomplete coverage. The CI watcher requires explicit workflow IDs and a full commit SHA. See its skill for event selection, authentication, and remaining readiness checks.
hooks/register.tsx 772 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Plan, PlanStage, PlanState, StepStatus, TitleState } from '../types'
5
6const TOOL = 'mcp__progress__track'
7const plans = atom({ plugin: 'progress', key: 'plans' } as const, [])
8const isOpen = atom({ plugin: 'progress', key: 'isOpen' } as const, true)
9const tick = atom({ plugin: 'progress', key: 'tick' } as const, 0)
10const titleState = atom({ plugin: 'progress', key: 'title' } as const, { last: null, isUserOwned: false, hasAsked: false } as TitleState)
11
12const MAX_BARS = 3
13const FADE_MS = 1500 // a finished bar fades out over this long once the next prompt is sent
14const FADE_FRAMES = 10
15const STRIP_LINGER_MS = 5000
16const MAX_STRIPS = 4
17const AGENTS = 'agents:auto' // slug() never yields ':', so no model id can take it
18const PX_PER_COL = 8 // desktop reports about 8 CSS px per column
19
20const ACCENT = '#7AA2F7'
21const UNLIT = '#3A3936'
22const DIM = '#8A8884'
23const STATE_COLOR: Record<PlanState, string> = { running: ACCENT, needs_input: '#E0A33A', paused: DIM, error: '#E5484D', done: '#3FB68B' }
24const STATE_GLYPH: Record<PlanState, string> = { running: '●', needs_input: '?', paused: '●', error: '!', done: '✓' }
25const AGENT_COLOR: Record<AgentRun['state'], string> = { running: ACCENT, waiting: '#E0A33A', done: '#3FB68B', error: '#E5484D' }
26const STATUSES: StepStatus[] = ['pending', 'active', 'done', 'error', 'skipped']
27
28const RULES = `# Progress bars
29Tasks needing more than ~3 edits or commands get a bar via ${TOOL}: create it once with a title and the full breakdown (2-7 stages with short steps, or one stage for a flat list; titles of at most 4 words, in the user's language), then update it with short calls only: {id, next:true} when the active step is finished, or {id, done:[...], active:"..."}, {id, failed:"...", note}. Send state "needs_input" with a note before asking the user to decide. When the work is finished, close the bar with {id, state:"done"} before the final reply. The bar title also becomes the session name, so make it describe the task. Never describe the bars to the user.`
30
31type Raw = Record<string, unknown>
32const str = (v: unknown, max = 120) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
33const status = (v: unknown): StepStatus => (STATUSES.includes(v as StepStatus) ? (v as StepStatus) : 'pending')
34const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : []) as Raw[]
35const isFinished = (s: StepStatus) => s === 'done' || s === 'skipped'
36const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
37const slug = (s: string) =>
38 s
39 .toLowerCase()
40 .replace(/[^\p{L}\p{N}]+/gu, '-')
41 .replace(/^-|-$/g, '')
42 .slice(0, 40) || 'plan'
43
44// ---------- plan model ----------
45
46// short updates against the stored plan: {next:true}, {done:[titles]}, {active:title}, {failed:title}
47function applyOps(stages: PlanStage[], input: Raw): PlanStage[] {
48 const out = stages.map(s => ({ ...s, steps: s.steps.map(st => ({ ...st })) }))
49 const steps = out.flatMap(s => s.steps)
50 const find = (title: string) => steps.find(st => same(st.title, title))
51 if (input.next === true) {
52 const activeAt = steps.findIndex(st => st.status === 'active')
53 const at = activeAt >= 0 ? activeAt : steps.findIndex(st => !isFinished(st.status))
54 const cur = steps[at]
55 if (cur) cur.status = 'done'
56 const following = steps.slice(at + 1).find(st => st.status === 'pending')
57 if (following) following.status = 'active'
58 }
59 for (const t of Array.isArray(input.done) ? input.done : []) {
60 const st = typeof t === 'string' ? find(t) : undefined
61 if (st) st.status = 'done'
62 }
63 const active = typeof input.active === 'string' ? find(input.active) : undefined
64 if (active) {
65 const at = steps.indexOf(active)
66 steps.forEach((st, i) => {
67 if (st.status === 'active' && i !== at) st.status = i < at ? 'done' : 'pending'
68 })
69 active.status = 'active'
70 }
71 const failed = typeof input.failed === 'string' ? find(input.failed) : undefined
72 if (failed) failed.status = 'error'
73
74 return out
75}
76
77function normalize(input: Raw, prev: Plan | null, now: number, id: string): Plan {
78 const isPartial = list(input.stages).length === 0 && prev !== null
79 const stages: PlanStage[] = isPartial
80 ? applyOps(prev.stages, input)
81 : list(input.stages)
82 .map(s => ({
83 name: str(s.name, 60) || 'Stage',
84 steps: list(s.steps).map(st => ({ title: str(st.title) || 'Step', status: status(st.status) })),
85 }))
86 .filter(s => s.steps.length > 0)
87 const steps = stages.flatMap(s => s.steps)
88 // a fresh plan with nothing active starts on its first open step
89 if (!isPartial && !steps.some(s => s.status === 'active')) {
90 const first = steps.find(s => !isFinished(s.status) && s.status !== 'error')
91 if (first) first.status = 'active'
92 }
93 const title = str(input.title, 60) || prev?.title || 'Plan'
94 const isAllDone = steps.length > 0 && steps.every(s => isFinished(s.status))
95 const asked = input.state as PlanState
96 const state: PlanState = STATE_GLYPH[asked] ? asked : isAllDone ? 'done' : typeof input.failed === 'string' ? 'error' : 'running'
97 // any update ends a pause; the paused time stays out of the clock
98 const pausedAt = prev?.state === 'paused' ? (prev.pausedAt ?? null) : null
99 const pausedMs = (prev?.pausedMs ?? 0) + (pausedAt !== null && state !== 'paused' ? now - pausedAt : 0)
100
101 return {
102 id,
103 title,
104 stages,
105 state,
106 note: str(input.note, 160) || null,
107 startedAt: prev?.startedAt ?? now,
108 doneAt: state === 'done' ? (prev?.doneAt ?? now) : null,
109 pausedAt: state === 'paused' ? (pausedAt ?? now) : null,
110 pausedMs,
111 fadeAt: null,
112 agents: prev?.agents ?? [],
113 }
114}
115
116type Where = { finished: number; total: number; stage: number; step: number; stageSize: number; activeTitle: string | null }
117
118function where(p: Plan): Where {
119 const flat = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
120 const finished = flat.filter(x => isFinished(x.step.status)).length
121 const cur = flat.find(x => x.step.status === 'active') ?? flat.find(x => !isFinished(x.step.status))
122 const stage = cur?.i ?? Math.max(0, p.stages.length - 1)
123 const stageSize = p.stages[stage]?.steps.length ?? 0
124
125 return { finished, total: flat.length, stage, step: cur ? cur.j + 1 : stageSize, stageSize, activeTitle: cur?.step.title ?? null }
126}
127
128// lit share of the rule; a done bar is full even if the model closed it with steps open
129const fraction = (p: Plan) => {
130 const w = where(p)
131 return p.state === 'done' ? 1 : w.finished / Math.max(1, w.total)
132}
133
134const percent = (p: Plan) => Math.round(fraction(p) * 100)
135
136const duration = (ms: number) => {
137 const sec = Math.max(0, Math.round(ms / 1000))
138 if (sec < 60) return `${sec}s`
139 const min = Math.floor(sec / 60)
140 if (min < 60) return `${min}m${String(sec % 60).padStart(2, '0')}s`
141 return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
142}
143
144// 0 until the fade starts, 1 when the bar leaves
145const fadeOf = (p: Plan, now: number) => (p.fadeAt ? Math.min(1, Math.max(0, (now - p.fadeAt) / FADE_MS)) : 0)
146
147// terminal fade: characters drop out in a scattered order as f goes 0 -> 1
148const scatter = (i: number) => {
149 const x = Math.sin(i * 127.1 + 311.7) * 43758.5453
150 return x - Math.floor(x)
151}
152const dissolve = (text: string, f: number) => (f <= 0 ? text : [...text].map((c, i) => (scatter(i) < f ? ' ' : c)).join(''))
153
154// the clock stops while a bar is paused or done
155const elapsed = (p: Plan, now: number) => duration((p.doneAt ?? p.pausedAt ?? now) - p.startedAt - (p.pausedMs ?? 0))
156
157const stageLabel = (p: Plan) => {
158 const w = where(p)
159 return `${p.stages[w.stage]?.name ?? ''} ${w.step}/${w.stageSize}`
160}
161
162// the words beside the title: where the work is, or what it waits on
163function detail(p: Plan): string {
164 const note = p.note ? ` · ${p.note}` : ''
165 if (p.state === 'needs_input') return `Needs your input${note}`
166 if (p.state === 'error') return `Failed${note}`
167 if (p.state === 'done') {
168 const total = where(p).total
169 return `Completed · ${total} step${total === 1 ? '' : 's'}`
170 }
171 const active = where(p).activeTitle
172 return `${p.state === 'paused' ? 'Paused · ' : ''}${stageLabel(p)}${active ? ` · ${active}` : ''}`
173}
174
175const isOpenPlan = (p: Plan) => p.id !== AGENTS && p.state !== 'done'
176
177// ---------- drawing ----------
178
179const LINE_H = 3
180const NOTCH = 2
181
182// where the lit part ended at the last draw, so a redraw glides from there; equal values keep the svg string stable
183const lastFill = new Map<string, number>()
184
185// desktop: one hairline, notched where stages meet; the lit part glides to its new edge and a shine runs over it;
186// a fading bar animates its own opacity, and the string stays the same each frame so the animation runs once
187function lineSvg(p: Plan, W: number, isFading: boolean): string {
188 const w = where(p)
189 const total = Math.max(1, w.total)
190 const fx = fraction(p) * W
191 const color = STATE_COLOR[p.state]
192 const from = lastFill.get(p.id) ?? fx
193 lastFill.set(p.id, fx)
194 const glide =
195 Math.abs(from - fx) > 0.5
196 ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".5s" calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1" fill="freeze"/>`
197 : ''
198 let segs = ''
199 let at = 0
200 p.stages.forEach((s, i) => {
201 const x0 = (at / total) * W + (i > 0 ? NOTCH / 2 : 0)
202 at += s.steps.length
203 const x1 = (at / total) * W - (i < p.stages.length - 1 ? NOTCH / 2 : 0)
204 segs += `<rect x="${x0.toFixed(1)}" width="${Math.max(0, x1 - x0).toFixed(1)}" height="${LINE_H}" rx="1.5"/>`
205 })
206 const shine =
207 p.state === 'running' && fx > 0
208 ? `<rect width="${(W * 0.25).toFixed(1)}" height="${LINE_H}" fill="url(#shine)"><animate attributeName="x" from="${(-W * 0.25).toFixed(1)}" to="${W.toFixed(1)}" dur="2s" repeatCount="indefinite"/></rect>`
209 : ''
210 const fade = isFading
211 ? `<animate attributeName="opacity" from="1" to="0" dur="${FADE_MS / 1000}s" calcMode="spline" keyTimes="0;1" keySplines=".4 0 .6 1" fill="freeze"/>`
212 : ''
213 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${LINE_H}" viewBox="0 0 ${W} ${LINE_H}">
214<defs><linearGradient id="shine"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
215<clipPath id="lit"><rect width="${fx.toFixed(1)}" height="${LINE_H}">${glide}</rect></clipPath></defs>
216<g>${fade}<g fill="${UNLIT}">${segs}</g><g clip-path="url(#lit)"><g fill="${color}">${segs}</g>${shine}</g></g></svg>`
217}
218
219// terminal: a heavy rule, lit up to a half-cell head
220function lineText(p: Plan, cols: number): { lit: string; rest: string } {
221 const filled = Math.round(fraction(p) * cols)
222 if (filled >= cols) return { lit: '━'.repeat(cols), rest: '' }
223 if (filled === 0) return { lit: '', rest: '━'.repeat(cols) }
224 return { lit: `${'━'.repeat(filled - 1)}╸`, rest: '━'.repeat(cols - filled) }
225}
226
227// strips that show: unfinished first, finished ones fold after a few seconds, failed ones stay
228function visibleAgents(p: Plan, now: number): { shown: AgentRun[]; hidden: number } {
229 const live = p.agents.filter(a => a.state !== 'done' || now - (a.endedAt ?? now) < STRIP_LINGER_MS)
230 const ranked = [...live].sort((a, b) => Number(a.state === 'done') - Number(b.state === 'done'))
231 return { shown: ranked.slice(0, MAX_STRIPS), hidden: Math.max(0, ranked.length - MAX_STRIPS) }
232}
233
234// ---------- engine glue ----------
235
236// adds or replaces one bar by id; past MAX_BARS the oldest finished bars make room, open ones never do;
237// computed inside update() from the latest list, so parallel writers do not drop each other
238function placeBar(all: readonly Plan[], next: Plan): Plan[] {
239 const rest = all.some(p => p.id === next.id) ? all.map(p => (p.id === next.id ? next : p)) : [...all, next]
240 while (rest.length > MAX_BARS) {
241 const doneAt = rest.findIndex(p => p.state === 'done' && p.id !== next.id)
242 if (doneAt < 0) break
243 rest.splice(doneAt, 1)
244 }
245 return rest
246}
247
248// drops what the module keeps per bar once the bar is gone
249function forget(id: string) {
250 lastFill.delete(id)
251 for (const [agentId, home] of agentHome) if (home === id) agentHome.delete(agentId)
252}
253
254async function syncStatus($: EngineInterface) {
255 const live = (await read($, plans)).filter(isOpenPlan).at(-1)
256 if (!live || (await read($, isOpen))) return $.ui.status(undefined)
257 const w = where(live)
258 $.ui.status(`${live.title} · ${stageLabel(live)}${w.activeTitle ? ` · ${w.activeTitle}` : ''}`)
259}
260
261async function dropPlan($: EngineInterface, id: string) {
262 forget(id)
263 await update($, plans, all => all.filter(p => p.id !== id))
264 await syncStatus($)
265}
266
267// a done bar holds until the next prompt; then every done bar fades out and leaves
268async function fadeDoneBars($: EngineInterface) {
269 const now = await $.clock.now()
270 let fading: string[] = []
271 await update($, plans, all => {
272 fading = []
273 return all.map(p => {
274 if (p.state !== 'done' || p.fadeAt) return p
275 fading.push(p.id)
276 return { ...p, fadeAt: now }
277 })
278 })
279 if (fading.length === 0) return
280 // the terminal has no opacity: redraw a few frames while the fade runs
281 for (let k = 1; k < FADE_FRAMES; k++) {
282 $.clock.after((k * FADE_MS) / FADE_FRAMES, () => update($, tick, n => n + 1))
283 }
284 $.clock.after(FADE_MS, async () => {
285 // a bar reopened during the fade has lost its fadeAt and stays
286 const gone = (await read($, plans)).filter(p => fading.includes(p.id) && p.state === 'done' && p.fadeAt === now)
287 for (const p of gone) forget(p.id)
288 if (gone.length > 0) await update($, plans, all => all.filter(p => !gone.some(g => g.id === p.id)))
289 await syncStatus($)
290 })
291}
292
293async function putPlan($: EngineInterface, next: Plan) {
294 let prev: Plan | undefined
295 let evicted: string[] = []
296 await update($, plans, all => {
297 prev = all.find(p => p.id === next.id)
298 const placed = placeBar(all, next)
299 evicted = all.filter(p => !placed.some(x => x.id === p.id)).map(p => p.id)
300 return placed
301 })
302 evicted.forEach(forget)
303 if (!prev && next.id !== AGENTS) await update($, isOpen, () => true)
304 await syncStatus($)
305}
306
307// a tiny plan for /bars-demo
308const DEMO = (now: number): Plan => {
309 const s = (title: string, st: StepStatus) => ({ title, status: st })
310 return {
311 id: 'demo',
312 title: 'Orders module',
313 state: 'running',
314 note: null,
315 startedAt: now - 252_000,
316 doneAt: null,
317 agents: [
318 { id: 'demo-a', title: 'Explore schema', state: 'running', tool: 'Grep', startedAt: now - 41_000, endedAt: null },
319 { id: 'demo-b', title: 'Check migrations', state: 'done', tool: 'Done', startedAt: now - 80_000, endedAt: now - 1_000 },
320 ],
321 stages: [
322 { name: 'Analysis', steps: [s('Read modules', 'done'), s('Find dependencies', 'done'), s('List changes', 'done')] },
323 { name: 'Migration', steps: [s('Schema', 'done'), s('Migration', 'done'), s('Move data', 'active'), s('Indexes', 'pending')] },
324 { name: 'API', steps: [s('Endpoints', 'pending'), s('Validation', 'pending')] },
325 { name: 'Verify', steps: [s('Tests', 'pending'), s('Build', 'pending')] },
326 ],
327 }
328}
329
330// ---------- agents: drawn from engine events alone, no model calls ----------
331// module maps: a reload forgets running agents, whose strips then stay until the bar closes
332const agentHome = new Map<string, string>() // agentId -> bar id
333const toolUses = new Map<string, string>() // tool_use_id -> agentId, to find who waits on a permission
334
335async function editAgent($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) {
336 const home = agentHome.get(agentId)
337 if (!home) return
338 const before = (await read($, plans)).find(p => p.id === home)?.agents.find(a => a.id === agentId)
339 if (!before) return
340 const after = change(before)
341 if (after.state === before.state && after.tool === before.tool && after.endedAt === before.endedAt) return
342 const now = await $.clock.now()
343 await update($, plans, all =>
344 all.map(p => {
345 if (p.id !== home) return p
346 const agents = p.agents.map(a => (a.id === agentId ? change(a) : a))
347 if (p.id !== AGENTS) return { ...p, agents }
348 // the mod's own bar is done once every agent on it has ended
349 const isOver = agents.every(a => a.state === 'done' || a.state === 'error')
350 return { ...p, agents, state: isOver ? (agents.some(a => a.state === 'error') ? 'error' : 'done') : 'running', doneAt: isOver ? now : null }
351 }),
352 )
353}
354
355const STEP_SCHEMA = { type: 'object', required: ['title', 'status'], properties: { title: { type: 'string' }, status: { enum: STATUSES } } }
356const WORK_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
357const WORK_BEFORE_NUDGE = 4
358const CALLS_BEFORE_STALE = 6
359const TITLE_PROMPT = 'Name this work session in 3 to 5 words, Title Case, no quotes or trailing punctuation. Reply with the name only.\n\n<request>\n'
360
361export const register: Register = on => {
362 // per-turn counts; a reload only restarts them
363 let workCalls = 0
364 let sinceUpdate = 0
365 let isNudged = false
366
367 on('session.start', async ($, e, next) => {
368 await $.tool.register({
369 name: 'track',
370 description: 'Live progress bar above the prompt, one per id. Create with title + stages; update with short ops (next, done, active, failed) or state.',
371 inputSchema: {
372 type: 'object',
373 required: ['id'],
374 properties: {
375 id: { type: 'string', description: 'Bar id; reuse it for updates' },
376 title: { type: 'string', description: 'At most 4 words; also used as the session name' },
377 stages: {
378 type: 'array',
379 description: 'Full breakdown, only when creating or restructuring',
380 items: { type: 'object', required: ['name', 'steps'], properties: { name: { type: 'string' }, steps: { type: 'array', items: STEP_SCHEMA } } },
381 },
382 next: { type: 'boolean', description: 'Active step finished, start the next one' },
383 done: { type: 'array', items: { type: 'string' }, description: 'Step titles now finished' },
384 active: { type: 'string', description: 'Step title now in progress' },
385 failed: { type: 'string', description: 'Step title that failed' },
386 state: { enum: ['running', 'needs_input', 'error', 'done'] },
387 note: { type: 'string', description: 'One line for needs_input or error' },
388 },
389 },
390 })
391 // one tick a second while anything moves: clocks, the pulsing glyph, strips folding
392 $.clock.every(1000, async () => {
393 const all = await read($, plans)
394 const isMoving = all.some(p => p.state === 'running' || p.state === 'needs_input' || p.agents.some(a => a.endedAt === null))
395 if (isMoving || agentHome.size > 0) await update($, tick, n => n + 1)
396 })
397 await $.command.register({ name: 'bars', description: 'Show or hide the progress bars' })
398 await $.command.register({ name: 'bars-clear', description: 'Remove all progress bars' })
399 await $.command.register({ name: 'bars-demo', description: 'Show a sample progress bar' })
400 await syncStatus($)
401
402 return next(e)
403 })
404
405 on('prompt.compose', async ($, e, next) => {
406 const result = await next(e)
407 return { sections: [...result.sections, { id: 'progress:rules', text: RULES, scope: 'session' as const }] }
408 })
409
410 on('turn.start', async ($, e, next) => {
411 workCalls = 0
412 sinceUpdate = 0
413 isNudged = false
414 return next(e)
415 })
416
417 // the person answering clears "needs input"; open bars ride along as one short context line
418 on('prompt.submit', async ($, e, next) => {
419 if (e.origin.kind !== 'composer') return next(e)
420 const all = await read($, plans)
421 if (all.some(p => p.state === 'needs_input')) {
422 await update($, plans, xs => xs.map(p => (p.state === 'needs_input' ? { ...p, state: 'running' as const, note: null } : p)))
423 }
424 const open = all.filter(isOpenPlan)
425 if (open.length === 0) return next(e)
426 const line = `progress: open bars ${open.map(p => `${p.id} (${stageLabel(p)})`).join(', ')}`
427 return next({ ...e, context: [...(e.context ?? []), line] })
428 })
429
430 // session name: the newest open bar's title, else a short Haiku name for the first prompt;
431 // a name the person set themselves is never replaced
432 on('classic.UserPromptSubmit', async ($, e, next) => {
433 const isPerson = e.source === undefined || e.source === 'user'
434 // the person's next prompt fades the done bars
435 if (isPerson) await fadeDoneBars($)
436 const result = await next(e)
437 if (result.sessionTitle || !isPerson) return result
438 const t = await read($, titleState)
439 if (t.isUserOwned) return result
440 const current = e.session_title?.trim()
441 if (current && current !== t.last) {
442 // someone else named it: the person (a rename) or a resumed session
443 await update($, titleState, s => ({ ...s, isUserOwned: true }))
444 return result
445 }
446 let title = (await read($, plans)).filter(isOpenPlan).at(-1)?.title ?? ''
447 if (!title && !t.hasAsked && t.last === null && e.prompt.trim().length > 0) {
448 await update($, titleState, s => ({ ...s, hasAsked: true }))
449 const named = await $.model.complete({ model: 'haiku', prompt: `${TITLE_PROMPT}${e.prompt.slice(0, 1500)}\n</request>`, maxTokens: 24, effort: 'low', timeoutMs: 4000 })
450 if (named.isAnswered) title = str(named.text.replace(/["'`*#.]/g, ''), 60)
451 }
452 if (!title || title === t.last) return result
453 await update($, titleState, s => ({ ...s, last: title }))
454 return { ...result, sessionTitle: title }
455 })
456
457 on('tool.call', { tool: TOOL }, async ($, e) => {
458 const raw = e as unknown as Raw
459 const now = await $.clock.now()
460 const id = slug(str(raw.id, 60) || str(raw.title, 60))
461 const prev = (await read($, plans)).find(p => p.id === id) ?? null
462 const next = normalize(raw, prev, now, id)
463 if (next.stages.length === 0) return { deny: `track: no bar "${id}" yet; create it with title and stages.` }
464 sinceUpdate = 0
465 await putPlan($, next)
466 const w = where(next)
467 return { result: `${id}: ${w.finished}/${w.total}, ${next.state}${w.activeTitle && next.state !== 'done' ? `, active "${w.activeTitle}"` : ''}` }
468 })
469
470 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
471 const live = (await read($, plans)).filter(p => (p.state === 'running' || p.state === 'needs_input') && p.id !== AGENTS).at(-1)
472 const mark = (from: PlanState, to: PlanState, note?: null) =>
473 update($, plans, all => all.map(p => (p.id === live?.id && p.state === from ? { ...p, state: to, ...(note === null ? { note } : {}) } : p)))
474 if (live?.state === 'running') await mark('running', 'needs_input')
475 const ran = await next(e)
476 // answered: back to work, the question's note goes with it
477 if (live) await mark('needs_input', 'running', null)
478 return ran
479 })
480
481 // main loop: soft reminders only, never a refusal
482 on('tool.call', async ($, e, next) => {
483 if (e.agentId) {
484 const agentId = e.agentId
485 if (!agentHome.has(agentId)) return next(e)
486 await editAgent($, agentId, a => ({ ...a, state: 'running', tool: String(e.tool).replace(/^mcp__[^_]+__/, '') }))
487 if (e.tool_use_id) toolUses.set(e.tool_use_id, agentId)
488 const ran = await next(e)
489 if (e.tool_use_id) toolUses.delete(e.tool_use_id)
490 await editAgent($, agentId, a => (a.state === 'waiting' ? { ...a, state: 'running' } : a))
491 return ran
492 }
493 if (!WORK_TOOLS.has(String(e.tool))) return next(e)
494 const ran = await next(e)
495 if (ran.deny !== undefined || ran.isReadOnly) return ran
496 workCalls += 1
497 sinceUpdate += 1
498 const hasLive = (await read($, plans)).some(isOpenPlan)
499 const note = (text: string) => ({ ...ran, context: [...(ran.context ?? []), text] })
500 if (!hasLive && !isNudged && workCalls >= WORK_BEFORE_NUDGE) {
501 isNudged = true
502 return note(`progress: this task has several steps; create a bar with ${TOOL}.`)
503 }
504 if (hasLive && sinceUpdate >= CALLS_BEFORE_STALE) {
505 sinceUpdate = 0
506 return note('progress: the bar may be stale; send {id, next:true} if a step finished.')
507 }
508 return ran
509 })
510
511 on('agent.spawn', async ($, e, next) => {
512 const started = await next(e)
513 if (!('agentId' in started) || !started.agentId) return started
514 const id = started.agentId
515 const now = await $.clock.now()
516 const parentHome = e.parentAgentId ? agentHome.get(e.parentAgentId) : undefined
517 const run: AgentRun = { id, title: (e.description || e.subagentType || 'Agent').slice(0, 60), state: 'running', tool: 'Starting', startedAt: now, endedAt: null }
518 let home = AGENTS
519 let isNew = false
520 await update($, plans, all => {
521 // the parent's bar, else the newest open bar, else the mod's own; a bar closed meanwhile is skipped
522 const isLive = (h: string | undefined) => h !== undefined && h !== AGENTS && all.some(p => p.id === h && p.state !== 'done')
523 home = [parentHome, all.filter(isOpenPlan).at(-1)?.id].find(isLive) ?? AGENTS
524 isNew = false
525 const host = all.find(p => p.id === home)
526 if (host) {
527 // a finished agents bar starts a fresh batch
528 const agents = host.id === AGENTS && host.state !== 'running' ? [run] : [...host.agents, run]
529 return all.map(p => (p.id === home ? { ...p, agents, ...(p.id === AGENTS ? { state: 'running' as const, doneAt: null } : {}) } : p))
530 }
531 isNew = true
532 return placeBar(all, { id: AGENTS, title: 'Agents', stages: [], state: 'running', note: null, startedAt: now, doneAt: null, agents: [run] })
533 })
534 agentHome.set(id, home)
535 if (isNew) await update($, isOpen, () => true)
536 return started
537 })
538
539 // a subagent held on a permission prompt turns amber until the call goes on
540 on('tool.check', async ($, e, next) => {
541 const verdict = await next(e)
542 const useId = e.tool_use_id
543 const agentId = useId ? toolUses.get(useId) : undefined
544 if (agentId && useId && verdict.decision === 'ask') {
545 // the mode often settles an ask in a blink; only a call still held after a moment waits on the person
546 $.clock.after(600, async () => {
547 if (toolUses.get(useId) === agentId) await editAgent($, agentId, a => ({ ...a, state: 'waiting', tool: 'Needs approval' }))
548 })
549 }
550 return verdict
551 })
552
553 on('turn.complete', async ($, e, next) => {
554 const agentId = e.agentId
555 if (agentId && agentHome.has(agentId)) {
556 const now = await $.clock.now()
557 const isFailed = e.reason !== 'answer'
558 const tool = e.reason === 'aborted' ? 'Stopped' : isFailed ? 'Failed' : 'Done'
559 await editAgent($, agentId, a => ({ ...a, state: isFailed ? 'error' : 'done', tool, endedAt: now }))
560 agentHome.delete(agentId)
561 }
562 if (!agentId) {
563 // the main turn ended: a running plan whose steps are all finished closes itself, any other pauses
564 const now = await $.clock.now()
565 let isChanged = false
566 await update($, plans, all => {
567 isChanged = false
568 return all.map(p => {
569 const steps = p.stages.flatMap(s => s.steps)
570 if (p.id === AGENTS || p.state !== 'running' || steps.length === 0) return p
571 isChanged = true
572 return steps.every(s => isFinished(s.status)) ? { ...p, state: 'done' as const, doneAt: now } : { ...p, state: 'paused' as const, pausedAt: now }
573 })
574 })
575 if (isChanged) await syncStatus($)
576 }
577 return next(e)
578 })
579
580 on('command.run', { command: 'bars' }, async $ => {
581 if ((await read($, plans)).length === 0) return { text: 'No bars yet. /bars-demo shows a sample.' }
582 const open = await read($, isOpen)
583 await update($, isOpen, () => !open)
584 await syncStatus($)
585 return { text: open ? 'Bars hidden; the status line shows the current step.' : 'Bars shown.' }
586 })
587
588 on('command.run', { command: 'bars-clear' }, async $ => {
589 for (const p of await read($, plans)) forget(p.id)
590 await update($, plans, () => [])
591 await syncStatus($)
592 return { text: 'Bars removed.' }
593 })
594
595 on('command.run', { command: 'bars-demo' }, async $ => {
596 await putPlan($, DEMO(await $.clock.now()))
597 await update($, isOpen, () => true)
598 return { text: 'Sample bar shown above the prompt.' }
599 })
600
601 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
602 const count = (await read($, plans)).length
603 const below = await next(e)
604 if (count === 0) return below
605 const open = await read($, isOpen)
606 const { Box, Button } = $.ui.resolve(e)
607 const press = async () => {
608 await update($, isOpen, () => !open)
609 await syncStatus($)
610 }
611 return (
612 <Box flexDirection="row" alignItems="center" gap={1}>
613 <Button key="bars-toggle" dimColor={!open} label={count > 1 ? `Bars ${count}` : 'Bars'} onPress={press} />
614 {below}
615 </Box>
616 )
617 })
618
619 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
620 const all = await read($, plans)
621 if (all.length === 0 || e.props.hasSurvey || !(await read($, isOpen))) return next(e)
622 const t = $.ui.resolve(e)
623 const { Box, Button, Text } = t
624 // the terminal's table answers Svg with an empty box, so its text drawing is chosen by surface
625 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : null
626 const n = await read($, tick)
627 const now = await $.clock.now()
628 const cols = Math.max(40, e.props.bodyColumns || 100)
629
630 const isTerminal = Svg === null
631 const dim = DIM
632
633 // terminal rows share column widths, so every rule starts and ends at the same place
634 const tracked = all.filter(p => p.id !== AGENTS)
635 const titleCols = Math.min(Math.round(cols * 0.25), Math.max(8, ...tracked.map(p => p.title.length)))
636 const detailCols = Math.min(Math.round(cols * 0.35), Math.max(8, ...tracked.map(p => detail(p).length)))
637 const timeCols = Math.max(4, ...tracked.map(p => elapsed(p, now).length))
638 const ruleCols = Math.max(8, cols - titleCols - detailCols - timeCols - 16)
639
640 const strips = (p: Plan) => {
641 const v = visibleAgents(p, now)
642 const count = v.shown.length + (v.hidden > 0 ? 1 : 0)
643 const branch = (i: number) => (isTerminal ? (i === count - 1 ? '└ ' : '├ ') : '')
644 const rows = v.shown.map((a, i) => {
645 const c = AGENT_COLOR[a.state]
646 const dot = a.state === 'running' ? (n % 2 === 0 ? '●' : '○') : a.state === 'done' ? '✓' : a.state === 'error' ? '✕' : '?'
647 const isEnded = a.state === 'done'
648 return (
649 <Box key={`agent-${a.id}`} flexDirection="row" gap={1} paddingLeft={2}>
650 <Text dimColor>{branch(i)}</Text>
651 <Text color={c}>{dot}</Text>
652 <Text dimColor={isEnded} wrap="truncate">
653 {a.title}
654 </Text>
655 <Text color={dim}>{isEnded ? 'done' : a.tool}</Text>
656 <Box flexGrow={1} />
657 <Text color={dim}>{duration((a.endedAt ?? now) - a.startedAt)}</Text>
658 </Box>
659 )
660 })
661 if (v.hidden > 0) {
662 rows.push(
663 <Box key={`agents-more-${p.id}`} flexDirection="row" gap={1} paddingLeft={2}>
664 <Text dimColor>{branch(count - 1)}</Text>
665 <Text color={dim}>{`+${v.hidden} more`}</Text>
666 </Box>,
667 )
668 }
669 return rows
670 }
671
672 const bar = (p: Plan) => {
673 const f = fadeOf(p, now)
674 const isFading = Boolean(p.fadeAt)
675 const fade = (s: string) => dissolve(s, f)
676 const close = !isFading ? <Button key={`close-${p.id}`} plain dimColor label="✕" onPress={() => dropPlan($, p.id)} /> : null
677 if (p.id === AGENTS) {
678 if (p.state === 'done') {
679 const count = p.agents.length
680 const label = `${p.title} · ${count} agent${count === 1 ? '' : 's'} · ${elapsed(p, now)}`
681 return [
682 <Box key={`bar-${p.id}`} flexDirection="row" gap={1}>
683 <Text wrap="truncate">
684 <Text color={STATE_COLOR.done}>{fade('✓')}</Text>
685 <Text color={dim}>{` ${fade(label)}`}</Text>
686 </Text>
687 <Box flexGrow={1} />
688 {close}
689 </Box>,
690 ]
691 }
692 const ended = p.agents.filter(a => a.endedAt !== null).length
693 return [
694 <Box key={`bar-${p.id}`} flexDirection="row" gap={1}>
695 <Text color={STATE_COLOR[p.state]}>{STATE_GLYPH[p.state]}</Text>
696 <Text bold>Agents</Text>
697 <Text color={dim}>{`${ended}/${p.agents.length} done`}</Text>
698 <Box flexGrow={1} />
699 {close}
700 </Box>,
701 ...strips(p),
702 ]
703 }
704 const pct = percent(p)
705 const color = STATE_COLOR[p.state]
706 // only a running bar blinks; paused, done and the rest hold still
707 const glyph = p.state === 'running' && n % 2 === 1 ? '○' : STATE_GLYPH[p.state]
708 const words = detail(p)
709 const wordsColor = p.state === 'running' ? dim : color
710 const pctText = `${String(pct).padStart(3, ' ')}%`
711 const below = isFading ? [] : strips(p)
712
713 if (isTerminal) {
714 const rule = lineText(p, ruleCols)
715 return [
716 <Box key={`bar-${p.id}`} flexDirection="row" gap={1}>
717 <Text color={color}>{fade(glyph)}</Text>
718 <Box width={titleCols} flexShrink={0}>
719 <Text bold wrap="truncate">
720 {fade(p.title)}
721 </Text>
722 </Box>
723 <Text>
724 <Text color={color}>{fade(rule.lit)}</Text>
725 <Text color={UNLIT}>{fade(rule.rest)}</Text>
726 </Text>
727 <Box width={detailCols} flexShrink={0}>
728 <Text color={wordsColor} wrap="truncate">
729 {fade(words)}
730 </Text>
731 </Box>
732 <Box width={timeCols} flexShrink={0}>
733 <Text color={dim}>{fade(elapsed(p, now))}</Text>
734 </Box>
735 <Text color={dim}>{fade(pctText)}</Text>
736 {close}
737 </Box>,
738 ...below,
739 ]
740 }
741
742 const W = (cols - 2) * PX_PER_COL
743 const alt = `${p.title}: ${words}, ${pct}%`
744 return [
745 <Box key={`bar-${p.id}`} flexDirection="column" gap={1}>
746 <Box flexDirection="row" alignItems="center" gap={1}>
747 <Text color={color}>{fade(glyph)}</Text>
748 <Text bold wrap="truncate">
749 {fade(p.title)}
750 </Text>
751 <Text color={wordsColor} wrap="truncate">
752 {fade(words)}
753 </Text>
754 <Box flexGrow={1} />
755 <Text color={dim}>{fade(elapsed(p, now))}</Text>
756 <Text>{fade(pctText)}</Text>
757 {close}
758 </Box>
759 <Svg source={lineSvg(p, W, isFading)} alt={alt} width={W} height={LINE_H} isInteractive />
760 </Box>,
761 ...below,
762 ]
763 }
764
765 return (
766 <Box flexDirection="column" gap={1}>
767 {all.flatMap(bar)}
768 </Box>
769 )
770 })
771}
772types/index.d.ts 36 lines1export type StepStatus = 'pending' | 'active' | 'done' | 'error' | 'skipped'
2export type PlanState = 'running' | 'needs_input' | 'paused' | 'error' | 'done'
3export type PlanStep = { title: string; status: StepStatus }
4export type PlanStage = { name: string; steps: PlanStep[] }
5export type AgentRun = {
6 id: string
7 title: string
8 state: 'running' | 'waiting' | 'done' | 'error'
9 tool: string
10 startedAt: number
11 endedAt: number | null
12}
13export type Plan = {
14 id: string
15 title: string
16 stages: PlanStage[]
17 state: PlanState
18 note: string | null
19 startedAt: number
20 doneAt: number | null
21 // set when the main turn ends with the bar unfinished; cleared on the next update
22 pausedAt?: number | null
23 // time spent paused, left out of the elapsed time
24 pausedMs?: number
25 // set on the next prompt after the bar is done; the bar fades out from here
26 fadeAt?: number | null
27 agents: AgentRun[]
28}
29export type TitleState = { last: string | null; isUserOwned: boolean; hasAsked: boolean }
30
31declare module 'claude-code' {
32 interface PluginState {
33 progress: { plans: Plan[]; isOpen: boolean; tick: number; title: TitleState }
34 }
35}
36