SLOPSHOPPER

agarzon

Personal Claude Code skills and workflow extensions.

newbandguardtoasttoolprocess
v0.11.0MITupdated 2026-10-06agarzon/claude-plugins/plugins/agarzon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agarzon
› 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 ⧗ cache 60m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⧗ cache 60m
README

claude-plugins

Alexander Garzon's personal Claude Code plugin marketplace. A public GitHub repo that doubles as a CC marketplace, distributing custom skills, hooks, themes and output styles (and later commands/agents/MCP) across all machines via Claude Code's native autoUpdate.

Install

claude plugin marketplace add agarzon/claude-plugins
claude plugin install agarzon@agarzon-plugins

Set autoUpdate: true for the agarzon-plugins entry in ~/.claude/plugins/known_marketplaces.json so machines pull new skills on the next session.

Add a skill, hook, mod, theme, or output style

  1. Drop plugins/agarzon/skills/<name>/SKILL.md (or output-styles/<name>.md, or edit hooks/hooks.json, or the mod in hooks/register.tsx; check a mod with claude plugin validate plugins/agarzon and claude plugin test plugins/agarzon).
  2. Bump version in plugins/agarzon/.claude-plugin/plugin.json.
  3. Commit and push. Machines with autoUpdate pull it on the next session.

Step 2 is not optional — without a version bump nothing propagates.

Contents

  • handoff / wrap (skills) — save pending work to HANDOFF.md and continue in a fresh session, or close the day. See below.
  • challenge (skill, /challenge only) — questions a plan or decision in rounds until every choice is settled, then posts the agreed plan and the decisions worth recording. Builds nothing.
  • what (skill, /what only) — the last answer did not land: says it again with context, in Simplified Technical English. /what <part> re-explains only that part.
  • Session mod (hooks/register.tsx) — prompt-cache countdown and warning, output-style switcher, context-fill nudge, handoff automation, and the list of loaded skills. See below.
  • claude-mem sync (hooks + scripts) — keeps claude-mem memory in step across machines. See below.
  • ELI5 (output style) — small words, short answers, for a fried brain. Selectable as agarzon:ELI5 — plugin styles are namespaced plugin:style, and bare ELI5 resolves to nothing. Pick it in the /config panel, or set "outputStyle": "agarzon:ELI5" in settings.json. Note that the inline /config outputStyle= completion only offers the five built-ins, so this style never appears there. Files in output-styles/ are picked up by convention; no plugin.json key needed.

handoff and wrap

HANDOFF.md, at the repo root and kept out of git through .git/info/exclude, is the to-do list that carries work between sessions. It holds only pending work: each item is removed when done and the file is deleted when empty.

  • /handoff writes or merges the file, then calls the mod's handoff_ready tool. When the turn ends the mod runs /rename <name>, /clear, /rename <name>-2, and sends the next session "Read HANDOFF.md and continue". Claude Code carries a session's name across /clear, so the second rename keeps the two sessions apart in history.
  • /wrap does the same file plus the end-of-day chores (commits, artifacts to delete, memory and vault updates, approved in one batch), renames the session and stops.

Session mod

A mod is a plugin hooks module: hooks/hooks.json lists it under modules, next to the classic command hooks. The row it draws above the prompt:

⧗ cache 42m │ [ Concise ] │ ctx 62% → /handoff
loaded: ponytail·hook 1.3k  superpowers·hook 3.3k  plugin-authoring 4.9k ×2
  • Cache countdown. The prompt cache lives 1 h (transcripts show only ephemeral_1h writes). The clock restarts when a main-loop turn that made an API call completes; subagent turns and local commands do not count. At 10 min left: a toast and a sound (powershell.exe on WSL, afplay on macOS). Past zero the next message re-caches the whole context at 2x input price.
  • Output style. The button cycles the /config outputStyle row. It offers only the built-in styles, so agarzon:ELI5 is not in the rotation.
  • Context nudge. At 60 % fill, a toast and the ctx marker suggest /handoff.
  • Loaded list. Every skill body and every hook-injected block in context now, read from the transcript so it survives --resume. Skills cyan, hooks magenta, red ×N when a body is in context more than once. That happens across a restart or --resume: Claude Code's "already loaded" dedupe lives in process memory, so a re-invocation injects the whole body again, and a typed /skill re-injects every time. Only /compact or a fresh session removes the copies.

