SLOPSHOPPER

tripwire

Catches recurring agent failures from past transcripts: whole-disk find, foreground poll loops, errors masked by | tail, forgotten background shells

newguardcommandtoaststatustimer
v0.1.0MITupdated 2026-10-03oaustegard/claude-code-mods/tripwire
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tripwire
› 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 › /tripwire ⎿ tripwire: Nothing tripped yet. ⎿ tripwire: ⎿ tripwire: No background tasks open. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Plugins for Claude Code that run code around the agent's tool calls: they can block a call before it runs, add a note to its result, or put a message in front of the person. They work in the terminal and in the desktop app's Code tab. Each folder is one plugin.

ModWhat it does
tripwireBlocks or annotates shell mistakes that recur in Claude Code sessions: whole-disk find, long foreground sleep loops, errors hidden by `\tail`, forgotten background shells
usage-dollarsSpend in dollars for today and the month so far against a monthly budget: status line, toast at 80% and 100%, /spend
model-advisorClassifies a session's first prompt with Jev and, when it needs a stronger model than the session is on, offers to run /model and resend it

The hook API these mods use is marked early access in Claude Code's own type declarations and can change between releases. Those declarations were written by Claude Code 2.1.286, the version tripwire was developed against.

Layout

A mod folder holds .claude-plugin/plugin.json, hooks/hooks.json naming one module, the module itself, and types/index.d.ts when the mod keeps state. Claude Code generates .claude-plugin/types/ the first time it loads a mod, so that directory is not in the repo.

Enabling

List each mod folder, :-separated, in the env block of ~/.claude/settings.json. Claude Code reads this variable only from user settings, never from a project's:

"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/tripwire" }

Interactive sessions watch the folder and reload on save. To check a change before committing it:

claude plugin validate tripwire

tripwire

Tripwire was built from 17 local session transcripts: 546 Bash calls. Each rule below answers a mistake that appears in them more than once. A rule can block a call before it runs, add a note the model reads after the result, or show the person a toast or a status line.

RuleKindEvidence (replayed on the transcripts)
find over /, ~, $HOME or /Users/<name> with no -maxdepthblock5 calls, each moved to the background automatically and left running past the end of the task
foreground loop whose sleep totals more than 120s (an unbounded while/until with sleep ≥ 5 counts; timeout N caps the total)block35 calls; the person's reaction to one: "what on earth is taking so long?"
error text in output whose pipeline ends in tail/head and exits 0note20 of 233 such calls
zsh glob abort, macOS has no timeout, gh pr create without --head, gh pr view --json mergednoteone fix line each
background shells still openstatus line, plus a toast at turn end"what are the 5 shells that are open still doing?"
foreground call running longer than 5 minutesstatus line, then a toasta 51-minute foreground Agent call

The replay applies the same patterns the rules use to the Bash calls in those transcripts. The counts describe one person's sessions, so another machine will see different numbers.

# tripwire:allow in a command lets a block through when the person asked for exactly that. /tripwire lists the counts for the current session and for all sessions (kept in the plugin store) and the open background tasks. The counts show whether a rule still fires, and a rule that never trips is a candidate for deletion.

usage-dollars

Books each session's cost ($.session.usage().cost.usd, what /cost totals) into a per-day ledger in the plugin store, shared by every session on the machine. The status line shows $1.20 today · $34.10/$100.00 month (34%), a toast fires once at 80% and once at 100% of the budget, and /spend prints the same figures plus what remains. Per-conversation spend is not shown: the default donut chart button covers it.

Set the budget in the config menu (monthlyBudgetUsd, default 100, 0 hides it). The ledger starts when the mod is first loaded, so earlier spend is not counted, and a resumed session's past cost is not re-booked.

model-advisor

The first-prompt model advisor from oaustegard/claude-workspace (scripts/model_advisor.py), rebuilt as a mod. The hook version guessed the session's model from the CLI's argv and could only block the prompt and ask the person to switch and resend by hand. The mod reads the model with $.session.model(). On a confident upgrade it holds the prompt and offers three buttons above the prompt box: Switch to Opus 5.5 and send (it runs /model opus and resends), Send on Sonnet 5.5, or Edit, which puts the text back in the box. /advisor switch and /advisor send do the same from the keyboard, and plain /advisor lists the recent decisions.

