SLOPSHOPPER

pin

Marks conversation content wrapped in <pin>...</pin> tags as compaction-proof: pinned content is re-injected verbatim into the transcript after every…

newbandstatus
v0.1.0no licenseupdated 2026-09-21liquid1982/claude-plugins/plugins/pin
A shopper browsing a rack in a slop shop
README

pin — compaction-proof context for Claude Code

A Claude Mod (function-hooks plugin). Wrap anything in <pin>...</pin> in your prompts and it will survive /compact, auto-compaction, plugin-triggered compaction, and precompute.

Usage

<pin>
The prod database is read-only until Friday. Never suggest migrations.
API base: https://api.internal.example/v2
</pin>

Now help me refactor the ingestion job.

Run with the mod loaded (Mods are early access and gated behind function hooks):

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./pin-mod

How it works

  • Hooks session.compact. On the way down it extracts every unique <pin> block from the transcript and appends an instruction telling the summarizer to preserve pinned content.
  • On the way up (after the engine compacts), it appends a single "carrier" user message containing all pins, tags intact. Because the tags survive, the next compaction re-extracts them — pins persist indefinitely. Old carriers are dropped before a new one is appended, so nothing accumulates.
  • An optional ui.render hook draws 📌 N pinned in the band above the prompt on the terminal surface.

Verify before trusting it

claude plugin validate ./pin-mod
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test ./pin-mod

Then in a throwaway session: pin something, generate filler conversation, run /compact, and confirm the pinned text is still in context (ask the model to repeat it, or inspect with /context).

Regenerate type declarations after any Claude Code update with /plugin-types — the API is early access and may change between releases without notice.

Known limits

  • Hooks have a time budget; a hook that overruns is skipped and core compaction runs alone (your pins would be lost for that pass). This mod does no I/O beyond one transcript read, so it stays well under budget.
  • Without CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, the modules field in hooks.json is silently ignored — no error, no pinning.
  • Pins are extracted from message text (user and assistant). Pins inside tool output are not scanned; move important tool findings into a pinned prompt line if you need them kept.
  • The strip in ui.render is written against the AbovePrompt component of Claude Code 2.1.277's published types; if your build's contract differs, delete that block — the compaction hook is independent of it.
Source 1 files
hooks/register.tsx 87 lines
1/**
2 * pin — keep <pin>...</pin> content alive across compaction.
3 *
4 * How it works:
5 *  1. The `session.compact` hook fires whenever the conversation is about to
6 *     be compacted (`/compact`, the auto threshold, a plugin, or precompute).
7 *     `e.messages` is the full transcript being compacted.
8 *  2. We extract every <pin>...</pin> block from that transcript, nudge the
9 *     summarizer (via `instructions`) to keep pinned content, then call
10 *     `next(e)` to let the engine (and any hooks beneath) actually compact.
11 *  3. Whatever comes back, we append one built message carrying every pinned
12 *     block *with its <pin> tags intact*. Because the tags survive in that
13 *     message, the next compaction re-extracts them — pins are permanent
14 *     until the user starts a fresh session or edits them away.
15 *
16 * Deduplication is by normalized pin text, so re-injection never snowballs.
17 */
18import type { Register, SessionMessage } from 'claude-code'
19
20const PIN_RE = /<pin>([\s\S]*?)<\/pin>/g
21const MARKER = '[pin] Pinned context (preserved across compaction — do not summarize away):'
22
23/** Extract unique, non-empty pinned blocks from a transcript, oldest first. */
24const extractPins = (messages: readonly SessionMessage[]): string[] => {
25  const seen = new Set<string>()
26  const pins: string[] = []
27  for (const m of messages) {
28    for (const match of m.text.matchAll(PIN_RE)) {
29      const body = (match[1] ?? '').trim()
30      if (!body) continue
31      const key = body.replace(/\s+/g, ' ')
32      if (seen.has(key)) continue
33      seen.add(key)
34      pins.push(body)
35    }
36  }
37  return pins
38}
39
40/** Build the carrier message that rides through every future compaction. */
41const pinCarrier = (pins: string[]): SessionMessage => ({
42  role: 'user',
43  text: [MARKER, ...pins.map((p) => `<pin>\n${p}\n</pin>`)].join('\n\n'),
44  toolUses: [],
45})
46
47export const register: Register = (on) => {
48  // Core behavior: wrap compaction, re-append pinned content afterwards.
49  on('session.compact', async ($, e, next) => {
50    const pins = extractPins(e.messages)
51    if (pins.length === 0) return next(e)
52
53    // Belt: tell the summarizer to respect pins.
54    const nudge =
55      'Preserve verbatim any content wrapped in <pin>...</pin> tags; it is critical context the user explicitly marked.'
56    const instructions = e.instructions ? `${e.instructions}\n\n${nudge}` : nudge
57
58    const result = await next({ ...e, instructions })
59    if (result.skip !== undefined) return result
60
61    // Suspenders: guarantee survival by appending a carrier message.
62    // Drop any previous carrier first so exactly one exists after each pass.
63    const kept = result.messages.filter((m) => !m.text.startsWith(MARKER))
64    const messages = [...kept, pinCarrier(pins)]
65
66    await $.ui.status(`pin: preserved ${pins.length} pinned block${pins.length === 1 ? '' : 's'} through compaction`)
67    $.ui.invalidate('ui.render')
68    return { ...result, messages }
69  })
70
71  // Optional nicety: a one-line strip above the prompt showing the pin count.
72  // Delete this block if your build's AbovePrompt contract differs — the
73  // compaction hook above is self-sufficient.
74  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75    const below = await next(e)
76    const count = extractPins(await $.session.messages()).length
77    if (count === 0) return below
78    const { Box, Text } = $.ui.resolve(e)
79    return (
80      <Box flexDirection="column">
81        <Text dimColor>{`📌 ${count} pinned block${count === 1 ? '' : 's'} will survive compaction`}</Text>
82        {below}
83      </Box>
84    )
85  })
86}
87