The mod API is early access and changes between releases; claude plugin validate reports anything the running build would refuse.

allowed-tools in a skill's frontmatter is a command-only key; adding it to a SKILL.md makes the skill fail to load with Execute skill: <name>.

claude-mem sync

claude-mem stores its memory in a local SQLite database per machine, so each machine accumulates its own history and none of them ever see each other's. These hooks close that gap without a server.

FileRole
hooks/hooks.jsonSessionStart → import peers · Stop → publish own new rows
scripts/mem-sync.shthe hook entry point: export \import
scripts/mem-export.shDB → /api/import payload. --since <epoch> for incremental
scripts/mem-import.shchunked, ordered, resumable import. --dry-run does an FK check without sending

A resumed session keeps its content_session_id but gets a new memory_session_id, while sdk_sessions is unique on content_session_id — so a peer's version of a session you already hold is dropped as a duplicate and its summaries then fail the foreign key, jamming that peer's import permanently. mem-import.sh rewrites incoming session ids to the local ones before posting. Each side relinks on the way in, so the two machines disagreeing about the label is harmless. --dry-run cannot catch this: its FK check is payload-internal.

Data travels as JSON in ~/General/claude-mem-sync/<device>.json over Syncthing — never git, this repo is public. Set CLAUDE_MEM_SYNC_DIR to point elsewhere.

One writer per file is what makes this safe: a machine only ever writes its own <device>.json, so no two machines touch the same file and .sync-conflict-* cannot happen. Device name comes from CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME, else claude-mem's settings, else hostname -s.

Export reads SQLite directly rather than claude-mem's read API, which caps out near 200 rows and rewrites session ids such that its own output fails the foreign key on re-import. Import goes through the worker's POST /api/import so dedupe, transactions and FTS triggers stay the vendor's problem.

Sync never blocks a session: every path exits 0 and problems go to ~/.claude-mem/logs/mem-sync.log.

Accepted ceilings. Append-only — deletions and title/project edits do not propagate. Identical work done on two machines survives twice, because dedupe keys on session id and those differ per machine. Only one summary per session survives an import. Embeddings never sync; Chroma is local per machine.

One-time consolidation

To seed machines that have been drifting apart, bypass the hooks and merge snapshots by hand:

mem-export.sh --db <snapshot>.db --out peer.json
mem-import.sh peer.json --dry-run   # expect 0 orphans
mem-import.sh peer.json

Take snapshots with sqlite3 <db> ".backup <out>" — the database is WAL-mode with a live writer, so cp can capture a torn state.

Then seed the watermark on each machine so the first Stop hook publishes only new work instead of re-shipping the history the machines already share:

date +%s000 > ~/.claude-mem/mem-sync.watermark

Skip this and the first export publishes every row the machine holds — correct, but a needlessly large first sync that every peer then re-imports and skips.

See docs/design.md for the full design and rationale.

