Per-session context-window observability. A mod writes each session's context-window figures to a snapshot file and tells Claude its context zone (smart…

A Claude Code plugin that makes each session's context-window usage observable to Claude itself and to any session or tool that needs it, so long-running workflows can route heavy work away from a degraded context before quality slips, instead of guessing. Four parts:
hooks/register.tsx), a mod: a hooks module Claude Code runs in its own process. It tells Claude when the session crosses into a worse context zone, runs the optional blocking gate, shows the person a toast at a crossing, answers /context-guard and the mcp__context-guard__status tool, draws an optional band row, and writes the snapshot file. It needs Claude Code 2.1.287 or later; older builds are unsupported. See The module.scripts/context-zone.sh). context-zone.sh <session_id> prints exactly one word: smart / acceptable / dumb / unknown. Two band shapes, combined conservatively (the worse computable zone wins): percentage bands over used_percentage (shipped defaults smart ≤ 50 < acceptable ≤ 75 < dumb) and window-class token bands over occupancy (total_input_tokens + total_output_tokens; shipped defaults 100k/150k on a 200k window, 128k/250k on a 1M window). Bands come from the machine-scope ~/.claude/context-guard/zones.json when present and valid, else from the shipped defaults. Zones say where you are; consumers decide what to do.hooks/post-compact-mark.sh), a settings hook, so it runs where mods are off: it writes an evidence-degraded marker next to the session's snapshot, and the module honors it: a compacted session's effective zone is dumb regardless of its post-compaction numbers. Its row carries a 60-second timeout. The module is the only source of zone lines and the only gate.reference/reader-contract.md), the authoritative consumer contract: the snapshot path pattern, file shape, the 10-minute staleness rule, fail-open capability detection, the zones.json shape, session-id discovery via ${CLAUDE_SESSION_ID}, and the zone-is-not-a-compaction-indicator rule. Its companion reference/cloud-headless-capture.md is the writer-side channel inventory: why the module is the capture channel, which other channels were checked and rejected (with sources and dates), including the two that do carry live occupancy and still cannot supply a snapshot, and why unknown in a session where the module does not run is structural rather than a defect.The module decides from the live session's figures in interactive, -p, --bg and /loop sessions alike, through a TypeScript copy of the resolver's band function over the same bands; a shared fixture (scripts/context-zone.fixtures.mjs) holds the two resolvers to the same answer. Tested with claude plugin test on Claude Code 2.1.288, and in live runs on 2.1.288: interactive, -p, --bg and /loop sessions, /compact, /branch and --resume on Linux, and -p on native Windows. The maintainer reviewed and accepted each place those runs differed from the retired statusline tee, such as an idle session's snapshot going stale where the tee kept rewriting it. No run was made in the Desktop app, VS Code, a cloud session, or under an organization's sign-in.
A line goes to Claude only at a boundary, appended to the context of a main-thread tool result or of a prompt; a crossing seen when a turn ends reaches Claude with the next prompt. With the default options and zones.json:
| When | Line |
|---|---|
| The session first reaches a worse zone this cycle | once per zone, "acceptable zone (2 of 3)." |
The session comes within approach_margin points (5) of a zone edge or a zones.json threshold; where a token band edge decides the crossing, within that many points of the window in tokens | once per boundary, "acceptable zone (2 of 3), nearing dumb." |
The session passes a zones.json threshold | once per threshold, "past an operator threshold", with the threshold's action |
After a compaction (not the precompute kind), and after /resume or /branch | the verdict, once, only when it is past smart; after a compaction it is dumb zone (3 of 3, compacted) |
When the module loads into a session that already has turns (a --resume launch, a reload after an options change, a hooks-worker restart) | the verdict, once, only when it is past smart |
After /clear | nothing: the new session starts in smart and a fresh cycle |
A dip below a boundary sends nothing and starts no new cycle; only a return to smart does. An unknown reading sends nothing and changes nothing. Lines state facts only and never tell Claude what to do; that is Claude's and the user's call. Every line carries only the verdict: the zone word and its rank of three. A crossing or restatement inside the approach margin of the next zone adds ", nearing <zone>", and an approach line that would repeat it is not sent. Lines due at one carrier: a crossing or restatement recorded before a pending restatement merges into it; a crossing recorded after it is the newer verdict and replaces it. zone_line_data adds figures (percent, tokens, window); by default a line carries none, and it never carries a session id. A configured action's sentence (zones.json actions and thresholds, see the reader contract) appears at its crossing, never before. Subagents get no line. Each line sent to Claude, and each gate denial, is also written as sent to the debug log (claude --debug).
At a crossing the person gets the continuation menu (continue, /compact, /clear, /session-flow:handoff then /clear) as a 4-second toast, such as smart → acceptable · continue, /compact, /clear or handoff, and one transcript line Claude does not read, ending more: /context-guard. context_guard_toast turns the toast off; the transcript line stays. Both come right after the response, tool call, prompt or status read (/context-guard or the status tool) that showed the crossing, even when Claude's line waits for the next prompt. A turn operator mode holds gets neither, and neither does a session whose first reading is already past smart, which gets only Claude's line. A crossing already shown in an unattended turn is not offered again in the next typed turn; Claude gets it at that turn's first carrier. On every surface but the terminal (the Desktop app, VS Code, mobile), where a toast may not show, the line is also drawn as one notice row above the prompt until the next typed prompt. /context-guard writes the route through /session-flow:workflow and When your context fills up as a transcript line Claude does not read. The menu never reaches Claude: an exit menu in model context manufactures the model's own initiative to stop, summarize, or hand off, which the instruction-audit catalog flags as check I23.
With zone_report_mode set to operator, a turn a person started by typing (or through the Remote Control bridge) gets no line. When that turn ends, the line is offered as the prompt box's suggestion (Tab takes it) and shown as one notice row above the prompt, on every surface and never as a toast; with text in the box, only the notice shows, and the suggestion is offered again once the box is empty. Where nobody can take a suggestion, the line goes to Claude as in automatic mode: -p and SDK turns, /loop and scheduled turns, task notifications, a session with no drawing surface, and a suggestion the session reports it cannot show. A suggestion that was shown but not taken goes to Claude as the ordinary line at the next turn no person started (a --bg launch turn reads as typed, so its suggestion can go unseen); the next typed turn instead drops it unsent. Claude Code offers no way to withdraw a shown suggestion, so it stays in the box after that hand-off.
Upstream's render-sites table lists the band's site, AbovePrompt, as drawn in the terminal and the Desktop app. No probe of this plugin ran in the Desktop app or VS Code.
AbovePrompt row changes the apps it lists, or a Desktop run of this plugin is made.With zone_hook_mode set to blocking (or a block action in zones.json), the module denies new Write, Edit, NotebookEdit, Agent and Workflow calls in the blocked zone once the session has spent its zone_gate_grace_calls budget, with a reason Claude reads. Handoff-path writes (a path that contains "handoff"), reads, Bash and Skill calls are never gated, so a durable handoff is always writable. Subagent calls are judged by the session's zone and count against the same budget. In a turn a person typed the block applies; in headless, loop, schedule and notification turns only a compacted session is blocked, unless zone_block_unattended is same-as-typed. Leaving the blocked zone, an unknown reading, and a compaction each reset the budget. The gate fails open: an unknown zone or a failing hook lets the call run.
/context-guard with no argument prints the verdict with its figures, the percent bands beside the token bands of the session's window class (the worse of the two decides the zone), approach margin and gate mode, the band and toast state, and where zones.json lives and whether it is present. Claude reads that reply, as it reads any command's output, so the continuation route and this README's link go to a separate transcript line Claude does not read. /context-guard band on and band off set the band row for the session, and a bare band toggles it. The band row is off by default; context_guard_band turns it on. It shows ctx <n>% (<zone>) above the prompt, with - in place of the figure before the first response. Claude can call mcp__context-guard__status for the exact figures from the last API response, the zone, whether a compaction degraded the evidence, the bands and the gate state. Where the session refuses the tool's registration (an organization policy can refuse a user mod's tools), the module logs one debug line saying so, and the lines, gate, band and writes carry on.
With HOOK_TELEMETRY_SINK set, the module sends envelopes per the hook-telemetry convention to the sink, fire-and-forget, and only on fires that act:
zone-crossing-inject, status ok, for each fire that sends lines, with hook_event tool.call or prompt.submit and data zone, previous, armed and injected: true; and for each operator-mode suggestion shown, with hook_event turn.complete and data zone, previous, armed, injected: false and suggested: true.zone-gate, status blocked, for each denial, with hook_event tool.call and data zone (the blocked zone), grace and calls_seen.A fire that sends nothing emits nothing, and no record carries a path field. A relative sink path is joined onto the session's project root. Unset, nothing is sent.
The module writes ~/.claude/context-guard/context/<session_id>.json in the reader contract's shape through lib/write-snapshot.mjs, run with node, which replaces it atomically, keeps the file and directory owner-only on POSIX, and at most hourly prunes files older than 14 days. A changed body is written at once; an unchanged one at most once per 60 seconds. Writes happen after every tool call, at each measurement after a response, from a 15-second timer that runs only while a turn runs, and at the session's end; an idle session writes nothing, so its file goes stale after 10 minutes. They follow the plugin's on/off switch only: context_guard_hooks_enabled and zone_lines_enabled do not stop them, and a project that disables the plugin in enabledPlugins gets no snapshot, lines or gate in its sessions. A session id outside [A-Za-z0-9_-] is never written. Where a process cannot start, the module writes nothing and logs that once to the debug log.
Below Claude Code 2.1.287, under disableAllHooks or an organization's allowManagedModsOnly, with --bare or --safe-mode, when mods are switched off remotely, or after the hooks worker crashes, the module does not run: no lines, no gate (blocking mode does nothing), no band, no status tool, no module writes. The PostCompact marker, a settings hook, still runs wherever settings hooks do (disableAllHooks stops those too), and readers fall back as the reader contract says. /context-guard:check and /context-guard:setup check report "mods off" in that case. A hook that throws passes its event through unchanged.
session_id becomes a filename, so the module writes only for an id of [A-Za-z0-9_-] and skips the snapshot for anything else.used_percentage, null current_usage (the states before the first response and right after /compact), a non-ISO captured_at, a snapshot whose embedded session_id differs from the requested one, or missing jq all resolve unknown. Consumers take their conservative path on data they cannot trust, never a fabricated zone. The shipped bands are declared judgment defaults. zones.json is the tuning path. The reader contract points at the model-config page's default auto-compact thresholds and records how they relate to the bands. The trigger itself is operator-tunable: autoCompactWindow, CLAUDE_CODE_AUTO_COMPACT_WINDOW, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, and autoCompactEnabled / DISABLE_AUTO_COMPACT. Bands belong below whatever it resolves to, normalized into the percentage shape, so the session reaches a boundary decision before the harness compacts for it. Note that used_percentage always measures against the model's full window, so a lowered auto-compact window no longer shows up in the percentage. The reader contract owns those surfaces, their verification dates, and the rationale.The module's hooks run inside Claude Code and start two kinds of process. The snapshot write runs one node lib/write-snapshot.mjs, at most once per changed body or per 60 seconds for an unchanged one, which also runs the hourly prune. With HOOK_TELEMETRY_SINK set, each telemetry envelope starts one sink process, not awaited: one per fire that sends lines, per operator-mode suggestion shown and per gate denial. A tool call or prompt that writes nothing and sends no envelope starts no process and writes no file; the gate and the lines start none of their own beyond that envelope. The write is awaited inside the tool.call hook, so a slow write holds that one tool result, within Claude Code's own-time limit for a hook.
claude plugin test plugins/context-guard enforces it: the budget: case in hooks/context-guard.test.ts asserts, with no telemetry sink set, no process on calls that write nothing, one per write and none for the gate; the telemetry: cases assert one envelope per acting fire and none on other calls; and the floor: case asserts the 60-second floor. The tests the module replaced, and what holds their budget now:
| Retired test | What it held | Held now by |
|---|---|---|
| The statusline tee's suite | The snapshot body, atomic write, rename retry, prune and the processes per render | The snapshot: cases and the budget: case in hooks/context-guard.test.ts; the helper's own suite, lib/write-snapshot.test.mjs, for the atomic write, rename retry, prune and temp files |
| The statusline shim's suite | The shim finding the installed tee | Nothing: no shim ships |
| The wiring compose script's suite | Composing a statusLine around the shim | Nothing: nothing is composed now |
| The crossing hook's and the PreToolUse gate's suites, process counts included | The crossing lines, the gate and the processes per fire | The line, gate and budget: cases in hooks/context-guard.test.ts |
The hook-census ceiling on the crossing hook in .performance/ratchets.json | Processes per crossing-hook fire | The budget: case: 0 processes on a call that writes nothing |
The PostCompact marker is the one settings hook left. Per docs/conventions/hook-budget/README.md it fires once per compaction. Measured on Windows 11 under Git Bash, twelve trials against an interleaved bash -c : floor, old and new interleaved in one loop (2026-09-02): 9 processes to 4, 9.4 spawn-equivalents before and 5.7 after (0.7.34), with date replaced by printf's clock (a date fallback below bash 4.2) and mkdir and rm behind existence guards. Its row keeps its 60-second timeout: an earlier measurement put a hook of this plugin at 22.0 s on Windows with Defender real-time protection, and a timeout caps a stalled hook without speeding a normal one.
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install context-guard@<marketplace>
Install at user scope (the default), so every session on the machine writes its snapshot. Nothing needs wiring. /context-guard:setup check reports whether the mod runs and what each option is set to; /context-guard:setup apply seeds ~/.claude/context-guard/zones.json from the shipped bands when you want a file to tune.
The module needs Claude Code 2.1.287 or later (older builds are unsupported). Its snapshot writes and the PostCompact marker row need Node.js on PATH: Claude Code's native binary neither ships nor uses Node (setup). The marker and the zone resolver run on Bash (Git Bash on native Windows, so install Git for Windows). The zone resolver, which /context-guard:setup check runs, and setup apply's merge into an existing zones.json need jq on PATH; the module and the marker do not. /context-guard:setup check reports these prerequisites; /context-guard:check reports whether node and jq resolve. context_window fields can be null before the first response and right after /compact; readers own null handling.
The userConfig options:
| Option | What it controls |
|---|---|
context_guard_hooks_enabled | The module's lines and gate, and the PostCompact marker (default true). Snapshot writes continue when it is off. |
zone_lines_enabled | The module's lines to Claude, and the operator-mode suggestions (default true). |
zone_report_mode | automatic (default) or operator; see Operator mode. |
zone_line_data | What a line carries beside its zone: percent, tokens, window (default zone). |
zone_hook_mode | advisory (default) or blocking; see Blocking gate. |
zone_gate_grace_calls | Blocking's grace budget (default 20). |
zone_block_unattended | post-compaction (default) or same-as-typed: what unattended turns get in blocking. |
context_guard_band | The band row (default false); /context-guard band on turns it on for one session. |
context_guard_toast | The toast at a zone crossing (default true); the transcript line stays when it is off. |
The module reads its options when it loads. Claude Code reloads a module when its options change, so a change takes effect from the next event, with no restart. The PostCompact marker hook reads context_guard_hooks_enabled at session start.
A bad option value does not switch the module off: a zone_gate_grace_calls that is not a whole number from 0 to 999999999, or a zone_line_data item it does not know, reads as that option's default, with one transcript line per option naming it, and a choice outside its list reads as its default with Claude Code's own line. A value of the wrong type (text in a number or on/off option) is still refused by Claude Code, which then loads none of the module.
Register in the claude-code/index.d.ts types Claude Code writes for its build (see create: get the types for your build).Per-zone actions, their wording, the approach margin and extra thresholds live in ~/.claude/context-guard/zones.json beside the bands (shape in the reader contract). The snapshot path and the 10-minute staleness rule are deliberately not configurable: they are contract constants that cross-plugin consumers inline from the reader contract; a per-user override would silently split the writer from its readers. Band numbers are the one tunable, via ~/.claude/context-guard/zones.json (shape in the reader contract), which any display of the operator's own may read too, so display and consumers never drift. Disabling everything is enabledPlugins / uninstall.
<!-- BEGIN GENERATED: plugin options. Edit plugin.json, then run scripts/sync-plugin-options-docs.py -->
Generated from this plugin's .claude-plugin/plugin.json. Every option Claude Code will prompt for when the plugin is enabled, with the environment variable each hook reads it from.
| Option | Type | Default | Environment variable | Description |
|---|---|---|---|---|
context_guard_hooks_enabled | boolean | true | CLAUDE_PLUGIN_OPTION_CONTEXT_GUARD_HOOKS_ENABLED | Runs the module's zone lines and blocking gate and the PostCompact marker hook. On by default; off, none of them acts. Snapshot writes continue either way. |
zone_lines_enabled | boolean | true | CLAUDE_PLUGIN_OPTION_ZONE_LINES_ENABLED | Sends Claude one line when the session crosses into a worse context zone, approaches a boundary, or passes a zones.json threshold, and restates the zone after a compaction, a resume or a reload. On by default. |
zone_report_mode | string | "automatic" | CLAUDE_PLUGIN_OPTION_ZONE_REPORT_MODE | automatic (default) sends the lines to Claude; operator holds them in a turn a person typed and offers the person a ready-made prompt and a notice row when the turn ends. Headless, loop and schedule turns get automatic lines either way. |
| zone_line_data | string | "zone" | `CLAUDE_PLUGIN_OPTION_ZO
hooks/register.tsx 907 lines1import type { EngineInterface, PromptOrigin, Register, Timer, ToolCallInput } from 'claude-code'
2import { RANK, readBands, resolveZone, tokenShape, type Bands, type TokenShape, type Zone } from './zone.ts'
3
4const CONTRACT_DIR = 'context-guard'
5const PREFIX = 'context-guard: '
6const NEXT_ZONE: Partial<Record<Zone, Zone>> = { smart: 'acceptable', acceptable: 'dumb' }
7const SESSION_ID = /^[A-Za-z0-9_-]+$/
8const GATED_TOOLS = ['Write', 'Edit', 'NotebookEdit', 'Agent', 'Workflow']
9const PERSON_ORIGINS = ['composer', 'bridge']
10const REOFFER_MS = 5_000
11const FLOOR_MS = 60_000
12// A tick lands a write once the floor has passed, so a write is never later than the floor plus one tick.
13const WRITE_TIMER_MS = 15_000
14const HELPER = 'lib/write-snapshot.mjs'
15const DATA_ITEMS = ['zone', 'percent', 'tokens', 'window']
16const ACTIONS = ['none', 'save-state', 'handoff', 'block'] as const
17const ACTION_TEXT: Record<Action, string> = {
18 none: '',
19 'save-state': 'save-state',
20 handoff: 'handoff',
21 block: 'new Write, Edit, NotebookEdit, Agent and Workflow calls are denied past the grace budget; handoff-path writes, reads, Bash and Skill calls stay allowed',
22}
23
24type Action = (typeof ACTIONS)[number]
25type Rule = { action: Action; text?: string }
26type Threshold = Rule & { at: number }
27type Settings = { bands: Bands; margin: number; actions: Partial<Record<Zone, Rule>>; thresholds: Threshold[] }
28type Config = {
29 enabled: boolean
30 lines: boolean
31 operator: boolean
32 data: Set<string>
33 blocking: boolean
34 grace: number
35 blockUnattended: boolean
36 band: boolean
37 toast: boolean
38}
39// The snapshot body: reference/reader-contract.md "Snapshot file shape".
40type Snapshot = {
41 captured_at: string
42 session_id: string
43 cli_version?: string
44 context_window: {
45 total_input_tokens?: number
46 total_output_tokens?: number
47 context_window_size: number
48 used_percentage: number | null
49 remaining_percentage: number | null
50 current_usage: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number } | null
51 }
52}
53type Reading = { zone: Zone | undefined; degraded: boolean; percent?: number; tokens?: number; window: number; token?: TokenShape }
54// shown: the person's channel has handled it: the batch's last crossing got the transcript line
55// (and the toast, when on), or it came from 'unobserved' and was deliberately skipped.
56type Crossing = { kind: 'crossing'; from: string; zone: Zone; degraded: boolean; handedOff?: boolean; shown?: boolean; armedBefore: number }
57type Event =
58 | Crossing
59 | { kind: 'restate'; zone: Zone; degraded: boolean }
60 | { kind: 'approach'; zone: Zone; toward: string }
61 | { kind: 'threshold'; zone: Zone; degraded: boolean; rule: Threshold }
62type Session = {
63 armed: number
64 last: Zone | undefined
65 fired: Set<number>
66 approached: Set<string>
67 compacted: boolean
68 pending: Event[]
69 restate: boolean
70 reading: Reading | undefined
71 grace: number
72 written: { sig: string; at: number } | undefined
73 body: Snapshot | undefined
74 offered: Event[]
75}
76// A crossing's notice is drawn only where a toast may not show (any surface but the terminal);
77// operator mode's held line is drawn on every surface.
78type Notice = { text: string; kind: 'crossing' | 'operator' }
79type State = {
80 origin: PromptOrigin | undefined
81 forceAutomatic: boolean
82 notice: Notice | undefined
83 bandShown: boolean
84 band: string | undefined
85 reading: Reading | undefined
86 recheckTool: boolean
87 reofferTimer: Timer | undefined
88 writeTimer: Timer | undefined
89 writing: Promise<void>
90 loggedOnce: Set<string>
91 sessions: Map<string, Session>
92 carry: boolean
93 settings: Settings
94 zonesText: string | null
95 zonesKey: string | undefined
96}
97
98// plugin.json declares no number bounds, because the engine refuses the whole module for a value
99// outside them; the module checks them here, uses the default and names each bad option once.
100const badOption = (name: string, kind: string, fallback: string) => `context-guard: option ${name} ${kind}; it reads as the default, ${fallback}`
101const quote = (s: string) => JSON.stringify(s.length > 40 ? `${s.slice(0, 40)}...` : s)
102
103export const parseConfig = (options: Record<string, unknown>): Config & { bad: string[] } => {
104 const bad: string[] = []
105 let items = String(options.zone_line_data ?? '')
106 .split(',')
107 .map(s => s.trim().toLowerCase())
108 .filter(s => s !== '')
109 const unknown = items.filter(s => !DATA_ITEMS.includes(s))
110 if (unknown.length > 0) {
111 bad.push(badOption('zone_line_data', `has an item that is not zone, percent, tokens or window (${quote(unknown.join(', '))})`, 'zone'))
112 items = []
113 }
114 let grace = Number(options.zone_gate_grace_calls ?? 20)
115 const graceKind = !Number.isInteger(grace) ? 'not a whole number' : grace < 0 ? 'a negative number' : grace > 999_999_999 ? 'above 999999999' : undefined
116 if (graceKind !== undefined) {
117 bad.push(badOption('zone_gate_grace_calls', `is ${graceKind}`, '20'))
118 grace = 20
119 }
120 return {
121 enabled: options.context_guard_hooks_enabled !== false,
122 lines: options.zone_lines_enabled !== false,
123 operator: options.zone_report_mode === 'operator',
124 data: new Set(['zone', ...items]),
125 blocking: options.zone_hook_mode === 'blocking',
126 grace,
127 blockUnattended: options.zone_block_unattended === 'same-as-typed',
128 band: options.context_guard_band === true,
129 toast: options.context_guard_toast !== false,
130 bad,
131 }
132}
133
134// C0 and C1 controls, DEL and the Unicode line and paragraph separators each break a line
135const breaksLine = (c: number): boolean => c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029
136const oneLine = (s: string): string => {
137 let out = ''
138 let inRun = false
139 for (const ch of s) {
140 const brk = breaksLine(ch.charCodeAt(0))
141 if (!brk) out += ch
142 else if (!inRun) out += ' '
143 inRun = brk
144 }
145 return out.trim()
146}
147
148const asRule = (value: unknown): Rule | undefined => {
149 if (typeof value !== 'object' || value === null) return undefined
150 const v = value as Record<string, unknown>
151 if (!ACTIONS.includes(v.action as Action)) return undefined
152 // one line: a newline in the operator's text must not start a line that reads as the guard's own
153 const text = typeof v.text === 'string' ? oneLine(v.text) : ''
154 return { action: v.action as Action, ...(text !== '' ? { text } : {}) }
155}
156
157// zones.json: the bands as the resolver reads them, plus the mod's keys; an absent or invalid key
158// means its default, so a file written for the resolver alone keeps working.
159export const parseSettings = (zones: string | null): Settings => {
160 const { bands } = readBands(zones)
161 let z: Record<string, unknown> = {}
162 try {
163 const parsed: unknown = zones === null ? {} : JSON.parse(zones)
164 if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) z = parsed as Record<string, unknown>
165 } catch {
166 // malformed: every mod key takes its default
167 }
168 const margin = typeof z.approach_margin === 'number' ? z.approach_margin : NaN
169 const actionsIn = (typeof z.actions === 'object' && z.actions !== null ? z.actions : {}) as Record<string, unknown>
170 const actions: Partial<Record<Zone, Rule>> = {}
171 for (const zone of ['smart', 'acceptable', 'dumb'] as const) {
172 const rule = asRule(actionsIn[zone])
173 if (rule) actions[zone] = rule
174 }
175 const thresholds = (Array.isArray(z.thresholds) ? z.thresholds : [])
176 .map(t => {
177 const rule = asRule(t)
178 const at = (t as Record<string, unknown>)?.at_percent
179 return rule && typeof at === 'number' && at > 0 && at <= 100 ? { ...rule, at } : undefined
180 })
181 .filter((t): t is Threshold => t !== undefined)
182 .sort((a, b) => a.at - b.at)
183 return { bands, margin: Number.isFinite(margin) && margin >= 0 && margin < 100 ? margin : 5, actions, thresholds }
184}
185
186const newSession = (restate: Session['restate']): Session => ({
187 armed: 0,
188 last: undefined,
189 fired: new Set(),
190 approached: new Set(),
191 compacted: false,
192 pending: [],
193 restate,
194 reading: undefined,
195 grace: 0,
196 written: undefined,
197 body: undefined,
198 offered: [],
199})
200
201// Records what the reading crossed since the last one as pending events. Each zone is reported once
202// per cycle: a zone's line fires when the session first reaches it, a dip below a boundary changes
203// nothing, and only a return to smart opens a new cycle.
204export const recordReading = (s: Session, reading: Reading, settings: Settings) => {
205 s.reading = reading
206 const { zone, percent } = reading
207 if (zone === undefined) return
208 if (s.restate) {
209 if (zone !== 'smart') s.pending.push({ kind: 'restate', zone, degraded: reading.degraded })
210 s.restate = false
211 s.armed = Math.max(s.armed, RANK[zone])
212 s.last = zone
213 for (const t of settings.thresholds) if (percent !== undefined && percent >= t.at) s.fired.add(t.at)
214 return
215 }
216 if (RANK[zone] > s.armed) {
217 s.pending.push({ kind: 'crossing', from: s.last ?? 'unobserved', zone, degraded: reading.degraded, armedBefore: s.armed })
218 s.armed = RANK[zone]
219 } else if (zone === 'smart' && s.armed > 0) {
220 s.armed = 0
221 s.fired.clear()
222 s.approached.clear()
223 }
224 s.last = zone
225 if (percent !== undefined) {
226 for (const t of settings.thresholds) {
227 if (!s.fired.has(t.at) && percent >= t.at) {
228 s.fired.add(t.at)
229 s.pending.push({ kind: 'threshold', zone, degraded: reading.degraded, rule: t })
230 }
231 }
232 }
233 // One approach line per boundary per cycle, in the shape that decides the boundary: the token
234 // shape when its edge sits below the percentage edge, else the percentage shape. The margin is in
235 // percentage points, of the window in the token shape.
236 if (settings.margin <= 0) return
237 const { smart, acceptable } = settings.bands
238 const tok = reading.token
239 const near = (edgePercent: number, edgeTokens: number | undefined) => {
240 if (tok !== undefined && edgeTokens !== undefined && (percent === undefined || edgeTokens < (edgePercent * tok.size) / 100)) {
241 return tok.used >= edgeTokens - (settings.margin * tok.size) / 100 && tok.used <= edgeTokens
242 }
243 return percent !== undefined && percent >= edgePercent - settings.margin && percent <= edgePercent
244 }
245 const boundaries = [
246 { key: 'acceptable', toward: 'acceptable', near: s.armed < 1 && near(smart, tok?.smart) },
247 { key: 'dumb', toward: 'dumb', near: s.armed < 2 && near(acceptable, tok?.acceptable) },
248 ...settings.thresholds.map(t => ({
249 key: `t${t.at}`,
250 toward: 'an operator threshold',
251 near: percent !== undefined && !s.fired.has(t.at) && percent >= t.at - settings.margin,
252 })),
253 ]
254 for (const b of boundaries) {
255 if (b.near && !s.approached.has(b.key)) {
256 s.approached.add(b.key)
257 s.pending.push({ kind: 'approach', zone, toward: b.toward })
258 }
259 }
260}
261
262// The verdict as Claude and the person read it: acceptable zone (2 of 3), dumb zone (3 of 3, compacted).
263const verdictText = (zone: Zone, degraded: boolean) => `${zone} zone (${RANK[zone] + 1} of 3${degraded && zone === 'dumb' ? ', compacted' : ''})`
264
265const dataText = (r: Reading | undefined, cfg: Config) => {
266 const items: string[] = []
267 if (cfg.data.has('percent') && r?.percent !== undefined) items.push(`${r.percent}% of the window used`)
268 if (cfg.data.has('tokens') && r?.tokens !== undefined) items.push(`${r.tokens} tokens in context`)
269 if (cfg.data.has('window') && r !== undefined) items.push(`a ${r.window}-token window`)
270 return items.length === 0 ? '' : `, ${items.join(', ')}`
271}
272
273const sentence = (source: string, rule: Rule) => {
274 const text = (rule.text ?? ACTION_TEXT[rule.action]).replace(/\.+$/, '')
275 return text === '' || rule.action === 'none' && rule.text === undefined ? '' : ` context-guard (${source}): ${text}.`
276}
277
278// The rules a zone carries: its zones.json action, else blocking mode's block at the dumb zone.
279const zoneRules = (zone: Zone, settings: Settings, cfg: Config): { source: string; rule: Rule }[] => {
280 const set = settings.actions[zone]
281 if (set) return [{ source: `operator setting for the ${zone} zone`, rule: set }]
282 return zone === 'dumb' && cfg.blocking ? [{ source: 'operator setting for the dumb zone', rule: { action: 'block' } }] : []
283}
284
285// The zone whose block is in force for this session now, if any: its zone's, or a passed threshold's.
286const blockFor = (s: Session, settings: Settings, cfg: Config) => {
287 const zone = s.reading?.zone
288 if (zone === undefined) return undefined
289 if (zoneRules(zone, settings, cfg).some(r => r.rule.action === 'block')) return zone
290 return settings.thresholds.some(th => th.action === 'block' && s.fired.has(th.at)) ? zone : undefined
291}
292
293export const renderEvent = (e: Event, s: Session, cfg: Config, settings: Settings) => {
294 const data = dataText(s.reading, cfg)
295 const action = (zone: Zone) =>
296 zoneRules(zone, settings, cfg)
297 .map(z => sentence(z.source, z.rule))
298 .join('')
299 // A line states facts only: what to do about them is left to the model and the user.
300 const line = (zone: Zone, degraded: boolean, hint: string) => `context-guard: ${verdictText(zone, degraded)}${data}${hint}.`
301 // Within the approach margin of the next zone's boundary, the verdict says which zone is near.
302 const near = (zone: Zone) => {
303 const toward = NEXT_ZONE[zone]
304 return toward !== undefined && s.approached.has(toward) ? `, nearing ${toward}` : ''
305 }
306 switch (e.kind) {
307 case 'crossing':
308 case 'restate':
309 return `${line(e.zone, e.degraded, near(e.zone))}${action(e.zone)}`
310 case 'approach':
311 return line(e.zone, false, `, nearing ${e.toward}`)
312 case 'threshold':
313 return `${line(e.zone, e.degraded, ', past an operator threshold')}${sentence('operator setting for a threshold', e.rule)}`
314 }
315}
316
317// The verdict lines due at one carrier: a line that another one already starts with (an approach
318// line its crossing repeats, with or without an action after it) is sent once.
319const renderAll = (events: Event[], s: Session, cfg: Config, settings: Settings) => {
320 const lines = [...new Set(events.map(e => renderEvent(e, s, cfg, settings)))]
321 return lines.filter(l => !lines.some(o => o !== l && o.startsWith(l)))
322}
323
324// Appends lines to what Claude reads and writes each to the debug log, so the log holds what Claude was told.
325const withLines = <T extends { context?: readonly string[] }>($: EngineInterface, e: T, lines: readonly string[]): T => {
326 for (const line of lines) $.ui.log(line, { to: 'debug' })
327 return lines.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...lines] }
328}
329
330const homeDir = async ($: EngineInterface) => (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || undefined
331
332// zones.json, re-read only when it appears, disappears or its mtime moves.
333async function loadSettings($: EngineInterface, st: State, home: string | undefined) {
334 if (home === undefined) return st.settings
335 const path = `${home}/.claude/${CONTRACT_DIR}/zones.json`
336 const stat = await $.fs.stat(path).catch(() => undefined)
337 const key = stat === undefined ? 'absent' : `${stat.mtimeMs}:${stat.size}`
338 if (key !== st.zonesKey) {
339 const text = stat === undefined ? null : await $.fs.read(path).then(String).catch(() => null)
340 st.settings = parseSettings(text)
341 st.zonesText = text
342 st.zonesKey = key
343 }
344 return st.settings
345}
346
347const isoSeconds = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
348
349// The session's state, created on first use: after /clear or a resume no session.start fires, so
350// every hook starts the new conversation's state lazily.
351const sessionFor = (st: State, sid: string) => {
352 let s = st.sessions.get(sid)
353 if (s === undefined) {
354 s = newSession(st.carry)
355 st.carry = false
356 st.sessions.set(sid, s)
357 }
358 return s
359}
360
361async function current($: EngineInterface, st: State) {
362 return sessionFor(st, await $.session.id())
363}
364
365async function refresh($: EngineInterface, st: State) {
366 const sid = await $.session.id()
367 const s = sessionFor(st, sid)
368 const home = await homeDir($)
369 const [usage, version, now, settings] = await Promise.all([
370 $.session.usage({ breakdown: 'summary' }),
371 $.session.version().catch(() => undefined),
372 $.clock.now(),
373 loadSettings($, st, home),
374 ])
375 const c = usage.context
376 const api = c.breakdown?.apiUsage ?? null
377 const body: Snapshot = {
378 captured_at: isoSeconds(now),
379 session_id: sid,
380 ...(version?.version ? { cli_version: version.version } : {}),
381 context_window: {
382 ...(c.tokens === undefined ? {} : { total_input_tokens: c.tokens }),
383 ...(api ? { total_output_tokens: api.output_tokens } : {}),
384 context_window_size: c.window,
385 used_percentage: c.percent ?? null,
386 remaining_percentage: c.percent === undefined ? null : 100 - c.percent,
387 current_usage: api
388 ? {
389 input_tokens: api.input_tokens,
390 output_tokens: api.output_tokens,
391 cache_creation_input_tokens: api.cache_creation_input_tokens,
392 cache_read_input_tokens: api.cache_read_input_tokens,
393 }
394 : null,
395 },
396 }
397 const { word } = resolveZone({ sid, snapshot: JSON.stringify(body), zones: st.zonesText, nowSec: Math.floor(now / 1000) })
398 if (!s.compacted && home !== undefined && SESSION_ID.test(sid)) {
399 s.compacted = await $.fs.exists(`${home}/.claude/${CONTRACT_DIR}/context/${sid}.compacted`).catch(() => false)
400 }
401 const zone = s.compacted ? 'dumb' : word === 'unknown' ? undefined : word
402 const token = tokenShape(body.context_window, body.cli_version, settings.bands)
403 const reading: Reading = { zone, degraded: s.compacted, percent: c.percent, tokens: c.tokens, window: c.window, token }
404 recordReading(s, reading, settings)
405 // A reading with no figures (usage gone at exit, or between responses) never replaces one that had them.
406 if (body.context_window.used_percentage !== null || s.body === undefined || s.body.context_window.used_percentage === null) s.body = body
407 st.reading = reading
408 const band = bandText(reading)
409 if (band !== st.band) {
410 st.band = band
411 $.ui.invalidate('ui.render')
412 }
413 return { sid, s, body, settings }
414}
415
416const isPersonTurn = (st: State) => st.origin !== undefined && PERSON_ORIGINS.includes(st.origin.kind)
417
418// Operator mode holds the lines in a turn a person typed, where a suggestion can show.
419async function operatorHolds($: EngineInterface, st: State, cfg: Config) {
420 if (!cfg.operator || st.forceAutomatic || !isPersonTurn(st)) return false
421 return (await $.session.surfaces()).length > 0
422}
423
424// The continuation menu is the person's: a toast, and a transcript line Claude does not read. It
425// never goes into Claude's context. The engine titles the toast with the plugin's name.
426const toastText = (from: string, to: string) => `${from} → ${to} · continue, /compact, /clear or handoff`
427const menuLine = (from: string, to: string) =>
428 `context-guard: ${from} → ${to} · options: continue, /compact, /clear, or /session-flow:handoff then /clear · more: /context-guard`
429const menuZone = (zone: Zone, degraded: boolean) => (degraded && zone === 'dumb' ? 'dumb (compacted)' : zone)
430const noticeShows = (st: State, surface: string) => st.notice !== undefined && (st.notice.kind === 'operator' || surface !== 'terminal')
431
432// The lines a carrier attaches now, consumed; none when none is due or operator mode holds them.
433// One fire of a hook, for its telemetry envelope: the event, when it started, and the optional
434// correlation keys (docs/conventions/hook-telemetry, "Correlation keys").
435type Fire = { event: string; startMs: number; toolUseId?: unknown; agentId?: unknown }
436const PLAIN_ID = /^[A-Za-z0-9._-]+$/
437const RANK_WORD = ['smart', 'acceptable', 'dumb'] as const
438
439// Sends one envelope to HOOK_TELEMETRY_SINK, fire-and-forget; no sink set, nothing is sent. A
440// relative sink path is joined onto the session's project root, else skipped.
441async function emitTelemetry($: EngineInterface, hook: string, status: string, data: Record<string, unknown>, fire: Fire) {
442 const sink = await $.env.get('HOOK_TELEMETRY_SINK')
443 if (!sink) return
444 let path = sink
445 if (!/^(\/|[A-Za-z]:[\\/])/.test(sink)) {
446 const root = (await $.session.root().catch(() => undefined)) || (await $.env.get('CLAUDE_PROJECT_DIR'))
447 if (!root) return
448 path = `${root.replace(/[\\/]+$/, '')}/${sink}`
449 }
450 const [now, sid] = await Promise.all([$.clock.now(), $.session.id()])
451 const corr = Object.fromEntries(
452 [
453 ['session_id', sid],
454 ['tool_use_id', fire.toolUseId],
455 ['agent_id', fire.agentId],
456 ].filter(([, v]) => typeof v === 'string' && PLAIN_ID.test(v)),
457 )
458 const envelope = {
459 schema_version: '1.1',
460 timestamp: isoSeconds(now),
461 hook,
462 hook_event: fire.event,
463 status,
464 duration_ms: Math.max(0, Math.round(now - fire.startMs)),
465 ...corr,
466 data,
467 }
468 void $.process.run([path], { stdin: `${JSON.stringify(envelope)}\n`, timeoutMs: 10_000 }).catch(() => undefined)
469}
470
471// zone-crossing-inject's data for a fire that sent lines (data/zone-crossing-inject.schema.json).
472const crossingData = (s: Session, events: Event[], extra: Record<string, unknown>) => {
473 const crossed = events.filter(e => e.kind === 'crossing').at(-1)
474 const zone = s.reading?.zone ?? 'unknown'
475 return crossed?.kind === 'crossing'
476 ? { zone, previous: crossed.from === 'unobserved' ? '' : crossed.from, armed: RANK_WORD[crossed.armedBefore], ...extra }
477 : { zone, previous: s.last ?? '', armed: RANK_WORD[s.armed], ...extra }
478}
479
480async function takeLines($: EngineInterface, st: State, cfg: Config, fire: Fire): Promise<string[]> {
481 const s = st.sessions.get(await $.session.id())
482 if (s === undefined) return []
483 if (!cfg.enabled) {
484 s.pending = []
485 return []
486 }
487 const holds = cfg.lines && (await operatorHolds($, st, cfg))
488 // A restatement says the verdict as of when it was recorded, so a crossing or an earlier
489 // restatement before it merges into it rather than reaching Claude twice. A crossing recorded
490 // after it is the newer verdict and replaces it. No await from here until s.pending is replaced.
491 const pending = s.pending
492 const lastRestate = pending.findLastIndex(e => e.kind === 'restate')
493 const keep = pending.findLastIndex(e => e.kind === 'crossing') > lastRestate ? -1 : lastRestate
494 const merged =
495 lastRestate < 0 ? pending : pending.filter((e, i) => i > lastRestate || i === keep || (e.kind !== 'crossing' && e.kind !== 'restate'))
496 // A hold keeps lines for the suggestion, except a crossing the person was already shown (read in an
497 // unattended turn): offering it again would repeat it, so it goes to Claude as in automatic mode.
498 const seen = (e: Event) => e.kind === 'crossing' && e.shown === true
499 const events = holds ? merged.filter(seen) : merged
500 s.pending = holds ? merged.filter(e => !seen(e)) : []
501 if (holds && events.length === 0) return []
502 st.forceAutomatic = false
503 let lines: string[] = []
504 if (cfg.lines && events.length > 0) {
505 await emitTelemetry($, 'zone-crossing-inject', 'ok', crossingData(s, events, { injected: true }), fire)
506 lines = renderAll(events, s, cfg, st.settings)
507 }
508 // The person's channel runs after Claude's lines are built, so a failing toast never drops one.
509 showCrossing($, st, cfg, events)
510 return lines
511}
512
513// Shows the person the last crossing among events not yet shown: a toast, a transcript line and the
514// crossing notice. Each crossing is shown once. A handed-off suggestion already reached the person,
515// and a crossing from 'unobserved' (a first reading already past smart) is never shown.
516function showCrossing($: EngineInterface, st: State, cfg: Config, events: Event[]) {
517 const due = events.filter((e): e is Crossing => e.kind === 'crossing' && !e.handedOff && !e.shown)
518 const crossed = due.at(-1)
519 const showable = crossed !== undefined && crossed.from !== 'unobserved'
520 const to = showable ? menuZone(crossed.zone, crossed.degraded) : ''
521 if (showable) {
522 st.notice = { text: menuLine(crossed.from, to), kind: 'crossing' }
523 $.ui.log(st.notice.text)
524 }
525 for (const e of due) e.shown = true
526 if (!showable) return
527 // The engine drops a failing toast itself; it never throws into the module.
528 if (cfg.toast) $.ui.toast(toastText(crossed.from, to))
529 $.ui.invalidate('ui.render')
530}
531
532// A crossing read with a turn's final answer has no carrier until the next prompt, so the person
533// sees it at the measurement; Claude's line still waits for that carrier. A turn operator mode
534// holds keeps it for the suggestion.
535async function showPending($: EngineInterface, st: State, cfg: Config) {
536 const s = st.sessions.get(await $.session.id())
537 if (s === undefined || !cfg.enabled) return
538 if (cfg.lines && (await operatorHolds($, st, cfg))) return
539 showCrossing($, st, cfg, s.pending)
540}
541
542// Offers the held lines as the prompt box's suggestion. With text in the box it waits (false);
543// where a suggestion cannot show, the lines go to Claude at the next carrier.
544async function offer($: EngineInterface, st: State, s: Session, fire: Fire) {
545 if (st.notice?.kind !== 'operator') return true
546 const box = await $.prompt.read()
547 if (box.text.trim() !== '') return false
548 const { isShown } = await $.prompt.suggest({ text: st.notice.text })
549 if (isShown) {
550 await emitTelemetry($, 'zone-crossing-inject', 'ok', crossingData(s, s.pending, { injected: false, suggested: true }), fire)
551 // Shown is not taken: kept until the next turn says whether a person saw it.
552 s.offered.push(...s.pending.splice(0))
553 } else {
554 st.notice = undefined
555 st.forceAutomatic = true
556 }
557 $.ui.invalidate('ui.render')
558 return true
559}
560
561function stopTimer(timer: Timer | undefined) {
562 timer?.cancel()
563 return undefined
564}
565
566// The band row: ctx <n>% (<zone>).
567export const bandText = (r: Reading | undefined) => {
568 const zone = r?.zone === undefined ? '' : ` (${r.degraded && r.zone === 'dumb' ? 'dumb, compacted' : r.zone})`
569 return `ctx ${r?.percent === undefined ? '-' : `${r.percent}%${zone}`}`
570}
571
572const USAGE = 'usage: /context-guard [band [on|off]]'
573const README = 'https://github.com/melodic-software/claude-code-plugins/blob/main/plugins/context-guard/README.md'
574const DOCS = 'https://code.claude.com/docs/en/context-window#when-your-context-fills-up'
575
576// /context-guard with no argument: the verdict with its figures and the settings in force. The reply
577// is stored as a transcript row Claude reads, so it carries no menu, router pointer or link; those
578// go to a transcript line Claude does not read.
579const POINTER = `context-guard: next step: route it with /session-flow:workflow (if installed), or see ${DOCS} · more: ${README}`
580async function statusText($: EngineInterface, st: State, cfg: Config) {
581 const { s, body, settings } = await refresh($, st)
582 await showPending($, st, cfg)
583 const r = s.reading
584 const w = body.context_window
585 const verdict = r?.zone === undefined ? 'zone unknown' : verdictText(r.zone, r.degraded)
586 const figures =
587 w.used_percentage === null
588 ? `no reading yet (a ${w.context_window_size}-token window)`
589 : `${w.used_percentage}% of a ${w.context_window_size}-token window used (${w.total_input_tokens ?? '?'} tokens)`
590 const home = await homeDir($)
591 const zones = home === undefined ? 'zones.json: no home directory' : `${home}/.claude/${CONTRACT_DIR}/zones.json (${st.zonesText === null ? 'absent' : 'present'})`
592 const { smart, acceptable } = settings.bands
593 const t = tokenShape(w, body.cli_version, settings.bands)
594 const bands = t
595 ? `Bands (the worse decides): smart up to ${smart}% and ${t.smart} tokens, acceptable up to ${acceptable}% and ${t.acceptable} tokens`
596 : `Bands: smart up to ${smart}%, acceptable up to ${acceptable}%`
597 return [
598 `${verdict}, ${figures}`,
599 `${bands}; approach margin ${settings.margin} points; gate ${cfg.blocking ? `blocking, ${cfg.grace} grace calls` : 'advisory'}`,
600 `This session: band row ${st.bandShown ? 'on' : 'off'}, zone-change toast ${cfg.toast ? 'on' : 'off'}`,
601 `Settings: ${zones}`,
602 ].join('\n')
603}
604
605async function statusJson($: EngineInterface, st: State, cfg: Config) {
606 const { s, body, settings } = await refresh($, st)
607 await showPending($, st, cfg)
608 const w = body.context_window
609 const t = tokenShape(w, body.cli_version, settings.bands)
610 return JSON.stringify({
611 source: 'the last API response',
612 zone: s.reading?.zone ?? 'unknown',
613 evidence_degraded: s.compacted,
614 used_percentage: w.used_percentage,
615 total_input_tokens: w.total_input_tokens ?? null,
616 total_output_tokens: w.total_output_tokens ?? null,
617 context_window_size: w.context_window_size,
618 bands: {
619 smart_max_used_percentage: settings.bands.smart,
620 acceptable_max_used_percentage: settings.bands.acceptable,
621 smart_max_tokens: t?.smart ?? null,
622 acceptable_max_tokens: t?.acceptable ?? null,
623 },
624 approach_margin: settings.margin,
625 gate: { mode: cfg.blocking ? 'blocking' : 'advisory', grace_calls: cfg.grace, calls_counted: s.grace },
626 })
627}
628
629const logOnce = ($: EngineInterface, st: State, key: string, text: string) => {
630 if (st.loggedOnce.has(key)) return
631 st.loggedOnce.add(key)
632 $.ui.log(`context-guard: ${text}`, { to: 'debug' })
633}
634
635// Writes the session's snapshot through the shared helper. Decided in memory: a body unchanged
636// since the last write is rewritten at most once per 60 s (floor-bound in the helper too), so a
637// call that writes nothing starts no process. Where $.process.run is unavailable nothing is
638// written; readers then read unknown, as they do with no file.
639async function writeSnapshot($: EngineInterface, st: State, read: boolean) {
640 const sid = await $.session.id()
641 if (read) await refresh($, st)
642 const s = st.sessions.get(sid)
643 const body = s?.body
644 if (s === undefined || body === undefined) return
645 const home = await homeDir($)
646 if (home === undefined || !SESSION_ID.test(sid)) return
647 const now = await $.clock.now()
648 const sig = JSON.stringify({ ...body, captured_at: undefined })
649 const same = s.written?.sig === sig
650 if (same && s.written !== undefined && now - s.written.at < FLOOR_MS) return
651 const target = `${home}/.claude/${CONTRACT_DIR}/context/${sid}.json`
652 // A body with no figures (before a session's first response, or after session.end or a fresh
653 // load dropped the in-memory one) never goes over a file on disk that has them.
654 if (body.context_window.used_percentage === null && (await diskHasFigures($, target))) return
655 const argv = ['node', `${$.plugin.root}/${HELPER}`, target, '--prune', ...(same ? ['--floor', String(FLOOR_MS / 1000)] : [])]
656 // Only a write the helper decided (written, or skipped by rule) dedupes; a failed one is tried at the next carrier.
657 try {
658 const run = await $.process.run(argv, { stdin: JSON.stringify(body), timeoutMs: 10_000 })
659 if (run.exitCode === 0 || run.exitCode === 3) s.written = { sig, at: now }
660 else logOnce($, st, 'write-failed', `snapshot write failed (exit ${run.exitCode}): ${run.stderr.trim()}`)
661 } catch (error) {
662 logOnce($, st, 'write-threw', `snapshot write did not run: ${error instanceof Error ? error.message : String(error)}`)
663 }
664}
665
666async function diskHasFigures($: EngineInterface, target: string) {
667 const text = await $.fs.read(target).then(String).catch(() => null)
668 if (text === null) return false
669 try {
670 return typeof JSON.parse(text)?.context_window?.used_percentage === 'number'
671 } catch {
672 return false
673 }
674}
675
676function queueWrite($: EngineInterface, st: State, read = false) {
677 st.writing = st.writing.then(() => writeSnapshot($, st, read)).catch(() => undefined)
678 return st.writing
679}
680
681// A refused tool registration (a policy can refuse a user mod's tools) is logged once; everything
682// else the module does carries on without the tool.
683async function registerSurfaces($: EngineInterface, st: State) {
684 const [tool] = await Promise.allSettled([
685 $.tool.register({
686 name: 'status',
687 description:
688 "Returns this session's context-window reading as JSON: `zone` (smart, acceptable, dumb or unknown; the worse of the percent and token bands decides it), `used_percentage`, input and output token totals, `context_window_size`, the band edges and approach margin in force, `evidence_degraded` (true after a compaction, which forces the zone to dumb), and the gate's mode (advisory or blocking) with its grace calls and calls counted. Figures come from the last API response: before the first response and right after a compaction, `used_percentage` and the token totals are null and the zone is unknown (dumb after a compaction), and they never include text added since that response, so they change only when a response arrives. By default context-guard also adds a line to the next prompt or tool result when the zone worsens or nears a boundary. Read-only.",
689 inputSchema: { type: 'object', properties: {}, additionalProperties: false },
690 }),
691 $.command.register({
692 name: 'context-guard',
693 description: 'Context zone status and details; band on or off sets the band row for this session, bare band toggles it',
694 argumentHint: '[band [on|off]]',
695 }),
696 ])
697 if (tool.status === 'rejected') {
698 const reason = tool.reason instanceof Error ? tool.reason.message : String(tool.reason)
699 logOnce($, st, 'tool-register', `the status tool could not register: ${reason}`)
700 }
701}
702
703// Blocking: the reason a gated call is denied, or undefined to let it run. A typed turn gets the
704// configured block; an unattended turn only the post-compaction one, unless configured otherwise.
705// The budget counts in memory, synchronously after the reading, so calls dispatched together never
706// overspend it.
707async function gate($: EngineInterface, st: State, cfg: Config, e: ToolCallInput, fire: Fire) {
708 if (!cfg.enabled || !GATED_TOOLS.includes(e.tool)) return undefined
709 const { s, settings } = await refresh($, st)
710 const block = blockFor(s, settings, cfg)
711 if (block === undefined) {
712 s.grace = 0
713 return undefined
714 }
715 if (!isPersonTurn(st) && !cfg.blockUnattended && !s.compacted) return undefined
716 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
717 const target = String(input.file_path ?? input.notebook_path ?? '')
718 if (/handoff/i.test(target)) return undefined
719 s.grace += 1
720 if (s.grace <= cfg.grace) return undefined
721 await emitTelemetry($, 'zone-gate', 'blocked', { zone: block, grace: cfg.grace, calls_seen: s.grace }, fire)
722 return (
723 `${PREFIX}${e.tool} denied: ${block} zone, grace budget of ${cfg.grace} calls spent. ` +
724 'Reads, Bash, Skill and handoff-path writes still run.'
725 )
726}
727
728export const register: Register = (on, options) => {
729 const cfg = parseConfig(options)
730 const st: State = {
731 origin: undefined,
732 forceAutomatic: false,
733 notice: undefined,
734 bandShown: cfg.band,
735 band: undefined,
736 reading: undefined,
737 recheckTool: false,
738 reofferTimer: undefined,
739 writeTimer: undefined,
740 writing: Promise.resolve(),
741 loggedOnce: new Set(),
742 sessions: new Map(),
743 carry: false,
744 settings: parseSettings(null),
745 zonesText: null,
746 zonesKey: undefined,
747 }
748
749 on('session.start', async ($, e, next) => {
750 for (const line of cfg.bad) {
751 if (!st.loggedOnce.has(line)) $.ui.log(line)
752 st.loggedOnce.add(line)
753 }
754 await registerSurfaces($, st)
755 // A fresh load mid-session (a reload, a worker respawn, an enable, a --resume launch): the
756 // earlier lines already reached Claude, so only a verdict past smart is restated.
757 if ((await $.session.turns()) > 0) {
758 const s = await current($, st)
759 s.pending = []
760 s.restate = true
761 }
762 return next(e)
763 }).catch(($, e, next) => next(e))
764
765 on('session.end', async ($, e, next) => {
766 st.reofferTimer = stopTimer(st.reofferTimer)
767 st.writeTimer = stopTimer(st.writeTimer)
768 // A -p exit does not drop the last reading the floor held back. The last refreshed body, not
769 // a fresh read: usage at exit can come back empty, and a session never refreshed writes nothing.
770 await queueWrite($, st)
771 st.sessions.delete(e.sessionId)
772 st.carry = e.reason === 'resume'
773 st.origin = undefined
774 st.recheckTool = true
775 return next(e)
776 }).catch(($, e, next) => next(e))
777
778 on('session.compact', async ($, e, next) => {
779 const result = await next(e)
780 if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result && result.skip)) {
781 const s = await current($, st)
782 s.compacted = true
783 s.restate = true
784 s.grace = 0
785 }
786 return result
787 }).catch(($, e, next) => next(e))
788
789 on('prompt.submit', async ($, e, next) => {
790 const startMs = await $.clock.now()
791 // Only a prompt that starts a turn sets its origin; one delivered into a running turn does not.
792 if (e.turnId === undefined) {
793 st.origin = e.origin
794 // A shown suggestion: a person's turn means they saw it and chose, so it goes unsent; a turn
795 // no person started (a --bg launch turn reads as typed, so its suggestion went unseen) gets
796 // it as the ordinary line.
797 const s = await current($, st)
798 const handOff = s.offered.splice(0)
799 if (!isPersonTurn(st)) s.pending.unshift(...handOff.map(ev => (ev.kind === 'crossing' ? { ...ev, handedOff: true } : ev)))
800 if (st.notice !== undefined && (isPersonTurn(st) || handOff.length > 0)) {
801 st.notice = undefined
802 st.reofferTimer = stopTimer(st.reofferTimer)
803 $.ui.invalidate('ui.render')
804 }
805 }
806 // session.start does not fire after /clear or a resume: register the tool again if it is gone.
807 if (st.recheckTool) {
808 st.recheckTool = false
809 const tools = await $.tool.list().catch(() => undefined)
810 if (tools !== undefined && !tools.some(t => t.name === `mcp__${$.plugin.name}__status`)) await registerSurfaces($, st)
811 }
812 await refresh($, st)
813 return next(withLines($, e, await takeLines($, st, cfg, { event: 'prompt.submit', startMs })))
814 }).catch(($, e, next) => next(e))
815
816 on('session.measure', async ($, e, next) => {
817 await refresh($, st)
818 await queueWrite($, st)
819 await showPending($, st, cfg)
820 return next(e)
821 }).catch(($, e, next) => next(e))
822
823 on('turn.start', async ($, e, next) => {
824 st.reofferTimer = stopTimer(st.reofferTimer)
825 // Keeps the snapshot fresh through one long tool call or subagent run.
826 st.writeTimer ??= $.clock.every(WRITE_TIMER_MS, () => {
827 void queueWrite($, st, true)
828 })
829 return next(e)
830 }).catch(($, e, next) => next(e))
831
832 on('turn.complete', async ($, e, next) => {
833 if (e.agentId !== undefined) return next(e)
834 const startMs = await $.clock.now()
835 st.writeTimer = stopTimer(st.writeTimer)
836 if (cfg.enabled && cfg.lines && (await operatorHolds($, st, cfg))) {
837 const s = await current($, st)
838 const lines = renderAll(s.pending, s, cfg, st.settings)
839 if (lines.length > 0) {
840 st.notice = { text: `FYI, ${PREFIX}${lines.map(l => l.replace(PREFIX, '')).join(' ')}`, kind: 'operator' }
841 $.ui.invalidate('ui.render')
842 const fire: Fire = { event: 'turn.complete', startMs }
843 if (!(await offer($, st, s, fire))) {
844 st.reofferTimer ??= $.clock.every(REOFFER_MS, () => {
845 void offer($, st, s, fire)
846 .then(done => {
847 if (done) st.reofferTimer = stopTimer(st.reofferTimer)
848 })
849 .catch(() => undefined)
850 })
851 }
852 }
853 }
854 return next(e)
855 }).catch(($, e, next) => next(e))
856
857 on('command.run', { command: 'context-guard' }, async ($, e, next) => {
858 const [sub, arg, ...rest] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
859 if (sub === undefined) {
860 const text = await statusText($, st, cfg)
861 $.ui.log(POINTER, { to: 'transcript' })
862 return { text }
863 }
864 if (sub !== 'band' || rest.length > 0 || (arg !== undefined && arg !== 'on' && arg !== 'off')) return { text: USAGE }
865 st.bandShown = arg === undefined ? !st.bandShown : arg === 'on'
866 $.ui.invalidate('ui.render')
867 // Claude Code puts the plugin's name before a command's reply, so no reply carries it again.
868 return { text: `band row ${st.bandShown ? 'on' : 'off'} for this session` }
869 }).catch(($, e, next) => next(e))
870
871 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
872 const theirs = await next(e)
873 const notice = noticeShows(st, e.surface) ? st.notice?.text : undefined
874 if (e.props.hasSurvey || (!st.bandShown && notice === undefined)) return theirs
875 const { Box, Text } = $.ui.resolve(e)
876 return (
877 <Box flexDirection="column">
878 {st.bandShown ? (
879 <Text dimColor wrap="truncate">
880 {bandText(st.reading)}
881 </Text>
882 ) : null}
883 {notice !== undefined ? <Text wrap="wrap">{notice}</Text> : null}
884 {theirs}
885 </Box>
886 )
887 }).catch(($, e, next) => next(e))
888
889 on('tool.call', async ($, e, next) => {
890 if (e.tool === `mcp__${$.plugin.name}__status`) return { result: await statusJson($, st, cfg) }
891 const fire: Fire = { event: 'tool.call', startMs: await $.clock.now(), toolUseId: (e as { tool_use_id?: unknown }).tool_use_id, agentId: e.agentId }
892 const deny = await gate($, st, cfg, e, fire)
893 if (deny !== undefined) {
894 $.ui.log(deny, { to: 'debug' })
895 return { deny }
896 }
897 const result = await next(e)
898 // The gate's envelope timed the call before the tool ran; the line work after it starts its own clock.
899 const after: Fire = { ...fire, startMs: await $.clock.now() }
900 await refresh($, st)
901 await queueWrite($, st)
902 if (e.agentId !== undefined) return result
903 if (result.deny !== undefined || result.isError) return result
904 return withLines($, result, await takeLines($, st, cfg, after))
905 }).catch(($, e, next) => next(e))
906}
907hooks/zone.ts 154 lines1// The zone resolver of scripts/context-zone.sh, in TypeScript: the same gates, bands, combination
2// rule and malformed-file notices over the same snapshot and zones.json text. The shared fixture
3// (scripts/context-zone.fixtures.mjs) holds the two equal; the reader contract is the authority.
4
5export type Word = 'smart' | 'acceptable' | 'dumb' | 'unknown'
6export type Zone = Exclude<Word, 'unknown'>
7export type Bands = { smart: number; acceptable: number; tokens: [number, number, number][] }
8
9export const STALENESS_SECONDS = 600
10const TOKEN_SEMANTICS_MIN_VERSION = '2.1.132'
11export const DEFAULT_BANDS: Bands = {
12 smart: 50,
13 acceptable: 75,
14 tokens: [
15 [200_000, 100_000, 150_000],
16 [1_000_000, 128_000, 250_000],
17 ],
18}
19export const RANK: Record<Zone, number> = { smart: 0, acceptable: 1, dumb: 2 }
20const ZONES: Zone[] = ['smart', 'acceptable', 'dumb']
21
22type Json = Record<string, unknown>
23const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
24const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
25// jq's `x // null`: false and null both fall through.
26const orNull = (v: unknown) => (v === undefined || v === null || v === false ? null : v)
27
28const parse = (text: string): { ok: true; value: unknown } | { ok: false } => {
29 try {
30 return { ok: true, value: JSON.parse(text) }
31 } catch {
32 return { ok: false }
33 }
34}
35
36// Keys an object with no edge keys may hold and still keep the default bands silently; the mod
37// reads all but token_bands.
38const KNOWN_KEYS = ['token_bands', 'actions', 'approach_margin', 'thresholds']
39
40// zones.json: each shape validated on its own, a malformed one falls back with its notice.
41export const readBands = (zones: string | null): { bands: Bands; notices: string[] } => {
42 if (zones === null) return { bands: DEFAULT_BANDS, notices: [] }
43 const parsed = parse(zones)
44 if (!parsed.ok) return { bands: DEFAULT_BANDS, notices: ['percent', 'token_bands'] }
45 const z = parsed.value
46 const bands: Bands = { ...DEFAULT_BANDS }
47 const notices: string[] = []
48 const s = isObject(z) ? orNull(z.smart_max_used_percentage) : null
49 const a = isObject(z) ? orNull(z.acceptable_max_used_percentage) : null
50 if (isNumber(s) && isNumber(a) && s > 0 && s < a && a <= 100) {
51 bands.smart = s
52 bands.acceptable = a
53 } else if (!(isObject(z) && s === null && a === null && Object.keys(z).every(k => KNOWN_KEYS.includes(k)))) {
54 notices.push('percent')
55 }
56 const tb = isObject(z) ? orNull(z.token_bands) : null
57 if (tb !== null) {
58 const entries = isObject(tb) ? Object.entries(tb) : []
59 const valid =
60 entries.length > 0 &&
61 entries.every(([key, v]) => {
62 if (!/^[0-9]+$/.test(key) || !isObject(v)) return false
63 const sm = orNull(v.smart_max_tokens)
64 const am = orNull(v.acceptable_max_tokens)
65 return isNumber(sm) && isNumber(am) && sm > 0 && sm < am && am <= Number(key)
66 })
67 if (valid) {
68 bands.tokens = entries
69 .map(([key, v]) => [Number(key), (v as Json).smart_max_tokens as number, (v as Json).acceptable_max_tokens as number] as [number, number, number])
70 .sort((x, y) => x[0] - y[0])
71 } else {
72 notices.push('token_bands')
73 }
74 }
75 return { bands, notices }
76}
77
78const versionAtLeast = (candidate: string, min: string) => {
79 if (!/^[0-9]+(\.[0-9]+)*$/.test(candidate)) return false
80 const c = candidate.split('.').map(Number)
81 const m = min.split('.').map(Number)
82 const n = Math.max(c.length, m.length)
83 for (let i = 0; i < n; i += 1) {
84 const x = c[i] ?? 0
85 const y = m[i] ?? 0
86 if (x !== y) return x > y
87 }
88 return true
89}
90
91const band = (value: number, smart: number, acceptable: number): Zone => (value <= smart ? 'smart' : value <= acceptable ? 'acceptable' : 'dumb')
92export const worse = (a: Zone, b: Zone): Zone => (RANK[a] >= RANK[b] ? a : b)
93
94// A strict YYYY-MM-DDTHH:MM:SSZ that names a real instant, refusing values a lenient parser
95// would normalize (February 30, second 60, hour 24).
96const epochOf = (capturedAt: string): number | undefined => {
97 const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/.exec(capturedAt)
98 if (!m) return undefined
99 const d = new Date(0)
100 d.setUTCFullYear(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
101 d.setUTCHours(Number(m[4]), Number(m[5]), Number(m[6]), 0)
102 const ms = d.getTime()
103 if (!Number.isFinite(ms)) return undefined
104 const back = d.toISOString().replace(/\.\d{3}Z$/, 'Z')
105 return back === capturedAt ? ms / 1000 : undefined
106}
107
108export type TokenShape = { used: number; size: number; smart: number; acceptable: number }
109
110// The token shape of one snapshot body: occupancy, window size and the band row's edges;
111// undefined when it is not computable.
112export const tokenShape = (w: Json, cliVersion: unknown, bands: Bands): TokenShape | undefined => {
113 const input = orNull(w.total_input_tokens)
114 const output = orNull(w.total_output_tokens)
115 const size = orNull(w.context_window_size)
116 if (
117 !(isNumber(input) && input >= 0 && isNumber(output) && output >= 0 && isNumber(size) && size > 0) ||
118 !versionAtLeast(typeof cliVersion === 'string' ? cliVersion : '', TOKEN_SEMANTICS_MIN_VERSION)
119 ) return undefined
120 const used = input + output
121 const row = bands.tokens.filter(([cls]) => cls <= size).at(-1)
122 return used <= size && row !== undefined ? { used, size, smart: row[1], acceptable: row[2] } : undefined
123}
124
125// The zone of one snapshot body, both shapes, worse wins; undefined when neither is computable.
126export const zoneOfWindow = (w: Json, cliVersion: unknown, bands: Bands): Zone | undefined => {
127 const p = orNull(w.used_percentage)
128 const pz = isNumber(p) && p >= 0 && p <= 100 ? band(p, bands.smart, bands.acceptable) : undefined
129 const t = tokenShape(w, cliVersion, bands)
130 const tz = t && band(t.used, t.smart, t.acceptable)
131 return pz && tz ? worse(pz, tz) : (pz ?? tz)
132}
133
134export const resolveZone = (args: { sid: string; snapshot: string | null; zones: string | null; nowSec: number }): { word: Word; notices: string[] } => {
135 const unknown = (notices: string[] = []) => ({ word: 'unknown' as const, notices })
136 if (!/^[A-Za-z0-9_-]+$/.test(args.sid) || args.snapshot === null) return unknown()
137 const { bands, notices } = readBands(args.zones)
138 const parsed = parse(args.snapshot)
139 if (!parsed.ok || !isObject(parsed.value)) return unknown(notices)
140 const s = parsed.value
141 if (typeof orNull(s.captured_at) !== 'string') return unknown(notices)
142 if (orNull(s.session_id) !== args.sid) return unknown(notices)
143 const w = orNull(s.context_window)
144 if (!isObject(w) || orNull(w.current_usage) === null) return unknown(notices)
145 const at = epochOf(s.captured_at as string)
146 if (at === undefined) return unknown(notices)
147 const age = args.nowSec - at
148 if (age < -60 || age > STALENESS_SECONDS) return unknown(notices)
149 const zone = zoneOfWindow(w, s.cli_version, bands)
150 return zone === undefined ? unknown(notices) : { word: zone, notices }
151}
152
153export const isZone = (word: unknown): word is Zone => ZONES.includes(word as Zone)
154