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

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.
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.
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:
| Harness | Path |
|---|---|
| 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.
| Harness | Behavior |
|---|---|
| Claude Code native | session.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 classic | Counter only. No token data, so no warnings. |
| Kiro | Counter only, as above. See kiro/UNSUPPORTED.md. |
| Codex | Counter 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).
See mods/README.md.
hooks/register.ts 36 lines1import 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}
36lib/core.mjs 80 lines1// 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}
80lib/common.mjs 214 lines1// 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