The rules are the hook's: upgrades only, fired at 75% confidence. A prompt that only points at the work (Resume 71afa1b8) makes no call, and only the first prompt of a fresh session is classified, since a switch after the first turn re-reads the whole context at full price. A prompt from a phone or the web (the Remote Control bridge), or one carrying an attachment, is never held. It runs, and the model is told to quote the advice and give the substantive work to a subagent on the stronger model. #no-advice in a prompt skips it. A Routine or SDK session (CLAUDE_CODE_ENTRYPOINT remote_trigger*/sdk*) is never advised, and MODEL_ADVISOR=off turns it off. Any classifier failure lets the prompt through.

The classifier is Jev, reached through TypeSafe (TYPESAFE_API_KEY) or the Cloudflare AI Gateway (CF_ACCOUNT_ID, CF_API_TOKEN, CF_GATEWAY_ID), read from the environment. With neither present the mod does nothing. Options: autoSwitch switches and resends without asking; downgrade also suggests cheaper tiers; quiet hides the one-line verdict the status line otherwise shows when it stays silent.

The mod exports MODEL_ADVISOR_MOD=1, and the workspace hook stands down when it sees that, so loading both advises once. hooks/advice.ts carries the tier table and criteria, and scripts/model_advisor.py has a copy that must change with it.