Source 2 files
hooks/register.tsx 271 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CacheAlert, HandoffMode, LedgerItem, PendingHandoff } from '../types'
5
6const TTL_MS = 60 * 60 * 1000
7const WARN_MS = 10 * 60 * 1000
8const TICK_MS = 15 * 1000
9const NUDGE_PERCENT = 60
10const HANDOFF_TOOL = 'mcp__agarzon__handoff_ready'
11
12const lastAt = atom({ plugin: 'agarzon', key: 'cacheLastAt' } as const, null)
13const leftMin = atom({ plugin: 'agarzon', key: 'cacheLeftMin' } as const, null)
14const alert = atom({ plugin: 'agarzon', key: 'cacheAlert' } as const, 'none' as CacheAlert)
15const isBusy = atom({ plugin: 'agarzon', key: 'isBusy' } as const, false)
16const style = atom({ plugin: 'agarzon', key: 'style' } as const, null)
17const contextNudge = atom({ plugin: 'agarzon', key: 'contextNudge' } as const, null)
18const pendingHandoff = atom({ plugin: 'agarzon', key: 'pendingHandoff' } as const, null)
19const transcriptDir = atom({ plugin: 'agarzon', key: 'transcriptDir' } as const, null)
20const ledger = atom({ plugin: 'agarzon', key: 'ledger' } as const, [] as LedgerItem[])
21
22const WSL_CHIME = ['powershell.exe', '-NoProfile', '-Command', "(New-Object Media.SoundPlayer 'C:\\Windows\\Media\\Windows Exclamation.wav').PlaySync()"]
23const MAC_CHIME = ['afplay', '/System/Library/Sounds/Glass.aiff']
24
25async function chime($: EngineInterface) {
26  const argv = (await $.env.get('WSL_DISTRO_NAME')) ? WSL_CHIME : MAC_CHIME
27  await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => {})
28}
29
30async function readStyle($: EngineInterface) {
31  return (await $.config.list()).find(row => row.key === 'outputStyle')
32}
33
34async function cycleStyle($: EngineInterface) {
35  const row = await readStyle($)
36  if (!row || row.kind !== 'choice') return
37  const options = row.options ?? []
38  const value = options[(options.indexOf(String(row.value)) + 1) % options.length]
39  if (value === undefined) return
40  const result = await $.config.set({ key: 'outputStyle', value })
41  if ('deny' in result && result.deny) $.ui.toast(`Output style not changed: ${result.deny}`)
42  else await update($, style, () => value)
43}
44
45async function tick($: EngineInterface) {
46  const at = await read($, lastAt)
47  if (at === null) return
48
49  const left = TTL_MS - ((await $.clock.now()) - at)
50  const min = Math.max(0, Math.ceil(left / 60_000))
51  if ((await read($, leftMin)) !== min) await update($, leftMin, () => min)
52
53  const state = await read($, alert)
54  if (left <= 0 && state !== 'cold') {
55    await update($, alert, () => 'cold' as const)
56    const tokens = (await $.session.usage()).context.tokens ?? 0
57    $.ui.toast(`Prompt cache expired: the next message re-caches ~${Math.round(tokens / 1000)}k tokens at 2x input price`, { timeoutMs: 60_000 })
58  } else if (left > 0 && left <= WARN_MS && state === 'none' && !(await read($, isBusy))) {
59    await update($, alert, () => 'soon' as const)
60    $.ui.toast(`Prompt cache expires in ${min}m: send a message to keep it warm`, { timeoutMs: 60_000 })
61    void chime($)
62  }
63}
64
65function resumePrompt(path: string) {
66  return `Read ${path} and continue with its To do list. HANDOFF.md holds only pending work: remove each item from the file as soon as it is done, and delete the file when nothing is left.`
67}
68
69async function checkHandoff($: EngineInterface, input: Record<string, unknown>): Promise<PendingHandoff | string> {
70  const { path, name, mode } = input
71  if (typeof path !== 'string' || !path.startsWith('/') || !path.endsWith('/HANDOFF.md')) return 'path must be the absolute path of a HANDOFF.md file'
72  if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,59}$/.test(name)) return 'name must be kebab-case, up to 60 characters'
73  if (mode !== 'refresh' && mode !== 'wrap') return "mode must be 'refresh' or 'wrap'"
74  if (mode === 'refresh') {
75    const stat = await $.fs.stat(path).catch(() => null)
76    if (!stat || stat.kind !== 'file' || stat.size === 0) return `${path} is missing or empty: write it before handing off`
77  }
78  return { mode: mode as HandoffMode, name, path }
79}
80
81function nextName(name: string) {
82  const match = /^(.*)-(\d+)$/.exec(name)
83  return match ? `${match[1]}-${Number(match[2]) + 1}` : `${name}-2`
84}
85
86const SKILL_MARK = 'Base directory for this skill: '
87
88function textOf(content: unknown): string {
89  if (typeof content === 'string') return content
90  if (Array.isArray(content)) return content.map(part => (typeof part === 'string' ? part : String((part as { text?: unknown }).text ?? ''))).join('\n')
91  return ''
92}
93
94function hookLabel(text: string, plugins: string[]) {
95  const lower = text.replace(/\S*\/\S*/g, ' ').toLowerCase()
96  const hits = plugins.map(name => [name, lower.indexOf(name.toLowerCase())] as const).filter(([, at]) => at >= 0)
97  if (hits.length) return hits.sort((a, b) => a[1] - b[1])[0]![0]
98  const line = text.replace(/\x1b\[[0-9;]*m/g, '').split('\n').map(l => l.replace(/<[^>]+>/g, '').trim())
99    .find(l => l && !l.startsWith('Output too large') && !l.startsWith('Preview')) ?? 'hook'
100  return line.length > 28 ? line.slice(0, 27) + '…' : line
101}
102
103function hookChars(text: string) {
104  const persisted = /Output too large \(([\d.]+)KB\)/.exec(text)
105  return persisted ? Number(persisted[1]) * 1024 : text.length
106}
107
108export function parseLedger(transcript: string, plugins: string[]): LedgerItem[] {
109  let items = new Map<string, LedgerItem>()
110  const add = (name: string, kind: LedgerItem['kind'], chars: number) => {
111    const item = items.get(`${kind}:${name}`) ?? { name, kind, tokens: 0, count: 0 }
112    items.set(`${kind}:${name}`, { ...item, tokens: item.tokens + Math.round(chars / 4), count: item.count + 1 })
113  }
114  for (const line of transcript.split('\n')) {
115    if (!line.includes(SKILL_MARK) && !line.includes('"hook_') && !line.includes('compact_boundary')) continue
116    let entry: { type?: string; subtype?: string; isMeta?: boolean; message?: { content?: unknown }; attachment?: { type?: string; content?: unknown } }
117    try {
118      entry = JSON.parse(line)
119    } catch {
120      continue
121    }
122    if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
123      items = new Map()
124    } else if (entry.type === 'user' && entry.isMeta) {
125      const text = textOf(entry.message?.content)
126      if (text.startsWith(SKILL_MARK)) add(text.slice(SKILL_MARK.length).split('\n')[0]!.split('/').pop() ?? '?', 'skill', text.length)
127    } else if (/^hook_(success|additional_context|system_message)$/.test(entry.attachment?.type ?? '')) {
128      const text = textOf(entry.attachment?.content)
129      if (text.trim()) add(hookLabel(text, plugins), 'hook', hookChars(text))
130    }
131  }
132  return [...items.values()]
133}
134
135async function refreshLedger($: EngineInterface) {
136  const dir = await read($, transcriptDir)
137  if (dir === null) return
138  const text = await $.fs.read(`${dir}/${await $.session.id()}.jsonl`).catch(() => null)
139  if (typeof text !== 'string') return
140  const plugins = Object.keys((await $.settings.read()).enabledPlugins ?? {}).map(key => key.split('@')[0]!)
141  const items = parseLedger(text, plugins)
142  await update($, ledger, () => items)
143}
144
145async function runHandoff($: EngineInterface, handoff: PendingHandoff) {
146  await $.command.run({ command: 'rename', args: handoff.name })
147  if (handoff.mode !== 'refresh') return
148  await $.command.run({ command: 'clear' })
149  await $.command.run({ command: 'rename', args: nextName(handoff.name) })
150  await $.prompt.submit({ text: resumePrompt(handoff.path), asUser: true })
151}
152
153export const register: Register = on => {
154  on('session.start', async ($, e, next) => {
155    await $.tool.register({
156      name: 'handoff_ready',
157      description: 'Call after writing HANDOFF.md. Renames this session; in refresh mode it then clears the session and starts the next one on the file.',
158      inputSchema: {
159        type: 'object',
160        properties: {
161          path: { type: 'string', description: 'Absolute path of the HANDOFF.md just written' },
162          name: { type: 'string', description: 'kebab-case name for the session that is ending' },
163          mode: { type: 'string', enum: ['refresh', 'wrap'] },
164        },
165        required: ['path', 'name', 'mode'],
166      },
167    })
168    $.clock.every(TICK_MS, () => void tick($))
169    const row = await readStyle($)
170    await update($, style, () => (row ? String(row.value) : null))
171    const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
172    const projectDir = `${configDir}/projects/${(await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')}`
173    await update($, transcriptDir, () => projectDir)
174    await refreshLedger($)
175    return next(e)
176  })
177
178  on('tool.call', { tool: HANDOFF_TOOL }, async ($, e) => {
179    const checked = await checkHandoff($, e as Record<string, unknown>)
180    if (typeof checked === 'string') return { deny: checked }
181    await update($, pendingHandoff, () => checked)
182    return { result: checked.mode === 'refresh' ? 'Handoff accepted: when this turn ends the session is renamed, cleared, and resumed from the file. End your turn now.' : 'Wrap accepted: the session is renamed when this turn ends.' }
183  })
184
185  on('turn.start', async ($, e, next) => {
186    await update($, isBusy, () => true)
187    return next(e)
188  })
189
190  on('turn.complete', async ($, e, next) => {
191    if (e.agentId === undefined) {
192      await update($, isBusy, () => false)
193      if (e.usage) {
194        const now = await $.clock.now()
195        await update($, lastAt, () => now)
196        await update($, alert, () => 'none' as const)
197        await tick($)
198      }
199      await refreshLedger($)
200      const handoff = await read($, pendingHandoff)
201      if (handoff) {
202        await update($, pendingHandoff, () => null)
203        $.clock.after(0, () => void runHandoff($, handoff))
204      }
205    }
206    return next(e)
207  })
208
209  on('session.measure', async ($, e, next) => {
210    const percent = e.context.percent
211    if (percent !== undefined && e.changed.includes('context')) {
212      const nudged = await read($, contextNudge)
213      if (percent < NUDGE_PERCENT) {
214        if (nudged !== null) await update($, contextNudge, () => null)
215      } else {
216        if (nudged === null) $.ui.toast(`Context at ${percent}%: time to /handoff and continue in a fresh session`, { timeoutMs: 30_000 })
217        if (nudged !== percent) await update($, contextNudge, () => percent)
218      }
219    }
220    return next(e)
221  })
222
223  on('session.end', async ($, e, next) => {
224    if (e.reason === 'clear') {
225      await update($, lastAt, () => null)
226      await update($, leftMin, () => null)
227      await update($, alert, () => 'none' as const)
228      await update($, contextNudge, () => null)
229      await update($, ledger, () => [])
230    }
231    return next(e)
232  })
233
234  on('config.set', { key: 'outputStyle' }, async ($, e, next) => {
235    const result = await next(e)
236    if ('value' in result) await update($, style, () => String(result.value))
237    return result
238  })
239
240  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
241    const [min, busy, current, nudge, loaded] = await Promise.all([read($, leftMin), read($, isBusy), read($, style), read($, contextNudge), read($, ledger)])
242    if (e.props.hasSurvey || (min === null && current === null && nudge === null && loaded.length === 0)) return next(e)
243
244    const { Box, Button, Text } = $.ui.resolve(e)
245    const cacheColor = min === null ? 'gray' : min === 0 ? 'red' : min > 20 ? 'green' : min > 10 ? 'yellow' : 'red'
246    const cacheText = busy || min === null ? '⧗ cache —' : min === 0 ? '⧗ cache cold' : `⧗ cache ${min}m`
247
248    return (
249      <Box flexDirection="column">
250        <Box flexDirection="row" gap={1}>
251          <Text color={cacheColor} bold={min === 0 && !busy}>{cacheText}</Text>
252          {current === null ? null : <Text dimColor>│</Text>}
253          {current === null ? null : <Button key="style" label={current} onPress={() => cycleStyle($)} />}
254          {nudge === null ? null : <Text dimColor>│</Text>}
255          {nudge === null ? null : <Text color="yellow">ctx {nudge}% → /handoff</Text>}
256        </Box>
257        {loaded.length === 0 ? null : (
258          <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
259            <Text dimColor>loaded:</Text>
260            {loaded.map(item => (
261              <Text key={`${item.kind}:${item.name}`} color={item.count > 1 ? 'red' : item.kind === 'hook' ? 'magenta' : 'cyan'}>
262                {item.name}{item.kind === 'hook' ? '·hook' : ''} {(item.tokens / 1000).toFixed(1)}k{item.count > 1 ? ` ×${item.count}` : ''}
263              </Text>
264            ))}
265          </Box>
266        )}
267      </Box>
268    )
269  })
270}
271
types/index.d.ts 21 lines
1export type CacheAlert = 'none' | 'soon' | 'cold'
2export type HandoffMode = 'refresh' | 'wrap'
3export type PendingHandoff = { mode: HandoffMode; name: string; path: string }
4export type LedgerItem = { name: string; kind: 'skill' | 'hook'; tokens: number; count: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'agarzon': {
9      cacheLastAt: number | null
10      cacheLeftMin: number | null
11      cacheAlert: CacheAlert
12      isBusy: boolean
13      style: string | null
14      contextNudge: number | null
15      pendingHandoff: PendingHandoff | null
16      transcriptDir: string | null
17      ledger: LedgerItem[]
18    }
19  }
20}
21