SLOPSHOPPER

Pinboard

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

newpanerowsguardcommandprompt
★ 5v0.1.0MITupdated 2026-10-07sirkitree/pinboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pinboard
│ ┃ Pinboard ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Open decisions ⏺ Read(src/auth.ts) │ ┃ No open decisions. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Todos ⎿ Added 2 lines, removed 1 line │ ┃ No todos yet. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Links │ ┃ Nothing created yet. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pinboard │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pinboard
Open decisions No open decisions. Todos No todos yet. Links l: clear Nothing created yet.
README

Pinboard

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.

Version Claude Code mod Claude Code 2.1.287+ Catalogue License: MIT

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

What it pins

  • Open decisions: questions Claude needs you to answer. They stay pinned until Claude closes them after you answer.
  • Todos: the session's task list, one action per item. The one Claude is working on is marked ▸ 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.
  • Links: URLs from actions that make something (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.

How Claude updates it

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.

How it opens

  • The pane opens by itself the first time something lands on an empty board.
  • Opened that way, Claude Code only seats it in a terminal at least 144 columns wide (110 once you've opened it yourself). Below that, run /pinboard.
  • /pinboard opens it at any width, even while Claude is working. Ctrl+X then X closes it.

Install

claude plugin marketplace add sirkitree/pinboard
claude plugin install pinboard@pinboard

Develop

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.

Source 2 files
hooks/register.tsx 242 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Pin, Todo } from '../types'
5
6const PANE = 'pinboard'
7const TITLE = 'Pinboard'
8const TOOL = 'mcp__pinboard__update'
9
10const decisions = atom({ plugin: 'pinboard', key: 'decisions' } as const, [] as Decision[])
11const todos = atom({ plugin: 'pinboard', key: 'todos' } as const, [] as Todo[])
12const links = atom({ plugin: 'pinboard', key: 'links' } as const, [] as Pin[])
13
14const DESCRIPTION = [
15  "Keep the session's task list and open decisions on the user's Pinboard, a sidebar that stays in view while the transcript scrolls.",
16  '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.',
17  'Use it in place of writing task lists in your reply whenever the work takes 3+ distinct steps or the user gives new instructions.',
18  '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.',
19  '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.',
20  'If blocked or partly done, leave it in progress and add a follow-up todo describing the blocker.',
21  'open_decisions: questions that need the user to choose. decide: close a decision by id once the user has answered.',
22  'The current board, with ids, is at the end of your system prompt.',
23].join(' ')
24
25const strings = { type: 'array', items: { type: 'string' } }
26const SCHEMA = {
27  type: 'object',
28  properties: {
29    add_todos: strings,
30    start_todo: { type: 'string' },
31    done_todos: strings,
32    remove_todos: strings,
33    open_decisions: strings,
34    decide: {
35      type: 'array',
36      items: { type: 'object', properties: { id: { type: 'string' }, answer: { type: 'string' } }, required: ['id', 'answer'] },
37    },
38  },
39}
40
41export type Update = {
42  add_todos?: string[]
43  start_todo?: string
44  done_todos?: string[]
45  remove_todos?: string[]
46  open_decisions?: string[]
47  decide?: { id: string; answer: string }[]
48}
49
50type Board = { todos: Todo[]; decisions: Decision[] }
51
52// The next id for a prefix: one past the highest in use
53const nextId = (prefix: string, ids: string[]) =>
54  prefix + (Math.max(0, ...ids.map(id => Number(id.slice(prefix.length)) || 0)) + 1)
55
56export function applyUpdate(board: Board, change: Update): Board {
57  let { todos: t, decisions: d } = board
58  for (const text of change.add_todos ?? []) t = [...t, { id: nextId('t', t.map(x => x.id)), text, isDone: false }]
59  for (const text of change.open_decisions ?? []) d = [...d, { id: nextId('d', d.map(x => x.id)), text }]
60  const done = new Set(change.done_todos ?? [])
61  const removed = new Set(change.remove_todos ?? [])
62  const decided = new Set((change.decide ?? []).map(x => x.id))
63  t = t.filter(x => !removed.has(x.id)).map(x => (done.has(x.id) ? { ...x, isDone: true } : x))
64  // One todo in progress at a time; finishing it ends its turn too
65  if (change.start_todo) t = t.map(x => ({ ...x, isActive: x.id === change.start_todo }))
66  t = t.map(x => (x.isDone && x.isActive ? { ...x, isActive: false } : x))
67  d = d.filter(x => !decided.has(x.id))
68  return { todos: t, decisions: d }
69}
70
71export function describeBoard(board: Board): string {
72  if (board.todos.length + board.decisions.length === 0) return 'Pinboard is empty.'
73  return [
74    'Pinboard now:',
75    ...board.todos.map(t => `${t.id} [${t.isDone ? 'x' : t.isActive ? '>' : ' '}] ${t.text}`),
76    ...board.decisions.map(d => `${d.id} [?] ${d.text}`),
77  ].join('\n')
78}
79
80// A reply asks the user something when one of its last lines, outside code, ends in a question mark
81export function asksUser(reply: string): boolean {
82  const prose = reply.replace(/```[\s\S]*?```/g, '')
83  const lines = prose.split('\n').map(l => l.trim()).filter(Boolean).slice(-3)
84  return lines.some(l => /\?[*_`)"'\]]*$/.test(l))
85}
86
87const NUDGE =
88  'Your reply ends on a question for the user, but the Pinboard has no open decision. ' +
89  'Call mcp__pinboard__update with open_decisions for it (close it with decide once answered), then end your turn. ' +
90  'If it was rhetorical, end your turn as is.'
91
92const MAKES_COMMAND = /\bgh\s+(?:(?:pr|issue|release|repo|gist)\s+create|(?:pr|issue)\s+comment)\b|\bgit\s+push\b/
93const MAKES_MCP = /^mcp__.*(?:create|draft|send|publish|share|canvas|upload)/i
94
95const URL = /https:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[A-Za-z0-9\-._~:/?#[\]!$&'()*+,;=%@]*)?/g
96
97export const urlPins = (text: string): Pin[] =>
98  [...new Set([...text.matchAll(URL)].map(m => m[0].replace(/[)\].,;:'!?*]+$/, '')))]
99    .filter(href => !href.includes('@') && href.length <= 2048)
100    .map(href => {
101      const gh = /github\.com\/[^/]+\/([^/]+)\/(pull|issues)\/(\d+)/.exec(href)
102      return { href, label: gh ? `${gh[1]} ${gh[2] === 'pull' ? 'PR' : 'issue'} #${gh[3]}` : href.slice(8) }
103    })
104
105const isEmpty = async ($: EngineInterface) =>
106  (await read($, decisions)).length + (await read($, todos)).length + (await read($, links)).length === 0
107
108// Runs a capture; opens the pane when it puts the first thing on an empty board
109async function capture($: EngineInterface, change: () => Promise<unknown>): Promise<void> {
110  const wasEmpty = await isEmpty($)
111  await change()
112  if (wasEmpty && !(await isEmpty($))) await $.ui.open({ id: PANE, title: TITLE })
113}
114
115export const register: Register = on => {
116  on('session.start', async ($, e, next) => {
117    await $.command.register({ name: 'pinboard', description: 'Open the pane of open decisions, todos and links', immediate: true })
118    await $.tool.register({ name: 'update', description: DESCRIPTION, inputSchema: SCHEMA })
119    // Todos parsed from replies by older versions have no id; the tool can't reach them
120    await update($, todos, old => old.filter(t => typeof t.id === 'string'))
121    return next(e)
122  })
123
124  on('command.run', { command: 'pinboard' }, async $ => {
125    await $.ui.open({ id: PANE, title: TITLE })
126    return {}
127  })
128
129  // The board rides at the end of the system prompt, so it never has to be repeated in replies
130  on('prompt.compose', async ($, e, next) => {
131    const { sections } = await next(e)
132    const board = describeBoard({ todos: await read($, todos), decisions: await read($, decisions) })
133    return { sections: [...sections, { id: 'pinboard:board', text: board, scope: 'session' }] }
134  })
135
136  on('tool.call', { tool: TOOL }, async ($, e) => {
137    if (e.agentId) return { deny: 'Only the main conversation updates the Pinboard.' }
138    let board: Board = { todos: [], decisions: [] }
139    await capture($, async () => {
140      board = applyUpdate({ todos: await read($, todos), decisions: await read($, decisions) }, e as Update)
141      await update($, todos, () => board.todos)
142      await update($, decisions, () => board.decisions)
143    })
144    return { result: describeBoard(board) }
145  })
146
147  // A question left only in the reply scrolls away; send Claude back once to pin it
148  on('classic.Stop', async ($, e, next) => {
149    const ran = await next(e)
150    if (ran.block || e.stop_hook_active || !asksUser(e.last_assistant_message ?? '')) return ran
151    if ((await read($, decisions)).length > 0) return ran
152    return { ...ran, block: NUDGE }
153  })
154
155  // Links only from actions that make something; reads, fetches and test output just mention URLs
156  on('tool.call', async ($, e, next) => {
157    const ran = await next(e)
158    const makes = e.tool === 'Bash' ? MAKES_COMMAND.test(e.command) : MAKES_MCP.test(e.tool)
159    if (e.agentId || !makes || !('text' in ran) || ran.isError) return ran
160    const found = urlPins(ran.text ?? '')
161    if (found.length > 0 && found.length <= 3) {
162      await capture($, () => update($, links, old => [...found, ...old.filter(p => !found.some(f => f.href === p.href))].slice(0, 12)))
163    }
164    return ran
165  })
166
167  // An update is one dim line in the transcript; the board itself is in the pane
168  on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e) => {
169    const { Text } = $.ui.resolve(e)
170    const change = (e.props.input ?? {}) as Update
171    const parts = [
172      change.add_todos?.length && `+${change.add_todos.length} todo`,
173      change.start_todo && `started ${change.start_todo}`,
174      change.done_todos?.length && `${change.done_todos.length} done`,
175      change.remove_todos?.length && `-${change.remove_todos.length} todo`,
176      change.open_decisions?.length && `+${change.open_decisions.length} decision`,
177      change.decide?.length && `${change.decide.length} decided`,
178    ].filter(Boolean)
179    return <Text dimColor>{'Pinboard: ' + (parts.join(', ') || 'no change')}</Text>
180  })
181
182  on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) =>
183    e.props.isErrored ? next(e) : $.ui.resolve(e).Text({ children: [''] }),
184  )
185
186  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
187    const { Box, Text, Button, Link } = $.ui.resolve(e)
188    // One cell of padding on every side
189    const inner = Math.max(10, e.props.bodyColumns - 2)
190    const allDecisions = await read($, decisions)
191    const allTodos = await read($, todos)
192    const allLinks = await read($, links)
193    const doneCount = allTodos.filter(t => t.isDone).length
194
195    const header = (title: string, count: string) => (
196      <Text bold>
197        {title} <Text dimColor>{count}</Text>
198      </Text>
199    )
200    const empty = (text: string) => <Text dimColor>  {text}</Text>
201    // The bullet stays in its own column, so wrapped lines indent under the text
202    const item = (bullet: string, text: string, isDim = false, color?: string) => (
203      <Box flexDirection="row" width={inner}>
204        <Text dimColor={isDim} color={color}>{'  ' + bullet + ' '}</Text>
205        <Box flexShrink={1} flexGrow={1}>
206          <Text dimColor={isDim} color={color} wrap="wrap">
207            {text}
208          </Text>
209        </Box>
210      </Box>
211    )
212
213    return (
214      <Box flexDirection="column" width={inner + 2} padding={1}>
215        {header('Open decisions', allDecisions.length ? String(allDecisions.length) : '')}
216        {allDecisions.length === 0 && empty('No open decisions.')}
217        {allDecisions.map(d => item('?', d.text))}
218        <Text> </Text>
219
220        {header('Todos', allTodos.length ? `${doneCount}/${allTodos.length}` : '')}
221        {allTodos.length === 0 && empty('No todos yet.')}
222        {allTodos.filter(t => !t.isDone).map(t => (t.isActive ? item('▸', t.text, false, 'warning') : item('○', t.text)))}
223        {/* Finished todos fold into one line so open work stays on top */}
224        {doneCount > 0 && <Text dimColor>{`  ✓ ${doneCount} done`}</Text>}
225        <Text> </Text>
226
227        <Box flexDirection="row" justifyContent="space-between" width={inner}>
228          {header('Links', allLinks.length ? String(allLinks.length) : '')}
229          <Button key="clear-links" label="clear" hotkey="l" plain dimColor onPress={() => update($, links, () => [])} />
230        </Box>
231        {allLinks.length === 0 && empty('Nothing created yet.')}
232        {allLinks.map(p => (
233          <Text wrap="truncate-middle">
234            {'  '}
235            <Link href={p.href} label={p.label} />
236          </Text>
237        ))}
238      </Box>
239    )
240  })
241}
242
types/index.d.ts 16 lines
1export 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    }
14  }
15}
16