SLOPSHOPPER

task-progress

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…

bandguardcommandprompttooltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-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 › /progress ⎿ task-progress: Progress rows hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

License: MIT Claude Code mods Tested on Claude Code 2.1.296 GitHub stars

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.

Mods

ModWhat it doesNetworkCan block a command?
task-progressOne progress row per plan the model reports, plus a row per running subagent.NoOnly its own tool, on bad input
limits-meter5-hour and weekly usage in the status line, plus running subagents. Toasts at 90% and 95%.NoNo
deploy-watchA light per Render service in the status line, with a toast when a deploy starts, goes live, fails or is canceled.Render API, read-onlyNo
house-rulesWarns when a goal-checklist strip breaks its rules. Optional: status-line check and a commit and PR title rule.NoOnly the title rule, and only if you turn it on

Install

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

task-progress

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
  • Plans: the model creates a plan for any task of three or more steps, then updates it with short calls: {id, next:true}, {id, done:[…], active:…}, {id, failed:…, note}, {id, state:…}. An unknown step title is refused with the list of valid ones.
  • Subagents: one row each, with name, model, current tool and elapsed time. They come from engine events and cost no tokens.
  • States: running, needs input, error, done. A finished plan leaves after doneBarSeconds (default 30).
  • Commands: /progress [show|hide] and /progress-clear.
  • Adds one short section to the system prompt telling the model when to create and update a plan.
  • Does not: make network calls, run shell commands, read or write files, block edits, or hold a turn open.

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.

limits-meter

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.

deploy-watch

Status line such as deploy: api 🟢 · web 🟡. A toast when a service's newest deploy starts, goes live, fails or is canceled.

OptionMeaning
renderApiKeyA Render API key. The mod only lists deploys. Stored as a sensitive option.
serviceIdsComma-separated Render service ids; label one with name=id, for example api=srv-abc123,web=srv-def456.
pollSecondsHow 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.

house-rules

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.

OptionDefaultMeaning
checkStatusLineoffWarn when a reply does not end on a 🟢/🟡/🔴 line of 100 characters or fewer.
enforceCommitRuleoffBlocks git commit and gh pr create unless the subject is type(scope): summary, 50 characters or fewer, lowercase after the colon, no trailing period.

Development

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.

Contributing

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.

License

MIT © 2026 Stylus Nexus

Source 2 files
hooks/register.ts 118 lines
1import 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}
118
hooks/plan.ts 126 lines
1export 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