Live plan checklist published as a sidebar section, with the subagents working each step

My personal Claude Code setup, packaged as a plugin marketplace so I can install exactly the pieces a given machine needs.
This is a public snapshot of how I extend Claude Code. It ships 11 plugins covering 20 skills, 1 sub-agent, 4 hooks, 5 mods, and a custom status line.
Most pieces follow my own paths and conventions, so treat them as reference patterns to borrow from rather than drop-in installs.
/plugin marketplace add hunterbrewer04/claude-toolkit
/plugin install school@brewkit
Install only the plugins that machine actually needs. Nothing here assumes the others are present.
Coursework tooling.
| Skill | What it does |
|---|---|
| course-setup | Scaffold a new course folder and clean Canvas file dumps; owns the school.json registry |
| grade-calc | Exact grade math from syllabus weights: current grade, what-ifs, target scores |
| rubric-check | Grade a draft against its assignment spec before submitting |
| study-guide | Turn lecture PDFs into a self-contained interactive HTML study package |
| notebooklm-course-sync | Keep a course's NotebookLM notebook in sync with local files |
| sapling-ai-detector | Scan text for AI-generated content with a per-sentence report |
Client and project delivery.
| Skill | What it does |
|---|---|
| linear-assistant | Create, update, and query Linear issues, projects, milestones, and cycles |
Personal-life tooling.
| Skill | What it does |
|---|---|
| apple-calendar | Read and write Apple Calendar from a machine that reaches it over the network via the apple-calendar MCP server |
Everything cross-cutting.
| Skill | What it does |
|---|---|
| claude-toolkit | Add, sync, and set up this repo's components across machines |
| claude-documentation | Generate consistent README docs for skills, hooks, and sub-agents |
| skill-builder | Build a new skill through a structured, validated process |
| docx | Create, read, and edit Word documents, including tracked changes and comments |
| notebooklm | Full programmatic NotebookLM API: notebooks, sources, artifacts, downloads |
| tailnet | Move files to tailnet servers, serve files over Tailscale, Taildrop to a phone |
Also ships three hooks that apply everywhere: a PreToolUse guard against committing .env files, a SessionStart agent-state tracker, and a Stop desktop notification.
My development workflow chain, split across two sessions with a context clear in between.
| Skill | What it does |
|---|---|
| spec | Turn an idea into an approved specification |
| plan | Break an approved spec into waves of file-disjoint tasks |
| implement | Execute the plan across persistent subagent slots in git worktrees |
| review | Whole-branch review pass with specialist fan-out |
| test | Run the plan's verification section, then commit and open the PR |
Includes the code-reviewer sub-agent used by the review step, and a SessionStart resume hook.
| Skill | What it does |
|---|---|
| skill-creator | Create and improve skills, run evals, benchmark performance, grade a SKILL.md against a structural rubric |
A mod: a docked sidebar for the widget mods. Widgets publish their section and handle their own buttons; the sidebar only draws, so a new widget needs no change here. The layout option picks how: tabs (the default) gives each widget its own pane, shown as tabs titled with the widget's badge (Checklist 93%), opened when the widget has something, focused when it asks for attention, and closed when it empties; stacked draws every section in one pane that opens itself on attention. The order option sets the tab order or the stacking. /sidebar opens it, /sidebar close hides it.
A mod: a live checklist of the current plan, drawn as a section in the sidebar. Claude posts the steps through its own checklist tool and checks each one off as it finishes, subagents working a step show under it with their status and tool-call count, and the status line carries done/total. /checklist prints the list, /checklist clear empties it.
A mod: questions Claude asks without stopping the work. Each one comes with the default Claude goes with meanwhile and gets an answer field in the sidebar. An answer reaches Claude on its next step, with a note to redo anything built on the default. Questions still open at the end of a turn get a toast.
A mod: the background commands and dev servers Claude started this session, shown in the sidebar with their ports and uptime and a button that kills one. Nothing is ever killed automatically; anything still running when the session ends gets a toast.
A mod: a second Claude session that reviews every commit the session you are working in makes. The first commit in a repo asks once (always, not now, never). On always, a Sonnet reviewer opens in a herdr pane beside you (or as a background session outside herdr) and gets each new commit range. Blockers go back to the builder, which fixes them, for up to two rounds; nits and clean reviews go to a Review tab in the sidebar, and nits are posted as one PR comment when the builder opens a PR. The reviewer cannot use the Edit or Write tools and closes when the last builder in that repo ends. /review-pair shows the status; on, off, forget and stop change it.
statusline/ holds an agnoster-inspired three-row status line: where you are, what you are running, and what you are burning. Point statusLine.command in settings.json at statusline-command.sh.
Nothing machine-specific is committed here. Two files live outside the repo:
| File | Used by | Notes |
|---|---|---|
~/.claude/tailnet-servers.json | tailnet | Server registry: addresses, SSH aliases, default destinations. See servers.example.json for the schema |
~/.claude/settings.json | everything | Permissions, env, enabled plugins, status line wiring |
hooks/register.tsx 463 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type {
5 AgentRun,
6 AgentRunStatus,
7 Checklist,
8 Item,
9 ItemStatus,
10 SidebarRow,
11 SidebarSection,
12 SidebarTone,
13} from '../types'
14
15const TOOL = 'mcp__checklist__checklist'
16const EMPTY: Checklist = { title: '', items: [] }
17const EMPTY_SECTION: SidebarSection = {
18 version: 1,
19 title: 'Checklist',
20 isEmpty: true,
21 attention: 0,
22 rows: [],
23}
24const MAX_AGENTS = 30
25const STATUSES: readonly ItemStatus[] = ['pending', 'in_progress', 'completed']
26
27const MARK: Record<ItemStatus, string> = { pending: '[ ]', in_progress: '[>]', completed: '[x]' }
28const ICON: Record<ItemStatus, string> = { pending: '○', in_progress: '▶', completed: '✔' }
29const ITEM_TONE: Record<ItemStatus, SidebarTone> = {
30 pending: 'muted',
31 in_progress: 'accent',
32 completed: 'done',
33}
34const RUN_ICON: Record<AgentRunStatus, string> = { running: '●', done: '✔', failed: '✗', stopped: '■' }
35const RUN_TONE: Record<AgentRunStatus, SidebarTone> = {
36 running: 'accent',
37 done: 'done',
38 failed: 'error',
39 stopped: 'muted',
40}
41
42const list = atom({ plugin: 'checklist', key: 'list' } as const, EMPTY)
43const agents = atom({ plugin: 'checklist', key: 'agents' } as const, [])
44const section = atom({ plugin: 'checklist', key: 'section' } as const, EMPTY_SECTION)
45
46const GUIDE = [
47 'The person watches a live checklist in the sidebar, fed by the mcp__checklist__checklist tool.',
48 'When the person hands you a plan, or a task will take three or more distinct steps, call it with action "set" and one item per step before you start.',
49 'Mark a step in_progress when you start it and completed as soon as it is done, as you go, never in one batch at the end.',
50 "When you hand a step to a subagent, set that step's agent to the exact description you pass to the Agent tool, and complete the step once its result is in.",
51 'Skip the checklist for questions and one-step tasks.',
52].join(' ')
53
54const DESCRIPTION = [
55 'Shows the person a live checklist of the current plan in the sidebar.',
56 '"set" replaces the list with `items` (one short line per step, in order) and an optional `title`.',
57 '"add" appends `items`.',
58 '"update" changes steps by their 1-based number in `updates`: `status` (pending, in_progress, completed) and `agent`, the exact description given to the Agent tool for the subagent working the step ("" unlinks it).',
59 '"clear" empties the list. Subagents may only use "update".',
60].join(' ')
61
62const INPUT_SCHEMA = {
63 type: 'object',
64 properties: {
65 action: { type: 'string', enum: ['set', 'add', 'update', 'clear'] },
66 title: { type: 'string', description: 'A short name for the plan, shown at the top of its sidebar section.' },
67 items: {
68 type: 'array',
69 items: { type: 'string' },
70 description: 'For set and add: one short line per step, in order.',
71 },
72 updates: {
73 type: 'array',
74 description: 'For update: the steps to change, by their 1-based number.',
75 items: {
76 type: 'object',
77 properties: {
78 item: { type: 'integer', minimum: 1 },
79 status: { type: 'string', enum: STATUSES },
80 agent: { type: 'string' },
81 },
82 required: ['item'],
83 },
84 },
85 },
86 required: ['action'],
87}
88
89type Update = { item: number; status?: ItemStatus; agent?: string }
90
91type Request =
92 | { action: 'set' | 'add'; title?: string; items: string[] }
93 | { action: 'update'; title?: string; updates: Update[] }
94 | { action: 'clear' }
95
96export const register: Register = on => {
97 on('session.start', async ($, e, next) => {
98 await $.tool.register({
99 name: 'checklist',
100 description: DESCRIPTION,
101 inputSchema: INPUT_SCHEMA,
102 isDeferred: false,
103 })
104 showStatus($, await read($, list))
105 await $.command.register({
106 name: 'checklist',
107 description: 'Print the plan checklist, or clear it',
108 argumentHint: '[clear]',
109 })
110 await publish($)
111
112 return next(e)
113 })
114
115 on('command.run', { command: 'checklist' }, async ($, e) => {
116 if (e.args.trim() === 'clear') {
117 await reset($)
118 return { text: 'Checklist cleared.' }
119 }
120
121 return { text: summarize(await read($, list)) }
122 })
123
124 on('prompt.compose', async ($, e, next) => {
125 const composed = await next(e)
126 if (!e.tools.includes(TOOL)) return composed
127
128 return {
129 sections: [...composed.sections, { id: 'checklist:guide', text: GUIDE, scope: 'session' }],
130 }
131 })
132
133 on('tool.call', { tool: TOOL }, async ($, e) => {
134 const request = parse(e as unknown as Record<string, unknown>)
135 if (typeof request === 'string') return { deny: request }
136 if (e.agentId !== undefined && request.action !== 'update') {
137 return { deny: 'Only the main session sets the checklist; a subagent may only update steps.' }
138 }
139
140 let error: string | undefined
141 const current = await change(
142 $,
143 value => {
144 const applied = apply(value, request)
145 if (typeof applied === 'string') {
146 error = applied
147 return value
148 }
149 return applied
150 },
151 request.action === 'set',
152 )
153 if (error !== undefined) return { deny: error }
154 if (request.action === 'set') {
155 await update($, agents, runs => runs.filter(run => run.status === 'running'))
156 await publish($)
157 }
158
159 return { result: summarize(current) }
160 }).catch(() => ({ deny: 'The checklist could not be updated.' }))
161
162 on('tool.call', async ($, e, next) => {
163 if (e.agentId !== undefined) await countTool($, e.agentId)
164 const ran = await next(e)
165 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
166 await mirror($, e, ran)
167 }
168
169 return ran
170 }).catch(($, e, next) => next(e))
171
172 on('agent.spawn', async ($, e, next) => {
173 const spawned = await next(e)
174 const id = spawned.agentId
175 if (spawned.deny === undefined && id !== undefined) {
176 const run: AgentRun = {
177 id,
178 description: e.description,
179 type: e.subagentType,
180 status: 'running',
181 tools: 0,
182 }
183 await update($, agents, runs => [...runs.filter(one => one.id !== id), run].slice(-MAX_AGENTS))
184 await publish($)
185 }
186
187 return spawned
188 }).catch(($, e, next) => next(e))
189
190 on('turn.complete', async ($, e, next) => {
191 const id = e.agentId
192 if (id !== undefined) {
193 const status: AgentRunStatus =
194 e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
195 const runs = await read($, agents)
196 if (runs.some(run => run.id === id)) {
197 await update($, agents, all => all.map(run => (run.id === id ? { ...run, status } : run)))
198 await publish($)
199 }
200 }
201
202 return next(e)
203 })
204
205 on('session.end', async ($, e, next) => {
206 if (e.reason === 'clear') await reset($)
207
208 return next(e)
209 })
210}
211
212function parse(input: Record<string, unknown>): Request | string {
213 const { action, title } = input
214 if (title !== undefined && typeof title !== 'string') return '`title` must be a string.'
215 const named = title === undefined ? {} : { title }
216
217 if (action === 'clear') return { action }
218 if (action === 'set' || action === 'add') {
219 const { items } = input
220 if (!Array.isArray(items) || items.length === 0 || !items.every(item => typeof item === 'string')) {
221 return `"${action}" needs \`items\`: a non-empty list of strings.`
222 }
223 return { action, ...named, items: items.map(item => item.trim()) }
224 }
225 if (action === 'update') {
226 const { updates } = input
227 if (!Array.isArray(updates) || updates.length === 0) {
228 return '"update" needs `updates`: a non-empty list of { item, status?, agent? }.'
229 }
230 const parsed: Update[] = []
231 for (const raw of updates) {
232 if (typeof raw !== 'object' || raw === null) return 'Each update must be an object.'
233 const { item, status, agent } = raw as Record<string, unknown>
234 if (typeof item !== 'number' || !Number.isInteger(item) || item < 1) {
235 return 'Each update needs `item`: the 1-based step number.'
236 }
237 if (status !== undefined && !STATUSES.includes(status as ItemStatus)) {
238 return '`status` must be pending, in_progress or completed.'
239 }
240 if (agent !== undefined && typeof agent !== 'string') return '`agent` must be a string.'
241 parsed.push({
242 item,
243 ...(status === undefined ? {} : { status: status as ItemStatus }),
244 ...(agent === undefined ? {} : { agent }),
245 })
246 }
247 return { action, ...named, updates: parsed }
248 }
249
250 return '`action` must be set, add, update or clear.'
251}
252
253function apply(current: Checklist, request: Request): Checklist | string {
254 if (request.action === 'clear') return EMPTY
255 if (request.action === 'update') return applyUpdates(current, request.updates, request.title)
256 if (request.action === 'set') {
257 return {
258 title: request.title ?? '',
259 items: request.items.map((text, index) => ({ id: `c${index + 1}`, text, status: 'pending' })),
260 }
261 }
262
263 const ids = new Set(current.items.map(item => item.id))
264 let n = current.items.length + 1
265 const added: Item[] = request.items.map(text => {
266 while (ids.has(`c${n}`)) n += 1
267 ids.add(`c${n}`)
268 return { id: `c${n}`, text, status: 'pending' }
269 })
270
271 return { title: request.title ?? current.title, items: [...current.items, ...added] }
272}
273
274function applyUpdates(current: Checklist, updates: Update[], title?: string): Checklist | string {
275 const missing = updates.find(one => one.item > current.items.length)
276 if (missing !== undefined) {
277 return `There is no step ${missing.item}; the list has ${current.items.length}.`
278 }
279 const items = current.items.map((item, index) => {
280 let changed = item
281 for (const one of updates) {
282 if (one.item !== index + 1) continue
283 if (one.status !== undefined) changed = { ...changed, status: one.status }
284 if (one.agent !== undefined) {
285 const { agent: _unlinked, ...rest } = changed
286 changed = one.agent === '' ? rest : { ...rest, agent: one.agent }
287 }
288 }
289 return changed
290 })
291
292 return { title: title ?? current.title, items }
293}
294
295async function change(
296 $: EngineInterface,
297 edit: (current: Checklist) => Checklist,
298 isNewPlan = false,
299): Promise<Checklist> {
300 let wasEmpty = false
301 const current = await update($, list, value => {
302 wasEmpty = value.items.length === 0
303 return edit(value)
304 })
305 showStatus($, current)
306 await publish($, current.items.length > 0 && (wasEmpty || isNewPlan))
307
308 return current
309}
310
311async function reset($: EngineInterface): Promise<void> {
312 await update($, list, () => EMPTY)
313 await update($, agents, () => [])
314 showStatus($, EMPTY)
315 await publish($)
316}
317
318async function publish($: EngineInterface, bump = false): Promise<void> {
319 const current = await read($, list)
320 const runs = await read($, agents)
321 await update($, section, previous => build(current, runs, previous.attention + (bump ? 1 : 0)))
322}
323
324function build(current: Checklist, runs: AgentRun[], attention: number): SidebarSection {
325 const total = current.items.length
326 const done = current.items.filter(item => item.status === 'completed').length
327 const active = current.items.filter(item => item.status === 'in_progress').length
328 const isFinished = total > 0 && done === total
329 const linked = new Set(current.items.flatMap(item => (item.agent ? [item.agent] : [])))
330 const others = runs.filter(run => !linked.has(run.description))
331 const rows: SidebarRow[] = []
332
333 if (total > 0) rows.push({ kind: 'progress', fraction: done / total, label: `${done} of ${total}` })
334 current.items.forEach((item, index) => {
335 const run = item.agent ? runs.filter(one => one.description === item.agent).pop() : undefined
336 rows.push({
337 kind: 'item',
338 icon: ICON[item.status],
339 tone: ITEM_TONE[item.status],
340 number: index + 1,
341 text: item.text,
342 state: item.status === 'completed' ? 'done' : item.status === 'in_progress' ? 'active' : 'normal',
343 ...(item.agent ? { sub: subOf(item.agent, run) } : {}),
344 })
345 })
346 if (others.length > 0) {
347 rows.push({ kind: 'divider', label: 'Subagents' })
348 for (const run of others) {
349 rows.push({
350 kind: 'item',
351 icon: RUN_ICON[run.status],
352 tone: RUN_TONE[run.status],
353 text: `${run.description} ${describeRun(run)}`,
354 state: run.status === 'running' ? 'normal' : 'done',
355 })
356 }
357 }
358 if (total > 0) {
359 rows.push(
360 isFinished
361 ? { kind: 'text', text: `✔ All ${total} steps done`, tone: 'done', bold: true }
362 : { kind: 'text', text: `${done} done · ${active} active · ${total - done - active} to go` },
363 )
364 }
365
366 return {
367 version: 1,
368 title: current.title || 'Checklist',
369 ...(total > 0 ? { badge: `${Math.round((done / total) * 100)}%` } : {}),
370 tone: isFinished ? 'done' : 'accent',
371 isEmpty: total === 0 && runs.length === 0,
372 attention,
373 rows,
374 }
375}
376
377function subOf(agent: string, run: AgentRun | undefined): { icon: string; tone: SidebarTone; text: string } {
378 if (run === undefined) return { icon: '○', tone: 'muted', text: `${agent} · waiting to start` }
379
380 return { icon: RUN_ICON[run.status], tone: RUN_TONE[run.status], text: describeRun(run) }
381}
382
383async function countTool($: EngineInterface, id: string): Promise<void> {
384 const runs = await read($, agents)
385 if (!runs.some(run => run.id === id)) return
386 await update($, agents, all =>
387 all.map(run =>
388 run.id === id ? { ...run, status: 'running' as const, tools: run.tools + 1 } : run,
389 ),
390 )
391 await publish($)
392}
393
394async function mirror($: EngineInterface, e: ToolCallInput, ran: ToolCallResult): Promise<void> {
395 if (e.tool === 'TodoWrite') {
396 const items: Item[] = e.todos.map((todo, index) => ({
397 id: `w${index + 1}`,
398 text: todo.content,
399 status: todo.status,
400 }))
401 await change($, current => ({ title: current.title, items }))
402 } else if (e.tool === 'TaskCreate') {
403 const id = (ran.result as { task?: { id?: unknown } } | undefined)?.task?.id
404 if (typeof id !== 'string') return
405 const item: Item = { id: `t${id}`, text: e.subject, status: 'pending' }
406 await change($, current => ({ ...current, items: [...current.items, item] }))
407 } else if (e.tool === 'TaskUpdate') {
408 if ((ran.result as { success?: unknown } | undefined)?.success !== true) return
409 const id = `t${e.taskId}`
410 const { status, subject } = e
411 await change($, current => ({
412 ...current,
413 items:
414 status === 'deleted'
415 ? current.items.filter(item => item.id !== id)
416 : current.items.map(item =>
417 item.id === id
418 ? {
419 ...item,
420 ...(subject === undefined ? {} : { text: subject }),
421 ...(status === undefined ? {} : { status }),
422 }
423 : item,
424 ),
425 }))
426 }
427}
428
429function showStatus($: EngineInterface, current: Checklist): void {
430 const total = current.items.length
431 if (total === 0) {
432 $.ui.status(undefined)
433 return
434 }
435 const done = current.items.filter(item => item.status === 'completed').length
436 const active = current.items.find(item => item.status === 'in_progress')
437 if (done === total) {
438 $.ui.status(`checklist ${done}/${total} done`)
439 } else {
440 $.ui.status(`checklist ${done}/${total}${active ? `: ${clip(active.text, 40)}` : ''}`)
441 }
442}
443
444function summarize(current: Checklist): string {
445 if (current.items.length === 0) return 'The checklist is empty.'
446 const done = current.items.filter(item => item.status === 'completed').length
447 const head = `${current.title || 'Checklist'}: ${done}/${current.items.length} done`
448 const lines = current.items.map(
449 (item, index) =>
450 `${index + 1}. ${MARK[item.status]} ${item.text}${item.agent ? ` (agent: ${item.agent})` : ''}`,
451 )
452
453 return [head, ...lines].join('\n')
454}
455
456function describeRun(run: AgentRun): string {
457 return `${run.type} · ${run.status} · ${run.tools} tool call${run.tools === 1 ? '' : 's'}`
458}
459
460function clip(text: string, max: number): string {
461 return text.length > max ? `${text.slice(0, max - 1)}…` : text
462}
463types/index.d.ts 54 lines1export type ItemStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Item = {
4 id: string
5 text: string
6 status: ItemStatus
7 /** The description given to the Agent tool for the subagent working this step. */
8 agent?: string
9}
10
11export type Checklist = {
12 title: string
13 items: Item[]
14}
15
16export type AgentRunStatus = 'running' | 'done' | 'failed' | 'stopped'
17
18export type AgentRun = {
19 id: string
20 description: string
21 type: string
22 status: AgentRunStatus
23 tools: number
24}
25
26export type SidebarTone = 'accent' | 'done' | 'muted' | 'error' | 'warn' | 'text'
27
28export type SidebarRow =
29 | { kind: 'progress'; fraction: number; label?: string }
30 | { kind: 'item'; icon: string; tone?: SidebarTone; number?: number; text: string;
31 state?: 'normal' | 'active' | 'done';
32 sub?: { icon?: string; tone?: SidebarTone; text: string };
33 action?: { key: string; label: string } }
34 | { kind: 'input'; key: string; placeholder?: string; hint?: string }
35 | { kind: 'button'; key: string; label: string; primary?: boolean }
36 | { kind: 'text'; text: string; tone?: SidebarTone; bold?: boolean }
37 | { kind: 'divider'; label: string }
38
39export type SidebarSection = {
40 version: 1
41 title: string
42 badge?: string
43 tone?: SidebarTone
44 isEmpty: boolean
45 attention: number
46 rows: SidebarRow[]
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 checklist: { list: Checklist; agents: AgentRun[]; section: SidebarSection }
52 }
53}
54