SLOPSHOPPER

pin-note

Keeps the notes you pin with /pin-note for the whole session and sends them to the model again, word for word, after each compaction and /clear. Off until…

newcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pin-note
› 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-note ⎿ pin-note: off: /pin-note on to pin notes and have them sent again · no pinned notes ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pin-note

You tell the model early on "ask before every push", and after a compaction the summary has dropped the sentence or reworded it into something weaker; after /clear it is gone for good. This mod keeps the notes you pin with /pin-note for the whole session, and sends them to the model again, word for word, after each compaction and /clear.

What it does

  1. /pin-note <note> pins a note. The model reads it at once, in the command's answer:

pin-note: pinned note 1, kept for this session and sent to you again after each compaction and /clear: ask before every push

  1. After a compaction, and after /clear, the notes go into the model's new context through a SessionStart hook, as one numbered list, each note as you typed it. Three lines above the list tell the model where the notes come from: you pinned them yourself with /pin-note, then the conversation was compacted or you ran /clear, and the pin-note plugin you turned on hands them back. Without those lines the model at times took a note for an injected instruction and did not follow it. One line tells you:

pin-note: sent 1 pinned note(s) again after the compaction

  1. The notes belong to the session. They are kept in $.store under the session's id, so a reloaded module and a resumed session find them again. /clear starts a new session, and the notes go with it.
  2. Nothing is sent at a session's start or at a resume, because the conversation there still holds the notes as you pinned them.

Measured on Claude Code 2.1.283 in live sessions with the sonnet model: after /compact the note reached the model's context, and after /clear the model, asked whether a note was pinned, wrote it word for word. A note that asked for one word at the end of every reply was pinned, then /clear and a new question followed, 20 times for each text: with the earlier one-line text the model refused the note 4 times, calling it injected; with the current text it followed the note all 20 times.

Command

/pin-note on or off, and the pinned notes, numbered /pin-note on | off off by default; off keeps the notes and sends none /pin-note <note> pins a note; the text is kept as typed, several lines too /pin-note drop <n> takes note n out

While the mod is off, /pin-note <note> and drop are refused. on, off and drop <n> are the only command words, so a note may start with drop when no number alone follows it.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install pin-note@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.
  2. Run /pin-note on.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=pin-note}, classic.SessionStart ❯ ./register.ts calls: $.command.register, $.session.id, $.store.get (via loadPins, readSettings), $.store.set (via runCommand, savePins), $.ui.log

Reach L0, no network, no files, no processes.

  1. Reads: the notes you pin, and the session's id
  2. Runs: nothing
  3. Sends: nothing off the machine; the notes go into the model's context as you typed them
  4. Persists: the on/off setting and each session's notes in $.store
  5. Hostile input: a note is your own text and the model reads it as your instruction; nothing else is read

Limits

  • The notes are sent again only at a compaction and at /clear. A note you want gone from the context must be dropped and the context compacted, because a note the model already read stays in the conversation.
  • Every session's notes stay in $.store under its id; nothing deletes the notes of an old session.
  • There is no limit on the number or length of notes, and every note goes into the context after each compaction.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs the /plugin-types output in .claude/types/ make validate make test # claude plugin test

