A progress row above the prompt for each plan the model reports, plus a row per running subagent. The bar shows what the model reports; it costs no tokens to…

Small Claude Code mods from Stylus Nexus: a progress row above the prompt, a usage meter, a Render deploy light, and a checker for team conventions. Each mod is a few hundred lines of TypeScript with tests, so you can read one in a sitting before you install it.
A mod is a Claude Code plugin with a hooks module: it can add status lines, rows above the prompt, toasts, tools and commands.
Read before you install. A mod runs inside your Claude Code session and sees your prompts, tool calls and answers. Review a mod's source first. The table says which mods touch the network or can block a command.
| Mod | What it does | Network | Can block a command? |
|---|---|---|---|
task-progress | One progress row per plan the model reports, plus a row per running subagent. | No | Only its own tool, on bad input |
limits-meter | 5-hour and weekly usage in the status line, plus running subagents. Toasts at 90% and 95%. | No | No |
deploy-watch | A light per Render service in the status line, with a toast when a deploy starts, goes live, fails or is canceled. | Render API, read-only | No |
house-rules | Warns when a goal-checklist strip breaks its rules. Optional: status-line check and a commit and PR title rule. | No | Only the title rule, and only if you turn it on |
/plugin marketplace add stylusnexus/claude-mods
/plugin install task-progress@stylusnexus-mods
Replace task-progress with any mod name above. Run /plugin to enable, disable or configure it later.
Requires Claude Code 2.1.286 or newer, the first build that loads hooks modules (a similar mod, zycck/claude-mods, documents the same minimum). These mods were built and tested on 2.1.296; older builds have not been tried. Check yours with claude --version.
A progress row above the prompt for each plan the model reports:
Ship login · Build · 2/6 steps · ▓▓▓░░░░░░░ 33% · running
↳ explorer · sonnet · Grep · 0:42
{id, next:true}, {id, done:[…], active:…}, {id, failed:…, note}, {id, state:…}. An unknown step title is refused with the list of valid ones.doneBarSeconds (default 30)./progress [show|hide] and /progress-clear.The bar shows what the model reports. A full bar is not proof the work is done; check the work.
Not yet verified: it has not run in a long live session, and a subagent's row leaves on that subagent's first finished turn, so a subagent with several turns may drop off early.
Status line such as 5h 42% · wk 18% · 2 agents. Toasts once when a window passes 90% and again at 95%, with the time until it resets. No options, no network.
Status line such as deploy: api 🟢 · web 🟡. A toast when a service's newest deploy starts, goes live, fails or is canceled.
| Option | Meaning |
|---|---|
renderApiKey | A Render API key. The mod only lists deploys. Stored as a sensitive option. |
serviceIds | Comma-separated Render service ids; label one with name=id, for example api=srv-abc123,web=srv-def456. |
pollSeconds | How often to check, 10 to 600. Default 30. |
The only network calls are GET https://api.render.com/v1/services/<id>/deploys?limit=1 with your key.
Built for one team's goal-checklist convention: rows of ⬜ 🔄 🟦 🚀 ✅ with a tally line, directly above a 🟢/🟡/🔴 status line. By default it only shows a toast when a printed strip breaks the rules (tally mismatch, skipped stage, merged item with no SHA, strip with nothing changed, strip when no checklist is active). Read strip.ts before relying on it.
| Option | Default | Meaning |
|---|---|---|
checkStatusLine | off | Warn when a reply does not end on a 🟢/🟡/🔴 line of 100 characters or fewer. |
enforceCommitRule | off | Blocks git commit and gh pr create unless the subject is type(scope): summary, 50 characters or fewer, lowercase after the colon, no trailing period. |
Each mod lives in plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and hooks/*.ts. Pure logic sits in its own file with unit tests beside it; register.ts holds the hooks.
cd plugins/<name>
claude plugin test .
claude plugin validate .
There is no CI yet: the checks above need the Claude Code CLI and have not been wired into GitHub Actions.
Issues and pull requests are welcome. Keep a mod small, keep its tests passing, and say in the README table whether it uses the network or can block a command. Use type(scope): summary commit titles of 50 characters or fewer.
MIT © 2026 Stylus Nexus
hooks/register.ts 118 lines1import type { Register } from 'claude-code'
2import { agentRow, applyOp, createPlan, planRow, RULES, visible, type Agent, type Plan } from './plan'
3
4const TOOL = 'mcp__task-progress__task_progress'
5const COLORS: Record<string, string> = { error: 'red', needs_input: 'yellow', done: 'green' }
6
7export const register: Register = (on, options) => {
8 const keepMs = Math.max(0, Number(options.doneBarSeconds ?? 30)) * 1000
9 const plans = new Map<string, Plan>()
10 const agents = new Map<string, Agent>()
11 let isHidden = false
12
13 on('session.start', async ($, e, next) => {
14 await $.tool.register({
15 name: 'task_progress',
16 description:
17 'Create or update a progress plan shown to the person. Create: {id, title, stages:[{name, steps:[{title}]}]}. Update: {id, next:true} | {id, done:[titles], active:title} | {id, failed:title, note} | {id, state:"needs_input"|"running"|"error"|"done", note}.',
18 inputSchema: {
19 type: 'object',
20 properties: {
21 id: { type: 'string' },
22 title: { type: 'string' },
23 stages: {
24 type: 'array',
25 items: {
26 type: 'object',
27 properties: { name: { type: 'string' }, steps: { type: 'array', items: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] } } },
28 required: ['name', 'steps'],
29 },
30 },
31 next: { type: 'boolean' },
32 done: { type: 'array', items: { type: 'string' } },
33 active: { type: 'string' },
34 failed: { type: 'string' },
35 state: { type: 'string', enum: ['running', 'needs_input', 'error', 'done'] },
36 note: { type: 'string' },
37 },
38 required: ['id'],
39 },
40 })
41 await $.command.register({ name: 'progress', description: 'Show or hide the progress rows above the prompt', argumentHint: '[show|hide]' })
42 await $.command.register({ name: 'progress-clear', description: 'Remove every progress plan from the display' })
43 $.clock.every(1000, () => {
44 if (plans.size > 0 || agents.size > 0) $.ui.invalidate('ui.render')
45 })
46 return next(e)
47 })
48
49 on('tool.call', { tool: TOOL }, async ($, e) => {
50 const now = await $.clock.now()
51 const id = typeof e.id === 'string' ? e.id.trim() : ''
52 const existing = plans.get(id)
53 const out = Array.isArray(e.stages) ? createPlan(e, now) : existing ? applyOp(existing, e, now) : { error: `No plan "${id}". Create it first with stages.` }
54 if ('error' in out) return { deny: out.error }
55 plans.set(out.plan.id, out.plan)
56 $.ui.invalidate('ui.render')
57 return { result: out.reply }
58 })
59
60 on('tool.call', async ($, e, next) => {
61 try {
62 const a = e.agentId === undefined ? undefined : agents.get(e.agentId)
63 if (a) {
64 a.tool = e.tool
65 $.ui.invalidate('ui.render')
66 }
67 } catch {}
68 return next(e)
69 })
70
71 on('agent.spawn', async ($, e, next) => {
72 const r = await next(e)
73 try {
74 if (r.deny === undefined && r.agentId !== undefined) {
75 agents.set(r.agentId, { name: e.name ?? e.description, model: r.model, startedAt: await $.clock.now() })
76 $.ui.invalidate('ui.render')
77 }
78 } catch {}
79 return r
80 })
81
82 on('turn.complete', async ($, e, next) => {
83 if (e.agentId !== undefined && agents.delete(e.agentId)) $.ui.invalidate('ui.render')
84 return next(e)
85 })
86
87 on('prompt.compose', async ($, e, next) => {
88 const r = await next(e)
89 return { sections: [...r.sections, { id: 'task-progress:rules', text: RULES, scope: 'shared' }] }
90 })
91
92 on('command.run', { command: 'progress' }, async ($, e) => {
93 const arg = e.args.trim()
94 isHidden = arg === 'hide' ? true : arg === 'show' ? false : !isHidden
95 $.ui.invalidate('ui.render')
96 return { text: isHidden ? 'Progress rows hidden.' : 'Progress rows shown.' }
97 })
98
99 on('command.run', { command: 'progress-clear' }, async ($) => {
100 plans.clear()
101 $.ui.invalidate('ui.render')
102 return { text: 'Progress plans cleared.' }
103 })
104
105 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
106 if (isHidden || e.props.hasSurvey) return next(e)
107 const now = await $.clock.now()
108 for (const p of [...plans.values()]) if (visible([p], now, keepMs).length === 0) plans.delete(p.id)
109 const rows = [
110 ...[...plans.values()].map(p => ({ text: planRow(p), color: COLORS[p.state] })),
111 ...[...agents.values()].map(a => ({ text: agentRow(a, now), color: undefined as string | undefined })),
112 ]
113 if (rows.length === 0) return next(e)
114 const { Box, Text } = $.ui.resolve(e)
115 return Box({ flexDirection: 'column', children: rows.map(r => Text({ color: r.color, dimColor: r.color === undefined, wrap: 'truncate-end', children: r.text })) })
116 })
117}
118hooks/plan.ts 126 lines1export type PlanState = 'running' | 'needs_input' | 'error' | 'done'
2export type StepStatus = 'todo' | 'active' | 'done' | 'failed'
3export type Step = { title: string; status: StepStatus }
4export type Stage = { name: string; steps: Step[] }
5export type Plan = { id: string; title: string; stages: Stage[]; state: PlanState; note?: string; doneAt?: number }
6export type Agent = { name: string; model: string; tool?: string; startedAt: number }
7export type Outcome = { plan: Plan; reply: string } | { error: string }
8
9const STATES: readonly string[] = ['running', 'needs_input', 'error', 'done']
10const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
11const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
12
13export const RULES = `# Task progress
14You have a tool, mcp__task-progress__task_progress, that drives a progress bar the person sees above their prompt. The bar shows only what you report; it cannot see your work, so keep it true.
15- For a task with 3 or more steps, create a plan first: {id, title, stages:[{name, steps:[{title}]}]}. Use short, unique step titles. Skip it for smaller tasks.
16- Update it as you go with short calls: {id, next:true} when the current step is finished; {id, done:[titles], active:title} to jump; {id, failed:title, note} when a step fails; {id, state:'needs_input'|'running'|'error'|'done', note} when your state changes.
17- Set needs_input before you ask the person something. The plan completes itself when every step is done.
18- This is a status display only. It never replaces doing or checking the work.`
19
20export function createPlan(input: Record<string, unknown>, now: number): Outcome {
21 const id = str(input.id)
22 if (id === '') return { error: 'A plan needs an id.' }
23 if (!Array.isArray(input.stages) || input.stages.length === 0) return { error: 'A plan needs at least one stage.' }
24 const seen = new Set<string>()
25 const stages: Stage[] = []
26 for (const raw of input.stages) {
27 const steps: Step[] = []
28 for (const s of isObj(raw) && Array.isArray(raw.steps) ? raw.steps : []) {
29 const title = str(isObj(s) ? s.title : s)
30 if (title === '') return { error: 'Every step needs a title.' }
31 if (seen.has(title)) return { error: `Step titles must be unique; "${title}" appears twice.` }
32 seen.add(title)
33 steps.push({ title, status: 'todo' })
34 }
35 if (steps.length === 0) return { error: 'Every stage needs at least one step.' }
36 stages.push({ name: str(isObj(raw) ? raw.name : '') || `Stage ${stages.length + 1}`, steps })
37 }
38 const first = stages[0]?.steps[0]
39 if (first) first.status = 'active'
40 const plan: Plan = { id, title: str(input.title) || id, stages, state: 'running' }
41 void now
42 return { plan, reply: `Plan ${id} created: ${summary(plan)}` }
43}
44
45const stepsOf = (plan: Plan) => plan.stages.flatMap(s => s.steps)
46
47export function summary(plan: Plan): string {
48 const steps = stepsOf(plan)
49 const done = steps.filter(s => s.status === 'done').length
50 const next = steps.find(s => s.status === 'active')?.title
51 return `${done}/${steps.length} done${next ? `; current: ${next}` : ''}.`
52}
53
54export function applyOp(plan: Plan, op: Record<string, unknown>, now: number): Outcome {
55 const steps = stepsOf(plan)
56 const find = (title: string) => steps.find(s => s.title === title)
57 const unknown = (title: string): Outcome => ({
58 error: `Unknown step "${title}". Valid step titles: ${steps.map(s => `"${s.title}"`).join(', ')}.`,
59 })
60 const doneTitles = Array.isArray(op.done) ? op.done.map(str) : []
61 const active = str(op.active)
62 const failed = str(op.failed)
63 for (const t of [...doneTitles, active, failed]) if (t !== '' && !find(t)) return unknown(t)
64 const state = str(op.state)
65 if (state !== '' && !STATES.includes(state)) return { error: `Unknown state "${state}". Use ${STATES.join(', ')}.` }
66
67 let moved = false
68 if (op.next === true) {
69 const current = steps.find(s => s.status === 'active') ?? steps.find(s => s.status === 'todo')
70 if (current) current.status = 'done'
71 const upcoming = steps.find(s => s.status === 'todo')
72 if (upcoming) upcoming.status = 'active'
73 moved = true
74 }
75 for (const t of doneTitles) (find(t) as Step).status = 'done'
76 if (doneTitles.length > 0) moved = true
77 if (active !== '') {
78 for (const s of steps) if (s.status === 'active') s.status = 'todo'
79 ;(find(active) as Step).status = 'active'
80 moved = true
81 }
82 if (moved && (plan.state === 'error' || plan.state === 'needs_input')) plan.state = 'running'
83 if (failed !== '') {
84 ;(find(failed) as Step).status = 'failed'
85 plan.state = 'error'
86 plan.note = str(op.note) || undefined
87 } else if (state !== '') {
88 plan.state = state as PlanState
89 plan.note = str(op.note) || undefined
90 }
91 if (steps.every(s => s.status === 'done')) plan.state = 'done'
92 if (plan.state === 'done') plan.doneAt ??= now
93 else delete plan.doneAt
94 return { plan, reply: `${plan.id}: ${plan.state}, ${summary(plan)}` }
95}
96
97export function bar(percent: number, width = 10): string {
98 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
99 return '▓'.repeat(filled) + '░'.repeat(width - filled)
100}
101
102export function planRow(plan: Plan): string {
103 const steps = stepsOf(plan)
104 const done = steps.filter(s => s.status === 'done').length
105 const percent = steps.length === 0 ? 0 : Math.round((done / steps.length) * 100)
106 const stage =
107 plan.stages.find(s => s.steps.some(x => x.status === 'active')) ??
108 plan.stages.find(s => s.steps.some(x => x.status !== 'done')) ??
109 plan.stages[plan.stages.length - 1]
110 const note = plan.note ? ` (${plan.note})` : ''
111 return `${plan.title} · ${stage?.name ?? ''} · ${done}/${steps.length} steps · ${bar(percent)} ${percent}% · ${plan.state}${note}`
112}
113
114export function elapsed(ms: number): string {
115 const s = Math.max(0, Math.floor(ms / 1000))
116 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
117}
118
119export const agentRow = (a: Agent, now: number) =>
120 `↳ ${a.name} · ${a.model} · ${a.tool ?? 'thinking'} · ${elapsed(now - a.startedAt)}`
121
122// Plans still on screen: a done plan leaves once `keepMs` has passed.
123export function visible(plans: Plan[], now: number, keepMs: number): Plan[] {
124 return plans.filter(p => p.state !== 'done' || now - (p.doneAt ?? now) < keepMs)
125}
126