Source 2 files
hooks/register.ts 283 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BgTask, Counts } from '../types'
5
6// Each trip below answers a failure mined from this machine's transcripts
7// (May–Oct 2026). The deny texts are what the model reads, so they name the
8// alternative, not just the rule.
9
10const bg = atom({ plugin: 'tripwire', key: 'bg' } as const, [] as BgTask[])
11const counts = atom({ plugin: 'tripwire', key: 'counts' } as const, {} as Counts)
12
13const ALLOW = /#\s*tripwire:allow\b/
14const FG_WARN_MS = 5 * 60_000
15const BG_MAX_MS = 2 * 60 * 60_000 // Bash's own background ceiling
16
17// `find /`, `find ~`, `find $HOME`, `find /Users/<me>` with no depth limit:
18// 5 of these were auto-backgrounded and left running past the PR.
19const FIND_ROOT =
20  /(?:^|[\s;&|(`])find\s+(?:-[HLP]\s+)*(?:\/|~\/?|\$HOME\/?|\/Users(?:\/[^/\s]+)?\/?|\/System\/?|\/Library\/?)(?=\s|$)/
21
22// A foreground loop that sleeps: 27 runs of `for i in $(seq 1 118); ... sleep 5`
23// held the session ~10 minutes each.
24// Only a sleep inside a shell loop's do…done body counts, and an explicit
25// `timeout N` around it caps the estimate.
26function pollSeconds(cmd: string): number {
27  const LOOP = /\b(for|while|until)\b([^\n]*?)(?:;|\n)\s*do\b([\s\S]*?)\bdone\b/g
28  const cap = cmd.match(/\bg?timeout\s+(\d+)\b/)
29  let worst = 0
30  for (const [, kind, head = '', body = ''] of cmd.matchAll(LOOP)) {
31    const sleep = body.match(/\bsleep\s+(\d+(?:\.\d+)?)/)
32    if (!sleep) continue
33    const per = Number(sleep[1])
34    let total: number
35    if (kind !== 'for') {
36      total = per >= 5 ? Infinity : 0
37    } else {
38      const seq = head.match(/\bseq\s+(?:(\d+)\s+)?(\d+)\b/)
39      const brace = head.match(/\{(\d+)\.\.(\d+)\}/)
40      const list = head.match(/\bin\s+([^$`(){}]+)$/)
41      const n = seq
42        ? Number(seq[2]) - Number(seq[1] ?? 1) + 1
43        : brace
44          ? Number(brace[2]) - Number(brace[1]) + 1
45          : list
46            ? list[1].trim().split(/\s+/).length
47            : 1
48      total = n * per
49    }
50    worst = Math.max(worst, total)
51  }
52  return cap ? Math.min(worst, Number(cap[1])) : worst
53}
54
55// Output that says "failed" under an exit status of 0: 146 of 631 Bash calls
56// piped into tail/head, which hid ModuleNotFoundError and `gh` aborts.
57const MASKED: RegExp[] = [
58  /Traceback \(most recent call last\)/,
59  /\b[A-Z]\w*(?:Error|Exception): \S/,
60  /^(?:error|fatal|aborted)(?:\[\w+\])?: /im,
61  /command not found/,
62  /^FAILED\b|\b\d+ failed\b/m,
63  /npm ERR!/,
64]
65
66const HINTS: [RegExp, string][] = [
67  [
68    /no matches found:/,
69    'zsh aborts on an unmatched glob or an unquoted ? or * (URLs with query strings included). Quote the argument, or guard globs with `setopt null_glob`.',
70  ],
71  [
72    /command not found: timeout|timeout: command not found/,
73    'macOS has no `timeout`. Use `gtimeout` (brew coreutils) or `perl -e \'alarm shift; exec @ARGV\' <secs> <cmd>`.',
74  ],
75  [
76    /must first push the current branch to a remote, or use the --head flag/,
77    '`gh pr create` could not infer the branch. Pass `--head <branch>`, plus `--repo owner/name` when not run from inside the clone.',
78  ],
79  [/Unknown JSON field: "?merged\b/, '`gh pr view --json` has no `merged` field; ask for `state` or `mergedAt`.'],
80]
81
82type BashRecord = {
83  backgroundTaskId?: string
84  timedOutAfterMs?: number
85}
86
87const inFlight = new Map<string, { label: string; startedAt: number; isWarned: boolean }>()
88
89const short = (s: string, n = 48) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
90const minutes = (ms: number) => `${Math.max(1, Math.round(ms / 60_000))}m`
91
92async function trip($: EngineInterface, name: string) {
93  await update($, counts, c => ({ ...c, [name]: (c[name] ?? 0) + 1 }))
94  const all = ((await $.store.get('counts')) ?? {}) as Counts
95  await $.store.set('counts', { ...all, [name]: (all[name] ?? 0) + 1 })
96}
97
98async function liveBg($: EngineInterface, now: number) {
99  const list = await read($, bg)
100  const live = list.filter(t => now - t.startedAt < BG_MAX_MS)
101  if (live.length !== list.length) await update($, bg, () => live)
102  return live
103}
104
105async function tick($: EngineInterface) {
106  const now = await $.clock.now()
107  const parts: string[] = []
108  const live = await liveBg($, now)
109  if (live.length > 0) {
110    const oldest = Math.min(...live.map(t => t.startedAt))
111    parts.push(`${live.length} bg · oldest ${minutes(now - oldest)}`)
112  }
113  for (const call of inFlight.values()) {
114    const ran = now - call.startedAt
115    if (ran < 60_000) continue
116    parts.push(`${short(call.label, 28)} ${minutes(ran)}`)
117    if (ran > FG_WARN_MS && !call.isWarned) {
118      call.isWarned = true
119      $.ui.toast(`Still in the foreground after ${minutes(ran)}: ${short(call.label)}`, { timeoutMs: 10_000 })
120    }
121  }
122  $.ui.status(parts.length > 0 ? `tripwire: ${parts.join(' · ')}` : undefined)
123}
124
125export const register: Register = on => {
126  on('session.start', async ($, e, next) => {
127    await $.command.register({
128      name: 'tripwire',
129      description: 'Show what tripwire caught this session and in total, and which background tasks are open',
130    })
131    $.clock.every(15_000, () => void tick($))
132
133    return next(e)
134  })
135
136  on('command.run', { command: 'tripwire' }, async $ => {
137    const now = await $.clock.now()
138    const session = await read($, counts)
139    const total = ((await $.store.get('counts')) ?? {}) as Counts
140    const names = [...new Set([...Object.keys(total), ...Object.keys(session)])].sort()
141    const live = await liveBg($, now)
142    const lines = [
143      names.length === 0
144        ? 'Nothing tripped yet.'
145        : names.map(n => `${n}: ${session[n] ?? 0} this session, ${total[n] ?? 0} total`).join('\n'),
146      live.length === 0
147        ? 'No background tasks open.'
148        : `Open background tasks:\n${live
149            .map(t => `  ${t.id}  ${minutes(now - t.startedAt)}  ${t.isAuto ? '(auto-backgrounded) ' : ''}${t.label}`)
150            .join('\n')}`,
151    ]
152
153    return { text: lines.join('\n\n') }
154  })
155
156  // How long every foreground call has been running, for the status line.
157  on('tool.call', async ($, e, next) => {
158    const isBackground = 'run_in_background' in e && e.run_in_background === true
159    if (isBackground || e.agentId) return next(e)
160    const label =
161      e.tool === 'Bash' ? (e.description ?? e.command) : e.tool === 'Agent' ? `Agent: ${e.description}` : e.tool
162    inFlight.set(e.tool_use_id, { label, startedAt: await $.clock.now(), isWarned: false })
163    try {
164      return await next(e)
165    } finally {
166      inFlight.delete(e.tool_use_id)
167    }
168  })
169
170  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
171    const cmd = e.command
172    const isAllowed = ALLOW.test(cmd)
173
174    // Quoted text is an argument (a prompt, a commit message), not a command.
175    const unquoted = cmd.replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, "''")
176    if (!isAllowed && FIND_ROOT.test(unquoted) && !/-maxdepth\b/.test(unquoted)) {
177      await trip($, 'findRoot')
178      return {
179        deny:
180          'tripwire: `find` over the whole disk or home directory runs for minutes and gets left in the background. ' +
181          'Use `mdfind -name <name>` (Spotlight), `mdfind -onlyin <dir> <query>`, or `find <specific dir> -maxdepth N`. ' +
182          'If the person asked for exactly this, append `# tripwire:allow`.',
183      }
184    }
185
186    const pollSecs = e.run_in_background === true || isAllowed ? 0 : pollSeconds(cmd)
187    if (pollSecs > 120) {
188      await trip($, 'pollLoop')
189      return {
190        deny:
191          `tripwire: this loop sleeps in the foreground for up to ${pollSecs === Infinity ? 'an unbounded time' : `${Math.round(pollSecs)}s`}, ` +
192          'and the person cannot do anything meanwhile. Use the Monitor tool with an until-loop (you are notified when it ' +
193          'fires), or run the command with run_in_background and wait for its notification.',
194      }
195    }
196
197    const ran = await next(e)
198    if (ran.deny !== undefined) return ran
199
200    const text = ran.text ?? ''
201    const context: string[] = []
202
203    if (ran.isError !== true && /\|\s*(?:tail|head)\b/.test(cmd) && !/pipefail/.test(cmd)) {
204      const hit = MASKED.map(re => text.match(re)).find(m => m !== null)
205      if (hit) {
206        await trip($, 'maskedError')
207        context.push(
208          `tripwire: the output contains "${short(hit[0].trim(), 60)}", but the exit status is 0 because the pipeline ends ` +
209            'in tail/head. Treat this command as failed until you have checked; `set -o pipefail;` keeps the real status.',
210        )
211      }
212    }
213
214    for (const [re, hint] of HINTS) {
215      if (re.test(text)) {
216        await trip($, 'hint')
217        context.push(`tripwire: ${hint}`)
218      }
219    }
220
221    if (ran.isError !== true) {
222      const record = (ran.result ?? {}) as BashRecord
223      const now = await $.clock.now()
224      if (record.backgroundTaskId) {
225        const task: BgTask = {
226          id: record.backgroundTaskId,
227          label: short(e.description ?? cmd, 60),
228          startedAt: now,
229          isAuto: record.timedOutAfterMs !== undefined,
230        }
231        await update($, bg, list => [...list.filter(t => t.id !== task.id), task])
232        if (task.isAuto) {
233          await trip($, 'autoBg')
234          $.ui.toast(`Auto-backgrounded after ${Math.round((record.timedOutAfterMs ?? 0) / 1000)}s: ${task.label}`)
235        }
236      }
237      void tick($)
238    }
239
240    return context.length > 0 ? { ...ran, context: [...(ran.context ?? []), ...context] } : ran
241  })
242
243  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
244    const ran = await next(e)
245    const id = e.task_id ?? e.shell_id
246    if (id) await update($, bg, list => list.filter(t => t.id !== id))
247
248    return ran
249  })
250
251  // Background tasks end with a <task-notification> row.
252  on('session.append', async ($, e, next) => {
253    for (const block of e.message.content) {
254      const text = block.type === 'text' && typeof block.text === 'string' ? block.text : ''
255      if (!text.includes('<task-notification>')) continue
256      const done = [...text.matchAll(/<task-id>([^<]+)<\/task-id>[\s\S]*?<status>(\w+)<\/status>/g)]
257        .filter(m => m[2] !== 'running')
258        .map(m => m[1])
259      if (done.length > 0) await update($, bg, list => list.filter(t => !done.includes(t.id)))
260    }
261
262    return next(e)
263  })
264
265  on('turn.complete', async ($, e, next) => {
266    const result = await next(e)
267    if (e.agentId) return result
268
269    const now = await $.clock.now()
270    const live = await liveBg($, now)
271    if (live.length > 0) {
272      await trip($, 'bgAtEnd')
273      $.ui.toast(
274        `${live.length} background task${live.length === 1 ? '' : 's'} still running: ` +
275          live.map(t => `${short(t.label, 30)} (${minutes(now - t.startedAt)})`).join(', '),
276        { timeoutMs: 10_000 },
277      )
278    }
279
280    return result
281  })
282}
283
types/index.d.ts 11 lines
1export type BgTask = { id: string; label: string; startedAt: number; isAuto: boolean }
2
3/** Keyed by trip name: findRoot, pollLoop, maskedError, hint, autoBg, bgAtEnd. */
4export type Counts = { [trip: string]: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    tripwire: { bg: BgTask[]; counts: Counts }
9  }
10}
11