SLOPSHOPPER

mods

Claude Code mods for the claude-skills workflow: skill toasts, safety guards for worktrees, running scripts, push/bump and subagent models, and /reap for…

newguardcommandtoaststatusprocess
★ 3v0.1.0MITupdated 2026-10-08newkayak12/claude-skills/mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods
› 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 › /reap ⎿ mods: nothing to reap. ⎿ mods: claude -p not checked: engine pid unknown ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mods

Claude Code mods (function hooks) for the claude-skills workflow: skill toasts, safety guards, and an orphan reaper.

Install & Uninstall

/plugin install mods@newkayak12-claude-skills
/plugin uninstall mods@newkayak12-claude-skills

trophy rides along. From this version, the first interactive session after you install or update this plugin installs trophy (achievements) once, in user scope, if you don't have it. Nothing is sent until you say yes; uninstalling trophy is respected (it is never reinstalled). To opt out beforehand: mkdir -p ~/.claude/plugins/.newkayak12-trophy-ride.done. Needs sh (Windows without one is not covered).

Requirements

Claude Code 2.1.292 or newer. Older builds print one stderr line and skip the mod; nothing else breaks. If the mod does not load, set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment before starting Claude Code.

Features

#FeatureWhat it doesScope
1Skill toastToast and status line when a skill from this marketplace is invokedinteractive
2Agent model guardActs when an Agent call names no model (the parent's model is used); fork always passes. See the option belowall sessions
3Running-script guardDenies Edit/Write on a .sh/.bash file while a process is running itall sessions
4Worktree guardDenies git worktree remove --force when the worktree has uncommitted changes; fails open if the path is goneall sessions
5Fetch reminderFetches at session start (interactive sessions only) and toasts if origin/main is aheadclaude-skills repo only
6Push / bump askAsks before git push and the patch-harness / teams/skills/patch/patch.mjs version bump scripts; the command is denied unless Run is chosen (dismissing the prompt also denies); headless sessions pass without askingclaude-skills repo only
7README without KORToast on git commit when a staged README.md has no staged KOR.mdclaude-skills repo only
8Alive countAt turn end, status line ⧗ n agent(s) · m claude -p child(ren) running · /reap for this session's unfinished agents and headless children; cleared at 0. Never stops anythinginteractive
9/reapLists this session's unfinished agents (pending/running/waiting/idle) and its claude -p processes, asks Reap/Cancel, then stops the agents with TaskStop and sends TERM to the claude -p processes (never their wrapper shells). Kills no process when this session's engine pid cannot be confirmedinteractive

"claude-skills repo only" means the session repo's remote matches /claude-skills(\.git)?$/; in any other repo these three do nothing. Interactive-only features do nothing in headless (claude -p) sessions.

The claude -p count is per session: only processes started below this session's engine process are counted. Processes that escape it (nohup, setsid, daemons reparented to pid 1) are not counted.

Options

agent_model_guard (set in /config, default toast):

  • toast: warn, the call proceeds.
  • deny: block the call. To enable: run /config, find mods.agent_model_guard, set it to deny. The mod never writes this setting itself.
  • off: do nothing.

Status and known limits

  • 0.1.0 (out of beta): /reap stops this session's orphan agents and claude -p children after a confirm, and the turn-end status line counts unfinished agents too; trophy rides along (installs trophy once on the first interactive session after an update, if missing).
  • Beta (0.1.0-beta.5): the bump ask now covers the teams patch tool (teams/skills/patch/patch.mjs).
  • Beta (0.1.0-beta.4): run band and its scanner removed (harness draws it); notices carry an icon (◆ ⚠ ↓ ⧗).
  • The skill toast needs the marketplace file to be readable from the plugin location; if not, no toast is shown.
  • The Agent guard also flags subagent types whose definition already pins a model, because it only sees the call's own model argument.
  • The worktree guard ignores a leading cd <dir> && in the same command; only -C <dir> or the session directory is used to resolve the path.
  • The claude -p count is a snapshot taken at turn end, not live.
Source 1 files
hooks/mod.tsx 252 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const REPO_NAME = /claude-skills(\.git)?$/
4const CLAUDE_P = /\bclaude\b.*\s-p(\s|$)/
5const MODEL_DENY = 'mods: pass model ("sonnet" for build work, "opus" for plan/judge) — without it the subagent inherits the parent model.'
6
7// reload-safe caches only; nothing here is read for diagnostics
8let mine: Set<string> | undefined
9
10const words = (cmd: string) => cmd.split(/\s+/).filter(Boolean)
11
12// absolute path of `p` against `base`, without `.` / `..` segments
13function resolve(base: string, p: string): string {
14  const out: string[] = []
15  for (const seg of (p.startsWith('/') ? p : `${base}/${p}`).split('/')) {
16    if (seg === '' || seg === '.') continue
17    if (seg === '..') out.pop()
18    else out.push(seg)
19  }
20  return `/${out.join('/')}`
21}
22
23// the marketplace.json of the marketplace this plugin came from, from the plugin root only (D1)
24function marketplaceCandidates(root: string): string[] {
25  const mkt = resolve(root, '../..').split('/').pop() ?? ''
26  return [
27    resolve(root, '../.claude-plugin/marketplace.json'),
28    resolve(root, `../../../../marketplaces/${mkt}/.claude-plugin/marketplace.json`),
29  ]
30}
31
32type Proc = { pid: number; ppid: number; cmd: string }
33
34function psRows(ps: string): Proc[] {
35  return ps.split('\n').flatMap(line => {
36    const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)
37    return m ? [{ pid: Number(m[1]), ppid: Number(m[2]), cmd: m[3] ?? '' }] : []
38  })
39}
40
41// pids of `engine` and every process below it
42function subtree(rows: Proc[], engine: number): Set<number> {
43  const below = new Set<number>([engine])
44  for (let grew = true; grew;) {
45    grew = false
46    for (const r of rows) {
47      if (!below.has(r.pid) && below.has(r.ppid)) {
48        below.add(r.pid)
49        grew = true
50      }
51    }
52  }
53  return below
54}
55
56// number of claude -p processes among the descendants of `engine` (D2); a wrapper shell
57// counts once: a match whose parent also matches is the same job
58function countClaudeP(ps: string, engine: number): number {
59  const rows = psRows(ps)
60  const below = subtree(rows, engine)
61  const hits = new Set(rows.filter(r => below.has(r.pid) && r.pid !== engine && CLAUDE_P.test(r.cmd)).map(r => r.pid))
62  return rows.filter(r => hits.has(r.pid) && !hits.has(r.ppid)).length
63}
64
65// the claude binary: `claude` on PATH, or an installed build that runs as .../claude/versions/<v>
66const isClaude = (cmd: string) => {
67  const bin = words(cmd)[0] ?? ''
68  return bin.split('/').pop() === 'claude' || /\/claude\/versions\/[^/]+$/.test(bin)
69}
70
71// what /reap kills: the claude binary itself run with -p/--print below `engine`, never a wrapper
72// shell (it exits with its child). undefined when `engine` is not this session's claude: pid 1 or
73// a non-claude row would reach other sessions' children.
74function claudePToKill(ps: string, engine: number): Proc[] | undefined {
75  const rows = psRows(ps)
76  const self = rows.find(r => r.pid === engine)
77  if (engine === 1 || !self || !isClaude(self.cmd)) return undefined
78  const below = subtree(rows, engine)
79  return rows.filter(r => below.has(r.pid) && r.pid !== engine && isClaude(r.cmd) && words(r.cmd).some(w => w === '-p' || w === '--print'))
80}
81
82const ALIVE = new Set(['pending', 'running', 'waiting', 'idle'])
83
84async function enginePid($: EngineInterface): Promise<number> {
85  const me = await $.process.run(['sh', '-c', 'echo $PPID'])
86  return Number(me.stdout.trim())
87}
88
89// status line: this session's unfinished agents and headless claude -p children (D2)
90async function aliveStatus($: EngineInterface): Promise<void> {
91  const engine = await enginePid($)
92  const ps = await $.process.run(['ps', '-A', '-o', 'pid=,ppid=,command='])
93  const n = Number.isInteger(engine) && engine > 0 ? countClaudeP(ps.stdout, engine) : 0
94  const a = (await $.agent.list()).filter(x => ALIVE.has(x.status)).length
95  const parts = [...(a > 0 ? [`${a} agent(s)`] : []), ...(n > 0 ? [`${n} claude -p child(ren)`] : [])]
96  $.ui.status(parts.length ? `⧗ ${parts.join(' · ')} running · /reap` : undefined)
97}
98
99export const register: Register = (on, options) => {
100  const mode = options.agent_model_guard
101
102  // Interactive only (shared rule 2): timers and toasts start when a surface exists.
103  on('session.start', async ($, e, next) => {
104    const r = await next(e)
105    if ((await $.session.surfaces()).length === 0) return r
106    await $.command.register({ name: 'reap', description: "Stop this session's unfinished agents and claude -p children (asks first)" })
107    // Fetch reminder in claude-skills: origin/main moves from other sessions. Detached: session.start is not held.
108    void (async () => {
109      const repo = await $.session.repo()
110      if (!repo?.remote || !REPO_NAME.test(repo.remote)) return
111      await $.process.run(['git', 'fetch', '-q', 'origin'], { cwd: repo.root, timeoutMs: 20000 })
112      const behind = await $.process.run(['git', 'rev-list', '--count', 'origin/main', '^HEAD'])
113      const n = Number(behind.stdout.trim())
114      if (n > 0) $.ui.toast(`↓ origin/main is ${n} commit(s) ahead — pull before editing`, { timeoutMs: 8000 })
115    })().catch(() => {})
116    return r
117  }).catch(($, e, next) => next(e))
118
119  // Skill toast: only skills from this marketplace.
120  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
121    const result = await next(e)
122    if (e.tool !== 'Skill' || (await $.session.surfaces()).length === 0) return result
123    if (!mine) {
124      mine = new Set()
125      for (const path of marketplaceCandidates($.plugin.root)) {
126        try {
127          const { plugins } = JSON.parse(await $.fs.read(path)) as { plugins: { name: string }[] }
128          mine = new Set(plugins.map(p => p.name))
129          break
130        } catch {
131          // try the next candidate; none readable leaves the set empty (no toast)
132        }
133      }
134    }
135    if (mine.has(e.skill.split(':')[0])) {
136      $.ui.toast(`◆ skill: ${e.skill}`)
137      $.ui.status(`◆ skill: ${e.skill}`)
138    }
139    return result
140  }).catch(($, e, next) => next(e))
141
142  // Subagent model guard: no model means the parent's (expensive) model. toast | deny | off (D3).
143  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
144    if (e.tool !== 'Agent' || e.model || e.subagent_type === 'fork' || mode === 'off') return next(e)
145    if (mode === 'deny') return { deny: MODEL_DENY }
146    if ((await $.session.surfaces()).length > 0) $.ui.toast('⚠ ' + MODEL_DENY.replace('mods: ', 'mods: no model on this Agent call. '), { timeoutMs: 6000 })
147    return next(e)
148  }).catch(($, e, next) => next(e))
149
150  // Running-script guard: bash reads a script by byte offset while it runs.
151  on('tool.call', ($, e, next) => {
152    if (e.tool !== 'Edit' && e.tool !== 'Write') return next(e)
153    if (!/\.(sh|bash)$/.test(e.file_path)) return next(e)
154    return $.process.run(['pgrep', '-f', e.file_path]).then(p =>
155      p.exitCode === 0 && p.stdout.trim()
156        ? { deny: `mods: ${e.file_path} is running (pid ${p.stdout.trim().split('\n').join(', ')}). Copy it and edit the copy, or wait until it exits.` }
157        : next(e))
158  }).catch(($, e, next) => next(e))
159
160  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
161    if (e.tool !== 'Bash') return next(e)
162    const cmd = e.command
163
164    // Worktree guard: never remove a worktree with uncommitted work. Fails open when the path is gone.
165    const wt = cmd.match(/git\s+(?:-C\s+(\S+)\s+)?worktree\s+remove\s+([^;&|]+)/)
166    if (wt) {
167      const args = words(wt[2])
168      if (args.some(a => a === '--force' || a === '-f')) {
169        const target = args.find(a => !a.startsWith('-'))
170        if (target) {
171          const cwd = await $.session.cwd()
172          const path = resolve(wt[1] ? resolve(cwd, wt[1]) : cwd, target)
173          const st = await $.process.run(['git', '-C', path, 'status', '--porcelain'])
174          if (st.exitCode === 0 && st.stdout.trim()) {
175            return { deny: `mods: ${path} has uncommitted changes:\n${st.stdout.slice(0, 500)}\nCommit (and verify with git log) before removing it.` }
176          }
177        }
178      }
179    }
180
181    // The rest encodes the claude-skills repo's rules (D4): other repos pass untouched.
182    const repo = await $.session.repo()
183    if (!repo?.remote || !REPO_NAME.test(repo.remote)) return next(e)
184
185    // Push / version bump: ask the person first; headless runs pass.
186    if (/\bgit\s+push\b|patch-harness\.mjs|skills\/patch\/patch\.mjs/.test(cmd) && (await $.session.surfaces()).length > 0) {
187      // a dismissed ask rejects: that is a refusal, not a failure to fail open on
188      const answer = await $.ui.ask(`Run \`${cmd.slice(0, 120)}\`?`, ['Run', 'Cancel']).catch(() => undefined)
189      if (answer !== 'Run') return { deny: 'mods: the person declined the push / version bump.' }
190    }
191
192    // README/KOR pair: a staged README.md without its KOR.md.
193    if (/\bgit\s+commit\b/.test(cmd)) {
194      const staged = await $.process.run(['git', 'diff', '--cached', '--name-only'])
195      const files = new Set(staged.stdout.split('\n').filter(Boolean))
196      const lonely = [...files].filter(f => f.endsWith('/README.md') && !files.has(f.replace(/README\.md$/, 'KOR.md')))
197      if (lonely.length) $.ui.toast(`⚠ README without KOR: ${lonely.join(', ')}`, { timeoutMs: 8000 })
198    }
199    return next(e)
200  }).catch(($, e, next) => next(e))
201
202  // Turn-end check: unfinished agents and headless claude -p children of this session (D2).
203  // Subagent turns refresh it too; harmless. Only /reap stops anything.
204  on('turn.complete', async ($, e, next) => {
205    const r = await next(e)
206    if ((await $.session.surfaces()).length === 0) return r
207    await aliveStatus($)
208    return r
209  }).catch(($, e, next) => next(e))
210
211  // /reap: stop unfinished agents (TaskStop) and kill claude -p children, after the person confirms.
212  // Answers its own command: neither the hook nor its .catch reads next.
213  on('command.run', { command: 'reap' }, async $ => {
214    if ((await $.session.surfaces()).length === 0) return { text: '/reap runs in interactive sessions only.' }
215    const alive = (await $.agent.list()).filter(x => ALIVE.has(x.status))
216    const ids = new Set(alive.map(x => x.id))
217    // a parent's stop ends its children: stopping them too would only report false failures
218    const agents = alive.filter(x => !x.parentId || !ids.has(x.parentId))
219    const engine = await enginePid($)
220    const ps = await $.process.run(['ps', '-A', '-o', 'pid=,ppid=,command='])
221    const procs = claudePToKill(ps.stdout, engine)
222    const unknown = procs ? [] : ['claude -p not checked: engine pid unknown']
223    const targets = [
224      ...agents.map(x => `agent: ${x.description} (${x.status})`),
225      ...(procs ?? []).map(p => `pid ${p.pid}: ${p.cmd.slice(0, 80)}`),
226    ]
227    if (!targets.length) return { text: ['nothing to reap.', ...unknown].join('\n') }
228    const answer = await $.ui.ask(`Reap these?\n${targets.join('\n')}`, ['Reap', 'Cancel']).catch(() => undefined)
229    if (answer !== 'Reap') return { text: 'reap cancelled; nothing stopped.' }
230    const lines: string[] = []
231    for (const x of agents) {
232      try {
233        const res = await $.tool.call({ tool: 'TaskStop', task_id: x.id })
234        if (res.deny !== undefined) lines.push(`failed: ${x.description} (${res.deny})`)
235        else if (res.isError) lines.push(`failed: ${x.description} (${res.text ?? 'error'})`)
236        else lines.push(`stopped: ${x.description}`)
237      } catch (err) {
238        lines.push(`failed: ${x.description} (${err instanceof Error ? err.message : String(err)})`)
239      }
240    }
241    if (procs?.length) {
242      const pids = procs.map(p => String(p.pid))
243      const k = await $.process.run(['kill', '-TERM', ...pids])
244      lines.push(k.exitCode === 0 ? `killed: claude -p ${pids.join(' ')}` : `failed: kill ${pids.join(' ')} (${k.stderr.trim()})`)
245    }
246    lines.push(...unknown)
247    await aliveStatus($)
248    $.ui.toast(`⧗ reap: ${lines.filter(l => /^(stopped|killed):/.test(l)).length} done, ${lines.filter(l => l.startsWith('failed')).length} failed`, { timeoutMs: 6000 })
249    return { text: ['/reap', ...lines].join('\n') }
250  }).catch(() => ({ text: '/reap failed; nothing more was stopped.' }))
251}
252