Shows spend in dollars for today and the month so far against a monthly budget, in the status line and /spend

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.
| Mod | What it does | |
|---|---|---|
tripwire | Blocks 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-dollars | Spend in dollars for today and the month so far against a monthly budget: status line, toast at 80% and 100%, /spend | |
model-advisor | Classifies 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.
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.
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 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.
| Rule | Kind | Evidence (replayed on the transcripts) |
|---|---|---|
find over /, ~, $HOME or /Users/<name> with no -maxdepth | block | 5 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) | block | 35 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 0 | note | 20 of 233 such calls |
zsh glob abort, macOS has no timeout, gh pr create without --head, gh pr view --json merged | note | one fix line each |
| background shells still open | status line, plus a toast at turn end | "what are the 5 shells that are open still doing?" |
| foreground call running longer than 5 minutes | status line, then a toast | a 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.
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.
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.
hooks/register.ts 70 lines1import type { Engine, Register } from 'claude-code'
2
3import type { Booked, Days } from '../types'
4import { book, dayKey, line, prune, summarize } from './ledger'
5
6const DAYS = 'days'
7const BOOKED = 'booked'
8const WARNED = 'warned'
9
10// Books this session's new spend into the shared day ledger and returns the summary.
11// `seed` records the session's current total as already booked: a resumed session
12// arrives with its past cost, which belongs to the days it was spent on.
13async function sync($: Engine, seed: boolean, budget: number) {
14 const usage = await $.session.usage()
15 const now = await $.clock.now()
16 if (usage.cost === undefined) return undefined
17
18 const session = String(usage.startedAt)
19 const days = ((await $.store.get(DAYS)) ?? {}) as Days
20 const booked = ((await $.store.get(BOOKED)) ?? {}) as Booked
21 const next =
22 seed && !(session in booked)
23 ? { days, booked: { ...booked, [session]: usage.cost.usd } }
24 : book(days, booked, session, usage.cost.usd, dayKey(now))
25 const pruned = prune(next.days, next.booked, now)
26 await $.store.set(DAYS, pruned.days)
27 await $.store.set(BOOKED, pruned.booked)
28
29 return summarize(pruned.days, now, budget)
30}
31
32export const register: Register = (on, options) => {
33 const budget = Number(options.monthlyBudgetUsd ?? 0)
34
35 on('session.start', async ($, e, next) => {
36 await $.command.register({ name: 'spend', description: 'Spend today and month to date against the budget' })
37 const s = await sync($, true, budget)
38 if (s) $.ui.status(line(s))
39
40 return next(e)
41 })
42
43 on('turn.complete', async ($, e, next) => {
44 const s = await sync($, false, budget)
45 if (s) {
46 $.ui.status(line(s))
47 if (s.budget > 0) {
48 const month = dayKey(await $.clock.now()).slice(0, 7)
49 const level = s.month >= s.budget ? 100 : s.month >= s.budget * 0.8 ? 80 : 0
50 const warned = ((await $.store.get(WARNED)) ?? {}) as { month?: string; level?: number }
51 const last = warned.month === month ? (warned.level ?? 0) : 0
52 if (level > last) {
53 await $.store.set(WARNED, { month, level })
54 $.ui.toast(`Budget ${level}% reached: ${line(s)}`)
55 }
56 }
57 }
58
59 return next(e)
60 })
61
62 on('command.run', { command: 'spend' }, async $ => {
63 const s = await sync($, false, budget)
64 if (!s) return { text: 'No cost ledger in this host.' }
65 const rest = s.budget > 0 ? `\nRemaining this month: ${Math.max(0, s.budget - s.month).toFixed(2)} USD` : ''
66
67 return { text: `${line(s)}${rest}` }
68 })
69}
70hooks/ledger.ts 57 lines1import type { Booked, Days, Summary } from '../types'
2
3const pad = (n: number) => String(n).padStart(2, '0')
4
5/** Local calendar day of an epoch-millisecond time, "YYYY-MM-DD". */
6export const dayKey = (ms: number): string => {
7 const d = new Date(ms)
8
9 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
10}
11
12/**
13 * Books the part of a session's cumulative `usd` not yet booked onto `day`.
14 * A cumulative figure that went down (a /clear, a reset ledger) books nothing
15 * and becomes the new baseline.
16 */
17export const book = (
18 days: Days,
19 booked: Booked,
20 session: string,
21 usd: number,
22 day: string,
23): { days: Days; booked: Booked } => {
24 const delta = Math.max(0, usd - (booked[session] ?? 0))
25
26 return {
27 days: delta > 0 ? { ...days, [day]: (days[day] ?? 0) + delta } : days,
28 booked: { ...booked, [session]: usd },
29 }
30}
31
32/** Drops days from before the current month and sessions older than 40 days. */
33export const prune = (days: Days, booked: Booked, now: number): { days: Days; booked: Booked } => {
34 const month = dayKey(now).slice(0, 7)
35 const cutoff = now - 40 * 86_400_000
36
37 return {
38 days: Object.fromEntries(Object.entries(days).filter(([k]) => k.startsWith(month))),
39 booked: Object.fromEntries(Object.entries(booked).filter(([k]) => Number(k) >= cutoff)),
40 }
41}
42
43export const summarize = (days: Days, now: number, budget: number): Summary => {
44 const day = dayKey(now)
45 const month = day.slice(0, 7)
46 const sum = Object.entries(days).reduce((s, [k, v]) => (k.startsWith(month) ? s + v : s), 0)
47
48 return { today: days[day] ?? 0, month: sum, budget }
49}
50
51export const usd = (n: number): string => `$${n.toFixed(2)}`
52
53export const line = (s: Summary): string =>
54 s.budget > 0
55 ? `${usd(s.today)} today · ${usd(s.month)}/${usd(s.budget)} month (${Math.round((s.month / s.budget) * 100)}%)`
56 : `${usd(s.today)} today · ${usd(s.month)} month`
57types/index.d.ts 8 lines1/** Dollars spent per local calendar day ("YYYY-MM-DD"), summed over every session. */
2export type Days = Record<string, number>
3
4/** Cumulative dollars already booked per session, keyed by the session's start time. */
5export type Booked = Record<string, number>
6
7export type Summary = { today: number; month: number; budget: number }
8