Question queue: Claude posts a question with a default, Hunter answers it in the sidebar, and the answer reaches Claude as context

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 288 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Question, QuestionAgent, SidebarRow, SidebarSection } from '../types'
5
6const TOOL = 'mcp__questions__ask'
7const SECTION = { plugin: 'questions', key: 'section' } as const
8const INPUT_PREFIX = 'questions:q'
9const MAX_AGENTS = 30
10const RECENT = 5
11const KEEP_ANSWERED = 20
12const CLIP = 60
13
14const items = atom({ plugin: 'questions', key: 'items' } as const, [])
15const agents = atom({ plugin: 'questions', key: 'agents' } as const, [])
16const attention = atom({ plugin: 'questions', key: 'attention' } as const, 0)
17
18const GUIDE = [
19 'Use mcp__questions__ask for a decision that has a sensible default and should not stop the work.',
20 'It takes `question` (one line) and `default`, the choice you go with until the person answers.',
21 'Use AskUserQuestion instead when the work cannot sensibly continue without the answer.',
22 'When an answer arrives as context, redo anything built on the default if the answer differs.',
23].join(' ')
24
25const DESCRIPTION = [
26 'Posts a question for the person to answer from the sidebar without stopping your work.',
27 '`question` is what to ask, in one line.',
28 '`default` is the choice you go with until they answer; their answer arrives as context when they send it.',
29].join(' ')
30
31const INPUT_SCHEMA = {
32 type: 'object',
33 properties: {
34 question: { type: 'string', description: 'What to ask, in one line.' },
35 default: {
36 type: 'string',
37 description: 'The choice you go with until the person answers.',
38 },
39 },
40 required: ['question', 'default'],
41}
42
43type Request = { question: string; default: string }
44
45export const register: Register = on => {
46 on('session.start', async ($, e, next) => {
47 await $.tool.register({
48 name: 'ask',
49 description: DESCRIPTION,
50 inputSchema: INPUT_SCHEMA,
51 isDeferred: false,
52 })
53 await publish($)
54
55 return next(e)
56 })
57
58 on('prompt.compose', async ($, e, next) => {
59 const composed = await next(e)
60 if (!e.tools.includes(TOOL)) return composed
61
62 return {
63 sections: [...composed.sections, { id: 'questions:guide', text: GUIDE, scope: 'session' }],
64 }
65 })
66
67 on('tool.call', async ($, e, next) => {
68 // Only the engine's own call reaches the model; another plugin's $.tool.call would take the answer and drop it.
69 const forModel = next.origin.plugin === 'engine'
70 const ran = await next(e)
71 if (!forModel || e.agentId !== undefined || ran.deny !== undefined) return ran
72
73 const text = await takeUndelivered($)
74 if (text === undefined) return ran
75
76 return { ...ran, context: [...(ran.context ?? []), text] }
77 }).catch(($, e, next) => next(e))
78
79 on('tool.call', { tool: TOOL }, async ($, e) => {
80 const request = parse(e as unknown as Record<string, unknown>)
81 if (typeof request === 'string') return { deny: request }
82
83 const current = await read($, items)
84 const n = current.reduce((most, one) => Math.max(most, one.n), 0) + 1
85 const askedBy = e.agentId === undefined ? undefined : await askerOf($, e.agentId)
86 const question: Question = {
87 id: `q${n}`,
88 n,
89 question: request.question,
90 default: request.default,
91 ...(askedBy === undefined ? {} : { askedBy }),
92 status: 'open',
93 delivered: false,
94 askedAt: Date.now(),
95 }
96 await update($, items, all => [...all, question])
97 await update($, attention, count => count + 1)
98 await publish($)
99 $.ui.toast(`New question: ${clip(question.question, CLIP)}`)
100
101 return {
102 result: `Posted as Q${n}. Going with: ${question.default}. If the person answers, the answer arrives as context; revisit anything built on the default then.`,
103 }
104 }).catch(() => ({ deny: 'The question could not be posted.' }))
105
106 on('prompt.submit', async ($, e, next) => {
107 const fresh = await undelivered($)
108 if (fresh.length === 0) return next(e)
109
110 const result = await next({ ...e, context: [...(e.context ?? []), render(fresh)] })
111 if (result.drop === undefined) await markDelivered($, fresh)
112
113 return result
114 }).catch(($, e, next) => next(e))
115
116 on('ui.input', { plugin: 'sidebar' }, async ($, e, next) => {
117 if (e.kind !== 'submit' || !e.element.startsWith(INPUT_PREFIX)) return next(e)
118 const text = e.value.trim()
119 if (text === '') return next(e)
120
121 const id = e.element.slice('questions:'.length)
122 const at = Date.now()
123 await update($, items, all =>
124 prune(
125 all.map(one =>
126 one.id === id
127 ? { ...one, status: 'answered' as const, answer: text, answeredAt: at, delivered: false }
128 : one,
129 ),
130 ),
131 )
132 await publish($)
133
134 return next(e)
135 }).catch(($, e, next) => next(e))
136
137 on('agent.spawn', async ($, e, next) => {
138 const spawned = await next(e)
139 const id = spawned.agentId
140 if (spawned.deny === undefined && id !== undefined) {
141 const run: QuestionAgent = { id, description: e.description }
142 await update($, agents, runs => [...runs.filter(one => one.id !== id), run].slice(-MAX_AGENTS))
143 }
144
145 return spawned
146 }).catch(($, e, next) => next(e))
147
148 on('turn.complete', async ($, e, next) => {
149 if (e.agentId === undefined) {
150 const open = (await read($, items)).filter(one => one.status === 'open').length
151 if (open > 0) $.ui.toast(`${open} ${open === 1 ? 'question' : 'questions'} still open`)
152 }
153
154 return next(e)
155 }).catch(($, e, next) => next(e))
156
157 on('session.end', async ($, e, next) => {
158 if (e.reason === 'clear') await reset($)
159
160 return next(e)
161 })
162}
163
164function parse(input: Record<string, unknown>): Request | string {
165 const { question, default: fallback } = input
166 if (typeof question !== 'string' || question.trim() === '') {
167 return '"ask" needs `question`: a non-empty string.'
168 }
169 if (typeof fallback !== 'string' || fallback.trim() === '') {
170 return '"ask" needs `default`: the choice you go with, a non-empty string.'
171 }
172
173 return { question: question.trim(), default: fallback.trim() }
174}
175
176async function askerOf($: EngineInterface, agentId: string): Promise<string> {
177 const runs = await read($, agents)
178
179 return runs.find(run => run.id === agentId)?.description ?? 'a subagent'
180}
181
182// Picks and marks in one update, so parallel calls cannot both take the same answer.
183async function takeUndelivered($: EngineInterface): Promise<string | undefined> {
184 let fresh: Question[] = []
185 await update($, items, all => {
186 fresh = all.filter(one => one.status === 'answered' && !one.delivered)
187 const ids = new Set(fresh.map(one => one.id))
188 return all.map(one => (ids.has(one.id) ? { ...one, delivered: true } : one))
189 })
190
191 return fresh.length === 0 ? undefined : render(fresh)
192}
193
194async function undelivered($: EngineInterface): Promise<Question[]> {
195 return (await read($, items)).filter(one => one.status === 'answered' && !one.delivered)
196}
197
198async function markDelivered($: EngineInterface, fresh: readonly Question[]): Promise<void> {
199 const ids = new Set(fresh.map(one => one.id))
200 await update($, items, all => all.map(one => (ids.has(one.id) ? { ...one, delivered: true } : one)))
201}
202
203function render(fresh: readonly Question[]): string {
204 return fresh
205 .map(
206 one =>
207 `The person answered Q${one.n} "${one.question}" (you went with "${one.default}"): ${one.answer ?? ''}. Redo anything built on the default if the answer differs.`,
208 )
209 .join('\n')
210}
211
212// Keeps at most KEEP_ANSWERED answered questions, dropping the oldest. The newest question is never
213// dropped, so the next question number cannot repeat one the model has already seen.
214function prune(all: readonly Question[]): Question[] {
215 const answered = all.filter(one => one.status === 'answered')
216 const excess = answered.length - KEEP_ANSWERED
217 if (excess <= 0) return [...all]
218
219 const newest = all.reduce((most, one) => Math.max(most, one.n), 0)
220 const drop = new Set(
221 answered
222 .filter(one => one.n !== newest)
223 .sort((a, b) => (a.answeredAt ?? 0) - (b.answeredAt ?? 0))
224 .slice(0, excess)
225 .map(one => one.id),
226 )
227
228 return all.filter(one => !drop.has(one.id))
229}
230
231async function publish($: EngineInterface): Promise<void> {
232 const all = await read($, items)
233 const count = await read($, attention)
234 await $.state.set(SECTION, build(all, count))
235}
236
237async function reset($: EngineInterface): Promise<void> {
238 await update($, items, () => [])
239 await update($, agents, () => [])
240 await publish($)
241}
242
243function build(all: readonly Question[], count: number): SidebarSection {
244 const open = all.filter(one => one.status === 'open')
245 const answered = all
246 .filter(one => one.status === 'answered')
247 .sort((a, b) => (b.answeredAt ?? 0) - (a.answeredAt ?? 0))
248 .slice(0, RECENT)
249
250 const rows: SidebarRow[] = []
251 for (const one of open) {
252 rows.push({
253 kind: 'item',
254 icon: '?',
255 tone: 'accent',
256 number: one.n,
257 text: one.question,
258 ...(one.askedBy === undefined ? {} : { sub: { text: `asked by ${one.askedBy}` } }),
259 })
260 rows.push({ kind: 'input', key: `questions:${one.id}`, placeholder: 'answer', hint: `going with: ${one.default}` })
261 }
262 if (answered.length > 0) {
263 rows.push({ kind: 'divider', label: 'Answered' })
264 for (const one of answered) {
265 rows.push({
266 kind: 'item',
267 icon: '✓',
268 tone: 'done',
269 state: 'done',
270 text: `Q${one.n} → ${one.answer ?? ''}`,
271 })
272 }
273 }
274
275 return {
276 version: 1,
277 title: 'Questions',
278 badge: `${open.length} open`,
279 isEmpty: all.length === 0,
280 attention: count,
281 rows,
282 }
283}
284
285function clip(text: string, max: number): string {
286 return text.length > max ? `${text.slice(0, max - 1)}…` : text
287}
288types/index.d.ts 55 lines1export type SidebarTone = 'accent' | 'done' | 'muted' | 'error' | 'warn' | 'text'
2
3export type SidebarRow =
4 | { kind: 'progress'; fraction: number; label?: string }
5 | { kind: 'item'; icon: string; tone?: SidebarTone; number?: number; text: string;
6 state?: 'normal' | 'active' | 'done';
7 sub?: { icon?: string; tone?: SidebarTone; text: string };
8 action?: { key: string; label: string } }
9 | { kind: 'input'; key: string; placeholder?: string; hint?: string }
10 | { kind: 'button'; key: string; label: string; primary?: boolean }
11 | { kind: 'text'; text: string; tone?: SidebarTone; bold?: boolean }
12 | { kind: 'divider'; label: string }
13
14export type SidebarSection = {
15 version: 1
16 title: string
17 badge?: string
18 tone?: SidebarTone
19 isEmpty: boolean
20 attention: number
21 rows: SidebarRow[]
22}
23
24export type Question = {
25 /** `q<n>`: the key suffix the sidebar's Input is drawn with. */
26 id: string
27 n: number
28 question: string
29 default: string
30 /** The subagent's description when the question came from a subagent. */
31 askedBy?: string
32 status: 'open' | 'answered'
33 answer?: string
34 /** When the answer was submitted; orders the Answered list. */
35 answeredAt?: number
36 delivered: boolean
37 askedAt: number
38}
39
40export type QuestionAgent = {
41 id: string
42 description: string
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 questions: {
48 items: Question[]
49 agents: QuestionAgent[]
50 attention: number
51 section: SidebarSection
52 }
53 }
54}
55