SLOPSHOPPER

context-monitor

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

new
v0.1.0MITupdated 2026-10-06aeriondyseti/context-monitor
A shopper browsing a rack in a slop shop
README

context-monitor

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.

What it watches

SignalNoteWarnStrongCritical
Context size (% of window)latest main-chain transcript entry50%75%90%
Compactionscounted via SessionStart (matcher: compact)246

Context window

Context thresholds are a percentage of the session's context window, resolved in this order:

  1. Compaction-window override — 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)
  2. Live window from the window-probe mod — the engine's own context_window_size for the current model, cached per session (see Window probe); follows /model switches
  3. Model family of the latest main-chain transcript entry — Opus / Fable / Sonnet → 1,000,000; Haiku → 200,000 (the windows the engine reports)
  4. Default → 200,000

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

Hooks

One bundled script (hooks/context-monitor.mjs) is wired to three events, branched by argv:

EventMatcherArgvBehavior
Stop*--stopAlways evaluates at end of turn.
PostToolUse*--post-tool-useThrottled to one evaluation per 30 seconds.
SessionStartcompact--session-compactIncrements the per-session compression counter.

Stop will never block — that would create an infinite re-entry loop.

Window probe (mod)

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.

Output

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.

Layout

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.

Building

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.

Releasing

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

  1. Add your changes under ## [Unreleased] in CHANGELOG.md and commit them. release refuses to run with an empty [Unreleased] section or a dirty tree.
  2. Optionally run npx @aeriondyseti/plugin-kit doctor to catch anything that would break the plugin once installed.
  3. From this repo, with the marketplace repo cloned alongside it (adjust the path if yours lives elsewhere), preview and then release:
   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.

  1. Push this repo first: git push origin main --follow-tags.
  2. Then commit and push the marketplace change. Pushing it first would pin a commit GitHub doesn't have yet, and installs would fail.

State

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.

Source 1 files
hooks/window-probe.ts 61 lines
1/**
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