Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/context-guard/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "context-guard",
"version": "0.7.48",
"version": "0.7.49",
"description": "Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks report once per transition into a worse zone across two channels \u2014 the continuation menu to the operator, who owns that choice, and to the model only the zone determination plus the counter-steer that a zone word is not a decay signal (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker.",
"author": {
"name": "Melodic Software",
Expand Down
70 changes: 70 additions & 0 deletions plugins/context-guard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,76 @@ All notable changes to the `context-guard` plugin.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.49]

### Fixed

- **`cg::read_payload_to` can no longer silently write to its own local instead of
the caller's variable.** `printf -v "$__cg_dest"` resolves the destination name
against the function's own scope, so a caller passing `input` or `chunk` — the
two names this function used for its accumulator and read block, and the two a
new caller reaches for first — had its variable left unset while the function
still returned 0. Success with no value and no error, which is the failure mode
that hides. No call site hit it (`zone-crossing-inject.sh` passes `INPUT`, the
wrapper passes `__cg_buf`), but the header invites new callers to adopt the
`_to` form, so the hazard was in front of the next caller rather than behind
this one. The internal locals are now `__cg_input` / `__cg_chunk`, the same
prefix convention `lib/hook-utils.sh` uses for `__hu_`. Because a prefix
reserves a namespace rather than abolishing the hazard, the three names that
are still internal (`__cg_dest`, `__cg_input`, `__cg_chunk`) are now refused
loudly with rc 2 and a stderr line instead of failing silently. `__cg_buf`
stays usable, since the printing wrapper passes it. `zone-crossing-inject.test.sh`
pins both halves: five caller-chosen names fill correctly, three reserved names
are refused, and the wrapper still returns the payload. The test fails five ways
against the pre-fix reader.

### Changed

