Skip to content

feat(ai-cli): shared context statusline with yellow/red thresholds - #125

Merged
aguil merged 11 commits into
masterfrom
feat/ai-cli-context-statusline
Aug 9, 2026
Merged

feat(ai-cli): shared context statusline with yellow/red thresholds#125
aguil merged 11 commits into
masterfrom
feat/ai-cli-context-statusline

Conversation

@aguil

@aguil aguil commented Aug 9, 2026

Copy link
Copy Markdown
Owner

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-statusline
  • dot_local/bin/lib/ai-context-thresholds.sh
  • dot_claude/hooks/executable_context-threshold-stop.sh
  • dot_claude/modify_settings.json.tmpl
  • .chezmoiscripts/run_after_25-cursor-statusline.sh
  • docs/ai-cli-context-statusline.md

Status

  • Merges cleanly into master. Verified with git merge-tree --write-tree;
    GitHub reports MERGEABLE / CLEAN.
  • Branch is behind master (cut from 4362151); a rebase would ease review but
    is not required to merge.
  • Still a draft pending your review.

Self-review pass

Ran the self-review-checks playbook: repo gates, then four
agents code-reviewagents triage rounds with fixes in between. Six issues
were found and fixed, each as its own commit.

Fixed

Finding Commit
Prettier failing on three files (CI red on open) 70076ab5
New shell files unlinted by qa.just f421f806
quality-statusline-state-merge-silent-failure adf05998
compliance-claude-settings-full-manage-clobber 85ee1711
performance-statusline-hotpath-jq-disk d2f7fcd3
compliance-modify-settings-missing-from-lint b85e3db2
quality-run-after-invalid-cli-config-aborts ce29d905
performance-statusline-green-breadcrumb-read 688f9f53
performance-statusline-yellow-rewrite 05b03f8a
security-statusline-us-field-split ab07bcdb

Two 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.tmpl fully managed ~/.claude/settings.json
from a two-key stub. chezmoi diff confirmed it would delete the live
"theme": "dark", and would wipe permissions, MCP config, and any other hooks.
Replaced with a modify_ script that merges only statusLine and this repo's
Stop hook — matching the merge-don't-clobber posture the Cursor run-after
already used.

Performance: statusline went from 8 jq spawns and a disk write on every
refresh 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.bats adds 14 cases (repo
unit 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-overwriteprintf > "$path" follows a
    pre-planted symlink out of the cache dir.
  • security-stop-hook-osc-injection — the Stop hook does not re-validate
    breadcrumb 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-commit failed on 2 of 4 runs with a corrupt npx install of prettier
(ENOTEMPTY: rename .../node_modules/prettier, and a truncated
legacy-cli.mjs). The hook runs npm exec --yes --package prettier@3.8.1 on
every CI run and nothing caches ~/.npm/_npx. It passes on re-run. Worth fixing
on master — prettier could come from .mise.toml like pre-commit and node
already do.

aguil added 11 commits August 1, 2026 13:50
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.
@aguil
aguil marked this pull request as ready for review August 9, 2026 15:01
@aguil
aguil merged commit cf51fcc into master Aug 9, 2026
1 of 2 checks passed
@aguil
aguil deleted the feat/ai-cli-context-statusline branch August 9, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant