SLOPSHOPPER

context-burn

Tracks context use per session in a status file and warns at 70% and 90% where the harness reports token use.

guardtoast
A shopper browsing a rack in a slop shop
README

context-burn

Counts tool calls per session in a small status file. Where the harness reports token use, it also warns once at 70% and once at 90% of the context window. It only observes: it never blocks a tool and never changes a tool result.

Reach and declared footprint

L1 (file writes). The manifest declares default writes to .claude/context-burn-status/*.json, .kiro/context-burn-status/*.json and .codex/context-burn-status/*.json, and reads VANGUARD_CONTEXT_BURN_STATUS_PATH. It declares no subprocess spawns or network egress. This is capability disclosure, not isolation; the status-path override can write outside these defaults.

Status file

Fields (pretty-printed JSON, rewritten in full on every event):

  • session_id: the session the counts belong to.
  • tool_call_count: tool calls seen in that session.
  • last_updated: ISO 8601 time of the last event.
  • threshold_events: { threshold, tokens, window, at } for each threshold crossed, at most one per threshold per session. Empty where no token data exists.

Default paths, relative to the project:

HarnessPath
Claude Code.claude/context-burn-status/<session-hash>.json
Kiro.kiro/context-burn-status/<session-hash>.json
Codex.codex/context-burn-status/<session-hash>.json

<session-hash> is the full lowercase SHA-256 of the UTF-8 session_id; raw session IDs never appear in the path. Missing or empty Node session IDs use unsessioned, so those events share one fallback file rather than isolated session state.

VANGUARD_CONTEXT_BURN_STATUS_PATH (when non-empty) is an exact path override. Using the same override across sessions opts out of default isolation. It can point outside the declared file_writes globs. Old single-file state is not migrated automatically.

Per harness

HarnessBehavior
Claude Code nativesession.start resets the file. After each tool call it reads context use from the session, toasts the 70% warning and the 90% critical message once each, and updates the file.
Claude Code classicCounter only. No token data, so no warnings.
KiroCounter only, as above. See kiro/UNSUPPORTED.md.
CodexCounter only, as above. See codex/UNSUPPORTED.md.

Messages: [context-burn] WARNING: 70% context used (<tokens> / <window> tokens) and [context-burn] CRITICAL: 90% context used (<tokens> / <window> tokens).

Limitations

  • Install either the native or the classic surface, not both: they share the same session status file and would double count.
  • Updates are unlocked read-modify-write operations, so same-session parallel hooks can lose increments. There is no automatic cleanup of old status files.
  • Classic PostToolUse fires only after a tool succeeds, so failed calls are not counted.
  • An unreadable or invalid status file, including malformed threshold event objects or fields, starts a fresh count; the Node hook says so on stderr.
  • The native API was verified against Claude Code 2.1.296. The Node hooks need Node 22+.

Install

See mods/README.md.

Source 3 files
hooks/register.ts 36 lines
1import type { Register } from 'claude-code'
2import { defaultStatusPath, freshStatus, parseStatus, recordCall, serializeStatus } from '../lib/core.mjs'
3
4async function statusPath($: any, sessionId: string): Promise<string> {
5  return (await $.env.get('VANGUARD_CONTEXT_BURN_STATUS_PATH')) || await defaultStatusPath('claude-code', sessionId)
6}
7
8export const register: Register = (on) => {
9  // A new session resets the file; a failure must never block startup.
10  on('session.start', async ($, e, next) => {
11    const at = new Date(await $.clock.now()).toISOString()
12    const sessionId = await $.session.id()
13    await $.fs.write(await statusPath($, sessionId), serializeStatus(freshStatus(sessionId, at)))
14    return next(e)
15  }).catch(($, e, next) => next(e))
16
17  // Observer: the tool runs first and its result is returned untouched, whatever happens here.
18  on('tool.call', async ($, e, next) => {
19    const r = await next(e)
20    const sessionId = await $.session.id()
21    const path = await statusPath($, sessionId)
22    let prev
23    try {
24      prev = parseStatus(await $.fs.read(path))
25    } catch {
26      prev = undefined
27    }
28    const at = new Date(await $.clock.now()).toISOString()
29    const { context } = await $.session.usage()
30    const { status, messages } = recordCall(prev, sessionId, at, context)
31    for (const m of messages) await $.ui.toast(m)
32    await $.fs.write(path, serializeStatus(status))
33    return r
34  }).catch(($, e, next) => next(e))
35}
36
lib/core.mjs 80 lines
1// Pure counter and threshold logic for context-burn. The native module and the Node hook
2// both import it, so the status file means the same thing on every harness.
3import { parseJson, sha256Hex } from './common.mjs'
4
5const STATUS_DIR = Object.freeze({
6  'claude-code': '.claude/context-burn-status',
7  kiro: '.kiro/context-burn-status',
8  codex: '.codex/context-burn-status',
9})
10
11// Never interpolate a raw session id into a filesystem path.
12export async function defaultStatusPath(harness, sessionId) {
13  return `${STATUS_DIR[harness]}/${await sha256Hex(sessionId)}.json`
14}
15
16const THRESHOLDS = [
17  [70, 'WARNING'],
18  [90, 'CRITICAL'],
19]
20
21// A new session starts at zero with no threshold events.
22export function freshStatus(sessionId, at) {
23  return { session_id: sessionId, tool_call_count: 0, last_updated: at, threshold_events: [] }
24}
25
26// Rejects anything that is not a context-burn status so a stray file is never extended.
27export function parseStatus(text) {
28  const s = parseJson(text, 'context-burn status')
29  const ok =
30    s !== null &&
31    typeof s === 'object' &&
32    !Array.isArray(s) &&
33    typeof s.session_id === 'string' &&
34    Number.isInteger(s.tool_call_count) &&
35    s.tool_call_count >= 0 &&
36    typeof s.last_updated === 'string' &&
37    Array.isArray(s.threshold_events) &&
38    s.threshold_events.every((t) =>
39      t !== null && typeof t === 'object' && !Array.isArray(t) &&
40      THRESHOLDS.some(([threshold]) => t.threshold === threshold) &&
41      Number.isFinite(t.tokens) && t.tokens >= 0 &&
42      Number.isFinite(t.window) && t.window > 0 &&
43      typeof t.at === 'string',
44    )
45  if (!ok) throw new Error('status file is not a context-burn status')
46  return s
47}
48
49// Counts one tool call and, when token data exists, records each threshold crossed for the
50// first time. Never mutates prev.
51export function recordCall(prev, sessionId, at, context) {
52  const base = prev?.session_id === sessionId ? prev : freshStatus(sessionId, at)
53  const status = {
54    ...base,
55    tool_call_count: base.tool_call_count + 1,
56    last_updated: at,
57    threshold_events: [...base.threshold_events],
58  }
59  const messages = []
60  const window = context?.window
61  const tokens =
62    context?.tokens ??
63    (context?.percent !== undefined && window > 0 ? Math.round((context.percent * window) / 100) : undefined)
64  if (tokens !== undefined && window > 0) {
65    const pct = context.percent ?? (tokens / window) * 100
66    for (const [threshold, label] of THRESHOLDS) {
67      if (pct >= threshold && !status.threshold_events.some((t) => t.threshold === threshold)) {
68        status.threshold_events.push({ threshold, tokens, window, at })
69        messages.push(`[context-burn] ${label}: ${threshold}% context used (${tokens} / ${window} tokens)`)
70      }
71    }
72  }
73  return { status, messages }
74}
75
76// Pretty JSON with a trailing newline; the whole file is rewritten each event.
77export function serializeStatus(status) {
78  return JSON.stringify(status, null, 2) + '\n'
79}
80
lib/common.mjs 214 lines
1// Shared helpers for the mods under mods/. scripts/generate-mods.mjs copies this file
2// byte-for-byte to mods/<id>/lib/common.mjs, so edit it here and run `npm run mods:write`.
3//
4// Pure on purpose: no imports, no environment, no I/O. Claude Code's native mod runtime
5// imports it and has no Node, and every harness must reach the same verdict on the same
6// input. Regexes compile without the `u` flag so Node and Python `re.ASCII`
7// (tests/validate-mods.py) read them the same way.
8
9export const LIMITS = Object.freeze({
10  // A redaction that still changes after this many passes is withheld as a whole.
11  maxPasses: 8,
12  // A guard candidate longer than this is not evaluated and counts as a hit.
13  maxCandidateChars: 65536,
14  // Guard candidates are not evaluated past this combined length; the call counts as a hit.
15  maxTotalChars: 262144,
16  // Only the tail of a leaf's context (key or previous sibling) is prefixed to it.
17  maxContextChars: 256,
18})
19
20const MARKER_PREFIX = '[REDACTED:'
21export const OVERFLOW_MARKER = '[REDACTED:overflow]'
22const OVERFLOW_HIT = Object.freeze({ id: 'overflow', name: 'redaction did not converge' })
23export const TOO_LARGE_HIT = Object.freeze({ id: 'too-large', name: 'input too large to evaluate' })
24
25const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
26const PATTERN_KEYS = new Set(['id', 'name', 'description', 'regex', 'flags', 'context', 'severity', 'agents'])
27const isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s !== '')
28
29// Parser diagnostics can quote credential-bearing data; never expose it in hook logs.
30export function parseJson(text, source) {
31  try {
32    return JSON.parse(text)
33  } catch {
34    throw new Error(`${source}: invalid JSON`)
35  }
36}
37
38// Validates a patterns.json-shaped list and compiles it. Throws on anything malformed so a
39// bad extension file fails closed in the caller instead of silently matching nothing.
40export function compilePatterns(list, source = 'patterns') {
41  if (!Array.isArray(list)) throw new Error(`${source}: expected an array of patterns`)
42  const seen = new Set()
43  return list.map((p, i) => {
44    const where = `${source}[${i}]`
45    if (p === null || typeof p !== 'object' || Array.isArray(p)) throw new Error(`${where}: expected an object`)
46    for (const k of Object.keys(p)) if (!PATTERN_KEYS.has(k)) throw new Error(`${where}: unknown key ${k}`)
47    if (typeof p.id !== 'string' || !ID_RE.test(p.id)) throw new Error(`${where}: id must be kebab-case`)
48    if (seen.has(p.id)) throw new Error(`${where}: duplicate id ${p.id}`)
49    seen.add(p.id)
50    if (typeof p.name !== 'string' || p.name === '') throw new Error(`${where}: name must be a non-empty string`)
51    if (typeof p.regex !== 'string' || p.regex === '') throw new Error(`${where}: regex must be a non-empty string`)
52    if (p.flags !== '' && p.flags !== 'i') throw new Error(`${where}: flags must be "" or "i"`)
53    if (!isStringArray(p.context) || p.context.length === 0) throw new Error(`${where}: context must be a non-empty string array`)
54    for (const k of ['description', 'severity']) {
55      if (p[k] !== undefined && typeof p[k] !== 'string') throw new Error(`${where}: ${k} must be a string`)
56    }
57    if (p.agents !== undefined && !isStringArray(p.agents)) throw new Error(`${where}: agents must be a string array`)
58    let rx, rxg
59    try {
60      rx = new RegExp(p.regex, p.flags)
61      rxg = new RegExp(p.regex, 'g' + p.flags)
62    } catch {
63      throw new Error(`${where}: regex does not compile`)
64    }
65    return Object.freeze({
66      id: p.id,
67      name: p.name,
68      severity: p.severity,
69      context: Object.freeze([...p.context]),
70      agents: p.agents ? Object.freeze([...p.agents]) : undefined,
71      rx,
72      rxg,
73      marker: `${MARKER_PREFIX}${p.id}]`,
74    })
75  })
76}
77
78// Every string value (not key) in depth-first order. The order follows the input, so the
79// same payload always yields the same list.
80export function stringLeaves(value, out = []) {
81  if (typeof value === 'string') out.push(value)
82  else if (Array.isArray(value)) for (const v of value) stringLeaves(v, out)
83  else if (value !== null && typeof value === 'object') for (const v of Object.values(value)) stringLeaves(v, out)
84  return out
85}
86
87// Guard candidates: every string leaf, plus every all-string array joined with spaces so
88// an argv-style command (["rm", "-rf", "/"]) reads like the shell line it runs.
89export function candidates(value) {
90  const out = stringLeaves(value)
91  const joinArrays = (v) => {
92    if (Array.isArray(v)) {
93      if (v.length > 1 && v.every((s) => typeof s === 'string')) out.push(v.join(' '))
94      v.forEach(joinArrays)
95    } else if (v !== null && typeof v === 'object') {
96      Object.values(v).forEach(joinArrays)
97    }
98  }
99  joinArrays(value)
100  return out
101}
102
103// The patterns tagged with `context` that match any text, in catalog order. Oversized input
104// is not evaluated (regex cost is unbounded there) and reports TOO_LARGE_HIT instead.
105export function matchPatterns(patterns, texts, context) {
106  let total = 0
107  for (const t of texts) {
108    total += t.length
109    if (t.length > LIMITS.maxCandidateChars || total > LIMITS.maxTotalChars) return [TOO_LARGE_HIT]
110  }
111  return patterns.filter((p) => p.context.includes(context) && texts.some((t) => p.rx.test(t)))
112}
113
114// One pattern pass after another until the text stops changing. Each pass can expose a new
115// match (password-in-url can swallow an earlier marker), so a single pass is not enough.
116function fixedPoint(text, patterns, hits) {
117  let x = text
118  for (let pass = 0; pass <= LIMITS.maxPasses; pass++) {
119    let y = x
120    for (const p of patterns) {
121      y = y.replace(p.rxg, (m) => {
122        if (m === '') return m
123        hits.add(p)
124        return p.marker
125      })
126    }
127    if (y === x) return x
128    x = y
129  }
130  hits.add(OVERFLOW_HIT)
131  return OVERFLOW_MARKER
132}
133
134// Redacts `s` with `ctx` in front so a secret split across a key and its value, or across
135// argv elements ("Bearer", "<token>"), still matches; then strips the context back off.
136function redactLeaf(s, ctx, patterns, hits) {
137  if (ctx === '') return fixedPoint(s, patterns, hits)
138  const pre = ctx.slice(-LIMITS.maxContextChars) + ' '
139  const y = fixedPoint(pre + s, patterns, hits)
140  if (y === OVERFLOW_MARKER) return y
141  const r = fixedPoint(pre, patterns, new Set())
142  if (y.startsWith(r)) return fixedPoint(y.slice(r.length), patterns, hits)
143  // A match spans the boundary: keep from the marker that covers it. Slicing changes
144  // anchors and boundaries, so also check the standalone leaf after contextual matching.
145  let d = 0
146  while (d < r.length && y[d] === r[d]) d++
147  const i = y.lastIndexOf(MARKER_PREFIX, d)
148  return fixedPoint(i >= 0 ? y.slice(i) : y, patterns, hits)
149}
150
151function walk(v, ctx, patterns, hits) {
152  if (typeof v === 'string') {
153    const local = new Set()
154    const out = redactLeaf(v, ctx, patterns, local)
155    if (out !== v) for (const h of local) hits.add(h)
156    return out
157  }
158  if (Array.isArray(v)) {
159    let prev = null
160    return v.map((el) => {
161      const out = walk(el, typeof prev === 'string' ? prev : ctx, patterns, hits)
162      prev = el
163      return out
164    })
165  }
166  if (v !== null && typeof v === 'object') {
167    const named = typeof v.name === 'string' ? v.name : typeof v.key === 'string' ? v.key : null
168    // fromEntries, not assignment: `out[k] = v` would drop an own "__proto__" key.
169    return Object.fromEntries(
170      Object.entries(v).map(([k, x]) => [
171        walk(k, '', patterns, hits),
172        walk(x, k === 'value' && named !== null ? named : k, patterns, hits),
173      ]),
174    )
175  }
176  return v
177}
178
179// Returns a copy of `value` with every match replaced by `[REDACTED:<id>]`, plus the
180// patterns that fired (catalog order, unique). Keys are redacted too; two keys that redact
181// to the same marker collapse into one, which loses data but never leaks it.
182export function redactValue(value, patterns) {
183  const hits = new Set()
184  const out = walk(value, '', patterns, hits)
185  const order = [...patterns, OVERFLOW_HIT]
186  return { value: out, hits: order.filter((p) => hits.has(p)) }
187}
188
189// Same shape as `value`, every string replaced by `text`. The fallback when redaction
190// itself failed: withhold everything rather than pass anything through.
191export function maskValue(value, text) {
192  if (typeof value === 'string') return text
193  if (Array.isArray(value)) return value.map((v) => maskValue(v, text))
194  if (value !== null && typeof value === 'object') {
195    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskValue(v, text)]))
196  }
197  return value
198}
199
200// JSON with object keys sorted at every level, so equal values hash equally.
201export function canonicalJson(value) {
202  if (Array.isArray(value)) return '[' + value.map((v) => (v === undefined ? 'null' : canonicalJson(v))).join(',') + ']'
203  if (value !== null && typeof value === 'object') {
204    const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
205    return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'
206  }
207  return JSON.stringify(value) ?? 'null'
208}
209
210export async function sha256Hex(text) {
211  const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
212  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
213}
214