SLOPSHOPPER

context-gauge

Band above the prompt: context fill, last turn's cost, session cost and rate limits; /gauge toggles it

newbandcommandtoaststatusprocess
v0.5.2no licenseupdated 2026-10-12KZ-HQ/god-mods/context-gauge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-gauge
› 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 › /gauge ⎿ context-gauge: Context gauge hidden. /gauge shows it again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

god-mods

Claude Code mods: hooks modules that change Claude's interface and behaviour, published as the god-mods plugin marketplace.

Install

In Claude Code:

/plugin marketplace add KZ-HQ/god-mods
/plugin install danger-guard@god-mods

Install the mods you want one by one. Restart Claude Code (a full restart; /reload-plugins does not load new mod code) for them to take effect.

Mods

ModWhat it does
agent-spend/spend [days] [here]: pane of subagent spend by model and agent type, with the costliest agents, from subagent transcripts
context-gaugeBand above the prompt: context fill, last turn's cost, session cost and rate limits; /gauge toggles it
danger-guardHolds risky shell commands and writes to protected data, previews what they would change, and asks before they run
long-turn-notifymacOS notification when a long turn finishes or Claude needs your input, in terminal sessions
merge-gateHolds gh pr merge until CI passes on the PR head; /gate runs the project's CI steps locally
mod-stats/mod-stats [days]: what each mod did, cost and saved, and how often it faulted
mod-faultsPassive: records any god-mod hook that fails, times out or is refused, and toasts at a mod's third fault in a session; /mod-faults. It sees only mods beneath it: put mod-faults@god-mods first in enabledPlugins (that order is the chain order)
next-stepsSuggests 2–3 likely next prompts after each turn; press 1/2/3 to draft one; /next off
prompt-shaperExpands your shorthand and attaches git state to each prompt; adds a project-toolchain note to the system prompt
secret-scrubberMasks API keys and tokens in tool output, and refuses to write them into source files
test-on-editWhen Claude's answer ends, runs the tests of the files it edited; failures go to Claude with your next prompt

Plugin order

Installed mods nest in the order of enabledPlugins in ~/.claude/settings.json, first outermost; a plugin can't choose its place. mod-faults only sees faults in mods beneath it, so keep it first:

"enabledPlugins": {
  "mod-faults@god-mods": true,
  "danger-guard@god-mods": true,
  ...
}

Plugins installed later are appended, so they land beneath it. Reinstalling mod-faults moves it to the end again; its start-up toast and /mod-faults name any mods it can't see.

Metrics

Every mod appends local JSON lines to ~/.claude/mods/metrics.jsonl; nothing leaves your machine. Rows (schema v: 2) carry t, mod, event, ver, session and project:

  • loaded: once per session start, with the mod's version
  • turn: once per turn, counting each hook's calls, e.g. {"tool.call": 3, "command.run{goal}": 1}
  • anything else: an outcome the mod recorded, e.g. merge-gate's verdict

Rows hold counts, kinds and lengths only, never prompt, command or tool-input text. /mod-stats summarises them. mod-faults adds a fault row per failed hook, naming the mod and event; it no longer writes ~/.claude/logs/god-mods-faults.jsonl, which can be deleted.

Development

Each mod is a folder with .claude-plugin/plugin.json and hooks/register.tsx, plus tests in tests/.

The metrics code lives once in shared/metrics.ts and is inlined into every register.tsx between // <metrics> markers, because the engine only lets $ into functions declared in the same file. Edit the shared file, then:

shared/sync.sh          # inline into every mod
shared/sync.sh --check  # fail if any copy is stale

Check a mod before committing:

claude plugin validate <mod>
claude plugin test <mod>

Bump the mod's version in its plugin.json and in .claude-plugin/marketplace.json together.

