SLOPSHOPPER

context-guard

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…

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-guard
› fix the failing auth test and add an audit log call ● context-guard: context-guard: next step: route it with /session-flow:workflow (if installed), or see https://code.claude.com/docs/en/context-window#when-your-context-fills-up · more: https://github.com/melodic-software/claude-code-plugins/blob/main/plugins/context-guard/README.md ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /context-guard ⎿ context-guard: zone unknown, 49% of a 200000-token window used (97400 tokens) ⎿ context-guard: Bands: smart up to 50%, acceptable up to 75%; approach margin 5 points; gate advisory ⎿ context-guard: This session: band row off, zone-change toast on ⎿ context-guard: Settings: /Users/dev/.claude/context-guard/zones.json (absent) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-guard

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:

  • The module (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.
  • Zone resolver (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.
  • PostCompact marker hook (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.
  • Reader contract (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

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.

Lines to Claude

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:

WhenLine
The session first reaches a worse zone this cycleonce 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 tokensonce per boundary, "acceptable zone (2 of 3), nearing dumb."
The session passes a zones.json thresholdonce per threshold, "past an operator threshold", with the threshold's action
After a compaction (not the precompute kind), and after /resume or /branchthe 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 /clearnothing: 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.

Operator mode

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.

  • Pointer: mods reference: render sites.
  • As of: 2026-10-03, Claude Code 2.1.288.
  • Recheck trigger: the AbovePrompt row changes the apps it lists, or a Desktop run of this plugin is made.

Blocking gate

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.

The command, the band row and the status tool

/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.

Telemetry

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.

Snapshot writes

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.

Where mods are off

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.

Behavior

  • Per-session, atomic snapshots. One file per session id (no cross-session last-writer-wins); readers never see torn JSON (temp file + rename, with a brief retry for the Windows rename-over-open-target case). Stale sibling files are pruned with a 14-day cutoff, far above the staleness window, so live-but-idle sessions always survive.
  • Path containment. 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.
  • Fail-open zone resolution. Absent, stale, or unparsable snapshots, null or out-of-range 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.
  • Integrity boundary (stated honestly). The snapshot directory is owner-only where POSIX modes work; on Windows ACL volumes it keeps the inherited ACLs and other local users could forge snapshots. Zones are routing hints. Consumers must never attach security or egress decisions to a zone word. See the reader contract's untrusted-data section.

Process budget

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.

  • Pointer: mods reference: limits.
  • As of: 2026-10-03, Claude Code 2.1.288.
  • Recheck trigger: that section changes a hook's time limit or what happens when it is reached.

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 testWhat it heldHeld now by
The statusline tee's suiteThe snapshot body, atomic write, rename retry, prune and the processes per renderThe 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 suiteThe shim finding the installed teeNothing: no shim ships
The wiring compose script's suiteComposing a statusLine around the shimNothing: nothing is composed now
The crossing hook's and the PreToolUse gate's suites, process counts includedThe crossing lines, the gate and the processes per fireThe line, gate and budget: cases in hooks/context-guard.test.ts
The hook-census ceiling on the crossing hook in .performance/ratchets.jsonProcesses per crossing-hook fireThe 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.

Install

/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.

Requirements

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.

Configuration

The userConfig options:

OptionWhat it controls
context_guard_hooks_enabledThe module's lines and gate, and the PostCompact marker (default true). Snapshot writes continue when it is off.
zone_lines_enabledThe module's lines to Claude, and the operator-mode suggestions (default true).
zone_report_modeautomatic (default) or operator; see Operator mode.
zone_line_dataWhat a line carries beside its zone: percent, tokens, window (default zone).
zone_hook_modeadvisory (default) or blocking; see Blocking gate.
zone_gate_grace_callsBlocking's grace budget (default 20).
zone_block_unattendedpost-compaction (default) or same-as-typed: what unattended turns get in blocking.
context_guard_bandThe band row (default false); /context-guard band on turns it on for one session.
context_guard_toastThe 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.

  • Pointer: the doc comment on Register in the claude-code/index.d.ts types Claude Code writes for its build (see create: get the types for your build).
  • As of: 2026-10-03, Claude Code 2.1.288.
  • Recheck trigger: that doc comment stops saying an options change reloads the plugin.

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 -->

Options reference

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.

OptionTypeDefaultEnvironment variableDescription
context_guard_hooks_enabledbooleantrueCLAUDE_PLUGIN_OPTION_CONTEXT_GUARD_HOOKS_ENABLEDRuns 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_enabledbooleantrueCLAUDE_PLUGIN_OPTION_ZONE_LINES_ENABLEDSends 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_modestring"automatic"CLAUDE_PLUGIN_OPTION_ZONE_REPORT_MODEautomatic (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

Source 2 files
hooks/register.tsx 907 lines
1import 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}
907
hooks/zone.ts 154 lines
1// 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