Keeps open decisions, the task list and links Claude creates in a sidebar pane, updated through its own tool

What needs you, pinned where you can see it.<br> Open decisions, the task list and the links Claude creates stay in a pane beside the conversation, so they don't scroll out of view.
<img src="docs/board-in-progress.png" alt="Pinboard pane beside a Claude Code session, with one open decision and two of four todos done" width="820">
The story of how it came together: Pinboard: a Claude Code mod.
▸ in the warning color (one at a time), open items show ○, and finished items fold into a single dim ✓ N done line so open work stays on top.gh pr|issue|release|repo|gist create, gh pr|issue comment, git push, and MCP tools that create, draft, send, publish, share or upload), newest first, up to 12. GitHub PRs and issues get short labels such as repo PR #12. Press l (or the clear button) to empty the list.Everything is session state: /clear and /resume start an empty board.
Pinboard registers a tool, mcp__pinboard__update, that Claude calls to add todos, start one, check them off or remove them by id, open decisions, and close them. Its description carries a few working rules borrowed from opencode's todowrite: update in real time, mark a todo done only after the work (and any verification) is actually done, and when blocked, leave it in progress and add a follow-up todo for the blocker. Each call shows as one dim line in the transcript (Pinboard: +2 todo, 1 decided), so lists don't have to be repeated in replies.
The current board, with ids, is added to the end of the system prompt on every request, so Claude always knows what's open. Pinboard doesn't depend on the TodoWrite or Task tools, which newer models don't get by default.
Questions are the easiest thing to leave in a reply, where they scroll away. When Claude finishes a turn whose last lines end in a question mark (code blocks aside) while the board has no open decision, Pinboard's Stop hook sends it back once to pin the question with open_decisions. It only nudges once per stop, so a rhetorical question can still end the turn.
/pinboard./pinboard opens it at any width, even while Claude is working. /pinboard close or Ctrl+X then X closes it.claude plugin marketplace add sirkitree/pinboard
claude plugin install pinboard@pinboard
claude plugin validate --strict .
claude plugin test .
claude --plugin-dir .
Built on the mods API as of Claude Code 2.1.288. That API is in early access and changes between releases.
hooks/register.tsx 405 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Decision, Pin, Todo } from '../types'
5
6const PANE = 'pinboard'
7const TITLE = 'Pinboard'
8// Docked width; matches Flightdeck's so the dock doesn't jump between panes
9const PANE_COLUMNS = 66
10const TOOL = 'mcp__pinboard__update'
11
12const decisions = atom({ plugin: 'pinboard', key: 'decisions' } as const, [] as Decision[])
13const todos = atom({ plugin: 'pinboard', key: 'todos' } as const, [] as Todo[])
14const links = atom({ plugin: 'pinboard', key: 'links' } as const, [] as Pin[])
15const paneOpen = atom({ plugin: 'pinboard', key: 'paneOpen' } as const, false)
16
17const DESCRIPTION = [
18 "Keep the session's task list and open decisions on the user's Pinboard, a sidebar that stays in view while the transcript scrolls.",
19 'Any question you end a reply on that needs the user to answer goes in open_decisions, however small the task, even a single yes/no.',
20 'Use it in place of writing task lists or decision lists in your reply, whenever the work takes 2+ distinct steps or the user gives new instructions.',
21 'add_todos: one action per item. start_todo: the todo id you are working on now; exactly one is in progress at a time. done_todos / remove_todos: todo ids.',
22 'Update in real time; do not batch completions. Mark a todo done only after the work is actually done, including any verification it needs, never based on intent.',
23 'If blocked or partly done, leave it in progress and add a follow-up todo describing the blocker.',
24 'open_decisions: questions that need the user to choose. decide: close a decision by id once the user has answered.',
25 'The current board, with ids, is at the end of your system prompt.',
26 "It is session-only: work that must outlive the session goes to a durable tracker (kindex task_add) instead.",
27].join(' ')
28
29// Pinboard is the person's task tracker; this rides in the system prompt beside the board
30export const GUIDE = [
31 "Pinboard is the user's task tracker (a pane beside the transcript); keep it current instead of listing tasks in replies.",
32 `For any job with 2+ steps, add the todos with ${TOOL} before starting work (load it with ToolSearch first if it is deferred).`,
33 'start_todo as each one begins; done_todos as each one finishes, after verifying it.',
34 'Put questions that need the user in open_decisions.',
35 "Pinboard holds only this session's steps and questions (/clear empties it). Kindex task_add is for work that must outlive the session (follow-ups, deferred items), never for the steps of the current job.",
36].join('\n')
37
38// Theme colour names (Flightdeck's palette), so light, dark and colour-blind themes all work
39const C = {
40 main: 'claude',
41 agent: 'suggestion',
42 gate: 'success',
43 amber: 'warning',
44 dim: 'inactive',
45 faint: 'subtle',
46} as const
47
48/** A gauge of `width` cells: ▰ filled, ▱ empty. */
49const gauge = (done: number, total: number, width: number) => {
50 const full = total ? Math.max(0, Math.min(width, Math.round((done / total) * width))) : 0
51 return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
52}
53
54/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
55const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
56 const out: T[] = []
57 let used = 0
58 for (const it of items) {
59 const w = it.label.length + 4
60 if (used + w > width) break
61 out.push(it)
62 used += w
63 }
64 return out
65}
66
67const strings = { type: 'array', items: { type: 'string' } }
68const SCHEMA = {
69 type: 'object',
70 properties: {
71 add_todos: strings,
72 start_todo: { type: 'string' },
73 done_todos: strings,
74 remove_todos: strings,
75 open_decisions: strings,
76 decide: {
77 type: 'array',
78 items: { type: 'object', properties: { id: { type: 'string' }, answer: { type: 'string' } }, required: ['id', 'answer'] },
79 },
80 },
81}
82
83export type Update = {
84 add_todos?: string[]
85 start_todo?: string
86 done_todos?: string[]
87 remove_todos?: string[]
88 open_decisions?: string[]
89 decide?: { id: string; answer: string }[]
90}
91
92type Board = { todos: Todo[]; decisions: Decision[] }
93
94// The next id for a prefix: one past the highest in use
95const nextId = (prefix: string, ids: string[]) =>
96 prefix + (Math.max(0, ...ids.map(id => Number(id.slice(prefix.length)) || 0)) + 1)
97
98export function applyUpdate(board: Board, change: Update): Board {
99 let { todos: t, decisions: d } = board
100 for (const text of change.add_todos ?? []) t = [...t, { id: nextId('t', t.map(x => x.id)), text, isDone: false }]
101 for (const text of change.open_decisions ?? []) d = [...d, { id: nextId('d', d.map(x => x.id)), text }]
102 const done = new Set(change.done_todos ?? [])
103 const removed = new Set(change.remove_todos ?? [])
104 const decided = new Set((change.decide ?? []).map(x => x.id))
105 t = t.filter(x => !removed.has(x.id)).map(x => (done.has(x.id) ? { ...x, isDone: true } : x))
106 // One todo in progress at a time; finishing it ends its turn too
107 if (change.start_todo) t = t.map(x => ({ ...x, isActive: x.id === change.start_todo }))
108 t = t.map(x => (x.isDone && x.isActive ? { ...x, isActive: false } : x))
109 d = d.filter(x => !decided.has(x.id))
110 return { todos: t, decisions: d }
111}
112
113export function describeBoard(board: Board): string {
114 if (board.todos.length + board.decisions.length === 0) return 'Pinboard is empty.'
115 return [
116 'Pinboard now:',
117 ...board.todos.map(t => `${t.id} [${t.isDone ? 'x' : t.isActive ? '>' : ' '}] ${t.text}`),
118 ...board.decisions.map(d => `${d.id} [?] ${d.text}`),
119 ].join('\n')
120}
121
122// A reply asks the user something when one of its last lines, outside code, ends in a question mark
123export function asksUser(reply: string): boolean {
124 const prose = reply.replace(/```[\s\S]*?```/g, '')
125 const lines = prose.split('\n').map(l => l.trim()).filter(Boolean).slice(-3)
126 return lines.some(l => /\?[*_`)"'\]]*$/.test(l))
127}
128
129const NUDGE =
130 'Your reply ends on a question for the user, but the Pinboard has no open decision. ' +
131 'Call mcp__pinboard__update with open_decisions for it (close it with decide once answered), then end your turn. ' +
132 'If it was rhetorical, end your turn as is.'
133
134const MAKES_COMMAND = /\bgh\s+(?:(?:pr|issue|release|repo|gist)\s+create|(?:pr|issue)\s+comment)\b|\bgit\s+push\b/
135const MAKES_MCP = /^mcp__.*(?:create|draft|send|publish|share|canvas|upload)/i
136
137const URL = /https:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[A-Za-z0-9\-._~:/?#[\]!$&'()*+,;=%@]*)?/g
138
139export const urlPins = (text: string): Pin[] =>
140 [...new Set([...text.matchAll(URL)].map(m => m[0].replace(/[)\].,;:'!?*]+$/, '')))]
141 .filter(href => !href.includes('@') && href.length <= 2048)
142 .map(href => {
143 const gh = /github\.com\/[^/]+\/([^/]+)\/(pull|issues)\/(\d+)/.exec(href)
144 return { href, label: gh ? `${gh[1]} ${gh[2] === 'pull' ? 'PR' : 'issue'} #${gh[3]}` : href.slice(8) }
145 })
146
147const isEmpty = async ($: EngineInterface) =>
148 (await read($, decisions)).length + (await read($, todos)).length + (await read($, links)).length === 0
149
150// Runs a capture; opens the pane when it puts the first thing on an empty board
151async function capture($: EngineInterface, change: () => Promise<unknown>): Promise<void> {
152 const wasEmpty = await isEmpty($)
153 await change()
154 if (wasEmpty && !(await isEmpty($))) await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
155}
156
157export const register: Register = on => {
158 on('session.start', async ($, e, next) => {
159 await $.command.register({
160 name: 'pinboard',
161 description: 'Open the pane of open decisions, todos and links; `close` hides it',
162 argumentHint: '[close]',
163 immediate: true,
164 })
165 await $.tool.register({ name: 'update', description: DESCRIPTION, inputSchema: SCHEMA })
166 // Todos parsed from replies by older versions have no id; the tool can't reach them
167 await update($, todos, old => old.filter(t => typeof t.id === 'string'))
168 // A reload keeps the pane up but starts the module over: re-read whether it is open
169 const isUp = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
170 await update($, paneOpen, () => isUp)
171 // No open on launch: only Flightdeck opens unasked, so Pinboard's tab lands second when its first item does
172 return next(e)
173 })
174
175 on('command.run', { command: 'pinboard' }, async ($, e) => {
176 if (/^(close|hide)$/i.test(e.args.trim())) {
177 await $.ui.close({ id: PANE })
178 return {}
179 }
180 await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
181 return {}
182 })
183
184 // Mod Tools' Show column reads paneOpen; the engine lists a plugin's panes only to that plugin
185 on('ui.open', { id: PANE }, async ($, e, next) => {
186 const opened = await next(e)
187 await update($, paneOpen, () => true)
188 return opened
189 }).catch(($, e, next) => next(e))
190
191 on('ui.close', { id: PANE }, async ($, e, next) => {
192 const closed = await next(e)
193 await update($, paneOpen, () => false)
194 return closed
195 }).catch(($, e, next) => next(e))
196
197 // The board rides at the end of the system prompt, so it never has to be repeated in replies
198 on('prompt.compose', async ($, e, next) => {
199 const { sections } = await next(e)
200 const board = describeBoard({ todos: await read($, todos), decisions: await read($, decisions) })
201 return {
202 sections: [
203 ...sections,
204 { id: 'pinboard:guide', text: GUIDE, scope: 'session' },
205 { id: 'pinboard:board', text: board, scope: 'session' },
206 ],
207 }
208 })
209
210 on('tool.call', { tool: TOOL }, async ($, e) => {
211 if (e.agentId) return { deny: 'Only the main conversation updates the Pinboard.' }
212 let board: Board = { todos: [], decisions: [] }
213 await capture($, async () => {
214 board = applyUpdate({ todos: await read($, todos), decisions: await read($, decisions) }, e as Update)
215 await update($, todos, () => board.todos)
216 await update($, decisions, () => board.decisions)
217 })
218 return { result: describeBoard(board) }
219 })
220
221 // A question left only in the reply scrolls away; send Claude back once to pin it
222 on('classic.Stop', async ($, e, next) => {
223 const ran = await next(e)
224 if (ran.block || e.stop_hook_active || !asksUser(e.last_assistant_message ?? '')) return ran
225 if ((await read($, decisions)).length > 0) return ran
226 return { ...ran, block: NUDGE }
227 })
228
229 // Links only from actions that make something; reads, fetches and test output just mention URLs
230 on('tool.call', async ($, e, next) => {
231 const ran = await next(e)
232 const makes = e.tool === 'Bash' ? MAKES_COMMAND.test(e.command) : MAKES_MCP.test(e.tool)
233 if (e.agentId || !makes || !('text' in ran) || ran.isError) return ran
234 const found = urlPins(ran.text ?? '')
235 if (found.length > 0 && found.length <= 3) {
236 await capture($, () => update($, links, old => [...found, ...old.filter(p => !found.some(f => f.href === p.href))].slice(0, 12)))
237 }
238 return ran
239 })
240
241 // An update is one dim line in the transcript; the board itself is in the pane
242 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e) => {
243 const { Text } = $.ui.resolve(e)
244 const change = (e.props.input ?? {}) as Update
245 const parts = [
246 change.add_todos?.length && `+${change.add_todos.length} todo`,
247 change.start_todo && `started ${change.start_todo}`,
248 change.done_todos?.length && `${change.done_todos.length} done`,
249 change.remove_todos?.length && `-${change.remove_todos.length} todo`,
250 change.open_decisions?.length && `+${change.open_decisions.length} decision`,
251 change.decide?.length && `${change.decide.length} decided`,
252 ].filter(Boolean)
253 return <Text dimColor>{'Pinboard: ' + (parts.join(', ') || 'no change')}</Text>
254 })
255
256 on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) =>
257 e.props.isErrored ? next(e) : $.ui.resolve(e).Text({ children: [''] }),
258 )
259
260 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
261 const { Box, Text, Button, Link } = $.ui.resolve(e)
262 const W = Math.max(40, e.props.bodyColumns)
263 // A card's text area: the width less its border and one cell of padding each side
264 const inner = W - 4
265 const allDecisions = await read($, decisions)
266 const allTodos = await read($, todos)
267 const allLinks = await read($, links)
268 const doneCount = allTodos.filter(t => t.isDone).length
269 const openCount = allTodos.length - doneCount
270
271 // A card's first row: the upper-case label and subtitle in its accent, a dim state on the right
272 const header = (accent: Color, label: string, subtitle: string, right: RenderChildren) => (
273 <Box justifyContent="space-between" width={inner}>
274 <Text color={accent} bold wrap="truncate">
275 {`${label.toUpperCase()} · ${subtitle}`}
276 </Text>
277 {right}
278 </Box>
279 )
280 // The glyph stays in its own column, so wrapped lines indent under the text
281 const item = (glyph: RenderChildren, text: string, props: { color?: Color; bold?: boolean; dimColor?: boolean } = {}) => (
282 <Box flexDirection="row" width={inner}>
283 <Box width={2} flexShrink={0}>
284 {glyph}
285 </Box>
286 <Box flexShrink={1} flexGrow={1}>
287 <Text {...props} wrap="wrap">
288 {text}
289 </Text>
290 </Box>
291 </Box>
292 )
293 const rule = <Text color={C.faint}>{'─'.repeat(W)}</Text>
294
295 const g = gauge(doneCount, allTodos.length, 8)
296 const todoCard = (
297 <Box flexDirection="column" borderStyle="round" borderColor={C.main} paddingX={1} width={W}>
298 {header(
299 C.main,
300 'todos',
301 `${openCount} open`,
302 <Text>
303 <Text color={C.main}>{g.on}</Text>
304 <Text color={C.faint}>{g.off}</Text>
305 <Text dimColor>{` ${doneCount}/${allTodos.length}`}</Text>
306 </Text>,
307 )}
308 {allTodos
309 .filter(t => !t.isDone)
310 .map(t =>
311 t.isActive
312 ? item(<Text color={C.main} bold>{'▶ '}</Text>, t.text, { color: C.main, bold: true })
313 : item(<Text dimColor>{'○ '}</Text>, t.text),
314 )}
315 {/* Finished todos fold into one line so open work stays on top */}
316 {doneCount > 0 && (
317 <Text>
318 <Text color={C.gate}>{'✓ '}</Text>
319 <Text dimColor>{`${doneCount} done`}</Text>
320 </Text>
321 )}
322 </Box>
323 )
324
325 const decisionCard = (
326 <Box flexDirection="column" borderStyle="round" borderColor={C.amber} paddingX={1} width={W}>
327 {header(C.amber, 'decisions', 'needs you', <Text dimColor>{`${allDecisions.length} open`}</Text>)}
328 {allDecisions.map(d => item(<Text color={C.amber} bold>{'? '}</Text>, d.text))}
329 </Box>
330 )
331
332 const linkCard = (
333 <Box flexDirection="column" borderStyle="round" borderColor={C.agent} paddingX={1} width={W}>
334 {header(
335 C.agent,
336 'links',
337 'created',
338 <Box columnGap={1}>
339 <Text dimColor>{String(allLinks.length)}</Text>
340 <Button key="clear-links" label="clear" hotkey="l" plain dimColor onPress={() => update($, links, () => [])} />
341 </Box>,
342 )}
343 {allLinks.map(p => (
344 <Text wrap="truncate-middle">
345 <Link href={p.href} label={p.label} />
346 </Text>
347 ))}
348 </Box>
349 )
350
351 const emptyCard = (
352 <Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1} width={W}>
353 <Text dimColor>pinboard</Text>
354 <Text color={C.faint}>nothing yet</Text>
355 </Box>
356 )
357
358 // Only sections with something in them; an empty board keeps one faint card
359 const cards = [allTodos.length > 0 && todoCard, allDecisions.length > 0 && decisionCard, allLinks.length > 0 && linkCard].filter(
360 Boolean,
361 )
362
363 const legend = fitLegend(
364 [
365 { label: 'todos', color: C.main },
366 { label: 'decisions', color: C.amber },
367 { label: 'links', color: C.agent },
368 ],
369 W,
370 )
371
372 return (
373 <Box flexDirection="column" width={W}>
374 <Box justifyContent="center">
375 <Text bold wrap="truncate">
376 <Text>PINBOARD</Text>
377 <Text color={C.dim}> · </Text>
378 <Text color={C.main}>{String(openCount)}</Text>
379 <Text> TODO</Text>
380 <Text color={C.dim}> · </Text>
381 <Text color={C.amber}>{String(allDecisions.length)}</Text>
382 <Text>{allDecisions.length === 1 ? ' DECISION' : ' DECISIONS'}</Text>
383 </Text>
384 </Box>
385 <Box justifyContent="center" columnGap={2}>
386 {legend.map(l => (
387 <Text>
388 <Text color={l.color}>■</Text>
389 <Text dimColor>{` ${l.label}`}</Text>
390 </Text>
391 ))}
392 </Box>
393 {cards.length === 0
394 ? emptyCard
395 : cards.map((card, i) => (
396 <Box flexDirection="column">
397 {i > 0 && rule}
398 {card}
399 </Box>
400 ))}
401 </Box>
402 )
403 })
404}
405types/index.d.ts 18 lines1export type Todo = { id: string; text: string; isDone: boolean; isActive?: boolean }
2
3export type Decision = { id: string; text: string }
4
5export type Pin = { href: string; label: string }
6
7declare module 'claude-code' {
8 interface PluginState {
9 pinboard: {
10 decisions: Decision[]
11 todos: Todo[]
12 links: Pin[]
13 /** Whether the pane is open; Mod Tools reads it for its Show column. */
14 paneOpen: boolean
15 }
16 }
17}
18