Reads each model request's own usage, shows the real context fill above the prompt, vetoes an auto-compact the advisor's double-counted usage would fire, and…

A Claude Code plugin marketplace. Each plugin is scoped to one pain, so you install the part you want and nothing else.

/plugin marketplace add AndrewDongminYoo/cc-agents-kit
/plugin install guard-hooks@cc-agents-kit
Upgrading needs claude plugin update guard-hooks@cc-agents-kit — the marketplace suffix is required, and installing again is a no-op that reports success. Refreshing the marketplace alone is not enough: the cache keeps one directory per version, and the runtime loads the version recorded at install time, so claude plugin details can report the new version while the old one is still what fires. Hooks are read when a session starts, so restart any session that is already open.
| Plugin | What it does |
|---|---|
guard-hooks | Nine defensive hooks — five block, three warn, one rewrites Bash output. |
context-handoff | Carry work across sessions, and keep the context that carries it lean. |
repo-gate | What runs between "the code works" and "it is pushed". |
context-meter | A mod that shows the real context fill and vetoes an auto-compact the advisor's usage would fire. |
quick-look | A mod that opens the files the agent touched in macOS Quick Look. |
Two shell mistakes recur across agent sessions, and both fail silently rather than with an error you would notice.
A backtick inside a double-quoted commit or PR message. zsh runs it as command substitution, so the backticked word is deleted from the message before git ever sees it — and the command not found scrolls past in the commit output.
git commit -m "fix: cancel `players` subscription"
# committed message: "fix: cancel subscription" ← the word is gone
An unquoted glob in an option value. zsh expands it against the current directory before the tool is ever invoked, so what the tool receives depends on what happens to sit next to you. Which of three outcomes you get is decided by the count of matches in the working directory, and only one of them is loud enough to notice on its own:
grep -rn "AudioPool" lib/ --include=*.dart
# no .dart in the CWD → zsh: no matches found ← the command never ran, and
# empty output reads as "no hits"
find . -name *.md
# several .md in the CWD → find: CLAUDE.md: unknown primary or operator, exit 1
find . -name *.md
# exactly one .md in the CWD → ./README.md, exit 0 ← the dangerous one: it ran
# as `find . -name README.md`, so every nested .md is missing and nothing said so
The middle case is loud. The other two are not: a zero-match abort leaves empty output that reads as found nothing rather than never ran, and a single match returns a short, plausible, wrong answer with a success exit code.
Neither produces a wrong-looking result, which is what makes them expensive. The guard blocks both shapes before the command runs.
A hook bundle runs on every tool call, so "it has tests" is not worth much on its own. These are the three properties that matter, and each is pinned by a case that fails when the property is removed:
exit 2 → exit 0, -ot → -nt, drop the reporting line — and confirm that regression fails. Mutants are parse-checked first, because bash exits 2 on a syntax error and an unparseable mutant would otherwise produce a vacuous pass.0 silently. A broken guard degrades to no guard, and never to a blocked tool call.PostToolUse hooks cannot block you. Three only attach a warning; the fourth rewrites a Bash result to mask a credential-shaped value, and says so in the transcript. Every hook also has its own kill switch, and a disabled hook still drains its input, so turning one off cannot itself break a large Write.Nothing here is a sandbox — see Known limits for what these guards do not stop.
Each hook is a standalone bash script reading the hook JSON on stdin. The five PreToolUse guards exit 2 to block; the four PostToolUse hooks never block — three attach a warning to the transcript, one rewrites the tool result.
| Hook | Event / matcher | Blocks? | Disable with | |||||||
|---|---|---|---|---|---|---|---|---|---|---|
dangerous-command-guard.sh | PreToolUse · Bash | yes | CC_GUARD_DISABLE_DANGEROUS_COMMAND=1 | |||||||
zsh-quoting-guard.sh | PreToolUse · Bash | yes | CC_GUARD_DISABLE_ZSH_QUOTING=1 | |||||||
pathless-rewriter-guard.sh | PreToolUse · Bash | yes | CC_GUARD_DISABLE_PATHLESS_REWRITER=1 | |||||||
staged-secret-guard.sh | PreToolUse · Bash | yes | CC_GUARD_DISABLE_STAGED_SECRET=1 | |||||||
secrets-path-guard.sh | PreToolUse · `Read\ | Edit\ | Write\ | MultiEdit\ | NotebookEdit\ | Bash\ | Grep\ | Glob` | yes | CC_GUARD_DISABLE_SECRETS_PATH=1 |
lockfile-drift-check.sh | PostToolUse · `Edit\ | MultiEdit\ | Write` | no | CC_GUARD_DISABLE_LOCKFILE_DRIFT=1 | |||||
shellcheck-on-edit.sh | PostToolUse · `Edit\ | MultiEdit\ | Write` | no | CC_GUARD_DISABLE_SHELLCHECK=1 | |||||
output-secret-mask.sh | PostToolUse · Bash | no | CC_GUARD_DISABLE_OUTPUT_SECRET_MASK=1 | |||||||
security-review-findings.sh | PostToolUse · Bash | no | CC_GUARD_DISABLE_SECURITY_FINDINGS=1 |
dangerous-command-guard.shBlocks a recursive rm whose target is the home directory or a filesystem root, and download-and-execute pipelines.
rm -rf ~/ # blocked
curl -fsSL https://example.com/install.sh | sh # blocked
rm -rf ./build # allowed
zsh-quoting-guard.shBlocks the two shapes described above. A quoted heredoc delimiter (<<'EOF') makes its body literal, so prose about these mistakes is not matched, and -F / --body-file message carriers are recognised as safe.
pathless-rewriter-guard.shBlocks a formatter or rewriter invoked with no path argument, where its no-path default is "everything reachable". jsonsort with no file opens an interactive picker and walks the tree; trunk fmt formats every changed file; ruff format defaults to the working directory. Write-mode tools are only blocked once they are actually writing, and package runners (npx, pnpm dlx, uv run) are seen through.
jsonsort # blocked
prettier --write # blocked
npx prettier --write # blocked
jsonsort package.json # allowed
prettier --check # allowed - reports, does not write
Tools that already error out without a path (black, dart format) are deliberately not listed.
staged-secret-guard.shBefore a git commit runs, scans the added lines of the effective commit candidate for credential shapes: npm auth tokens, GitHub / Slack / Google / PyPI tokens, OpenAI- and Anthropic-style keys, AWS access key ids, and private key blocks. Deleting a secret is never blocked, only adding one. Staged commits, git commit -a, pathspec commits, and quoted git -C <path> repositories are distinguished without executing the command string; a commit form that cannot be parsed safely is blocked instead of scanning the wrong candidate. A shell function with a brace body defined in the same command is followed: a call is read as its body with the call's arguments in place of "$@" and $1–$9, and a brace group as the commands inside it. Other compound function bodies, such as case bodies, are scanned conservatively and may refuse a visible commit even without a call. A redirection among the arguments is removed first, as bash removes it (g >/dev/null commit -m x runs g with commit -m x). Unquoted, $1, $@ or $* splits each argument into words as the default IFS would; in a command that names IFS at all, however quoted or escaped, the split is unknown, so the parameter is left unresolved and a literal argument holding commit is refused. A definition that may not have taken effect (inside a subshell, a brace group, a branch or a loop; joined by &&, ||, | or &; after a heredoc has opened; or since removed by unset, where an expanded name may be any function's) does not hide the one before it: the call is read as both. Where the arguments cannot be placed (shift, set, a function defined inside the body, a word before them that can split) they are left unresolved, and the parse refuses what it then cannot identify; a recursion stops being read 8 deep, where every level above has been, and past 4096 added tokens a call is no longer read either; either way it is refused if its words say commit, and past the token budget also if anything it can reach, function by function, names git or commit. When the program name itself is a variable or a command substitution ($g commit, $(command -v git) commit) there is no candidate to scan, so the command is refused if commit stands where git reads its subcommand, counting the word after one of git's own options that take a separate value as that value ($g --git-dir .git commit). A variable that the same command visibly assigns a value holding a git, /git or commit word (C='git commit'; $C -m x, G=git; "$G" -c alias.ci=commit ci, X='-Sgit commit' env -S '${X} -m x') is refused wherever it is expanded as the program or in an env -S string whose program the hook cannot read, from any NAME= word in the command, export, declare, local and an array's words included. A double-quoted program word is one word, so there only a git or /git value counts, and only when the word could still end in git ("$G/bin/tool" passes). The value is not evaluated, so some commands that run no commit are refused too: a read-only git (G=git; $G log), an unquoted path under a directory named git (ROOT=~/src/git; $ROOT/configure), and a program that is not git whose value holds a commit word (CZ='cz commit'; $CZ).
This is the commit-time counterpart to secrets-path-guard.sh, which blocks reading secret files - a value still reaches a diff by being typed, pasted, or written by a generator. High-confidence patterns only: a guard that cries wolf gets switched off. It is not a replacement for entropy-based scanning; run trufflehog or gitleaks in CI as well.
secrets-path-guard.shBlocks any tool call whose file_path, command, or path names a live secrets file — dotenv files and their variants, plus ~/.zprofile.secrets. Template variants stay readable for key names and documentation. Never copy real values into chat or another file to get around a refusal.
An operator can designate exact development dotenv paths with the default-off CC_GUARD_DEV_ENV_PATHS JSON array. Only in auto mode, recognized references to those paths leave the decision to normal permissions and auto evaluation; the hook never emits allow or ask. The agent can read the selected values and use ordinary absolute-path source commands. Unregistered dotenv paths and Keychain protection remain unchanged, and other deny rules still apply. See the development-access contract and tradeoffs.
The hook checks a Bash command in two stages. The first stage reads the whole command for .env tokens, as 0.4.0 did, and runs before the second, so its refusal never waits on the second stage. The second stage splits the command into words to find a recursive search; if it stops at one of its limits, or the harness kills the hook at its 5 s timeout, only the recursive-search check is lost, never the .env token check.
A grep exclusion value of exactly .env* names no file to read, so the first stage skips it in any command of up to 64 KiB, recursive or not, when it is attached and its * is quoted: --exclude='.env*', --exclude=".env*", --exclude=.env'*', --exclude=.env"*" or --exclude=.env\*, each ending at a word boundary. The attached value keeps its --exclude= prefix through any later shell parse (eval, sh -c, a -- before it), so the shell never expands it to a dotenv file; a deliberate text transform that cuts the prefix off is in the same class as cat .e?v, which 0.4.0 already let through. The quoted two-word forms --exclude '.env*' and --exclude ".env*" lose their quotes to a second parse (eval, sh -c, unquoted $(…) output), which then expands .env* into dotenv files, and after -- the value is a file operand. So the first stage skips a two-word value only in a grep, egrep or fgrep command of up to 4 KiB that it reads from the start of the command: plain words, complete '…' strings and "…" strings without $, a backtick or a backslash, a program word that is grep itself, and no -- before the value. cd src && grep -rn "API KEY" --exclude '.env*' . passes; eval grep -r KEY . --exclude '.env*', cat -- --exclude '.env*' and grep -r "$K" --exclude '.env*' . are refused, and the refusal points at the attached form. In a command over 64 KiB the value is refused as a .env token, as in 0.4.0, because stepping past each exclusion there costs time that grows with the square of the command's length; the 4 KiB limit for the two-word form has the same cause, since each grep command found reads the command again from its start. The unquoted forms --exclude .env* and --exclude=.env* are blocked, and the refusal names the quoted forms: the shell expands an unquoted * against the file names in the working directory before grep starts, so --exclude .env* becomes --exclude .env .env.local and .env.local a searched file, and --exclude=.env* becomes --exclude=.envjunk when a file of that name exists, which a checked-out repository can carry. Any other spelling of the value (--exclude=$'.env*', '--exclude=.env*', --exclude=.env*x, a redirection between --exclude and its value) stays a .env token, and every other dotenv token in the same command is still blocked. The second stage applies the same rule to every exclusion it reads: an exclusion whose word holds a * outside quotes does not count, so --exclude=.e"nv"*, which holds no .env token for the first stage, is blocked as a recursive search without an exclusion.
A recursive search names no dotenv file and can still print one, so a Bash command that runs one is blocked unless it excludes dotenv files. A grep, egrep or fgrep with -r, -R, --recursive, --dereference-recursive or -d recurse (bundled forms such as -rn and -Rli included) must exclude dotenv files, and the check follows grep's order rule: the last --include or --exclude that matches a file decides. grep accepts any unambiguous prefix of a long option, so --recurs, --dir=recurse and GNU grep's --incl='*' count as the full option; an abbreviated --exclude is ambiguous to grep and is never accepted as an exclusion. A long option that GNU grep 3.11 or macOS grep requires a value for, written whole or as a prefix either grep resolves to it (--group-separator, --g, --la, --exclude-), takes the next word as that value, so --label --exclude='.env*' excludes nothing; --context takes a value only when attached with =, because macOS grep reads it that way, and --binary is an option of its own, not --binary-files. An --include is safe only when its glob is *.<ext> and <ext> is not on the hook's fixed list of common dotenv suffixes (dotenv_ext in the script): --include=*.ts is safe, while --include=* and --include=*.local are not. So an accepted --exclude of .env* passes unless a later unsafe --include follows it, and safe includes with no unsafe one pass on their own: --include='*' --exclude='.env*' and --include='*.ts' pass, while --exclude='.env*' --include='*' is blocked. rg skips hidden files by default, but it reads a hidden file that a positive glob or a selected type matches, so it is blocked with --hidden, -., or -uu and higher (-uuH, -u -u and -.n included), with a -g, --glob or --iglob whose last path component can match a dotenv name (-g '*', -g '*.local', -g 'src/**'), or with a selected type that lists one (-t sh and -t all, because the built-in sh type lists .env, or -t x with a --type-add that gives x such a glob, in either order); a --type-add alone only defines a type and selects nothing, so rg --type-add 'x:*' KEY . passes. As in ripgrep, the last of --hidden, -., the second -u and --no-hidden decides whether hidden files are read, so rg --hidden --no-hidden and rg -uu --no-hidden pass while rg --no-hidden -uu is blocked. Such an rg then needs -g '!.env*' or --glob='!.env*' with no later glob that can match a dotenv name, because ripgrep lets a later glob override an earlier one (-g '*' after it re-includes dotenv files; -g '*.ts', -g 'src/**/*.ts' and -g '!*.md' do not); a glob exclusion overrides a type whatever their order. Its * must be quoted too (-g '!.env*', -g"!.env*", --glob='!.env*', --iglob '!.env*', -g!.env\*): an unquoted -g!.env*, -g !.env* or --glob=!.env* is expanded against file names before rg starts, so a file named -g!.envjunk replaces it, and it counts as no exclusion, for the --ignore-file last-glob rule as well. Every option that ripgrep 15.2.0 gives a value (-m, -j, --hostname-bin, --hyperlink-format, --dfa-size-limit and the rest) takes the next word as that value, so in rg --hidden --hostname-bin -g '!.env*' KEY . the -g is the value and !.env* a path, and the search is blocked. An rg that names --ignore-file (--ignore-file x or --ignore-file=x) is blocked on the same terms whatever else it says, because an ignore file can whitelist !.env and the hook does not read the file; it passes only when -g '!.env*' is its last glob, so -g '!.env*' -g '*.ts' is blocked, because *.ts matches a .env.production.ts. The check finds the program after assignments (NAME=value, NAME+=value, NAME[subscript]=value and NAME[subscript]+=value), redirections (2>/dev/null, 2>&1, > out.txt), the prefixes command, builtin, env, exec, nohup, time, xargs, sudo, nice and timeout (the external ones also by path, such as /usr/bin/env, and as Homebrew's g-prefixed GNU tools genv, gnohup, gtime, gxargs, gnice and gtimeout) with their options, and the shell words !, {, if, then, elif, else, while, until and do, in every segment that ;, &&, ||, |, &, a subshell parenthesis or a newline separates. A prefix option reads as that program parses it: each option that the macOS or GNU version, bash's exec or sudo gives a value (exec -a name, env -u NAME, nice -n 5, sudo -u root, sudo -U user, xargs -J %, time -o file and the rest) skips its value as the next word or as the rest of a short-option cluster (sudo -nu root, exec -afake), a long option takes its value after = or as the next word under any unambiguous prefix (sudo --us root), an optional value is read only when attached (GNU xargs --max-lines, --replace, --eof, -i, -e, -l), and timeout also skips its duration. A program or prefix name that bash looks up on PATH matches in any case, because the default APFS volume is case-insensitive and runs GREP or /usr/bin/GREP as grep: grep, egrep, fgrep, rg, env, nohup, xargs, sudo, nice, timeout and their path and g-prefixed forms, plus command and time, which macOS also ships as /usr/bin/command and /usr/bin/time, so COMMAND, TIME and /usr/bin/command run. exec and builtin have no executable, so EXEC is not found, and the shell words are keywords only in lower case, so those match only as written; option letters keep their case too (-E is not -e, -U is not -u). On a case-sensitive volume an upper-case GREP -r is refused although it would not run. env -S (--split-string) builds the command it runs from a string, so a grep or rg word in that string or after it, in any case, is refused, recursive or not. The shell removes a redirection and its target from the argument list wherever they stand, so the check does too: in grep -r KEY . > --include=*.ts the --include=*.ts names the output file, not an option, and the search is blocked, while a quoted '>' stays an ordinary word. An unquoted <, > or &> ends the word before it, as in the shell, so grep</dev/null -r KEY . and grep&>/dev/null -r KEY . are read as grep with a redirection and are blocked; only an fd number or {name} directly before the operator (2>&1, {fd}>out) stays part of the redirection. It reads the command inside an unquoted $(…), <(…), >(…) or backtick substitution as segments of its own, and the options on either side of a substitution still count for the command around it, so grep $(printf KEY) -r . is blocked. It does not read a quoted string as a command, nor a comment, which runs from a # that starts a word to the end of the line, so an --exclude inside a comment does not count. It reads a word as the shell passes it: $'…' ANSI-C quoting is decoded and the $ of $"…" is dropped, so grep $'-r' KEY . and rg $'--hidden' KEY . are blocked, and a backslash in an rg glob escapes the next character, as in ripgrep, so -g '\.*' counts as a glob that can match a dotenv name. It skips the body of a heredoc whose delimiter is quoted (<<'EOF', <<"EOF", <<\EOF), unless sh, bash, zsh, dash, ksh or ssh, in any case, appears on the same line. Both stages run after the development-path policy, which leaves the whole command to the harness as before; see Known limits.
cat .env # blocked
cat .env.example # allowed
grep -rn API_KEY src # blocked
grep -rn API_KEY src --exclude='.env*' # allowed
rg --hidden API_KEY # blocked
rg --hidden -g '!.env*' API_KEY # allowed
lockfile-drift-check.shAfter a dependency manifest is edited, warns when the sibling lockfile is now older than the manifest, so it gets regenerated with the CI-equivalent command before commit. Ecosystem-agnostic: package.json, pubspec.yaml, Cargo.toml, pyproject.toml, Gemfile, Podfile, composer.json.
shellcheck-on-edit.shAfter a .sh / .bash file is edited, runs shellcheck and attaches its findings to the transcript. Silently does nothing when shellcheck is not installed.
output-secret-mask.shAfter a Bash command finishes, scans its stdout and stderr with gitleaks' default rules and replaces every credential-shaped value with [REDACTED] before the model sees the result, through hookSpecificOutput.updatedToolOutput. A cat of a config file, a printenv, or a verbose CLI that echoes its token would otherwise leave the live value in the transcript for the rest of the session and in the session log on disk. The transcript also gets a one-line note saying how many values were masked, so the agent treats [REDACTED] as deliberate rather than as missing data.
This is the output-side counterpart to secrets-path-guard.sh (which blocks reading secret files) and staged-secret-guard.sh (which blocks committing them): a value that arrives through a command the other two allow is still caught on its way into the context. Silently does nothing when gitleaks is not installed, when the output is over 2 MB, or when the tool
hooks/register.tsx 286 lines1import { atom, read, update } from "claude-code";
2import type { Register, SessionMessage } from "claude-code";
3
4import type { ContextReading } from "../types";
5
6// Why this mod exists: a turn that calls the advisor runs several API
7// iterations, and the harness sums their input tokens into one usage record,
8// so the status line's percentage reads about twice the real context for that
9// turn. One such sample at 51% read as 103% and fired an auto-compact that
10// ended the session (memory: advisor-turns-double-count-context). The advisor
11// is a server tool: no tool.call, tool.check or classic PreToolUse hook runs
12// for it, so it cannot be blocked. It runs inside one API request, so that
13// request's usage is itself the sum of the message iterations around it
14// (2026-10-07: 593,788 = 294,371 + 299,417). turn.step does not carry the
15// iterations, so a request with server tools is read as an estimate. An
16// average would run low when an iteration grows the prompt, so the estimate
17// is an upper bound on the last iteration instead: the window only grows, so
18// the previous request's floor (its measured fill, or for an estimate the
19// fill it certainly reached) is no larger than any iteration here, and with
20// at least two iterations the last one is at most the sum less that floor.
21// With no previous reading the sum stands, and nothing is vetoed.
22// An estimate is never used to nudge.
23
24const PLUGIN = "context-meter";
25
26/** Below this real reading an auto-compact is a false alarm worth vetoing. */
27const SAFE_PERCENT = 70;
28/** ...but only when the harness reads at least this much higher. */
29const GAP_PERCENT = 20;
30/** The handoff-first policy's threshold (handoff-due-nudge.sh used 80). */
31const NUDGE_PERCENT = 80;
32/** The band stays quiet below this real reading unless the gap shows. */
33const SHOW_PERCENT = 50;
34/**
35 * UTF-8 bytes in a text. A token covers at least one byte, so this bounds the
36 * token count from above for any text, Korean or emoji included; an
37 * overestimate only lets a compaction through.
38 */
39function utf8Bytes(text: string): number {
40 let bytes = 0;
41 for (const char of text) {
42 const code = char.codePointAt(0) ?? 0;
43 bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
44 }
45 return bytes;
46}
47
48/**
49 * At most the tokens the conversation gained after the request a reading came
50 * from: that request's own answer and every message after it. A reading is
51 * one request old, so a large tool result since then is not in it.
52 */
53function tokensSince(messages: readonly SessionMessage[]): number {
54 let start = messages.length;
55 for (let i = messages.length - 1; i >= 0; i--) {
56 if (messages[i]?.role === "assistant") {
57 start = i;
58 break;
59 }
60 }
61 let bytes = 0;
62 for (const message of messages.slice(start)) {
63 bytes += utf8Bytes(message.text);
64 for (const use of message.toolUses) bytes += utf8Bytes(JSON.stringify(use.input));
65 for (const result of message.toolResults ?? []) bytes += utf8Bytes(result.text);
66 }
67 return bytes;
68}
69
70// The scan that lists a module's state values needs these spelled as literals.
71const reading = atom(
72 { plugin: "context-meter", key: "reading" } as const,
73 null,
74);
75const isNudged = atom(
76 { plugin: "context-meter", key: "isNudged" } as const,
77 false,
78);
79const isHidden = atom(
80 { plugin: "context-meter", key: "isHidden" } as const,
81 false,
82);
83
84const NUDGE_TEXT =
85 "Context usage has crossed 80% of the window (handoff-first policy, measured per request by the context-meter mod). " +
86 "Before working on the user's request, write a handoff to ~/.claude/handoffs/: if a handoff skill is available (the context-handoff plugin's, in FILE mode), use it; " +
87 "otherwise write a self-contained Markdown file yourself with the goal, the current state, the decisions made, the open problems and the first next step. " +
88 "Tell the user the file path, and recommend /clear + a fresh session (a SessionStart hook that surfaces ~/.claude/handoffs/, if one is installed, will find the file). Then continue with the user's request.";
89
90export const register: Register = (on) => {
91 // The reading the last veto relied on. A reading backs one veto at most, so
92 // an estimate that is wrong (an image added since the request is invisible
93 // here) delays a needed compaction by one request, never more.
94 let lastVetoedReading: string | null = null;
95 // A refused append leaves the nudge unsent, so it is retried on the next
96 // prompt; the operator is told only once.
97 let isRefusalToasted = false;
98
99 // One reading per request, from the request's own usage, main loop only.
100 on("turn.step", async function* ($, e, next) {
101 const response = yield* next(e);
102 if (e.agentId !== undefined || response.usage === null) return response;
103
104 const {
105 input_tokens,
106 cache_read_input_tokens,
107 cache_creation_input_tokens,
108 } = response.usage;
109 const summedTokens =
110 input_tokens + cache_read_input_tokens + cache_creation_input_tokens;
111 // Only a call that finished was followed by another message iteration; one
112 // cut short (no `endedAt`) added nothing to the sum.
113 const serverTools =
114 response.serverToolUses?.filter((use) => use.endedAt !== undefined)
115 .length ?? 0;
116 // Every iteration here is at least the previous floor, and the last one is
117 // at least their average, since each iteration's prompt holds the one before.
118 // Parallel calls share an iteration, so the call count only bounds the
119 // iteration count from above: a finished call proves two iterations, and
120 // the floor is subtracted once. Counting iterations high only lowers the
121 // average, which stays a floor.
122 const previousFloor = (await read($, reading))?.floorTokens ?? 0;
123 const average = Math.round(summedTokens / (serverTools + 1));
124 const tokens =
125 serverTools === 0
126 ? summedTokens
127 : Math.max(average, summedTokens - previousFloor);
128 const floorTokens =
129 serverTools === 0 ? summedTokens : Math.max(average, previousFloor);
130 const { context } = await $.session.usage();
131 const window = context.window;
132 if (!(window > 0)) return response;
133
134 const next_: ContextReading = {
135 turnId: e.turnId,
136 tokens,
137 floorTokens,
138 window,
139 percent: Math.round((tokens / window) * 100),
140 harnessPercent: context.percent ?? null,
141 summedTokens,
142 isEstimated: serverTools > 0,
143 };
144 await update($, reading, () => next_);
145 try {
146 // A copy the operator (or a later session) can read from disk, since
147 // the band is drawn only on the operator's screen.
148 await $.store.set("lastReading", next_);
149 } catch {
150 // Diagnostic only: a store that refuses must not cost the reading.
151 }
152 return response;
153 });
154
155 // Veto the auto-compact that only the double count asked for. Fail open:
156 // a compaction this hook cannot judge proceeds as the engine meant it to.
157 on("session.compact", { trigger: "auto" }, async ($, e, next) => {
158 if (e.agentId !== undefined) return next(e);
159 const real = await read($, reading);
160 if (real === null) return next(e);
161 const harness =
162 (await $.session.usage()).context.percent ?? real.harnessPercent;
163 if (harness === null) return next(e);
164
165 const gap = harness - real.percent;
166 // The window now: the reading plus what arrived after its request.
167 const nowPercent = Math.round(
168 ((real.tokens + tokensSince(e.messages)) / real.window) * 100,
169 );
170 const readingKey = `${real.turnId}:${real.summedTokens}`;
171 if (
172 nowPercent < SAFE_PERCENT &&
173 gap >= GAP_PERCENT &&
174 lastVetoedReading !== readingKey
175 ) {
176 lastVetoedReading = readingKey;
177 $.ui.toast(
178 `${PLUGIN}: auto-compact skipped, real context ${real.percent}% (harness read ${harness}%)`,
179 );
180 return {
181 skip: `${PLUGIN}: the last request carried ${real.percent}% of the window; the harness read ${harness}% (an advisor double count). Auto-compact skipped.`,
182 };
183 }
184 return next(e);
185 }).catch(($, e, next) => next(e));
186
187 // A compaction that ran leaves the reading one window old: drop it, so the
188 // next request measures again. A precompute keeps the window as it is.
189 on("session.compact", async ($, e, next) => {
190 const result = await next(e);
191 if (e.agentId === undefined && e.trigger !== "precompute" && !("skip" in result)) {
192 await update($, reading, () => null);
193 }
194 return result;
195 });
196
197 // The handoff nudge, once per session, by the real reading. The row the
198 // model reads goes through session.append; the operator gets a toast.
199 on("prompt.submit", async ($, e, next) => {
200 const real = await read($, reading);
201 if (
202 real !== null &&
203 !real.isEstimated &&
204 real.percent >= NUDGE_PERCENT &&
205 !(await read($, isNudged))
206 ) {
207 let isStored = false;
208 try {
209 // A refusal resolves to `{ deny }`; a run no plugin may shape
210 // (settings hooks off, a delegated observation) throws instead.
211 const appended = await $.session.append({
212 message: {
213 type: "user",
214 content: [{ type: "text", text: NUDGE_TEXT }],
215 },
216 });
217 isStored = appended.deny === undefined;
218 } catch {
219 isStored = false;
220 }
221 if (isStored) {
222 await update($, isNudged, () => true);
223 $.ui.toast(
224 `${PLUGIN}: real context ${real.percent}%, handoff nudge sent to the model`,
225 );
226 } else if (!isRefusalToasted) {
227 // The prompt still goes through; the nudge is tried again next prompt.
228 isRefusalToasted = true;
229 $.ui.toast(
230 `${PLUGIN}: real context ${real.percent}%, run /handoff before continuing`,
231 );
232 }
233 }
234 return next(e);
235 });
236
237 // /clear empties the window; the reading and the nudge start over.
238 on("session.end", async ($, e, next) => {
239 // The refusal toast is this process's own state; /resume and /branch end
240 // the session without a new session.start, so it starts over on any end.
241 isRefusalToasted = false;
242 if (e.reason === "clear") {
243 await update($, reading, () => null);
244 await update($, isNudged, () => false);
245 }
246 return next(e);
247 });
248
249 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
250 const real = await read($, reading);
251 if (e.props.hasSurvey || real === null || (await read($, isHidden)))
252 return next(e);
253
254 // The figure recorded at turn.step is the harness's previous one: it takes
255 // in the summed usage only after the step (2026-10-07: recorded 40 where
256 // the summed request read 81). Read it live, as the compact veto does.
257 const harnessPercent =
258 (await $.session.usage()).context.percent ?? real.harnessPercent;
259 const gap = harnessPercent === null ? 0 : harnessPercent - real.percent;
260 if (real.percent < SHOW_PERCENT && gap < GAP_PERCENT) return next(e);
261
262 const { Box, Button, Text } = $.ui.resolve(e);
263 const harness =
264 harnessPercent === null ? "" : ` · harness ${harnessPercent}%`;
265 const flag = gap >= GAP_PERCENT ? " · inflated" : "";
266 const value = real.isEstimated
267 ? `ctx ~${real.percent}% est`
268 : `ctx ${real.percent}% real`;
269
270 return (
271 <Box>
272 <Text dimColor>
273 {value}
274 {harness}
275 {flag}{" "}
276 </Text>
277 <Button
278 key="hide"
279 label="Hide"
280 onPress={() => update($, isHidden, () => true)}
281 />
282 </Box>
283 );
284 });
285};
286types/index.d.ts 36 lines1export type ContextReading = {
2 /** The turn whose last request produced this reading. */
3 turnId: string;
4 /** The context fill in tokens: measured, or estimated when `isEstimated`. */
5 tokens: number;
6 /**
7 * A fill the last iteration certainly reached: `tokens` when measured; when
8 * estimated, the larger of the iterations' average and the previous floor.
9 */
10 floorTokens: number;
11 /** The model's context window, in tokens. */
12 window: number;
13 /** `tokens` over `window`, whole percent. */
14 percent: number;
15 /** What the status line read at the same moment, or null when it had none. */
16 harnessPercent: number | null;
17 /** The request's own input total, as the API reported it. */
18 summedTokens: number;
19 /**
20 * True when the request ran a server tool (the advisor): its usage sums the
21 * message iterations around the call, and `tokens` is an upper bound on the
22 * last iteration.
23 */
24 isEstimated: boolean;
25};
26
27declare module "claude-code" {
28 interface PluginState {
29 "context-meter": {
30 reading: ContextReading | null;
31 isNudged: boolean;
32 isHidden: boolean;
33 };
34 }
35}
36