feat(ai-cli): shared context statusline with yellow/red thresholds - #125
Merged
Conversation
Surface context pressure in Cursor CLI and Claude Code (200K: 65/85, 1M: 20/65), with a Claude Stop latch for user-visible nudges before compact.
The branch predates the move to GitHub Actions and was never run through the repo's prettier hook, so `pre-commit run --all-files` fails on three files this PR touches: README.md docs/agent-live-systems.md docs/ai-cli-context-statusline.md All hunks are reflow-only inside prose and a table this PR added; no content changes. Without this the PR's own CI job is red on arrival.
`qa.just lint-shell` uses hardcoded allowlists for shfmt and shellcheck, so the four shell files this PR adds were silently unlinted: dot_local/bin/executable_ai-statusline dot_local/bin/lib/ai-context-thresholds.sh dot_claude/hooks/executable_context-threshold-stop.sh .chezmoiscripts/run_after_25-cursor-statusline.sh All four already pass shfmt -i 2 -ci and shellcheck clean, so this only locks in that state and guards the follow-up fixes on this branch. Reviewer-found (not a code-review harness finding).
Addresses code-review finding quality-statusline-state-merge-silent-failure
(warning, dot_local/bin/lib/ai-context-thresholds.sh:101).
Finding:
Session state updates can fail without aborting the statusline, leaving a
permanently unusable breadcrumb file. After that, `ui_alerted` never
persists, so every yellow/red refresh treats the prior level as green and
rings BEL again (statusline updateIntervalMs is hundreds of ms).
Two interacting behaviors cause this: `ai_context_state_merge` uses
`jq ... && mv`, so a jq failure is ignored under `set -e`; and callers pass
`ai_context_state_merge "$path" "$(jq ...)"` -- in bash, a failing command
substitution as a function argument does not trip `set -e`, so an empty
patch is written for a new file. Later merges then keep failing on the
corrupt file and never recover.
One reachable trigger is non-integer AI_CTX_YELLOW/AI_CTX_RED (docs say
integers): level ranking falls through to green on the bad [ -ge ], the
--argjson patch build fails, and the empty breadcrumb is created. Any other
corrupt/empty cache file has the same BEL-spam outcome.
How it is addressed:
- Thresholds are validated as plain integers. A malformed override is
ignored in favour of the window defaults instead of flowing into
[ -ge ] and jq --argjson. Reproduced before the fix as exit 2 plus jq
errors printed into the statusline; now exits 0 with default thresholds.
- ai_context_level guards pct/yellow/red so the comparison cannot error.
- ai_context_state_merge refuses empty or non-object patches, so a failed
command substitution in a caller can no longer create an empty
breadcrumb, and cleans up its temp file.
- A corrupt existing breadcrumb is now reset from the current patch rather
than failing every refresh forever, so the BEL latch self-heals.
Adds tests/shell/unit/ai_context_thresholds.bats covering the override
validation, the empty-patch guard, and the corrupt-file heal.
Addresses code-review finding compliance-claude-settings-full-manage-clobber (warning, dot_claude/settings.json.tmpl:1). Finding: This PR adds `dot_claude/settings.json.tmpl` as a full chezmoi target for `~/.claude/settings.json`, and `docs/ai-cli-context-statusline.md` states Claude settings are fully managed from that template. The template only defines `statusLine` and `hooks`. The live applied file already has an extra `theme` key that is not in the template, so `chezmoi apply ~/.claude/settings.json` (as documented) would drop it--and would likewise wipe any other operator/local Claude keys (permissions, MCP, additional hooks, etc.). That conflicts with the same PR's Cursor approach: `run_after_25-cursor-statusline.sh` deliberately merges only `statusLine` into `cli-config.json` so model/auth/state stay local. Claude settings accumulate local preferences the same way; full replace with a two-key stub is unsafe for an interactive config under this repo's live-systems guidance. Confirmed before the fix: `chezmoi diff ~/.claude/settings.json` showed "theme": "dark" being removed on this machine. How it is addressed: Replaces the full-file template with dot_claude/modify_settings.json.tmpl. chezmoi passes the current file on stdin and takes stdout as the new content, so the script sets only statusLine and the Stop hook and preserves every other key. Unlike a run_after script it stays visible to `chezmoi diff`/`status`, which matters for an interactive config. The Stop hook is matched by command and rewritten in place, so repeated applies do not stack duplicate entries. Missing jq or an unparseable settings file passes the content through untouched rather than replacing something chezmoi cannot read. Docs updated to describe merge semantics instead of full management.
Addresses code-review finding performance-statusline-hotpath-jq-disk
(warning, dot_local/bin/executable_ai-statusline:50).
Finding:
docs/ai-cli-context-statusline.md states both CLIs run
~/.local/bin/ai-statusline on each UI update. That path is a hot loop, not a
one-shot hook.
On every refresh, executable_ai-statusline re-parses the same stdin payload
through multiple jq processes (four field extracts plus
ai_context_detect_agent), then always calls ai_context_state_merge -- which
mkdir -p's, jq-merges, and mv's a per-session cache file -- even when level
stays green and only pct/updated_at change. An upward yellow/red transition
adds a second merge (10 jq / 2 mv observed).
Timed locally against the PR scripts: ~41-46ms mean per invocation (p50
~44ms). At statusline refresh rates that latency (and the repeated fork +
write) can add UI lag and needless SSD wear; collapsing to one jq parse and
writing state only on level/alert latch changes would cut most of the cost.
How it is addressed:
- One jq pass extracts model, pct, window, session_id and the agent kind as
@TSV, replacing four extracts plus ai_context_detect_agent. That helper
had a single caller and is folded in, so it is removed from the lib.
- The previous breadcrumb is read once for both ui_alerted and level, and
the BEL latch is folded into the same patch, so an upward transition now
writes once instead of twice.
- Steady green refreshes -- the common case -- persist nothing at all. State
is written only on a level change or an alert latch update, so pct in the
breadcrumb stays current exactly when the Stop hook needs it (yellow/red).
- ai_context_state_merge validates the patch with a shell prefix test rather
than a jq process, and only pays for jq validation on the rare reset path.
dirname/mkdir/date execs are avoided the same way.
Behavior is unchanged: same colors, thresholds, hint text, bar, and one BEL
per upward level transition.
Addresses code-review finding compliance-modify-settings-missing-from-lint-shell (warning, qa.just:9). Finding: `qa.just lint-shell` uses hardcoded shfmt/shellcheck allowlists. This PR already locked the other new AI statusline shell files into those lists specifically because unlisted scripts are silently unlinted. `dot_claude/modify_settings.json.tmpl` was added afterward as the bash `modify_` merger for `~/.claude/settings.json` (interactive config), but it is still absent from both `fmt_files` and `sc_files`. The script already passes `shfmt -d -i 2 -ci` and `shellcheck` clean, so the gap is coverage--not current lint debt. Regressions in the Claude settings merge path will not be caught by `just -f qa.just lint-shell` / `verify-shell`. How it is addressed: Adds the script to both allowlists. Its Go template action sits inside a double-quoted assignment, so shellcheck and shfmt both read the raw file as valid bash without rendering; verified clean before wiring it in. Gap introduced by this branch: the earlier lint commit predates the modify_ script.
Addresses code-review finding quality-run-after-invalid-cli-config-aborts-apply (warning, .chezmoiscripts/run_after_25-cursor-statusline.sh:26). Finding: `.chezmoiscripts/run_after_25-cursor-statusline.sh` soft-fails the initial statusLine read (`jq ... || true`), then under `set -e` runs a second unprotected `jq` rewrite. If `~/.cursor/cli-config.json` is malformed JSON or a non-object (e.g. a JSON array), that second `jq` exits non-zero and the script aborts -- which fails the whole `chezmoi apply` for an optional statusLine merge. The sibling Claude path (`dot_claude/modify_settings.json.tmpl`) already treats missing jq / non-object input as pass-through. This Cursor run-after should do the same (validate then `exit 0`) instead of letting apply die. Reproduced before the fix: exit 5 with a malformed file, and exit 5 again with a JSON array (`Cannot index array with string "statusLine"`). Both abort `chezmoi apply`. How it is addressed: Validates that cli-config.json parses as a JSON object before rewriting, and guards the rewrite itself so a jq failure leaves the file untouched and removes the temp file instead of aborting. Matches the pass-through posture of the Claude modify_ script.
Addresses code-review finding performance-statusline-green-breadcrumb-read-tax (warning, dot_local/bin/executable_ai-statusline:70). Finding: After context drops back to green, the statusline resets latches in the breadcrumb but leaves the file in place. Every later green refresh still takes the `[ -f "$state_path" ]` branch and runs jq to load `ui_alerted`/`level`, even though both default to green when the file is absent and the write gate then no-ops. That permanently raises post-compact / post-summarize statusline cost for the rest of the session (2 jq vs 1; ~23ms vs ~15ms avg in local timing) with no functional benefit beyond values already implied by a missing file. Unlinking the breadcrumb on green reset (or skipping the read when only defaults are needed) would restore the cheap green path the comments claim for the common case. How it is addressed: Green now unlinks the breadcrumb rather than writing an all-green reset. A missing file already means "green, nothing alerted": the statusline defaults both latches to green and the Stop hook short-circuits on a missing file, so this is the same state expressed by absence. This also makes the preceding commit's claim about the common case true -- post-compact green refreshes were still paying a jq read for values that absence already implies. updated_at, agent, yellow, red and window are written but never read back at green, so nothing loses information.
…nged Addresses code-review finding performance-statusline-yellow-rewrite-amplifies-refresh-cost (warning, dot_local/bin/executable_ai-statusline:93). Finding: While context is yellow or red, each statusline invocation always enters `ai_context_state_merge` with a freshly built jq patch--even when `pct`/`level` are unchanged. That path pays parse + state read + patch build + merge (4 `jq` process spawns) plus an atomic rewrite of `~/.cache/ai-context-alerts/<session>.json` on every UI update. This is worse on 1M-class windows (`>=500k`), where yellow starts at 20%, so the write-heavy path covers most of a long Claude session rather than only the last ~35% of a 200K window. Docs state the command runs on each UI update; at ~2x the pristine-green cost, frequent refreshes during streaming can add measurable statusline latency and disk churn. How it is addressed: The breadcrumb read already runs once per refresh, so it now also returns the stored pct. When level, pct and the alert latch are all unchanged there is nothing new to record, and the write is skipped entirely. Everything the Stop hook reads (pct, level, yellow, red, window, compact_cmd, stop_alerted) is a function of level and pct, so a skipped write leaves the hook seeing identical values. updated_at is written but never read back by any consumer. Verified with the same old-vs-new payload matrix used for the earlier statusline rewrite: rendered output is byte-identical across all cases.
Addresses code-review finding security-statusline-us-field-split (warning, dot_local/bin/executable_ai-statusline:30). Finding: executable_ai-statusline joins multiple jq-extracted fields with US (0x1f) and parses them with IFS=$'\x1f' read. model.display_name / model.id are not stripped of US before the join, so a display name containing 0x1f inserts extra fields and shifts pct, window, session_id, and agent. That lets a crafted model string override the session breadcrumb path (amplifying symlink overwrite against a chosen filename) and fabricate usage percentage / alert level independently of the real context_window.used_percentage. Reproduced before the fix: a payload reporting 5% (green) with US bytes in model.display_name rendered as "99%" in red and wrote its breadcrumb to hijacked.json instead of real.json. How it is addressed: Every extracted field is stripped of control characters inside the same jq pass before being joined, so no value can introduce a separator. This is correctness as much as hardening -- the separator was introduced by the earlier single-pass refactor on this branch, and a parser must sanitise the values it splits on. Defect introduced by this branch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Carries over Codeberg PR #125, which never landed. Opened as a draft — the
original was marked WIP.
Adds a shared AI-CLI context statusline with yellow/red thresholds:
dot_local/bin/executable_ai-statuslinedot_local/bin/lib/ai-context-thresholds.shdot_claude/hooks/executable_context-threshold-stop.shdot_claude/modify_settings.json.tmpl.chezmoiscripts/run_after_25-cursor-statusline.shdocs/ai-cli-context-statusline.mdStatus
master. Verified withgit merge-tree --write-tree;GitHub reports
MERGEABLE/CLEAN.master(cut from4362151); a rebase would ease review butis not required to merge.
Self-review pass
Ran the
self-review-checksplaybook: repo gates, then fouragents code-review→agents triagerounds with fixes in between. Six issueswere found and fixed, each as its own commit.
Fixed
70076ab5qa.justf421f806quality-statusline-state-merge-silent-failureadf05998compliance-claude-settings-full-manage-clobber85ee1711performance-statusline-hotpath-jq-diskd2f7fcd3compliance-modify-settings-missing-from-lintb85e3db2quality-run-after-invalid-cli-config-abortsce29d905performance-statusline-green-breadcrumb-read688f9f53performance-statusline-yellow-rewrite05b03f8asecurity-statusline-us-field-splitab07bcdbTwo of the above were defects introduced during this review pass, caught by a
before/after output matrix and fixed before landing.
Notable:
dot_claude/settings.json.tmplfully managed~/.claude/settings.jsonfrom a two-key stub.
chezmoi diffconfirmed it would delete the live"theme": "dark", and would wipepermissions, MCP config, and any other hooks.Replaced with a
modify_script that merges onlystatusLineand this repo'sStophook — matching the merge-don't-clobber posture the Cursor run-afteralready used.
Performance: statusline went from 8
jqspawns and a disk write on everyrefresh to 1 spawn and no write at green; steady yellow is 2 spawns. Measured
46ms → 15ms green, 46ms → 17ms yellow. Rendered output verified byte-identical
to the original across an 11-case payload matrix.
Tests:
tests/shell/unit/ai_context_thresholds.batsadds 14 cases (repounit suite 9 → 23). Each was confirmed to fail against the pre-fix code.
Accepted risk — not fixed
Both need an attacker who can already write inside
~/.cache/ai-context-alerts/,which implies they already control the account:
security-state-merge-symlink-overwrite—printf > "$path"follows apre-planted symlink out of the cache dir.
security-stop-hook-osc-injection— the Stop hook does not re-validatebreadcrumb fields before embedding them in an OSC 777 sequence. The statusline
only ever writes validated integers and a fixed command string there.
Both are cheap to harden (an
[ -L ]guard; integer re-validation in the hook)if you would rather close them than accept them.
CI note — pre-existing, not from this PR
pre-commitfailed on 2 of 4 runs with a corrupt npx install of prettier(
ENOTEMPTY: rename .../node_modules/prettier, and a truncatedlegacy-cli.mjs). The hook runsnpm exec --yes --package prettier@3.8.1onevery CI run and nothing caches
~/.npm/_npx. It passes on re-run. Worth fixingon
master— prettier could come from.mise.tomllikepre-commitandnodealready do.