Personal Claude Code skills and workflow extensions.

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.
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.
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).version in plugins/agarzon/.claude-plugin/plugin.json.autoUpdate pull it on the next session.Step 2 is not optional — without a version bump nothing propagates.
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.hooks/register.tsx) — prompt-cache countdown and warning, output-style switcher, context-fill nudge, handoff automation, and the list of loaded skills. 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.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.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
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./config outputStyle row. It offers only the built-in styles, so agarzon:ELI5 is not in the rotation.ctx marker suggest /handoff.--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 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.
| File | Role | |
|---|---|---|
hooks/hooks.json | SessionStart → import peers · Stop → publish own new rows | |
scripts/mem-sync.sh | the hook entry point: export \ | import |
scripts/mem-export.sh | DB → /api/import payload. --since <epoch> for incremental | |
scripts/mem-import.sh | chunked, 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.
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.
hooks/register.tsx 271 lines1import { 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}
271types/index.d.ts 21 lines1export 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