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

Claude Code mods (function hooks) for the claude-skills workflow: skill toasts, safety guards, and an orphan reaper.
/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. Needssh(Windows without one is not covered).
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.
| # | Feature | What it does | Scope |
|---|---|---|---|
| 1 | Skill toast | Toast and status line when a skill from this marketplace is invoked | interactive |
| 2 | Agent model guard | Acts when an Agent call names no model (the parent's model is used); fork always passes. See the option below | all sessions |
| 3 | Running-script guard | Denies Edit/Write on a .sh/.bash file while a process is running it | all sessions |
| 4 | Worktree guard | Denies git worktree remove --force when the worktree has uncommitted changes; fails open if the path is gone | all sessions |
| 5 | Fetch reminder | Fetches at session start (interactive sessions only) and toasts if origin/main is ahead | claude-skills repo only |
| 6 | Push / bump ask | Asks 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 asking | claude-skills repo only |
| 7 | README without KOR | Toast on git commit when a staged README.md has no staged KOR.md | claude-skills repo only |
| 8 | Alive count | At 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 anything | interactive |
| 9 | /reap | Lists 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 confirmed | interactive |
"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.
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.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).0.1.0-beta.5): the bump ask now covers the teams patch tool (teams/skills/patch/patch.mjs).0.1.0-beta.4): run band and its scanner removed (harness draws it); notices carry an icon (◆ ⚠ ↓ ⧗).model argument.cd <dir> && in the same command; only -C <dir> or the session directory is used to resolve the path.claude -p count is a snapshot taken at turn end, not live.hooks/mod.tsx 252 lines1import 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