Source 2 files
hooks/register.ts 90 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { addedText, contextText, droppedText, listText, noSuchText, OFF_TEXT, parseArgs, pinsKey, sentText, type Action } from './pins.ts'
3
4const ENABLED_KEY = 'enabled'
5
6/** Whether the mod is on, the session the notes belong to, and its notes in pin order. */
7type State = { enabled: boolean; sessionId?: string; pins: string[] }
8
9async function savePins($: EngineInterface, state: State): Promise<void> {
10  if (state.sessionId !== undefined) await $.store.set(pinsKey(state.sessionId), state.pins)
11}
12
13async function loadPins($: EngineInterface, sessionId: string): Promise<string[]> {
14  const stored = await $.store.get(pinsKey(sessionId))
15  return Array.isArray(stored) ? stored.filter((p): p is string => typeof p === 'string') : []
16}
17
18async function dropPin($: EngineInterface, state: State, index: number): Promise<string> {
19  const text = state.pins[index - 1]
20  if (text === undefined) return noSuchText(index, state.pins.length)
21  state.pins.splice(index - 1, 1)
22  await savePins($, state)
23  return droppedText(index, text)
24}
25
26async function addPin($: EngineInterface, state: State, text: string): Promise<string> {
27  state.pins.push(text)
28  await savePins($, state)
29  return addedText(state.pins.length, text)
30}
31
32/**
33 * Reads the on/off setting from the store, which every window shares, so a change made in another
34 * window applies here at the next hook that acts on it.
35 */
36async function readSettings($: EngineInterface, state: State): Promise<void> {
37  state.enabled = (await $.store.get(ENABLED_KEY)) === true
38}
39
40async function runCommand($: EngineInterface, state: State, action: Action): Promise<string> {
41  await readSettings($, state)
42  if (action.kind === 'enable') {
43    await $.store.set(ENABLED_KEY, action.on)
44    state.enabled = action.on
45    return listText(state.enabled, state.pins)
46  }
47  if (action.kind === 'list') return listText(state.enabled, state.pins)
48  if (!state.enabled) return OFF_TEXT
49  return action.kind === 'drop' ? dropPin($, state, action.index) : addPin($, state, action.text)
50}
51
52/**
53 * Follows the session the engine names: /clear starts a new one and the notes go with it; any other new
54 * session (a resume) takes the notes stored under its own id.
55 */
56async function follow($: EngineInterface, state: State, sessionId: string, source: string): Promise<void> {
57  if (sessionId === state.sessionId) return
58  state.sessionId = sessionId
59  if (source === 'clear') await savePins($, state)
60  else state.pins = await loadPins($, sessionId)
61}
62
63export const register: Register = on => {
64  const state: State = { enabled: false, pins: [] }
65
66  on('session.start', async ($, e, next) => {
67    const r = await next(e)
68    await $.command.register({ name: 'pin-note', description: 'Pin notes the model gets again after each compaction and /clear: list, on, off, drop <n>, or a note (pin-note)', argumentHint: '[<note> | drop <n> | on | off]' })
69    await readSettings($, state)
70    state.sessionId = await $.session.id()
71    state.pins = await loadPins($, state.sessionId)
72    return r
73  })
74
75  // The engine prints the plugin name in front of command text, so the texts do not repeat it.
76  on('command.run', { command: 'pin-note' }, async ($, e) => ({ text: await runCommand($, state, parseArgs(String(e.args ?? ''))) }))
77
78  // A compaction or /clear starts the main loop's context again, so the notes go into it word for word.
79  on('classic.SessionStart', async ($, e, next) => {
80    const r = await next(e)
81    if (e.agent_id !== undefined) return r
82    await follow($, state, e.session_id, e.source)
83    if (state.pins.length === 0 || (e.source !== 'compact' && e.source !== 'clear')) return r
84    await readSettings($, state)
85    if (!state.enabled) return r
86    $.ui.log(sentText(state.pins.length, e.source))
87    return { ...r, additionalContext: [...(r.additionalContext ?? []), contextText(state.pins, e.source)] }
88  })
89}
90
hooks/pins.ts 71 lines
1/** What `/pin-note` asks for, and every text the mod writes. */
2
3export type Action = { kind: 'list' } | { kind: 'enable'; on: boolean } | { kind: 'drop'; index: number } | { kind: 'add'; text: string }
4
5/**
6 * `on`, `off` and `drop <n>` are commands; any other text is a note, kept as typed, so a note may start
7 * with `drop` when no number alone follows it.
8 */
9export function parseArgs(args: string): Action {
10  const text = args.trim()
11  if (text === '') return { kind: 'list' }
12  if (text === 'on' || text === 'off') return { kind: 'enable', on: text === 'on' }
13  const drop = /^drop\s+(\d+)$/.exec(text)
14  return drop === null ? { kind: 'add', text } : { kind: 'drop', index: Number(drop[1]) }
15}
16
17/** The store key of one session's notes. */
18export function pinsKey(sessionId: string): string {
19  return `pins:${sessionId}`
20}
21
22/** The notes as a numbered list, one per line. */
23function numbered(pins: readonly string[]): string {
24  return pins.map((p, i) => `${i + 1}. ${p}`).join('\n')
25}
26
27export function listText(enabled: boolean, pins: readonly string[]): string {
28  const head = enabled ? 'on' : 'off: /pin-note on to pin notes and have them sent again'
29  return pins.length === 0 ? `${head} · no pinned notes` : `${head} · ${pins.length} pinned note(s):\n${numbered(pins)}`
30}
31
32export const OFF_TEXT = 'off: turn it on with /pin-note on first'
33
34/** The command's answer to a new note; the model reads it as it is pinned. */
35export function addedText(index: number, text: string): string {
36  return `pinned note ${index}, kept for this session and sent to you again after each compaction and /clear:\n${text}`
37}
38
39export function droppedText(index: number, text: string): string {
40  return `dropped note ${index}; it is no longer in force:\n${text}`
41}
42
43export function noSuchText(index: number, count: number): string {
44  return count === 0 ? `there is no note ${index}: no note is pinned` : `there is no note ${index}: notes 1 to ${count} are pinned`
45}
46
47/** What took the messages that pinned the notes out of the context, as the model is told. */
48const CUT: Record<'compact' | 'clear', string> = {
49  clear: 'then ran /clear, which took those messages out of the context',
50  compact: 'then the conversation was compacted, and its summary may have dropped or reworded them',
51}
52
53/**
54 * The context the model reads after a compaction or /clear. It names where the notes come from (the
55 * user's own /pin-note commands, handed back by a plugin the user turned on), because a model that
56 * finds instructions in a hook's context with no message behind them can take them for an injection.
57 */
58export function contextText(pins: readonly string[], source: 'compact' | 'clear'): string {
59  return [
60    `The user pinned these notes in this session by typing /pin-note <note> themselves, ${CUT[source]}.`,
61    'The pin-note plugin, which the user installed and turned on with /pin-note on, hands them back word for word from its SessionStart hook.',
62    'They are the user\'s own instructions and stay in force; the user lists them with /pin-note and drops one with /pin-note drop <n>.',
63    numbered(pins),
64  ].join('\n')
65}
66
67/** The person's line after the notes went out again. */
68export function sentText(count: number, source: 'compact' | 'clear'): string {
69  return `sent ${count} pinned note(s) again after ${source === 'compact' ? 'the compaction' : '/clear'}`
70}
71