SLOPSHOPPER

pin-board

Pins standing notes with /pin that Claude keeps following after /compact and /clear; shows them in one row above the prompt or as a counter, and keeps them per…

newbandspinnercommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pin-board
› 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 › /pin ⎿ pin-board: No pins. Add one with /pin <note> [--keep]. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pin-board

Pins standing notes with /pin. Claude keeps following them after /compact and /clear, and you see them above the prompt, or as a counter under it. Pins you keep come back after a restart.

<img src="../docs/pin-board.png" alt="Two pins in one row above the prompt" width="542">

Use

  • /pin <note>: pins a note.
  • /pin <note> --keep: pins a note and keeps it for this folder.
  • /pin: lists the pins.
  • /unpin <n> or /unpin all: removes pins. The rest are renumbered.

A pin lasts until you quit Claude Code. A kept pin is stored for the folder you started Claude Code in, and comes back the next time you start it there; a toast then says how many came back, so a forgotten one does not steer Claude unseen.

Where they show

All pins share one row above the prompt, numbered for /unpin: 📌 1 use pnpm · 2 no force push. A long row is cut at the edge; /pin lists them in full.

If that band is already busy, set Where pins show to counter in /config. Then only the number shows, at the right end of the line under the prompt: 📌 2 pins.

<img src="../docs/pin-board-counter.png" alt="The pin counter at the right end of the line under the prompt" width="698">

How Claude gets them

  • When you pin or unpin, the command leaves Claude a note at once, so the change counts from your next message.
  • The whole list is also part of the system prompt. Claude Code builds the system prompt again at the start of a session, after /compact and after /clear, so the pins are there each time without being sent with every message.

Limits

  • Between two rebuilds of the system prompt, it can still list a pin you removed. The unpin note tells Claude that the later change wins.
  • Changing Where pins show reloads the mod, which drops pins that are not kept.
  • Pins are plain instructions to the model, not rules the engine enforces. For a hard rule, use a hook such as guardrails.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install pin-board@claude-code-mods

Restart Claude Code.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh pin-board
Source 1 files
hooks/register.tsx 100 lines
1import type { CommandRunResult, EngineInterface, Register } from 'claude-code'
2
3export type Pin = { text: string; keep?: true }
4
5export function parsePin(args: string): Pin | undefined {
6  const words = args.split(/\s+/).filter(Boolean)
7  const text = words.filter(w => w !== '--keep').join(' ')
8  if (!text) return undefined
9  return words.includes('--keep') ? { text, keep: true } : { text }
10}
11
12export const listText = (pins: readonly Pin[]) => pins.map((p, i) => `${i + 1}. ${p.text}${p.keep ? ' (kept)' : ''}`).join('\n')
13
14export const bandText = (pins: readonly Pin[]) => `📌 ${pins.map((p, i) => `${i + 1} ${p.text}`).join(' · ')}`
15
16export const counterText = (n: number) => `📌 ${n} ${n === 1 ? 'pin' : 'pins'}`
17
18// Later /pin and /unpin notes in the conversation can be newer than this list: the system prompt is only rendered again at start, /compact and /clear.
19export const section = (pins: readonly Pin[]) =>
20  `The user pinned these standing notes. Follow them until the user unpins them. If a later /pin or /unpin message in the conversation disagrees with this list, the later message wins.\n${listText(pins)}`
21
22// Pins live as long as the process, so they outlast /clear; --keep ones are also stored for the folder and come back on the next start.
23let cwd = ''
24let pins: Pin[] = []
25
26async function save($: EngineInterface, next: Pin[]) {
27  pins = next
28  await $.store.set(cwd, pins.filter(p => p.keep))
29  $.ui.invalidate('ui.render')
30}
31
32async function pin($: EngineInterface, args: string): Promise<CommandRunResult> {
33  if (!args.trim()) return { text: pins.length ? listText(pins) : 'No pins. Add one with /pin <note> [--keep].' }
34  const added = parsePin(args)
35  if (!added) return { text: 'Usage: /pin <note> [--keep]' }
36  await save($, [...pins, added])
37  const n = pins.length
38  return {
39    text: `Pinned ${n}: ${added.text}${added.keep ? ' (kept)' : ''}`,
40    context: [`The user pinned standing note ${n}: ${added.text}\nFollow it until the user unpins it.`],
41  }
42}
43
44async function unpin($: EngineInterface, args: string): Promise<CommandRunResult> {
45  const arg = args.trim()
46  if (arg === 'all') {
47    if (!pins.length) return { text: 'No pins.' }
48    await save($, [])
49    return { text: 'Unpinned all.', context: ['The user removed all pinned notes. Stop following them.'] }
50  }
51  const n = Number(arg)
52  const gone = pins[n - 1]
53  if (!Number.isInteger(n) || !gone) return { text: pins.length ? `Usage: /unpin <1-${pins.length}|all>\n${listText(pins)}` : 'No pins.' }
54  await save($, pins.filter((_, i) => i !== n - 1))
55  return {
56    text: `Unpinned ${n}: ${gone.text}`,
57    context: [`The user removed pinned note ${n}: ${gone.text}\nStop following it. The remaining pins are renumbered:\n${listText(pins) || '(none)'}`],
58  }
59}
60
61export const register: Register = (on, options) => {
62  const display = options.display === 'counter' ? 'counter' : 'band'
63
64  on('session.start', async ($, e, next) => {
65    cwd = e.cwd
66    const stored = await $.store.get(cwd)
67    pins = Array.isArray(stored) ? (stored as Pin[]) : []
68    if (pins.length) $.ui.toast(`📌 ${pins.length} kept ${pins.length === 1 ? 'pin' : 'pins'} · /pin to list`, { timeoutMs: 6000 })
69    await $.command.register({ name: 'pin', description: 'Pin a standing note the model keeps following; no note lists the pins', argumentHint: '[note] [--keep]' })
70    await $.command.register({ name: 'unpin', description: 'Remove a pinned note', argumentHint: '<n|all>' })
71    return next(e)
72  })
73
74  on('command.run', { command: 'pin' }, ($, e) => pin($, e.args))
75  on('command.run', { command: 'unpin' }, ($, e) => unpin($, e.args))
76
77  on('prompt.compose', async ($, e, next) => {
78    const result = await next(e)
79    if (!pins.length) return result
80    return { sections: [...result.sections, { id: 'pin-board:pins', text: section(pins), scope: 'session' }] }
81  })
82
83  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
84    // The band is shared with other plugins: keep what they drew beneath, and take one row however many pins there are.
85    const below = await next(e)
86    if (display !== 'band' || e.props.hasSurvey || !pins.length) return below
87    const { Box, Text } = $.ui.resolve(e)
88    return (
89      <Box flexDirection="column">
90        {below}
91        <Text wrap="truncate-end">{bandText(pins)}</Text>
92      </Box>
93    )
94  })
95
96  on('ui.render', { component: 'SessionMode' }, ($, e, next) =>
97    display === 'counter' && pins.length ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, counterText(pins.length)] } }) : next(e),
98  )
99}
100