Single-purpose Claude Code plugin that watches per-session context usage and warns the user before quality degrades.

A single-purpose Claude Code plugin that watches per-session context usage and warns the user before quality degrades.
It does one thing: read the live token count out of the transcript, count how many auto-compactions have happened, and emit a tiered notification when either crosses a threshold. Output is rendered with @aeriondyseti/plugin-kit.
| Signal | Note | Warn | Strong | Critical |
|---|---|---|---|---|
| Context size (% of window) | latest main-chain transcript entry | 50% | 75% | 90% |
| Compactions | counted via SessionStart (matcher: compact) | 2 | 4 | 6 |
Context thresholds are a percentage of the session's context window, resolved in this order:
CLAUDE_CODE_AUTO_COMPACT_WINDOW env var, then autoCompactWindow in <cwd>/.claude/settings.local.json, <cwd>/.claude/settings.json, ~/.claude/settings.json (clamped to 100k–1M)context_window_size for the current model, cached per session (see Window probe); follows /model switchesIf the observed context is already larger than the resolved window, the session must be on the extended window, so it is treated as 1,000,000.
Token count = input_tokens + cache_read_input_tokens + cache_creation_input_tokens from the most recent main-chain entry, matching ccstatusline's accounting. Sidechain (subagent) entries and API errors are excluded.
One bundled script (hooks/context-monitor.mjs) is wired to three events, branched by argv:
| Event | Matcher | Argv | Behavior |
|---|---|---|---|
Stop | * | --stop | Always evaluates at end of turn. |
PostToolUse | * | --post-tool-use | Throttled to one evaluation per 30 seconds. |
SessionStart | compact | --session-compact | Increments the per-session compression counter. |
Stop will never block — that would create an infinite re-entry loop.
Command hooks are never told the session's model or context window, but function hooks can ask the engine. hooks/window-probe.ts is a function-hook module (listed under modules in hooks.json) that reads $.session.usage().context.window and writes it to ${TMPDIR}/context-monitor/<session-id>.window.json:
{ "window": 1000000, "model": "claude-opus-5-5", "updatedAt": 1791304339540 }
It refreshes on session start, at the start of every turn (so a model picked between turns is cached before that turn's PostToolUse / Stop hooks run) and after a compaction. On Claude Code builds without function-hook support the module simply doesn't load, and the command hook falls back to the model-family map.
Healthy session: empty JSON output (no notification).
When a threshold trips, a boxed message is shown to the user, e.g.:
┌─ · Session health warning ──────────────────────────────────────────────────┐
│ ⚠ Context size is 160,000 tokens (80% of 200,000) — compression approaching │
└─────────────────────────────────────────────────────────────────────────────┘
▸ Finish the current task, commit, and start a new session to preserve quality.
context-monitor/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ ├── context-monitor.mjs ← vendored single-file bundle (committed)
│ └── window-probe.ts ← function-hook module, loaded by the engine as-is
├── src/context-monitor.ts ← source for the bundle
├── tests/window-probe.test.ts
├── package.json
├── tsconfig.json
└── README.md
hooks/context-monitor.mjs is the artifact end users actually run. It bundles @aeriondyseti/plugin-kit so the plugin works with no npm install step on the install side.
npm install
npm run typecheck
npm run build # rewrites hooks/context-monitor.mjs
# window-probe mod
npm run test:mod # claude plugin test .
npm run typecheck:mod # needs .claude-plugin/types/, which Claude Code writes the
# first time it loads the plugin (e.g. claude --plugin-dir .)
The bundle targets Node 20+ ESM. It runs under any Claude Code installation since Node is the host runtime.
Always release with plugin-kit's release command. It bumps the version, tags the release, and pins this plugin's entry in the aeriondyseti-plugins marketplace to that exact tag and commit in one step, so the published plugin never drifts from this repo. Don't bump versions, create tags, or edit the marketplace entry by hand.
Before step 1, rebuild the bundle (npm run typecheck && npm run build) and commit hooks/context-monitor.mjs: users run the committed bundle, not src/.
## [Unreleased] in CHANGELOG.md and commit them. release refuses to run with an empty [Unreleased] section or a dirty tree.npx @aeriondyseti/plugin-kit doctor to catch anything that would break the plugin once installed. npx @aeriondyseti/plugin-kit release patch --plugin . --marketplace ../aeriondyseti-plugins/.claude-plugin/marketplace.json --dry-run
npx @aeriondyseti/plugin-kit release patch --plugin . --marketplace ../aeriondyseti-plugins/.claude-plugin/marketplace.json
Use patch, minor, major, or an explicit x.y.z. This bumps .claude-plugin/plugin.json and package.json (plus package-lock.json), moves [Unreleased] under the new version in CHANGELOG.md, commits Release x.y.z, tags vx.y.z, and updates ref, sha, and version in the marketplace entry. It never pushes.
git push origin main --follow-tags.Per-session counters live at ${TMPDIR}/context-monitor/<session-id>.json. They're best-effort: a missing or unreadable state file just means the next invocation starts cold.
hooks/window-probe.ts 61 lines1/**
2 * window-probe — function-hook half of context-monitor.
3 *
4 * Command hooks never see the session's model or context window, but the
5 * engine does: `$.session.usage().context.window` is the live window of the
6 * current model (the status line's `context_window_size`), and it follows
7 * `/model` switches. This module caches it where the command hook reads it:
8 *
9 * <os tmpdir>/context-monitor/<session-id>.window.json
10 * { "window": 1000000, "model": "claude-opus-5-5", "updatedAt": 1759766400000 }
11 *
12 * It refreshes on session start, at the start of every turn (so a model picked
13 * between turns is cached before that turn's PostToolUse / Stop hooks run) and
14 * after a compaction.
15 */
16import type { EngineInterface, Register } from 'claude-code';
17
18const STATE_DIR_NAME = 'context-monitor';
19
20/** Mirrors Node's `os.tmpdir()`, which the command hook uses for the same folder. */
21async function tmpdir($: EngineInterface, isWindows: boolean): Promise<string> {
22 const dir = isWindows
23 ? ((await $.env.get('TEMP')) ??
24 (await $.env.get('TMP')) ??
25 `${(await $.env.get('SystemRoot')) ?? 'C:\\Windows'}\\temp`)
26 : ((await $.env.get('TMPDIR')) ?? (await $.env.get('TMP')) ?? (await $.env.get('TEMP')) ?? '/tmp');
27 return dir.length > 1 && /[\\/]$/.test(dir) && !/^[A-Za-z]:\\$/.test(dir) ? dir.slice(0, -1) : dir;
28}
29
30async function cacheWindow($: EngineInterface): Promise<void> {
31 try {
32 const [usage, model, sessionId] = await Promise.all([$.session.usage(), $.session.model(), $.session.id()]);
33 const safe = sessionId.replace(/[^a-zA-Z0-9-]/g, '_');
34 const isWindows = (await $.env.get('OS')) === 'Windows_NT';
35 const path = [await tmpdir($, isWindows), STATE_DIR_NAME, `${safe}.window.json`].join(isWindows ? '\\' : '/');
36 const updatedAt = await $.clock.now();
37 await $.fs.write(path, JSON.stringify({ window: usage.context.window, model, updatedAt }));
38 } catch {
39 // best effort; without the cache the command hook falls back to the model map
40 }
41}
42
43export const register: Register = (on) => {
44 on('session.start', async ($, e, next) => {
45 const result = await next(e);
46 await cacheWindow($);
47 return result;
48 });
49
50 on('turn.start', async ($, e, next) => {
51 await cacheWindow($);
52 return next(e);
53 });
54
55 on('session.compact', async ($, e, next) => {
56 const result = await next(e);
57 await cacheWindow($);
58 return result;
59 });
60};
61