Source 2 files
hooks/register.tsx 243 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Register, SessionUsage } from 'claude-code'
3
4import type { GaugeSnap } from '../types'
5
6// Thresholds, in percent of the context window
7const WARN_AT = 60
8const HOT_AT = 80
9const OFFER_COMPACT_AT = 70
10const BAR_CELLS = 10
11// Absolute size where every call re-reads enough cache to drain the 5h limit fast
12const NAG_TOKENS = 400_000
13const HIDDEN_KEY = 'hidden'
14
15const snap = atom({ plugin: 'context-gauge', key: 'snap' } as const, null)
16const lastTurn = atom({ plugin: 'context-gauge', key: 'lastTurn' } as const, null)
17const isHidden = atom({ plugin: 'context-gauge', key: 'isHidden' } as const, false)
18
19const toSnap = (u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): GaugeSnap => ({
20  percent: u.context.percent ?? null,
21  tokens: u.context.tokens ?? null,
22  window: u.context.window,
23  usd: u.cost?.usd ?? null,
24  fiveHour: u.rateLimits.find(r => r.kind === 'five_hour')?.percentUsed ?? null,
25  weekly: u.rateLimits.find(r => r.kind === 'seven_day')?.percentUsed ?? null,
26})
27
28const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
29// Says what happened: a compaction takes a while, and a refusal used to vanish
30async function compactNow($: EngineInterface): Promise<void> {
31  $.ui.status('context-gauge: compacting…')
32  try {
33    const r = await $.session.compact()
34    track($, 'context-gauge', 'compact', r.skip !== undefined ? { outcome: 'skipped', reason: r.skip } : { outcome: 'done', before: r.tokensBefore, after: r.tokensAfter })
35    if (r.skip !== undefined) $.ui.toast(`context-gauge: compaction skipped: ${r.skip}`)
36    else $.ui.toast(`context-gauge: compacted${r.tokensBefore && r.tokensAfter ? ` ${k(r.tokensBefore)} → ${k(r.tokensAfter)}` : ''}`)
37  } catch (err) {
38    track($, 'context-gauge', 'compact', { outcome: 'error', reason: err instanceof Error ? err.message : String(err) })
39    $.ui.toast(`context-gauge: could not compact (${err instanceof Error ? err.message : String(err)}); try /compact`)
40  } finally {
41    $.ui.status(undefined)
42  }
43}
44
45// <metrics: generated from shared/metrics.ts by shared/sync.sh>
46// Shared mod metrics (schema v2). Source of truth: ~/.claude/mods/shared/metrics.ts.
47// shared/sync.sh inlines it between the metrics markers in each mod's register.tsx (the engine only lets $
48// into functions declared in the same file). Edit here, run sync.sh, never edit an inlined copy.
49//
50// Rows appended to ~/.claude/mods/metrics.jsonl, all with { v: 2, t, mod, event, ver, session, project }:
51//   loaded   once per load (logLoaded, or count() in the mod's session.start hook), at session.start: proves which version of the mod is running
52//   turn     once per turn with activity (logTurns, or count() in the mod's turn.complete hook): { hooks: { <event>: calls } }
53//   <other>  outcome rows from track(), e.g. merge-gate 'verdict'
54// Privacy: never put prompt, command, tool-input or matched-secret text in a row; lengths, kinds and counts only.
55
56const APPEND = 'mkdir -p "$HOME/.claude/mods" && printf "%s\\n" "$1" >> "$HOME/.claude/mods/metrics.jsonl"'
57
58
59const ctx = { ver: '', session: '', project: '' }
60const stats = new Map<string, number>()
61let writeFailed = false
62
63const message = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300)
64
65function write($: EngineInterface, mod: string, event: string, data: Record<string, unknown>): void {
66  void (async () => {
67    const t = new Date(await $.clock.now()).toISOString()
68    if (!ctx.project) ctx.project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? ''
69    const line = JSON.stringify({ v: 2, t, mod, event, ver: ctx.ver, session: ctx.session, project: ctx.project, ...data })
70    const { exitCode, stderr } = await $.process.run(['/bin/sh', '-c', APPEND, 'mod-metrics', line], { timeoutMs: 3000 })
71    if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
72  })().catch((err: unknown) => {
73    if (writeFailed) return
74    writeFailed = true
75    $.ui.toast(`${mod}: metrics write failed (${message(err)})`)
76  })
77}
78
79// An outcome row: something the mod decided or did. Fire-and-forget.
80function track($: EngineInterface, mod: string, event: string, data: Record<string, unknown> = {}): void {
81  write($, mod, event, data)
82}
83
84async function loaded($: EngineInterface): Promise<void> {
85  const mod = $.plugin.name
86  ctx.session = await $.session.id().catch(() => '')
87  ctx.project = ''
88  try {
89    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
90    ctx.ver = manifest.version ?? ''
91  } catch {
92    ctx.ver = '?'
93  }
94  write($, mod, 'loaded', {})
95}
96
97function flush($: EngineInterface): void {
98  const mod = $.plugin.name
99  if (!stats.size) return
100  const hooks = Object.fromEntries(stats)
101  stats.clear()
102  write($, mod, 'turn', { hooks })
103}
104
105// The engine allows one hook per event per plugin, and counts them by spelling. A mod that hooks
106// session.start or turn.complete itself calls count() in that hook, which writes the row; otherwise
107// it calls the matching helper once in register.
108function logLoaded(on: On): void {
109  on('session.start', async ($, e, next) => {
110    await loaded($)
111
112    return next(e)
113  })
114}
115
116function logTurns(on: On): void {
117  on('turn.complete', async ($, e, next) => {
118    flush($)
119
120    return next(e)
121  })
122}
123
124// First statement of every hook: counts the call into this turn's row. The engine only accepts
125// function literals as hooks, so calls are counted from inside rather than by wrapping.
126// In a session.start hook it also writes the loaded row; in a turn.complete hook it writes the turn row.
127function count($: EngineInterface, event: string): void {
128  stats.set(event, (stats.get(event) ?? 0) + 1)
129  if (event === 'session.start') void loaded($)
130  if (event === 'turn.complete') flush($)
131}
132// </metrics>
133
134// <money: generated from shared/money.ts by shared/sync.sh>
135// US dollars: three decimals under $1, where cents hide the cost of a cheap call; two from $1
136function usd(n: number): string {
137  return `$${Math.abs(n) < 1 ? n.toFixed(3) : n.toFixed(2)}`
138}
139// </money>
140
141export const register: Register = on => {
142  // Baseline for the running main-loop turn
143  let turnStartUsd = 0
144  let turnStartedAt = 0
145  // One toast per crossing; re-armed when context drops back (e.g. after a compact)
146  let hasNagged = false
147
148  on('session.start', async ($, e, next) => {
149    count($, 'session.start')
150    await $.command.register({ name: 'gauge', description: 'Show or hide the context gauge above the prompt' })
151    const usage = await $.session.usage()
152    await update($, snap, () => toSnap(usage))
153    // /gauge's choice carries over to new sessions
154    if ((await $.store.get(HIDDEN_KEY)) === true) await update($, isHidden, () => true)
155
156    return next(e)
157  })
158
159  on('session.measure', async ($, e, next) => {
160    count($, 'session.measure')
161    const s = toSnap(e)
162    await update($, snap, () => s)
163    const big = (s.tokens ?? 0) >= NAG_TOKENS
164    if (big && !hasNagged) track($, 'context-gauge', 'nag', { tokens: s.tokens })
165    if (big && !hasNagged) $.ui.toast(`context-gauge: context is ${k(s.tokens ?? 0)}; every call now re-reads it. Compact or start a new session to save usage.`)
166    hasNagged = big
167
168    return next(e)
169  })
170
171  on('turn.start', async ($, e, next) => {
172    count($, 'turn.start')
173    turnStartUsd = (await $.session.usage()).cost?.usd ?? 0
174    turnStartedAt = await $.clock.now()
175
176    return next(e)
177  })
178
179  on('turn.complete', async ($, e, next) => {
180    count($, 'turn.complete')
181    const done = await next(e)
182    if (e.agentId !== undefined) return done
183
184    const nowUsd = (await $.session.usage()).cost?.usd ?? turnStartUsd
185    const seconds = Math.round(((await $.clock.now()) - turnStartedAt) / 1000)
186    await update($, lastTurn, () => ({ usd: Math.max(0, nowUsd - turnStartUsd), seconds }))
187
188    return done
189  })
190
191  on('command.run', { command: 'gauge' }, async $ => {
192    count($, 'command.run{gauge}')
193    const hidden = await update($, isHidden, h => !h)
194    await $.store.set(HIDDEN_KEY, hidden).catch(() => undefined)
195
196    return { text: hidden ? 'Context gauge hidden. /gauge shows it again.' : 'Context gauge shown.' }
197  })
198
199  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
200    count($, 'ui.render')
201    const s = await read($, snap)
202    if (e.props.hasSurvey || s === null || (await read($, isHidden))) return next(e)
203
204    const turn = await read($, lastTurn)
205    const { Box, Text, Button } = $.ui.resolve(e)
206
207    const pct = s.percent
208    const filled = pct === null ? 0 : Math.min(BAR_CELLS, Math.round((pct / 100) * BAR_CELLS))
209    const bar = '▰'.repeat(filled) + '▱'.repeat(BAR_CELLS - filled)
210    const isBig = (s.tokens ?? 0) >= NAG_TOKENS
211    const color = pct === null ? undefined : pct >= HOT_AT || isBig ? 'red' : pct >= WARN_AT ? 'yellow' : 'green'
212
213    const parts: string[] = []
214    parts.push(pct === null ? 'ctx —' : `${pct}% · ${k(s.tokens ?? 0)}/${k(s.window)}`)
215    if (isBig) parts.push('over 400k: compact to save usage')
216    if (turn) parts.push(`last turn ${usd(turn.usd)} · ${turn.seconds}s`)
217    if (s.usd !== null) parts.push(`session ${usd(s.usd)}`)
218    if (s.fiveHour !== null) parts.push(`5h ${Math.round(s.fiveHour)}%`)
219    if (s.weekly !== null) parts.push(`7d ${Math.round(s.weekly)}%`)
220
221    const mine = (
222      <Box flexDirection="row" gap={1} width={Math.max(20, e.props.bodyColumns)}>
223        <Text color={color}>{bar}</Text>
224        <Text dimColor={!isBig} color={isBig ? 'red' : undefined} wrap="truncate-end">
225          {parts.join('  ·  ')}
226        </Text>
227        {/* Compacting under a running turn rewrites the conversation it is using */}
228        {(isBig || (pct !== null && pct >= OFFER_COMPACT_AT)) && !e.props.isWorking && (
229          <Button key="compact" label="Compact" onPress={() => compactNow($)} />
230        )}
231      </Box>
232    )
233    const below = await next(e)
234
235    return (
236      <Box flexDirection="column">
237        {mine}
238        {below}
239      </Box>
240    )
241  })
242}
243
types/index.d.ts 17 lines
1export type GaugeSnap = {
2  percent: number | null
3  tokens: number | null
4  window: number
5  usd: number | null
6  fiveHour: number | null
7  weekly: number | null
8}
9
10export type TurnCost = { usd: number; seconds: number }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'context-gauge': { snap: GaugeSnap | null; lastTurn: TurnCost | null; isHidden: boolean }
15  }
16}
17