- **zone-crossing hook and zone resolver: 8 process creations per fire down to 3, with no change
to what runs.** The plugin's own accounting reported a 3-process steady fire, but it counted
commands in command position, which counts invocations rather than processes. Bash elides the
extra fork inside `$(...)` only when the command carries no redirection of its own, so a
`2>/dev/null`, a `<<<`, or a pipeline written inside a substitution forks twice for one program;
a fork that never execs never reaches a command position, so the old count could not see it.
Under `strace -f` the fire was creating 8 processes. Every redirection on this path moved onto an
enclosing `{ ...; }` group, and the stdin payload is now assigned in-process instead of captured
through a command substitution. Program launches are unchanged at four: the same `jq`, `bash` and
`jq` still run over the same inputs. Affects `hooks/zone-crossing-inject.sh`, `hooks/payload.sh`
and `scripts/context-zone.sh`. Two hooks benefit: the zone-crossing hook itself, on both its
`PostToolBatch` and its `UserPromptSubmit` route, and the `PreToolUse` zone gate
(`hooks/zone-gate.sh`), which calls the same resolver and so inherits its share. The
`PostCompact` marker does **not**: `hooks/post-compact-mark.sh` never calls the resolver, and it
still reads its payload through `cg::read_payload` inside a command substitution, so its process
count is unchanged. No decision, emitted text, exit code or state file changes.
([#3520](https://github.com/melodic-software/claude-code-plugins/issues/3520))
- **The payload pass reads its input from a here-string, which spills to a temp file above 64KiB.**
Replacing `printf '%s' "$INPUT" | jq` with `jq` fed by `<<<"$INPUT"` is what removes two of the
eight process creations, but `<<<` is not a pipe: bash 5.1+ delivers a here-string through the
pipe buffer only while it fits, and at or above 64KiB writes it to `/tmp/sh-thd.*` instead
(measured: 60,000 bytes stays in the pipe, 65,536 opens the file). The old pipeline never touched
disk at any size. Output is byte-identical, but a `PostToolBatch` payload carrying every
serialized tool result routinely clears 64KiB, so large fires now write and read a temp file.
This is a real cost on the target platform, whose Defender real-time protection scans temp-file
writes — the trade is one guaranteed process creation per fire against disk I/O on the oversized
fires only. Recorded in the README's hook-cost accounting.
- **`payload.sh` grows `cg::read_payload_to`.** Assigns the drained payload to a caller-named
variable via `printf -v` rather than printing it for the caller to capture, which cost a
subshell per fire to move a string between two copies of the same shell. `cg::read_payload`
stays for existing callers and delegates to the new form, so there is one drain loop.

### Added

- **A process-creation budget in the contract test, asserted under `strace`.** `strace -f` counts
`clone`/`fork`/`vfork` on the steady non-crossing path and pins it at exactly 3, with the program
launches pinned at 4 so a fork saving cannot be confused with work removed. The pre-existing
command-position budget stays; it cannot see these forks, which is how the regression went
unnoticed. Skipped where `strace` is unavailable.
- **Redirection-placement behaviour tests.** A malformed `zones.json` drives the resolver's only
stderr path on this hook's route and pins that the notice reaches neither of the hook's streams,
that stdout stays one parseable JSON document, and that the shipped default bands still resolve
and inject; an unparsable payload pins that the payload pass's nonzero status still propagates
out of its new enclosing group rather than being absorbed by it.

## [0.7.48]

### Changed
Expand Down
68 changes: 68 additions & 0 deletions plugins/context-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,68 @@ Whole steady PostToolBatch fire, end to end: 15 processes before this pass (17 w
carries the token fields), 3 after. Those three are one `jq` in the hook, the resolver's own
process, and one `jq` inside it.

#### Counting invocations is not counting processes

The figures above count commands in command position, which counts `jq` and `bash` *invocations*.
That is not the number of processes the operating system creates, and the two came apart here.
Bash normally elides the extra fork inside `$(...)` and execs the command in the substitution's own
subshell, but only when that command carries no redirection of its own. A `2>/dev/null`, a `<<<`,
or a pipeline written *inside* the substitution defeats the elision, so bash forks the subshell and
then forks again to run the command. A fork that never execs never reaches a command position, so
the invocation count sees one process where the kernel made two.

Re-measured under `strace -f` (counting `clone`/`fork`/`vfork`), the steady fire that this section
reported as 3 was creating **8** processes. Each of the three call sites carried its redirection
inside the substitution and so cost double, and the payload pass cost triple because it was fed by
a `printf | jq` pipeline. Moving every redirection onto an enclosing `{ ...; }` group, and reading
the payload into a variable in-process rather than through a command substitution, brings the real
count to 3, the figure this section always claimed:

| Steady PostToolBatch fire | Process creations | Program launches (`execve`) |
|---|---|---|
| Before ([#3520](https://github.com/melodic-software/claude-code-plugins/issues/3520)) | 8 | 4 |
| After | 3 | 4 |

The program launches are unchanged, which is the point: the same `jq`, `bash` and `jq` still run
over the same inputs, and only the fork overhead around them is gone. On the hosts in
[#3508](https://github.com/melodic-software/claude-code-plugins/issues/3508) a process creation
costs 180 to 2,841 ms (median 1,108 ms at 501 concurrent processes) against about 1% user CPU, so
removing five of eight is the whole of the available saving on that class of host. This hook draws
on the per-turn ceiling as well as the per-tool-call one, because it fires on `UserPromptSubmit`
too.

Which hooks this reaches: the zone-crossing hook on both of its routes (`PostToolBatch` and
`UserPromptSubmit`), and the `PreToolUse` zone gate, which calls the same resolver and so inherits
its share. Not the `PostCompact` marker: `post-compact-mark.sh` never calls the resolver and still
captures its payload through a command substitution, so its count is untouched by this pass.

The contract test asserts the process-creation count under `strace` as an exact figure, alongside
the older command-position budget, so a redirection moved back inside a substitution fails a test
rather than quietly doubling a call site. Where `strace` is unavailable that assertion skips and
the command-position budget still runs.

#### The cost this pass added: a temp file on payloads over 64KiB

The saving is not free, and the charge is disk rather than CPU. Two of the five removed process
creations come from replacing `printf '%s' "$INPUT" | jq` with `jq` fed by `<<<"$INPUT"`, and a
here-string is not a pipe. Bash 5.1+ delivers one through the pipe buffer only while it fits; at or
above 64KiB it writes the string to a temp file (`/tmp/sh-thd.*`) and hands `jq` that descriptor.
Measured on bash 5.2.21: a 60,000-byte here-string opens no file, a 65,536-byte one opens
`/tmp/sh-thd.*` twice (create, then read). The pipeline this replaced never touched disk at any
size.

The extracted fields are byte-identical either way, so this changes no output. But a
`PostToolBatch` payload carries every serialized tool result and routinely clears 64KiB, so a large
fire now performs a temp-file write and read it did not perform before. That lands on the platform
this work is for: the #3508 hosts run Defender real-time protection, which scans temp-file writes,
and the 0.4.8 measurement below already attributes 22.0 s on that platform to it. The trade taken
is one process creation saved on **every** fire against disk I/O on the fires that exceed the
buffer, on hosts where a process creation costs 180 to 2,841 ms. Handing the hook's stdin straight
to `jq` would avoid both, and is declined for a separate reason: it would give up `payload.sh`'s
bounded `read -t 5` drain loop, which caps a stalled pipe at five seconds instead of letting it
block to the harness timeout. That loop is builtins only and costs no process, so keeping it is not
what the here-string pays for.

What went: every `dirname` call, replaced by parameter expansion (three in the zone-crossing hook,
two in each of the others); a second `jq`, by reading both envelope fields in one pass;
`tr -cd | head -c` on each of the two state markers, by `$(<file)` plus parameter expansion; and
Expand Down Expand Up @@ -220,6 +282,12 @@ Wall clock, n=20 after 2 warmup, host `spawn_probe` measurable (min 0.5 ms,
spread 1.78×): p50 4.8 → 1.4 ms, p95 5.0 → 1.5 ms. Blocking mode still sources
the library after the MODE check and is unchanged.

The spawn-equivalents above are command-position counts. Re-measured as process creations under
`strace -f` (0.7.49), the steady PostToolBatch and UserPromptSubmit fire was creating 8 processes
where those rows report 3; moving every redirection off the inside of a command substitution
brings it to 3 with the program launches unchanged. See "Counting invocations is not counting
processes" above for why the two counts differ and what it costs on a slow-spawn host.

The two advisory rows keep their 60-second timeout: the 0.4.8 measurement put this script at
22.0 s on Windows with Defender real-time protection, and a timeout caps a stalled hook without
speeding a normal one.
Expand Down
72 changes: 62 additions & 10 deletions plugins/context-guard/hooks/payload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,71 @@
# Returns 1 on an empty payload; callers fail open on that. On a stalled
# pipe the caller sees a truncated payload whose regex/jq extraction then
# fails its own validation — never a fabricated value.
#
# TWO ENTRY POINTS, ONE DRAIN LOOP. `cg::read_payload_to <varname>` assigns the
# payload to the named variable in the CALLER's process; `cg::read_payload`
# prints it, which every caller then has to wrap in `$(...)` — a command
# substitution, and so a forked subshell paid on the critical path of every
# fire. On a host where process creation costs hundreds of milliseconds (#3508)
# that fork is the entire cost of reading stdin, because the drain loop itself
# is nothing but `read` builtins. New callers take the `_to` form; the printing
# form stays for the callers that still use it and delegates to `_to` rather
# than duplicating the loop, so there is one drain implementation to change.
#
# WHY NOT HAND THE HOOK'S STDIN STRAIGHT TO `jq` and skip this file on the hot
# path. It would not save a process: via `_to` the loop is `read` builtins and
# costs zero. What it would save is the `<<<` here-string the caller then needs
# to re-feed the payload, which bash spills to a temp file at or above 64KiB
# (see zone-crossing-inject.sh's payload-pass note). The loop is kept anyway,
# because the bounded `read -t 5` below is the property that caps a stalled
# pipe: a slow reader truncates at five seconds instead of blocking to the
# harness timeout, which is the exact symptom #3508 is about. Disk I/O on
# oversized payloads is the smaller cost of the two.

cg::read_payload() {
local input="" chunk=""
# Every local here carries a `__cg_` prefix, including the accumulator and the
# read block. `printf -v "$__cg_dest"` resolves the destination name against
# THIS function's scope, so a local sharing a caller's chosen destination name
# would be assigned instead of the caller's variable, and the function would
# still return 0 — the caller sees success and an unset variable. The header
# above invites new callers to adopt the `_to` form, and `input` and `chunk`
# are the names such a caller reaches for first, so they must not be locals.
# Same reasoning, same prefix convention as `__hu_` in lib/hook-utils.sh.
cg::read_payload_to() {
local __cg_dest="$1"
# The prefix reserves a namespace, it does not abolish the hazard: these three
# names are this function's own locals, so `printf -v` would land on one of
# them instead of the caller's variable. Refuse loudly rather than returning 0
# with the caller's variable unset, which is the failure mode that makes this
# class of bug hard to see. Only these three are reserved; `__cg_buf`, which
# the cg::read_payload wrapper below passes, is deliberately not among them.
case $__cg_dest in
__cg_dest | __cg_input | __cg_chunk)
printf 'cg::read_payload_to: destination %s is a reserved internal name\n' \
"$__cg_dest" >&2
return 2
;;
*) ;; # every other name is the caller's to choose
esac
local __cg_input="" __cg_chunk=""
if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); then
while IFS= read -r -N 1048576 -t 5 chunk; do
input+="$chunk"
chunk=""
while IFS= read -r -N 1048576 -t 5 __cg_chunk; do
__cg_input+="$__cg_chunk"
__cg_chunk=""
done
input+="$chunk" # EOF/timeout leaves the final partial block in chunk
__cg_input+="$__cg_chunk" # EOF/timeout leaves the final partial block in __cg_chunk
else
IFS= read -r -d '' -t 5 input || true
IFS= read -r -d '' -t 5 __cg_input || true
fi
input=${input//$'\r'/}
[[ -n "$input" ]] || return 1
printf '%s' "$input"
__cg_input=${__cg_input//$'\r'/}
[[ -n "$__cg_input" ]] || return 1
# `printf -v`, not a `local -n` nameref: namerefs arrived in bash 4.3 and
# these scripts support the 3.2 macOS ships — the same support floor the -N
# fallback above exists for.
printf -v "$__cg_dest" '%s' "$__cg_input"
}

cg::read_payload() {
local __cg_buf=""
cg::read_payload_to __cg_buf || return 1
printf '%s' "$__cg_buf"
}
Loading