SLOPSHOPPER

progress

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

newbandspinnerguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /bars ⎿ progress: No bars yet. /bars-demo shows a sample. ● Orders module ━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━━━━━━ Migration 3/4 · Move data 4m12s 45% ✕ ├ ● Explore schema Grep 41s └ ✓ Check migrations done 1m19s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ Bars ] ⟨Claude Code's own drawing⟩

Draws

Band
● Orders module ━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━━━━━━ Migration 3/4 · Move data 4m12s 45% ✕ ├ ● Explore schema Grep 41s └ ✓ Check migrations done 1m19s
README

agentic-engineering-skills-personal

Private collection of reusable Claude Code skills, shared across projects.

Install

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).

Skills

SkillWhat it does
audit-skillAudit or improve skill instructions and helpers with source-specific evidence; includes optional comparison guidance and the Provencher article reference.
board-syncSync 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.
authmuxResolve 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.
codexWraps 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-modernizationUpgrade dependencies with official release review, local feature adoption, compatibility checks, and measured validation.
github-pr-validation-loopAudit 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-effectPrefer 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.
orchestrateCoordinates 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-logsReview live server logs, prioritize actionable failures, reproduce and fix their causes, validate through CI, and verify the result after an authorized deployment.

Mods

ModWhat it does
progressProgress 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.

Conventions

  • One directory per skill, SKILL.md inside, frontmatter per Claude Code skill format. Mods live under mods/, one plugin folder each.
  • Skills here are project-agnostic: per-project facts (repo, board number, doc paths) are inputs, never hardcoded.
  • Preserve each skill's invocation policy. Keep discovery concise, load conditional detail through references, and retain exact procedures where correctness requires them.

Credit

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.

Helper validation

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.

Source 2 files
hooks/register.tsx 772 lines
1import { 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}
772
types/index.d.ts 36 lines
1export 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