Lets Claude report progress on a long task as a bar in the status line

hooks/register.ts 64 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { progressLine } from './line'
4
5const TOOL = 'progress'
6
7let file = ''
8
9// ~/.claude/statusline.sh draws ~/.cache/claude-progress/<session id> as a bar.
10async function progressFile($: EngineInterface) {
11 if (!file) {
12 const home = await $.process.run(['printenv', 'HOME'])
13 file = `${home.stdout.trim()}/.cache/claude-progress/${await $.session.id()}`
14 }
15 return file
16}
17
18// Read every turn, so the bar shows up without the person having to ask for it.
19const GUIDANCE = {
20 id: 'task-progress:guidance',
21 scope: 'session',
22 text:
23 '# Progress bar\n' +
24 `When a request will take several steps (a feature, a refactor, a multi-file fix, a long investigation), ` +
25 `report progress with the mcp__task-progress__${TOOL} tool without being asked: once when you start, ` +
26 'again as each major step finishes, and with percent 100 when done. Keep the percent honest and the step ' +
27 'to a few words. Skip it for quick questions and one-step edits, and never mention it in your replies.',
28} as const
29
30export const register: Register = on => {
31 on('prompt.compose', async ($, e, next) => {
32 const composed = await next(e)
33 return { sections: [...composed.sections, GUIDANCE] }
34 })
35
36 on('session.start', async ($, e, next) => {
37 const r = await next(e)
38 await $.tool.register({
39 name: TOOL,
40 description:
41 'Show the person a progress bar in their status line for the multi-step task you are working on. ' +
42 'Call it when you start a long task and again each time a step finishes, with an honest overall percent ' +
43 'and the step now under way. Call it with percent 100 when the task is done. It returns nothing useful; ' +
44 'do not mention calling it.',
45 inputSchema: {
46 type: 'object',
47 properties: {
48 percent: { type: 'number', minimum: 0, maximum: 100, description: 'Overall completion, 0 to 100.' },
49 step: { type: 'string', description: 'A few words naming the step now under way, or "done".' },
50 },
51 required: ['percent', 'step'],
52 },
53 })
54 return r
55 })
56
57 on('tool.call', { tool: `mcp__task-progress__${TOOL}` }, async ($, e) => {
58 const line = progressLine(e as { percent?: unknown; step?: unknown })
59 if (!line) return { deny: 'percent must be a number from 0 to 100' }
60 await $.fs.write(await progressFile($), `${line}\n`)
61 return { result: 'ok' }
62 })
63}
64hooks/line.ts 9 lines1// The file ~/.claude/statusline.sh reads: `<percent>\t<step>`, one line.
2export function progressLine(input: { percent?: unknown; step?: unknown }): string | undefined {
3 const percent = Number(input.percent)
4 if (!Number.isFinite(percent)) return undefined
5 const clamped = Math.round(Math.min(100, Math.max(0, percent)))
6 const step = typeof input.step === 'string' ? input.step.replace(/[\t\r\n]+/g, ' ').trim().slice(0, 80) : ''
7 return `${clamped}\t${step}`
8}
9