SLOPSHOPPER

task-progress

Right-side pane: main model vitals plus task progress and the current step

newpaneguardcommandprompttimer
v0.6.0no licenseupdated 2026-10-09bob-park/claude-task-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-progress
│ ┃ Task Progress ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ — · main ⏺ Read(src/auth.ts) │ ┃ │ effort ▯▯▯▯ — 0 req ⎿ Read 6 lines │ ┃ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k ⏺ Update(src/auth.ts) │ ┃ │ $0.42 5h ▰▰▱▱▱ 31% ⎿ 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 │ │ › /task-progress │ ⎿ task-progress: Task progress opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Task Progress
╭──────────────────────────────────────────────────────╮ │ — · main ○ idle │ │ effort ▯▯▯▯ — 0 req │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ $0.42 5h ▰▰▱▱▱ 31% │ ╰──────────────────────────────────────────────────────╯
README

claude-task-progress

Claude Code mod: a pane docked to the right of the transcript with three sections:

  1. main — model, working/idle, effort, permission mode, request count, context gauge (⟲ compactions), cost and rate limits (layout from Flightdeck, MIT)
  2. TASKS — progress bar with done/total · % right next to it (in-progress tasks in yellow), and the current step on the line below, with its task number when it has one (▸ #2 Wiring). Tasks come from todos (TodoWrite / TaskCreate); with none (or all done while the plan has open tasks), from the active plan: a superpowers plan (docs/superpowers/plans/*.md, ### Task N: headings, completed by its SDD ledger or ticked steps) or a plan-mode plan (~/.claude/plans/*.md, its - [ ] checkboxes, else its numbered steps ticked as 1. [x]). Once every task is done it says when (✓ 모두 완료 · 오후 2:05, with the date from the next day on). While Claude works, a spinner turns and a bright cell sweeps the in-progress part of the bar. With no todos or plan, or once every task is done, a working turn shows the tool Claude is running (Bash · Run tests). A superpowers plan's completed tasks stay completed after finishing-a-development-branch drops the SDD ledger, and ⎇ finishing-a-development-branch shows under them until another plan starts
  3. AGENTS — each running subagent as 1: Explore auth --- Haiku 5.5 01:23: its number, task, model and running time, the latest on top. A finished agent leaves the list; one waiting on its own background work stays

task-progress demo

task-progress screenshot

The pane opens on session start. /task-progress reopens it, /task-progress close closes it. In a non-fullscreen terminal it sits inline above the prompt instead.

Install

/plugin install task-progress --marketplace bob-park/claude-task-progress

Answer y to add the marketplace, then pick a scope.

Source 3 files
hooks/register.tsx 409 lines
1import { atom, read, update } from 'claude-code'
2import type { BuiltinToolResults, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRun, Main, Plan, Task } from '../types'
5import { isPlanPath, keepCompleted, ledgerOwns, ledgerPath, parsePlan } from './plan'
6
7const tasks = atom({ plugin: 'task-progress', key: 'tasks' } as const, {} as Record<string, Task>)
8const plan = atom({ plugin: 'task-progress', key: 'plan' } as const, { path: '', tasks: [] } as Plan)
9const agents = atom({ plugin: 'task-progress', key: 'agents' } as const, {} as Record<string, AgentRun>)
10
11// a failed read keeps what the pane already shows
12const refreshPlan = async ($: EngineInterface, path: string) => {
13  const text = await $.fs.read(path).catch(() => null)
14  if (typeof text !== 'string') return
15  const at = ledgerPath(path)
16  const ledger = at ? await $.fs.read(at).catch(() => null) : null
17  const owned = typeof ledger === 'string' && ledgerOwns(path, ledger) ? ledger : undefined
18  const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
19  await update($, plan, prev => {
20    const tasks = parsePlan(text, owned)
21    // same plan (maybe now in the main checkout): done stays done
22    const same = !!prev?.path && base(prev.path) === base(path)
23    return { path, tasks: same ? keepCompleted(prev.tasks, tasks) : tasks, ...(same && prev.finishing ? { finishing: true } : {}) }
24  })
25}
26
27const DEFAULT_MAIN: Main = {
28  model: '',
29  effort: '',
30  mode: '',
31  steps: 0,
32  isRunning: false,
33  activity: '',
34  pct: null,
35  tokens: null,
36  window: 0,
37  compactions: 0,
38  costUsd: null,
39  limits: [],
40  doneAt: null,
41}
42const main = atom({ plugin: 'task-progress', key: 'main' } as const, DEFAULT_MAIN)
43
44const PANE = 'task-progress'
45const PANE_COLUMNS = 48
46
47// Formatting below is adapted from Flightdeck (MIT, github.com/scasella/claude-flightdeck)
48const prettyModel = (id: string) => {
49  if (!id) return '—'
50  const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d])/i.exec(id)
51  return m?.[1] ? `${m[1].charAt(0).toUpperCase()}${m[1].slice(1).toLowerCase()} ${m[2]}${m[3] ? `.${m[3]}` : ''}` : id.slice(0, 22)
52}
53const kTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
54const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
55const gauge = (pct: number, width: number) => {
56  const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
57  return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
58}
59const limitLabel = (kind: string) =>
60  kind
61    .replace(/five[_ -]?hours?/i, '5h')
62    .replace(/seven[_ -]?days?/i, '7d')
63    .replace(/[_-]+/g, ' ')
64    .trim()
65
66const setMain = ($: EngineInterface, patch: (m: Main) => Partial<Main>) => update($, main, m => ({ ...DEFAULT_MAIN, ...m, ...patch({ ...DEFAULT_MAIN, ...m }) }))
67
68type Usage = Awaited<ReturnType<EngineInterface['session']['usage']>>
69const usageOf = (u: Pick<Usage, 'context' | 'cost' | 'rateLimits'>): Partial<Main> => ({
70  pct: u.context.percent ?? null,
71  tokens: u.context.tokens ?? null,
72  window: u.context.window,
73  costUsd: u.cost?.usd ?? null,
74  limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
75})
76
77// while a turn runs the pane redraws on a clock so the spinner and shimmer move
78const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
79const FRAME_MS = 100
80const frame = () => Math.floor(Date.now() / FRAME_MS)
81let ticker: Timer | undefined
82// the clock runs while the main turn or any agent is working
83const animate = async ($: EngineInterface) => {
84  const [m, a] = await Promise.all([read($, main), read($, agents)])
85  const on = !!m?.isRunning || Object.values(a ?? {}).some(r => r.endedAt === undefined)
86  if (on) ticker ??= $.clock.every(FRAME_MS, () => $.ui.invalidate('ui.render'))
87  else ticker = void ticker?.cancel()
88}
89
90const mmss = (ms: number) => {
91  const s = Math.max(0, Math.floor(ms / 1000))
92  return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
93}
94// `오후 2:05`, and `10월 9일 오후 2:05` once it is another day
95export const doneTime = (at: number, now: number) => {
96  const d = new Date(at)
97  const h = d.getHours()
98  const day = d.toDateString() === new Date(now).toDateString() ? '' : `${d.getMonth() + 1}월 ${d.getDate()}일 `
99  return `${day}${h < 12 ? '오전' : '오후'} ${h % 12 || 12}:${String(d.getMinutes()).padStart(2, '0')}`
100}
101
102// todos win, unless all are done and a plan still has open work
103const shownTasks = (todos: Task[], p: Plan | undefined) => {
104  const planOpen = (p?.tasks ?? []).some(t => t.status !== 'completed')
105  return todos.some(t => t.status !== 'completed') || (todos.length > 0 && !planOpen) ? todos : (p?.tasks ?? [])
106}
107
108// the moment the shown list became all done; cleared once something is open again
109const stampDone = async ($: EngineInterface) => {
110  const [todos, p, m] = await Promise.all([read($, tasks).then(t => Object.values(t ?? {})), read($, plan), read($, main)])
111  const list = shownTasks(todos, p)
112  const allDone = list.length > 0 && list.every(t => t.status === 'completed')
113  const stamped = typeof m?.doneAt === 'number'
114  if (allDone && !stamped) {
115    const at = await $.clock.now()
116    await setMain($, () => ({ doneAt: at }))
117  } else if (!allDone && stamped) await setMain($, () => ({ doneAt: null }))
118}
119
120const numbered = (t: Task) => (t.n ? `#${t.n} ${t.label}` : t.label)
121
122// `Bash · Run tests`, `Read · plan.ts`: the tool and the most telling input it has
123const activityOf = (e: { tool: string; description?: unknown; file_path?: unknown; pattern?: unknown; command?: unknown }) => {
124  const what = [e.description, e.file_path, e.pattern, e.command].find(x => typeof x === 'string' && x) as string | undefined
125  return what ? `${e.tool} · ${what.slice(what.lastIndexOf('/') + 1)}` : e.tool
126}
127
128const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Task Progress', columns: PANE_COLUMNS, rows: 8 })
129
130export const register: Register = on => {
131  on('session.start', async ($, e, next) => {
132    await $.command.register({ name: 'task-progress', description: 'Open or close the task progress pane', argumentHint: '[close]' })
133    // a host without usage (headless, SDK) just starts without it
134    const u = await $.session.usage().catch(() => null)
135    if (u) await setMain($, () => usageOf(u))
136    void openPane($).catch(() => undefined)
137    return next(e)
138  })
139
140  on('command.run', { command: 'task-progress' }, async ($, e) => {
141    if (e.args.trim() === 'close') {
142      await $.ui.close({ id: PANE })
143      return { text: 'Task progress closed.' }
144    }
145    const opened = await openPane($)
146    return { text: opened.isPlaced ? 'Task progress opened.' : `Task progress is not shown yet: ${opened.reason}` }
147  })
148
149  on('classic.UserPromptSubmit', async ($, e, next) => {
150    if (e.permission_mode) await setMain($, () => ({ mode: e.permission_mode }))
151    return next(e)
152  })
153
154  on('turn.start', async ($, e, next) => {
155    await setMain($, () => ({ isRunning: true }))
156    // a new prompt clears agents that finished before it
157    await update($, agents, all => Object.fromEntries(Object.entries(all ?? {}).filter(([, r]) => r.endedAt === undefined)))
158    await animate($)
159    return next(e)
160  })
161
162  on('turn.complete', async ($, e, next) => {
163    const done = await next(e)
164    if (e.agentId) {
165      const { agentId } = e
166      // an agent waiting on its own background work ends its turn but isn't done
167      const status = (await $.agent.list().catch(() => [])).find(a => a.id === agentId)?.status
168      if (status === 'waiting') return done
169      const at = await $.clock.now()
170      await update($, agents, all => {
171        const r = all?.[agentId]
172        return r ? { ...all, [agentId]: { ...r, endedAt: at } } : all
173      })
174    } else await setMain($, () => ({ isRunning: false, activity: '' }))
175    await animate($)
176    return done
177  })
178
179  on('turn.step', async function* ($, e, next) {
180    const { agentId } = e
181    if (!agentId) await setMain($, m => ({ model: e.model, effort: String(e.effort ?? m.effort), steps: m.steps + 1 }))
182    else {
183      // a step after its end: the agent resumed once its background work came back
184      const r = (await read($, agents))?.[agentId]
185      if (r) {
186        const { endedAt, ...run } = r
187        await update($, agents, all => ({ ...all, [agentId]: { ...run, model: e.model } }))
188        if (endedAt !== undefined) await animate($)
189      }
190    }
191    return yield* next(e)
192  })
193
194  on('agent.spawn', async ($, e, next) => {
195    const spawned = await next(e)
196    const { agentId, model = '' } = spawned as { agentId?: string; model?: string }
197    if (agentId) {
198      const startedAt = await $.clock.now()
199      // next after the highest still listed: one left running keeps its number
200      await update($, agents, all => ({
201        ...all,
202        [agentId]: { n: Math.max(0, ...Object.values(all ?? {}).map(r => r.n)) + 1, label: e.description || e.subagentType, model, startedAt },
203      }))
204      await animate($)
205    }
206    return spawned
207  })
208
209  on('skill.prompt', async ($, e, next) => {
210    if (/(^|:)finishing-a-development-branch$/.test(e.skill)) await update($, plan, p => ({ ...(p ?? { path: '', tasks: [] }), finishing: true }))
211    return next(e)
212  })
213
214  on('session.measure', async ($, e, next) => {
215    await setMain($, () => usageOf(e))
216    return next(e)
217  })
218
219  on('session.compact', async ($, e, next) => {
220    const done = await next(e)
221    if (!e.agentId && e.trigger !== 'precompute') await setMain($, m => ({ compactions: m.compactions + 1 }))
222    return done
223  })
224
225  on('tool.call', async ($, e, next) => {
226    if (!e.agentId) await setMain($, () => ({ activity: activityOf(e as Parameters<typeof activityOf>[0]) }))
227    const ran = await next(e)
228    if (ran.deny !== undefined || ran.isError) return ran
229
230    if (e.tool === 'TodoWrite') {
231      // TodoWrite replaces the whole list
232      const list = Object.fromEntries(e.todos.map((t, i) => [`todo:${i}`, { status: t.status, label: t.activeForm || t.content }]))
233      await update($, tasks, () => list)
234    } else if (e.tool === 'TaskCreate') {
235      // next(e) ran before e.tool was narrowed, so result isn't typed per tool
236      const id = (ran.result as BuiltinToolResults['TaskCreate'] | undefined)?.task?.id
237      const label = e.activeForm || e.subject
238      if (id) await update($, tasks, all => ({ ...all, [id]: { status: 'pending' as const, label, n: id } }))
239    } else if (e.tool === 'TaskUpdate') {
240      const { taskId, status, activeForm, subject } = e
241      await update($, tasks, all => {
242        const { [taskId]: prev, ...rest } = all
243        if (status === 'deleted' || !prev) return rest
244        return { ...rest, [taskId]: { ...prev, status: status ?? prev.status, label: activeForm || subject || prev.label } }
245      })
246    }
247
248    if (e.agentId) {
249      await stampDone($)
250      return ran
251    }
252    const planned = e.tool === 'ExitPlanMode' ? (ran.result as BuiltinToolResults['ExitPlanMode'] | undefined)?.filePath : undefined
253    // Read too: a plan written in an earlier session is executed by reading it
254    const written = (e.tool === 'Write' || e.tool === 'Edit' || e.tool === 'Read') && isPlanPath(e.file_path) ? e.file_path : undefined
255    const path = planned ?? written ?? (await read($, plan))?.path
256    if (path) await refreshPlan($, path)
257    await stampDone($)
258    if (!planned) return ran
259    return { ...ran, context: [...(ran.context ?? []), `Mark each step done by ticking it (- [x], or 1. [x] for numbered steps) in ${planned} as you finish it.`] }
260  })
261
262  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
263    const [m, todos, p, runs, now] = await Promise.all([
264      read($, main).then(x => ({ ...DEFAULT_MAIN, ...x })),
265      read($, tasks).then(Object.values),
266      read($, plan),
267      // running ones only, the latest on top
268      read($, agents).then(a => Object.values(a ?? {}).filter(r => r.endedAt === undefined).sort((x, y) => y.startedAt - x.startedAt)),
269      $.clock.now(),
270    ])
271    const list = shownTasks(todos, p)
272    const { Box, Text } = $.ui.resolve(e)
273    const W = Math.max(30, e.props.bodyColumns)
274    const f = frame()
275    const spin = SPINNER[f % SPINNER.length]
276    // a background agent keeps the session working after the main turn ends
277    const busy = m.isRunning || runs.length > 0
278
279    // ---- main (layout from Flightdeck's main panel)
280    const effortN = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }[m.effort] ?? 0
281    const ctx = m.pct !== null ? gauge(m.pct, 10) : null
282    const mainPanel = (
283      <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={W}>
284        <Box justifyContent="space-between">
285          <Text color="cyan" bold>{`${prettyModel(m.model)} · main`}</Text>
286          <Text color={busy ? 'cyan' : undefined} dimColor={!busy}>{busy ? `${spin} working` : '○ idle'}</Text>
287        </Box>
288        <Text wrap="truncate">
289          <Text dimColor>effort </Text>
290          <Text color="cyan">{'▮'.repeat(effortN) + '▯'.repeat(4 - effortN)} </Text>
291          <Text color="cyan" bold>{m.effort || '—'}</Text>
292          {m.mode ? <Text dimColor>{`   mode ${m.mode}`}</Text> : null}
293          <Text dimColor>{`   ${m.steps} req`}</Text>
294        </Text>
295        {ctx ? (
296          <Text wrap="truncate">
297            <Text dimColor>ctx </Text>
298            <Text color={m.pct !== null && m.pct >= 80 ? 'red' : 'cyan'}>{ctx.on}</Text>
299            <Text dimColor>{ctx.off}</Text>
300            <Text bold>{` ${Math.round(m.pct ?? 0)}%`}</Text>
301            {m.tokens !== null ? <Text dimColor>{` ${kTokens(m.tokens)}/${kTokens(m.window)}`}</Text> : null}
302            {m.compactions > 0 ? <Text color="yellow">{`  ⟲${m.compactions}`}</Text> : null}
303          </Text>
304        ) : null}
305        {m.costUsd !== null || m.limits.length > 0 ? (
306          <Text wrap="truncate">
307            {m.costUsd !== null ? <Text>{`${fmtUsd(m.costUsd)}   `}</Text> : null}
308            {m.limits.slice(0, 2).map(l => {
309              const g = gauge(l.pct, 5)
310              return (
311                <Text key={l.kind}>
312                  <Text dimColor>{`${limitLabel(l.kind)} `}</Text>
313                  <Text color={l.pct >= 80 ? 'red' : 'cyan'}>{g.on}</Text>
314                  <Text dimColor>{`${g.off} ${Math.round(l.pct)}%  `}</Text>
315                </Text>
316              )
317            })}
318          </Text>
319        ) : null}
320      </Box>
321    )
322
323    // ---- agents: `1: label --- Model mm:ss`, the right column lined up
324    const agentsPanel =
325      runs.length > 0 ? (
326        <Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} width={W}>
327          <Text color="magenta" bold>AGENTS</Text>
328          {runs.map(r => {
329            const right = ` ${prettyModel(r.model)} ${mmss(now - r.startedAt)}`
330            const room = W - 4 - 2 - right.length - 4 // icon, then ` ---` at the least
331            let left = `${r.n}: ${r.label}`
332            if (left.length > room) left = `${left.slice(0, Math.max(0, room - 1))}…`
333            return (
334              <Text key={String(r.n)} color="yellow" wrap="truncate">
335                {`${spin} ${left} ${'-'.repeat(Math.max(3, room - left.length + 3))}${right}`}
336              </Text>
337            )
338          })}
339        </Box>
340      ) : null
341
342    // what Claude is doing right now, when no task says it
343    const activityPanel =
344      m.isRunning && m.activity ? (
345        <Box borderStyle="round" borderColor="yellow" paddingX={1} width={W}>
346          <Text color="yellow" wrap="truncate">{`${spin} ${m.activity}`}</Text>
347        </Box>
348      ) : null
349
350    if (list.length === 0) {
351      return (
352        <Box flexDirection="column">
353          {mainPanel}
354          {activityPanel}
355          {agentsPanel}
356        </Box>
357      )
358    }
359
360    // ---- tasks
361    const total = list.length
362    const done = list.filter(t => t.status === 'completed').length
363    const active = list.filter(t => t.status === 'in_progress')
364    const barW = Math.max(10, W - 4 - ` ${total}/${total} · 100%`.length - 1)
365    const filled = Math.round((done / total) * barW)
366    const activeCells = Math.round(((done + active.length) / total) * barW) - filled
367    const percent = Math.round((done / total) * 100)
368    const allDone = done === total
369    const next = list.find(t => t.status === 'pending')
370    // a bright cell sweeps across the in-progress part of the bar while working
371    const sweep = busy && activeCells > 0 ? f % activeCells : -1
372
373    return (
374      <Box flexDirection="column">
375        {mainPanel}
376        <Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1} width={W}>
377          <Text color="green" bold>{allDone ? '✓ TASKS' : 'TASKS'}</Text>
378          {/* count sits right after the bar so the eye doesn't travel */}
379          <Text wrap="truncate">
380            <Text color="green">{'█'.repeat(filled)}</Text>
381            {sweep >= 0 ? (
382              <Text color="yellow">
383                {'▓'.repeat(sweep)}
384                <Text bold>█</Text>
385                {'▓'.repeat(activeCells - sweep - 1)}
386              </Text>
387            ) : (
388              <Text color="yellow">{'▓'.repeat(activeCells)}</Text>
389            )}
390            <Text dimColor>{'░'.repeat(barW - filled - activeCells)}</Text>
391            <Text bold color="blue">{` ${done}/${total}`}</Text>
392            <Text dimColor>{` · ${percent}%`}</Text>
393          </Text>
394          {allDone ? (
395            <Text color="green">{m.doneAt !== null ? `✓ 모두 완료 · ${doneTime(m.doneAt, now)}` : '✓ 모두 완료'}</Text>
396          ) : active.length > 0 ? (
397            <Text color="yellow" wrap="truncate">{`${busy ? spin : '▸'} ${active.map(numbered).join(', ')}`}</Text>
398          ) : (
399            <Text dimColor wrap="truncate">{`○ 다음: ${next ? numbered(next) : '—'}`}</Text>
400          )}
401          {p?.finishing ? <Text color="cyan" wrap="truncate">{`${busy ? spin : '⎇'} finishing-a-development-branch`}</Text> : null}
402        </Box>
403        {allDone ? activityPanel : null}
404        {agentsPanel}
405      </Box>
406    )
407  })
408}
409
hooks/plan.ts 78 lines
1import type { Task } from '../types'
2
3const TASK_HEADING = /^###\s+Task\s+(\d+):\s*(.+)$/
4const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.+)$/
5const NUMBERED = /^(\d+)\.\s+(?:\[([ xX])\]\s+)?(.+)$/
6
7const clean = (s: string) => s.replace(/\*\*/g, '').trim()
8
9/** Plan lines outside fenced code blocks: plans quote templates inside fences */
10const planLines = (text: string) => {
11  let fenced = false
12  return text.split(/\r?\n/).filter(l => {
13    if (l.trimStart().startsWith('```')) fenced = !fenced
14    else return !fenced
15    return false
16  })
17}
18
19export const isPlanPath = (p: string) => /\/(docs\/superpowers\/plans|\.claude\/plans)\/[^/]+\.md$/.test(p)
20
21/** The SDD ledger beside a superpowers plan; null for any other plan */
22export const ledgerPath = (planPath: string) => {
23  const m = /^(.*)\/docs\/superpowers\/plans\/([^/]+)\.md$/.exec(planPath)
24  return m ? `${m[1]}/.superpowers/sdd/${m[2]}/progress.md` : null
25}
26
27/** A ledger's first line names the plan it tracks */
28export const ledgerOwns = (planPath: string, ledger: string) =>
29  (ledger.split(/\r?\n/, 1)[0] ?? '').includes(planPath.slice(planPath.lastIndexOf('/') + 1))
30
31/** Done items first get their status; the first open one is current once anything has started */
32const withCurrent = (items: Array<{ label: string; done: boolean; n?: string }>, started: boolean): Task[] => {
33  const current = items.findIndex(i => !i.done)
34  return items.map((i, k) => ({
35    status: i.done ? 'completed' : started && k === current ? 'in_progress' : 'pending',
36    label: i.label,
37    ...(i.n ? { n: i.n } : {}),
38  }))
39}
40
41/** Re-read plan keeps what was done: finishing a branch drops the SDD ledger */
42export const keepCompleted = (prev: Task[], next: Task[]): Task[] => {
43  const done = new Set(prev.filter(t => t.status === 'completed').map(t => t.label))
44  return withCurrent(
45    next.map(t => ({ label: t.label, n: t.n, done: t.status === 'completed' || done.has(t.label) })),
46    next.some(t => t.status !== 'pending') || next.some(t => done.has(t.label)),
47  )
48}
49
50export const parsePlan = (text: string, ledger?: string): Task[] => {
51  const lines = planLines(text)
52
53  // superpowers: `### Task N:` headings, steps beneath, ledger lines `Task N: complete`
54  const tasks: Array<{ n: string; label: string; steps: boolean[] }> = []
55  for (const line of lines) {
56    const h = TASK_HEADING.exec(line)
57    if (h) tasks.push({ n: h[1]!, label: clean(h[2]!), steps: [] })
58    else {
59      const c = CHECKBOX.exec(line)
60      if (c) tasks.at(-1)?.steps.push(c[1] !== ' ')
61    }
62  }
63  if (tasks.length > 0) {
64    const logged = new Set([...(ledger ?? '').matchAll(/^Task (\d+): complete/gm)].map(m => m[1]))
65    const started = logged.size > 0 || tasks.some(t => t.steps.some(Boolean))
66    return withCurrent(
67      tasks.map(t => ({ label: t.label, n: t.n, done: logged.has(t.n) || (t.steps.length > 0 && t.steps.every(Boolean)) })),
68      started,
69    )
70  }
71
72  // plan mode: checkboxes, else top-level numbered items (ticked as `1. [x]`)
73  const items = lines.map(l => CHECKBOX.exec(l)).filter(m => m !== null).map(m => ({ label: clean(m[2]!), done: m[1] !== ' ' }))
74  if (items.length > 0) return withCurrent(items, items.some(i => i.done))
75  const numbered = lines.map(l => NUMBERED.exec(l)).filter(m => m !== null).map(m => ({ label: clean(m[3]!), n: m[1]!, done: !!m[2] && m[2] !== ' ' }))
76  return withCurrent(numbered, numbered.some(i => i.done))
77}
78
types/index.d.ts 36 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3/** `n`: the task's number when its source has one (plan `Task N`, TaskCreate id, `1.` step) */
4export type Task = { status: TaskStatus; label: string; n?: string }
5
6/** A subagent the model spawned, listed under the tasks */
7export type AgentRun = { n: number; label: string; model: string; startedAt: number; endedAt?: number }
8
9/** The plan file progress is read from when there are no todos; `finishing` once finishing-a-development-branch ran on it */
10export type Plan = { path: string; tasks: Task[]; finishing?: boolean }
11
12/** Main-loop vitals, drawn like Flightdeck's main panel */
13export type Main = {
14  model: string
15  effort: string
16  mode: string
17  steps: number
18  isRunning: boolean
19  /** The main loop's latest tool call, shown when there are no tasks */
20  activity: string
21  pct: number | null
22  tokens: number | null
23  window: number
24  compactions: number
25  costUsd: number | null
26  limits: Array<{ kind: string; pct: number }>
27  /** When the shown tasks all became completed */
28  doneAt: number | null
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    'task-progress': { tasks: Record<string, Task>; main: Main; plan: Plan; agents: Record<string, AgentRun> }
34  }
35}
36