From bb8d6be44cdae2212a8d5f85c9ec2de7a6364241 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 1 Aug 2026 13:45:54 -0600 Subject: [PATCH 01/11] feat(ai-cli): add shared context statusline with yellow/red thresholds 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. --- .../run_after_25-cursor-statusline.sh | 33 +++++ README.md | 7 ++ docs/agent-live-systems.md | 7 ++ docs/ai-cli-context-statusline.md | 90 ++++++++++++++ .../executable_context-threshold-stop.sh | 64 ++++++++++ dot_claude/settings.json.tmpl | 19 +++ dot_local/bin/executable_ai-statusline | 117 ++++++++++++++++++ dot_local/bin/lib/ai-context-thresholds.sh | 105 ++++++++++++++++ 8 files changed, 442 insertions(+) create mode 100755 .chezmoiscripts/run_after_25-cursor-statusline.sh create mode 100644 docs/ai-cli-context-statusline.md create mode 100755 dot_claude/hooks/executable_context-threshold-stop.sh create mode 100644 dot_claude/settings.json.tmpl create mode 100755 dot_local/bin/executable_ai-statusline create mode 100755 dot_local/bin/lib/ai-context-thresholds.sh diff --git a/.chezmoiscripts/run_after_25-cursor-statusline.sh b/.chezmoiscripts/run_after_25-cursor-statusline.sh new file mode 100755 index 0000000..996853e --- /dev/null +++ b/.chezmoiscripts/run_after_25-cursor-statusline.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Ensure Cursor CLI statusLine points at the shared ai-statusline script. +# Does not rewrite other cli-config.json fields (model/auth/state stay intact). + +set -euo pipefail + +cfg="${HOME}/.cursor/cli-config.json" +script="${HOME}/.local/bin/ai-statusline" + +if [ ! -f "$cfg" ] || [ ! -x "$script" ]; then + exit 0 +fi + +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +current="$(jq -r '.statusLine.command // empty' "$cfg" 2>/dev/null || true)" +# Compare literal "~/.local/bin/..." in case an older config stored a tilde path. +# shellcheck disable=SC2088 +if [ "$current" = "$script" ] || [ "$current" = "~/.local/bin/ai-statusline" ]; then + exit 0 +fi + +tmp="$(mktemp)" +jq --arg cmd "$script" ' + .statusLine = { + "type": "command", + "command": $cmd, + "padding": 2 + } +' "$cfg" >"$tmp" +mv "$tmp" "$cfg" diff --git a/README.md b/README.md index bf93f15..349f1b5 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,13 @@ This is defined in `dot_bash_profile.tmpl` and `dot_zshrc.tmpl`. - `AI_FILE_LINES` (default `300`) - `AI_DIFF_LINES` (default `300`) +## Agent CLI context statusline + +Cursor CLI and Claude Code share `~/.local/bin/ai-statusline` for a +yellow/red context meter (200K-class: 65%/85%; 1M-class: 20%/65%). Claude also +gets a `Stop` hook nudge. Thresholds, source paths, overrides, and smoke tests: +**`docs/ai-cli-context-statusline.md`**. + ## Neovim version and install notes This Neovim config tracks newer APIs and plugins, and may not work with distro diff --git a/docs/agent-live-systems.md b/docs/agent-live-systems.md index abe2a1d..7b469c6 100644 --- a/docs/agent-live-systems.md +++ b/docs/agent-live-systems.md @@ -127,6 +127,13 @@ Tracked defaults **do not** rebind Ctrl+V; text paste stays on Ctrl+V. Agent clipboard images use tmux `prefix + p` (`paste-image-agent.sh` in `dot_tmux.conf.tmpl`), not WT keybindings. +## Agent CLI statusline / hooks + +Context meter and Claude `Stop` latch: +[`docs/ai-cli-context-statusline.md`](ai-cli-context-statusline.md). After +apply, **restart** Cursor CLI / Claude Code so `statusLine` and hooks reload; +do not treat a live session as validation of the new config. + ## Checklist before closing a dotfiles task - [ ] Rendered config parses (`tmux -f …`, `bash -n`, JSON valid for WT) diff --git a/docs/ai-cli-context-statusline.md b/docs/ai-cli-context-statusline.md new file mode 100644 index 0000000..50b88f7 --- /dev/null +++ b/docs/ai-cli-context-statusline.md @@ -0,0 +1,90 @@ +# Agent CLI context statusline + +Shared context-usage meter for **Cursor CLI** (`cursor-agent` / `agent`) and +**Claude Code**. Warns at yellow/red thresholds so you can compact deliberately +instead of waiting for auto-summarize / auto-compact. + +## Behavior + +Both CLIs run `~/.local/bin/ai-statusline` as a `statusLine` command. On each +UI update it reads session JSON from stdin and prints a colored bar: + +| `context_window_size` | Yellow | Red | Hint command | +| --------------------- | ------ | --- | ------------ | +| < 500k (200K-class) | 65% | 85% | `/summarize` (Cursor) | +| ≥ 500k (1M-class) | 20% | 65% | `/compact` (Claude) | + +Agent detection: Claude payloads include `cost`, `rate_limits`, and/or +`exceeds_200k_tokens`; otherwise Cursor is assumed. + +On an **upward** level change (green→yellow, yellow→red) the statusline rings +BEL once per level per session. Usage dropping back below yellow clears the +latch. + +Session breadcrumbs live under +`${XDG_CACHE_HOME:-~/.cache}/ai-context-alerts/.json`. + +### Claude Code Stop hook + +`~/.claude/hooks/context-threshold-stop.sh` runs on `Stop`. When the breadcrumb +shows yellow/red and that level has not yet been stop-notified, it returns: + +- `systemMessage` — user-visible nudge in the Claude UI +- `terminalSequence` — OSC 777 desktop notify + BEL + +Cursor CLI cannot mirror this today: interactive `cursor-agent` only fires +shell hooks, and IDE `stop` only supports `followup_message` (auto-submits a +prompt), not a passive user alert. Cursor relies on the statusline + BEL. + +## Source → applied + +| Source | Target | +| ------ | ------ | +| `dot_local/bin/executable_ai-statusline` | `~/.local/bin/ai-statusline` | +| `dot_local/bin/lib/ai-context-thresholds.sh` | `~/.local/bin/lib/ai-context-thresholds.sh` | +| `dot_claude/settings.json.tmpl` | `~/.claude/settings.json` | +| `dot_claude/hooks/executable_context-threshold-stop.sh` | `~/.claude/hooks/context-threshold-stop.sh` | +| `.chezmoiscripts/run_after_25-cursor-statusline.sh` | merges `statusLine` into `~/.cursor/cli-config.json` | + +`cli-config.json` is **not** fully managed (model/auth/state stay local). The +run-after only sets/updates the `statusLine` object. Claude `settings.json` **is** +fully managed from the template — edit the chezmoi source, not only the live +file. + +## Overrides + +```bash +export AI_CTX_YELLOW=50 +export AI_CTX_RED=80 +``` + +Both must be set to override the window-size defaults. + +## Apply and reload + +```bash +chezmoi apply ~/.local/bin/ai-statusline \ + ~/.local/bin/lib/ai-context-thresholds.sh \ + ~/.claude/settings.json \ + ~/.claude/hooks/context-threshold-stop.sh +# run_after_25 merges Cursor statusLine on full apply; or run it once: +bash ~/.local/share/chezmoi/.chezmoiscripts/run_after_25-cursor-statusline.sh +``` + +Restart the Cursor CLI / Claude Code session (or start a new one) so +`statusLine` / hooks reload. See `docs/agent-live-systems.md` for general +apply/reload hygiene. + +## Smoke test + +```bash +echo '{"session_id":"probe","model":{"display_name":"Test"},"context_window":{"used_percentage":70,"context_window_size":200000}}' \ + | ~/.local/bin/ai-statusline + +echo '{"session_id":"probe","model":{"display_name":"Opus"},"context_window":{"used_percentage":25,"context_window_size":1000000},"cost":{}}' \ + | ~/.local/bin/ai-statusline +``` + +Expect yellow hints at those percentages, with `/summarize` vs `/compact` +respectively. Clean up with +`rm -f ~/.cache/ai-context-alerts/probe.json`. diff --git a/dot_claude/hooks/executable_context-threshold-stop.sh b/dot_claude/hooks/executable_context-threshold-stop.sh new file mode 100755 index 0000000..cd50ac7 --- /dev/null +++ b/dot_claude/hooks/executable_context-threshold-stop.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Claude Code Stop hook: user-visible nudge when context crosses yellow/red. +# +# Depends on ~/.local/bin/ai-statusline writing a per-session breadcrumb under +# $XDG_CACHE_HOME/ai-context-alerts/. Emits systemMessage + terminalSequence +# once per level per session (resets when usage drops back to green). + +set -euo pipefail + +helper="${HOME}/.local/bin/lib/ai-context-thresholds.sh" +if [ ! -f "$helper" ]; then + exit 0 +fi +# shellcheck source=/dev/null +. "$helper" + +input="$(cat)" +session_id="$(printf '%s' "$input" | jq -r '.session_id // "unknown"')" +state_path="$(ai_context_session_state_path "$session_id")" + +if [ ! -f "$state_path" ]; then + exit 0 +fi + +pct="$(ai_context_state_get "$state_path" pct 0)" +level="$(ai_context_state_get "$state_path" level green)" +yellow="$(ai_context_state_get "$state_path" yellow 65)" +red="$(ai_context_state_get "$state_path" red 85)" +window="$(ai_context_state_get "$state_path" window 200000)" +compact_cmd="$(ai_context_state_get "$state_path" compact_cmd /compact)" +stop_alerted="$(ai_context_state_get "$state_path" stop_alerted green)" + +case "$pct" in + '' | *[!0-9]*) pct=0 ;; +esac + +if [ "$level" = "green" ]; then + exit 0 +fi + +prev_rank="$(ai_context_level_rank "$stop_alerted")" +cur_rank="$(ai_context_level_rank "$level")" +if [ "$cur_rank" -le "$prev_rank" ]; then + exit 0 +fi + +case "$level" in + red) + msg="Context at ${pct}% (red ≥${red}% for ${window}-token window). Run ${compact_cmd} soon — auto-compact may be imminent." + title="Claude Code · context red" + ;; + *) + msg="Context at ${pct}% (yellow ≥${yellow}% for ${window}-token window). Good spot to run ${compact_cmd} before continuing." + title="Claude Code · context yellow" + ;; +esac + +# OSC 777 desktop notify + BEL (allowlisted via Claude terminalSequence). +seq="$(printf '\033]777;notify;%s;%s\007\a' "$title" "$msg")" + +jq -nc --arg msg "$msg" --arg seq "$seq" \ + '{systemMessage:$msg, terminalSequence:$seq}' + +ai_context_state_merge "$state_path" "$(jq -nc --arg level "$level" '{stop_alerted:$level}')" diff --git a/dot_claude/settings.json.tmpl b/dot_claude/settings.json.tmpl new file mode 100644 index 0000000..da078bc --- /dev/null +++ b/dot_claude/settings.json.tmpl @@ -0,0 +1,19 @@ +{ + "statusLine": { + "type": "command", + "command": "{{ .chezmoi.homeDir }}/.local/bin/ai-statusline", + "padding": 2 + }, + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "{{ .chezmoi.homeDir }}/.claude/hooks/context-threshold-stop.sh" + } + ] + } + ] + } +} diff --git a/dot_local/bin/executable_ai-statusline b/dot_local/bin/executable_ai-statusline new file mode 100755 index 0000000..18cb187 --- /dev/null +++ b/dot_local/bin/executable_ai-statusline @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Shared statusline for Cursor CLI + Claude Code. +# Reads StatusLinePayload JSON on stdin; prints a colored context meter. +# +# Thresholds (by context_window_size): +# < 500k → yellow 65% / red 85% (200K-class) +# >= 500k → yellow 20% / red 65% (1M-class) +# Override with AI_CTX_YELLOW / AI_CTX_RED. +# +# On yellow/red transitions, rings BEL once per level per session and updates +# a cache breadcrumb for Claude's Stop hook. + +set -euo pipefail + +lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +. "$lib_dir/lib/ai-context-thresholds.sh" + +payload="$(cat)" + +model="$(printf '%s' "$payload" | jq -r '.model.display_name // .model.id // "?"')" +pct_raw="$(printf '%s' "$payload" | jq -r '.context_window.used_percentage // empty')" +window="$(printf '%s' "$payload" | jq -r '.context_window.context_window_size // 200000')" +session_id="$(printf '%s' "$payload" | jq -r '.session_id // "unknown"')" + +if [ -z "$pct_raw" ] || [ "$pct_raw" = "null" ]; then + printf '\033[90m[%s] ctx —\033[0m\n' "$model" + exit 0 +fi + +pct="$(printf '%s' "$pct_raw" | cut -d. -f1)" +# Guard non-numeric early-session values. +case "$pct" in + '' | *[!0-9]*) pct=0 ;; +esac +case "$window" in + '' | *[!0-9]*) window=200000 ;; +esac + +read -r yellow red < <(ai_context_thresholds "$window") +level="$(ai_context_level "$pct" "$yellow" "$red")" +agent="$(ai_context_detect_agent "$payload")" +compact_cmd="$(ai_context_compact_cmd "$agent")" + +state_path="$(ai_context_session_state_path "$session_id")" +prev_ui="$(ai_context_state_get "$state_path" ui_alerted green)" + +# Drop below yellow → clear latches so a later climb can alert again. +if [ "$level" = "green" ]; then + ai_context_state_merge "$state_path" "$(jq -nc \ + --argjson pct "$pct" \ + --arg level "$level" \ + --argjson window "$window" \ + --argjson yellow "$yellow" \ + --argjson red "$red" \ + --arg agent "$agent" \ + --arg compact_cmd "$compact_cmd" \ + --argjson now "$(date +%s)" \ + '{ + pct:$pct, level:$level, window:$window, yellow:$yellow, red:$red, + agent:$agent, compact_cmd:$compact_cmd, updated_at:$now, + ui_alerted:"green", stop_alerted:"green" + }')" +else + ai_context_state_merge "$state_path" "$(jq -nc \ + --argjson pct "$pct" \ + --arg level "$level" \ + --argjson window "$window" \ + --argjson yellow "$yellow" \ + --argjson red "$red" \ + --arg agent "$agent" \ + --arg compact_cmd "$compact_cmd" \ + --argjson now "$(date +%s)" \ + '{ + pct:$pct, level:$level, window:$window, yellow:$yellow, red:$red, + agent:$agent, compact_cmd:$compact_cmd, updated_at:$now + }')" +fi + +hint="" +color=$'\033[32m' +case "$level" in + yellow) + color=$'\033[33m' + hint=" · good spot to ${compact_cmd}" + ;; + red) + color=$'\033[31m' + hint=" · ${compact_cmd} soon" + ;; +esac + +# Progress bar (10 cells). +bar_width=10 +filled=$((pct * bar_width / 100)) +[ "$filled" -gt "$bar_width" ] && filled="$bar_width" +empty=$((bar_width - filled)) +bar="" +if [ "$filled" -gt 0 ]; then + printf -v fill '%*s' "$filled" '' + bar="${fill// /▓}" +fi +if [ "$empty" -gt 0 ]; then + printf -v pad '%*s' "$empty" '' + bar="${bar}${pad// /░}" +fi + +# One BEL per upward level transition (yellow, then red). +prev_rank="$(ai_context_level_rank "$prev_ui")" +cur_rank="$(ai_context_level_rank "$level")" +bell="" +if [ "$cur_rank" -gt "$prev_rank" ]; then + bell=$'\a' + ai_context_state_merge "$state_path" "$(jq -nc --arg level "$level" '{ui_alerted:$level}')" +fi + +printf '%s[%s] %s %s%%%s\033[0m%s\n' "$color" "$model" "$bar" "$pct" "$hint" "$bell" diff --git a/dot_local/bin/lib/ai-context-thresholds.sh b/dot_local/bin/lib/ai-context-thresholds.sh new file mode 100755 index 0000000..d0c7dc7 --- /dev/null +++ b/dot_local/bin/lib/ai-context-thresholds.sh @@ -0,0 +1,105 @@ +# shellcheck shell=bash +# Shared context-usage thresholds for Cursor CLI + Claude Code statusline/hooks. +# +# Defaults: +# ~200K windows → yellow 65% / red 85% +# ~1M windows → yellow 20% / red 65% +# Override with AI_CTX_YELLOW / AI_CTX_RED (integers). + +ai_context_cache_dir() { + printf '%s\n' "${XDG_CACHE_HOME:-$HOME/.cache}/ai-context-alerts" +} + +ai_context_thresholds() { + local window="${1:-200000}" + local yellow red + + if [ -n "${AI_CTX_YELLOW:-}" ] && [ -n "${AI_CTX_RED:-}" ]; then + printf '%s %s\n' "$AI_CTX_YELLOW" "$AI_CTX_RED" + return 0 + fi + + if [ "$window" -ge 500000 ]; then + yellow=20 + red=65 + else + yellow=65 + red=85 + fi + + printf '%s %s\n' "$yellow" "$red" +} + +# Prints: green | yellow | red +ai_context_level() { + local pct="${1:-0}" + local yellow="${2:-65}" + local red="${3:-85}" + + if [ "$pct" -ge "$red" ]; then + printf 'red\n' + elif [ "$pct" -ge "$yellow" ]; then + printf 'yellow\n' + else + printf 'green\n' + fi +} + +ai_context_level_rank() { + case "${1:-green}" in + red) printf '2\n' ;; + yellow) printf '1\n' ;; + *) printf '0\n' ;; + esac +} + +# Detect agent from statusline payload shape. +# Prints: claude | cursor +ai_context_detect_agent() { + local payload="$1" + if printf '%s' "$payload" | jq -e 'has("cost") or has("rate_limits") or has("exceeds_200k_tokens")' >/dev/null 2>&1; then + printf 'claude\n' + else + printf 'cursor\n' + fi +} + +ai_context_compact_cmd() { + case "${1:-cursor}" in + claude) printf '/compact\n' ;; + *) printf '/summarize\n' ;; + esac +} + +ai_context_session_state_path() { + local session_id="${1:-unknown}" + # Keep filenames boring for shell/json tooling. + session_id="${session_id//\//_}" + printf '%s/%s.json\n' "$(ai_context_cache_dir)" "$session_id" +} + +# Read a field from the session state file; default as $2. +ai_context_state_get() { + local path="$1" + local key="$2" + local default="${3:-}" + if [ ! -f "$path" ]; then + printf '%s\n' "$default" + return 0 + fi + jq -r --arg key "$key" --arg default "$default" '.[$key] // $default' "$path" 2>/dev/null || printf '%s\n' "$default" +} + +# Merge JSON object fields into the session state file. +ai_context_state_merge() { + local path="$1" + local patch_json="$2" + local dir + dir="$(dirname "$path")" + mkdir -p "$dir" + if [ -f "$path" ]; then + jq -c --argjson patch "$patch_json" '. * $patch' "$path" >"${path}.tmp" && mv "${path}.tmp" "$path" + else + printf '%s\n' "$patch_json" >"$path" + fi +} From 70076ab5c695c409f8c5172c45565048a3631d6d Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:03:32 -0600 Subject: [PATCH 02/11] style(ai-cli): apply prettier formatting to context statusline docs 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. --- README.md | 6 ++--- docs/agent-live-systems.md | 4 +-- docs/ai-cli-context-statusline.md | 41 +++++++++++++++---------------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 349f1b5..fd68be8 100644 --- a/README.md +++ b/README.md @@ -182,9 +182,9 @@ This is defined in `dot_bash_profile.tmpl` and `dot_zshrc.tmpl`. ## Agent CLI context statusline -Cursor CLI and Claude Code share `~/.local/bin/ai-statusline` for a -yellow/red context meter (200K-class: 65%/85%; 1M-class: 20%/65%). Claude also -gets a `Stop` hook nudge. Thresholds, source paths, overrides, and smoke tests: +Cursor CLI and Claude Code share `~/.local/bin/ai-statusline` for a yellow/red +context meter (200K-class: 65%/85%; 1M-class: 20%/65%). Claude also gets a +`Stop` hook nudge. Thresholds, source paths, overrides, and smoke tests: **`docs/ai-cli-context-statusline.md`**. ## Neovim version and install notes diff --git a/docs/agent-live-systems.md b/docs/agent-live-systems.md index 7b469c6..ed748b3 100644 --- a/docs/agent-live-systems.md +++ b/docs/agent-live-systems.md @@ -131,8 +131,8 @@ clipboard images use tmux `prefix + p` (`paste-image-agent.sh` in Context meter and Claude `Stop` latch: [`docs/ai-cli-context-statusline.md`](ai-cli-context-statusline.md). After -apply, **restart** Cursor CLI / Claude Code so `statusLine` and hooks reload; -do not treat a live session as validation of the new config. +apply, **restart** Cursor CLI / Claude Code so `statusLine` and hooks reload; do +not treat a live session as validation of the new config. ## Checklist before closing a dotfiles task diff --git a/docs/ai-cli-context-statusline.md b/docs/ai-cli-context-statusline.md index 50b88f7..5b316b2 100644 --- a/docs/ai-cli-context-statusline.md +++ b/docs/ai-cli-context-statusline.md @@ -6,13 +6,13 @@ instead of waiting for auto-summarize / auto-compact. ## Behavior -Both CLIs run `~/.local/bin/ai-statusline` as a `statusLine` command. On each -UI update it reads session JSON from stdin and prints a colored bar: +Both CLIs run `~/.local/bin/ai-statusline` as a `statusLine` command. On each UI +update it reads session JSON from stdin and prints a colored bar: -| `context_window_size` | Yellow | Red | Hint command | -| --------------------- | ------ | --- | ------------ | -| < 500k (200K-class) | 65% | 85% | `/summarize` (Cursor) | -| ≥ 500k (1M-class) | 20% | 65% | `/compact` (Claude) | +| `context_window_size` | Yellow | Red | Hint command | +| ---------------------- | ------ | --- | --------------------- | +| < 500k (200K-class) | 65% | 85% | `/summarize` (Cursor) | +| ≥ 500k (1M-class) | 20% | 65% | `/compact` (Claude) | Agent detection: Claude payloads include `cost`, `rate_limits`, and/or `exceeds_200k_tokens`; otherwise Cursor is assumed. @@ -32,24 +32,24 @@ shows yellow/red and that level has not yet been stop-notified, it returns: - `systemMessage` — user-visible nudge in the Claude UI - `terminalSequence` — OSC 777 desktop notify + BEL -Cursor CLI cannot mirror this today: interactive `cursor-agent` only fires -shell hooks, and IDE `stop` only supports `followup_message` (auto-submits a -prompt), not a passive user alert. Cursor relies on the statusline + BEL. +Cursor CLI cannot mirror this today: interactive `cursor-agent` only fires shell +hooks, and IDE `stop` only supports `followup_message` (auto-submits a prompt), +not a passive user alert. Cursor relies on the statusline + BEL. ## Source → applied -| Source | Target | -| ------ | ------ | -| `dot_local/bin/executable_ai-statusline` | `~/.local/bin/ai-statusline` | -| `dot_local/bin/lib/ai-context-thresholds.sh` | `~/.local/bin/lib/ai-context-thresholds.sh` | -| `dot_claude/settings.json.tmpl` | `~/.claude/settings.json` | -| `dot_claude/hooks/executable_context-threshold-stop.sh` | `~/.claude/hooks/context-threshold-stop.sh` | -| `.chezmoiscripts/run_after_25-cursor-statusline.sh` | merges `statusLine` into `~/.cursor/cli-config.json` | +| Source | Target | +| ------------------------------------------------------- | ---------------------------------------------------- | +| `dot_local/bin/executable_ai-statusline` | `~/.local/bin/ai-statusline` | +| `dot_local/bin/lib/ai-context-thresholds.sh` | `~/.local/bin/lib/ai-context-thresholds.sh` | +| `dot_claude/settings.json.tmpl` | `~/.claude/settings.json` | +| `dot_claude/hooks/executable_context-threshold-stop.sh` | `~/.claude/hooks/context-threshold-stop.sh` | +| `.chezmoiscripts/run_after_25-cursor-statusline.sh` | merges `statusLine` into `~/.cursor/cli-config.json` | `cli-config.json` is **not** fully managed (model/auth/state stay local). The -run-after only sets/updates the `statusLine` object. Claude `settings.json` **is** -fully managed from the template — edit the chezmoi source, not only the live -file. +run-after only sets/updates the `statusLine` object. Claude `settings.json` +**is** fully managed from the template — edit the chezmoi source, not only the +live file. ## Overrides @@ -86,5 +86,4 @@ echo '{"session_id":"probe","model":{"display_name":"Opus"},"context_window":{"u ``` Expect yellow hints at those percentages, with `/summarize` vs `/compact` -respectively. Clean up with -`rm -f ~/.cache/ai-context-alerts/probe.json`. +respectively. Clean up with `rm -f ~/.cache/ai-context-alerts/probe.json`. From f421f80620cfd381e37263fce835d407f49fcdb5 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:04:00 -0600 Subject: [PATCH 03/11] test(qa): lint the new AI statusline shell files `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). --- qa.just | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qa.just b/qa.just index 9658cd8..5dbde7e 100644 --- a/qa.just +++ b/qa.just @@ -6,10 +6,10 @@ default: # Lint shell scripts and test files. lint-shell: @set -euo pipefail; \ - fmt_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ + fmt_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/executable_ai-statusline" "{{ justfile_directory() }}/dot_local/bin/lib/ai-context-thresholds.sh" "{{ justfile_directory() }}/dot_claude/hooks/executable_context-threshold-stop.sh" "{{ justfile_directory() }}/.chezmoiscripts/run_after_25-cursor-statusline.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ while IFS= read -r _file; do fmt_files+=("$_file"); done < <(find "{{ justfile_directory() }}/tests/shell" -type f \( -name '*.sh' -o -name '*.bash' -o -name '*.bats' \) 2>/dev/null | sort); \ mise exec --locked -- shfmt -d -i 2 -ci "${fmt_files[@]}"; \ - sc_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/lib/clipboard-image.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ + sc_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/lib/clipboard-image.sh" "{{ justfile_directory() }}/dot_local/bin/executable_ai-statusline" "{{ justfile_directory() }}/dot_local/bin/lib/ai-context-thresholds.sh" "{{ justfile_directory() }}/dot_claude/hooks/executable_context-threshold-stop.sh" "{{ justfile_directory() }}/.chezmoiscripts/run_after_25-cursor-statusline.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ while IFS= read -r _file; do sc_files+=("$_file"); done < <(find "{{ justfile_directory() }}/tests/shell" -type f \( -name '*.sh' -o -name '*.bash' \) 2>/dev/null | sort); \ mise exec --locked -- shellcheck "${sc_files[@]}" From adf05998cde1f54e4100245642a7a1898ff7b2c0 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:05:24 -0600 Subject: [PATCH 04/11] fix(ai-cli): heal corrupt context breadcrumbs and reject bad thresholds 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. --- dot_local/bin/lib/ai-context-thresholds.sh | 55 +++++++++-- tests/shell/unit/ai_context_thresholds.bats | 103 ++++++++++++++++++++ 2 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 tests/shell/unit/ai_context_thresholds.bats diff --git a/dot_local/bin/lib/ai-context-thresholds.sh b/dot_local/bin/lib/ai-context-thresholds.sh index d0c7dc7..e1dc90d 100755 --- a/dot_local/bin/lib/ai-context-thresholds.sh +++ b/dot_local/bin/lib/ai-context-thresholds.sh @@ -10,14 +10,20 @@ ai_context_cache_dir() { printf '%s\n' "${XDG_CACHE_HOME:-$HOME/.cache}/ai-context-alerts" } +# True for plain non-negative integers only. Callers feed these values to +# `[ -ge ]` and `jq --argjson`, both of which abort the script on anything else. +ai_context_is_int() { + case "${1:-}" in + '' | *[!0-9]*) return 1 ;; + *) return 0 ;; + esac +} + ai_context_thresholds() { local window="${1:-200000}" local yellow red - if [ -n "${AI_CTX_YELLOW:-}" ] && [ -n "${AI_CTX_RED:-}" ]; then - printf '%s %s\n' "$AI_CTX_YELLOW" "$AI_CTX_RED" - return 0 - fi + ai_context_is_int "$window" || window=200000 if [ "$window" -ge 500000 ]; then yellow=20 @@ -27,6 +33,14 @@ ai_context_thresholds() { red=85 fi + # Overrides win only when both are integers. A malformed value used to be + # echoed straight through into `[ -ge ]` and `jq --argjson`, which killed the + # statusline under `set -e` and left an empty breadcrumb behind. + if ai_context_is_int "${AI_CTX_YELLOW:-}" && ai_context_is_int "${AI_CTX_RED:-}"; then + yellow="$AI_CTX_YELLOW" + red="$AI_CTX_RED" + fi + printf '%s %s\n' "$yellow" "$red" } @@ -36,6 +50,10 @@ ai_context_level() { local yellow="${2:-65}" local red="${3:-85}" + ai_context_is_int "$pct" || pct=0 + ai_context_is_int "$yellow" || yellow=65 + ai_context_is_int "$red" || red=85 + if [ "$pct" -ge "$red" ]; then printf 'red\n' elif [ "$pct" -ge "$yellow" ]; then @@ -91,15 +109,32 @@ ai_context_state_get() { } # Merge JSON object fields into the session state file. +# +# Never fatal: this runs on every statusline refresh, so a bad cache file must +# not take the meter down with it. A corrupt breadcrumb is reset from the +# current patch rather than failing forever and re-ringing BEL each refresh. ai_context_state_merge() { local path="$1" - local patch_json="$2" + local patch_json="${2:-}" local dir + + # Callers build the patch as `"$(jq -nc ...)"` in the argument list, where a + # jq failure does not trip `set -e`. Guard here so an empty or non-object + # patch cannot be written out as state. + if [ -z "$patch_json" ] || + ! printf '%s' "$patch_json" | jq -e 'type == "object"' >/dev/null 2>&1; then + return 0 + fi + dir="$(dirname "$path")" - mkdir -p "$dir" - if [ -f "$path" ]; then - jq -c --argjson patch "$patch_json" '. * $patch' "$path" >"${path}.tmp" && mv "${path}.tmp" "$path" - else - printf '%s\n' "$patch_json" >"$path" + mkdir -p "$dir" || return 0 + + if [ -f "$path" ] && + jq -c --argjson patch "$patch_json" '. * $patch' "$path" >"${path}.tmp" 2>/dev/null; then + mv "${path}.tmp" "$path" + return 0 fi + + rm -f "${path}.tmp" + printf '%s\n' "$patch_json" >"$path" } diff --git a/tests/shell/unit/ai_context_thresholds.bats b/tests/shell/unit/ai_context_thresholds.bats new file mode 100644 index 0000000..0cc606f --- /dev/null +++ b/tests/shell/unit/ai_context_thresholds.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats + +setup() { + REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../../.." && pwd)" + LIB="$REPO_ROOT/dot_local/bin/lib/ai-context-thresholds.sh" + STATUSLINE="$REPO_ROOT/dot_local/bin/executable_ai-statusline" + CACHE_DIR="$(mktemp -d)" + export XDG_CACHE_HOME="$CACHE_DIR" +} + +teardown() { + rm -rf "${CACHE_DIR:-}" +} + +payload() { + printf '{"session_id":"%s","model":{"display_name":"Opus"},' "${1:-t}" + printf '"context_window":{"used_percentage":%s,"context_window_size":%s}}' \ + "${2:-70}" "${3:-200000}" +} + +@test "thresholds use window defaults for 200K and 1M classes" { + run bash -c 'set -euo pipefail; . "$1"; ai_context_thresholds 200000' bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "65 85" ] + + run bash -c 'set -euo pipefail; . "$1"; ai_context_thresholds 1000000' bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "20 65" ] +} + +@test "integer overrides replace both thresholds" { + run bash -c \ + 'set -euo pipefail; . "$1"; AI_CTX_YELLOW=10 AI_CTX_RED=20 ai_context_thresholds 200000' \ + bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "10 20" ] +} + +@test "non-integer overrides fall back to window defaults" { + run bash -c \ + 'set -euo pipefail; . "$1"; AI_CTX_YELLOW=abc AI_CTX_RED=def ai_context_thresholds 200000' \ + bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "65 85" ] +} + +@test "a non-integer override does not take the statusline down" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c 'payload="$1"; shift; printf "%s" "$payload" | AI_CTX_YELLOW=abc AI_CTX_RED=def bash "$1"' \ + bash "$(payload crash 70 200000)" "$STATUSLINE" + [ "$status" -eq 0 ] + [[ "$output" != *"jq:"* ]] + [[ "$output" != *"integer expression expected"* ]] +} + +@test "level ranking tolerates non-numeric inputs" { + run bash -c 'set -euo pipefail; . "$1"; ai_context_level abc 65 85' bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "green" ] +} + +@test "an empty patch never becomes session state" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c ' + set -euo pipefail + . "$1" + state="$2" + ai_context_state_merge "$state" "" + [ -f "$state" ] && exit 1 + exit 0 + ' bash "$LIB" "$CACHE_DIR/empty.json" + [ "$status" -eq 0 ] +} + +@test "a corrupt breadcrumb heals instead of failing forever" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c ' + set -euo pipefail + . "$1" + state="$2" + printf "not json at all" >"$state" + ai_context_state_merge "$state" "{\"ui_alerted\":\"yellow\"}" + jq -r ".ui_alerted" "$state" + ' bash "$LIB" "$CACHE_DIR/corrupt.json" + [ "$status" -eq 0 ] + [ "$output" = "yellow" ] + [ ! -f "$CACHE_DIR/corrupt.json.tmp" ] +} + +@test "the BEL latch persists across refreshes once state is healthy" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + state="$CACHE_DIR/ai-context-alerts/latch.json" + + printf '%s' "$(payload latch 70 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] + + printf '%s' "$(payload latch 72 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] +} From 85ee17112545cd0d3cb978f927897e35007aacc4 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:08:47 -0600 Subject: [PATCH 05/11] fix(claude): merge statusline settings instead of replacing the file 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. --- docs/ai-cli-context-statusline.md | 33 +++++++++++++--------- dot_claude/modify_settings.json.tmpl | 41 ++++++++++++++++++++++++++++ dot_claude/settings.json.tmpl | 19 ------------- 3 files changed, 61 insertions(+), 32 deletions(-) create mode 100755 dot_claude/modify_settings.json.tmpl delete mode 100644 dot_claude/settings.json.tmpl diff --git a/docs/ai-cli-context-statusline.md b/docs/ai-cli-context-statusline.md index 5b316b2..c720714 100644 --- a/docs/ai-cli-context-statusline.md +++ b/docs/ai-cli-context-statusline.md @@ -38,18 +38,24 @@ not a passive user alert. Cursor relies on the statusline + BEL. ## Source → applied -| Source | Target | -| ------------------------------------------------------- | ---------------------------------------------------- | -| `dot_local/bin/executable_ai-statusline` | `~/.local/bin/ai-statusline` | -| `dot_local/bin/lib/ai-context-thresholds.sh` | `~/.local/bin/lib/ai-context-thresholds.sh` | -| `dot_claude/settings.json.tmpl` | `~/.claude/settings.json` | -| `dot_claude/hooks/executable_context-threshold-stop.sh` | `~/.claude/hooks/context-threshold-stop.sh` | -| `.chezmoiscripts/run_after_25-cursor-statusline.sh` | merges `statusLine` into `~/.cursor/cli-config.json` | - -`cli-config.json` is **not** fully managed (model/auth/state stay local). The -run-after only sets/updates the `statusLine` object. Claude `settings.json` -**is** fully managed from the template — edit the chezmoi source, not only the -live file. +| Source | Target | +| ------------------------------------------------------- | ----------------------------------------------------------- | +| `dot_local/bin/executable_ai-statusline` | `~/.local/bin/ai-statusline` | +| `dot_local/bin/lib/ai-context-thresholds.sh` | `~/.local/bin/lib/ai-context-thresholds.sh` | +| `dot_claude/modify_settings.json.tmpl` | merges `statusLine` + `Stop` into `~/.claude/settings.json` | +| `dot_claude/hooks/executable_context-threshold-stop.sh` | `~/.claude/hooks/context-threshold-stop.sh` | +| `.chezmoiscripts/run_after_25-cursor-statusline.sh` | merges `statusLine` into `~/.cursor/cli-config.json` | + +Neither config is fully managed. `cli-config.json` keeps model/auth/state local +and the run-after only sets the `statusLine` object. `settings.json` is a +chezmoi `modify_` script: it receives the current file on stdin and rewrites +only `statusLine` and this repo's `Stop` hook, so `theme`, `permissions`, MCP +servers, and any other hooks you add locally are preserved. Because it is a +managed target, `chezmoi diff ~/.claude/settings.json` still previews changes. + +The `Stop` entry is matched by command, so repeated applies rewrite it in place +instead of stacking duplicates. If `jq` is missing, or the file is not valid +JSON, the script passes the content through untouched rather than replacing it. ## Overrides @@ -58,7 +64,8 @@ export AI_CTX_YELLOW=50 export AI_CTX_RED=80 ``` -Both must be set to override the window-size defaults. +Both must be set, and both must be plain integers, to override the window-size +defaults. A missing or non-integer value falls back to the window defaults. ## Apply and reload diff --git a/dot_claude/modify_settings.json.tmpl b/dot_claude/modify_settings.json.tmpl new file mode 100755 index 0000000..8bb4cc2 --- /dev/null +++ b/dot_claude/modify_settings.json.tmpl @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Merge this repo's statusLine + Stop hook into ~/.claude/settings.json. +# +# chezmoi hands the current target content on stdin and uses stdout as the new +# content. Only the two keys below are owned here; theme, permissions, MCP +# servers, and any other hooks the operator adds locally must survive. + +set -euo pipefail + +statusline="{{ .chezmoi.homeDir }}/.local/bin/ai-statusline" +stop_hook="{{ .chezmoi.homeDir }}/.claude/hooks/context-threshold-stop.sh" + +current="$(cat)" +[ -n "${current//[[:space:]]/}" ] || current='{}' + +# Without jq, or against a file that is not a JSON object, pass the content +# through untouched. Rewriting here would be the clobber this script exists to +# prevent. +if ! command -v jq >/dev/null 2>&1; then + printf '%s' "$current" + exit 0 +fi +if ! printf '%s' "$current" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s' "$current" + exit 0 +fi + +# Stop entries are matched by command so re-applying rewrites in place rather +# than stacking a duplicate hook every time. +printf '%s' "$current" | jq \ + --arg statusline "$statusline" \ + --arg stop_hook "$stop_hook" ' + .statusLine = {type: "command", command: $statusline, padding: 2} + | .hooks //= {} + | .hooks.Stop = ( + ((.hooks.Stop // []) | map(select( + ([.hooks[]?.command] | index($stop_hook)) == null + ))) + + [{hooks: [{type: "command", command: $stop_hook}]}] + ) + ' diff --git a/dot_claude/settings.json.tmpl b/dot_claude/settings.json.tmpl deleted file mode 100644 index da078bc..0000000 --- a/dot_claude/settings.json.tmpl +++ /dev/null @@ -1,19 +0,0 @@ -{ - "statusLine": { - "type": "command", - "command": "{{ .chezmoi.homeDir }}/.local/bin/ai-statusline", - "padding": 2 - }, - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "{{ .chezmoi.homeDir }}/.claude/hooks/context-threshold-stop.sh" - } - ] - } - ] - } -} From d2f7fcd30a3482f44773ccf35a5e7e324cd69a9e Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:12:25 -0600 Subject: [PATCH 06/11] perf(ai-cli): collapse statusline to one jq pass and skip idle writes 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. --- dot_local/bin/executable_ai-statusline | 94 +++++++++++++-------- dot_local/bin/lib/ai-context-thresholds.sh | 37 ++++---- tests/shell/unit/ai_context_thresholds.bats | 47 +++++++++++ 3 files changed, 123 insertions(+), 55 deletions(-) diff --git a/dot_local/bin/executable_ai-statusline b/dot_local/bin/executable_ai-statusline index 18cb187..b23fda0 100755 --- a/dot_local/bin/executable_ai-statusline +++ b/dot_local/bin/executable_ai-statusline @@ -18,10 +18,31 @@ lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" payload="$(cat)" -model="$(printf '%s' "$payload" | jq -r '.model.display_name // .model.id // "?"')" -pct_raw="$(printf '%s' "$payload" | jq -r '.context_window.used_percentage // empty')" -window="$(printf '%s' "$payload" | jq -r '.context_window.context_window_size // 200000')" -session_id="$(printf '%s' "$payload" | jq -r '.session_id // "unknown"')" +# Both CLIs re-run this on every UI update, so the payload is parsed exactly +# once. Fields are joined with US (0x1f): model names contain spaces, and tab +# or newline would be IFS whitespace, which bash collapses -- an absent +# used_percentage would then shift every later field left. +model="" +pct_raw="" +window="" +session_id="" +agent="" +IFS=$'\x1f' read -r model pct_raw window session_id agent < <( + printf '%s' "$payload" | jq -r ' + [ + (.model.display_name // .model.id // "?"), + (.context_window.used_percentage // ""), + (.context_window.context_window_size // 200000), + (.session_id // "unknown"), + (if has("cost") or has("rate_limits") or has("exceeds_200k_tokens") + then "claude" else "cursor" end) + ] | map(tostring) | join("\u001f")' 2>/dev/null +) || true + +# An unparseable payload leaves everything empty; fall through to the dim +# placeholder below rather than aborting the statusline. +[ -n "$model" ] || model="?" +[ -n "$agent" ] || agent="cursor" if [ -z "$pct_raw" ] || [ "$pct_raw" = "null" ]; then printf '\033[90m[%s] ctx —\033[0m\n' "$model" @@ -39,29 +60,39 @@ esac read -r yellow red < <(ai_context_thresholds "$window") level="$(ai_context_level "$pct" "$yellow" "$red")" -agent="$(ai_context_detect_agent "$payload")" compact_cmd="$(ai_context_compact_cmd "$agent")" state_path="$(ai_context_session_state_path "$session_id")" -prev_ui="$(ai_context_state_get "$state_path" ui_alerted green)" -# Drop below yellow → clear latches so a later climb can alert again. -if [ "$level" = "green" ]; then - ai_context_state_merge "$state_path" "$(jq -nc \ - --argjson pct "$pct" \ - --arg level "$level" \ - --argjson window "$window" \ - --argjson yellow "$yellow" \ - --argjson red "$red" \ - --arg agent "$agent" \ - --arg compact_cmd "$compact_cmd" \ - --argjson now "$(date +%s)" \ - '{ - pct:$pct, level:$level, window:$window, yellow:$yellow, red:$red, - agent:$agent, compact_cmd:$compact_cmd, updated_at:$now, - ui_alerted:"green", stop_alerted:"green" - }')" -else +# Single read of the breadcrumb: the BEL latch and the last level together. +prev_ui="green" +prev_level="green" +if [ -f "$state_path" ]; then + IFS=$'\x1f' read -r prev_ui prev_level < <( + jq -r '[(.ui_alerted // "green"), (.level // "green")] | join("\u001f")' \ + "$state_path" 2>/dev/null + ) || true + [ -n "$prev_ui" ] || prev_ui="green" + [ -n "$prev_level" ] || prev_level="green" +fi + +# One BEL per upward level transition (yellow, then red). +prev_rank="$(ai_context_level_rank "$prev_ui")" +cur_rank="$(ai_context_level_rank "$level")" +bell="" +new_ui_alerted="" +if [ "$cur_rank" -gt "$prev_rank" ]; then + bell=$'\a' + new_ui_alerted="$level" +fi + +# Persist only when it can matter. A session sitting at green re-renders +# constantly and has nothing new to record, so the common case does no work at +# all; yellow/red keeps pct fresh for the Stop hook. Dropping back below yellow +# clears the latches so a later climb can alert again. +if [ "$level" != "green" ] || [ "$prev_level" != "green" ] || [ -n "$new_ui_alerted" ]; then + reset_latches=false + [ "$level" = "green" ] && reset_latches=true ai_context_state_merge "$state_path" "$(jq -nc \ --argjson pct "$pct" \ --arg level "$level" \ @@ -70,11 +101,15 @@ else --argjson red "$red" \ --arg agent "$agent" \ --arg compact_cmd "$compact_cmd" \ - --argjson now "$(date +%s)" \ + --argjson now "${EPOCHSECONDS:-$(date +%s)}" \ + --argjson reset "$reset_latches" \ + --arg ui_alerted "$new_ui_alerted" \ '{ pct:$pct, level:$level, window:$window, yellow:$yellow, red:$red, agent:$agent, compact_cmd:$compact_cmd, updated_at:$now - }')" + } + + (if $reset then {ui_alerted:"green", stop_alerted:"green"} else {} end) + + (if $ui_alerted == "" then {} else {ui_alerted:$ui_alerted} end)')" fi hint="" @@ -105,13 +140,4 @@ if [ "$empty" -gt 0 ]; then bar="${bar}${pad// /░}" fi -# One BEL per upward level transition (yellow, then red). -prev_rank="$(ai_context_level_rank "$prev_ui")" -cur_rank="$(ai_context_level_rank "$level")" -bell="" -if [ "$cur_rank" -gt "$prev_rank" ]; then - bell=$'\a' - ai_context_state_merge "$state_path" "$(jq -nc --arg level "$level" '{ui_alerted:$level}')" -fi - printf '%s[%s] %s %s%%%s\033[0m%s\n' "$color" "$model" "$bar" "$pct" "$hint" "$bell" diff --git a/dot_local/bin/lib/ai-context-thresholds.sh b/dot_local/bin/lib/ai-context-thresholds.sh index e1dc90d..439b96f 100755 --- a/dot_local/bin/lib/ai-context-thresholds.sh +++ b/dot_local/bin/lib/ai-context-thresholds.sh @@ -71,17 +71,6 @@ ai_context_level_rank() { esac } -# Detect agent from statusline payload shape. -# Prints: claude | cursor -ai_context_detect_agent() { - local payload="$1" - if printf '%s' "$payload" | jq -e 'has("cost") or has("rate_limits") or has("exceeds_200k_tokens")' >/dev/null 2>&1; then - printf 'claude\n' - else - printf 'cursor\n' - fi -} - ai_context_compact_cmd() { case "${1:-cursor}" in claude) printf '/compact\n' ;; @@ -119,22 +108,28 @@ ai_context_state_merge() { local dir # Callers build the patch as `"$(jq -nc ...)"` in the argument list, where a - # jq failure does not trip `set -e`. Guard here so an empty or non-object - # patch cannot be written out as state. - if [ -z "$patch_json" ] || - ! printf '%s' "$patch_json" | jq -e 'type == "object"' >/dev/null 2>&1; then - return 0 - fi + # jq failure does not trip `set -e`. Guard so an empty or truncated patch + # cannot be written out as state. This is a shell-only test on purpose: the + # statusline reaches here on refreshes, and spawning jq just to validate was + # a measurable share of the cost. + case "$patch_json" in + '{'*'}') ;; + *) return 0 ;; + esac - dir="$(dirname "$path")" - mkdir -p "$dir" || return 0 + dir="${path%/*}" + [ -d "$dir" ] || mkdir -p "$dir" || return 0 if [ -f "$path" ] && jq -c --argjson patch "$patch_json" '. * $patch' "$path" >"${path}.tmp" 2>/dev/null; then mv "${path}.tmp" "$path" return 0 fi - rm -f "${path}.tmp" - printf '%s\n' "$patch_json" >"$path" + + # No usable state file. This path is rare, so pay for real validation here + # rather than letting a malformed patch replace the breadcrumb wholesale. + if printf '%s' "$patch_json" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s\n' "$patch_json" >"$path" + fi } diff --git a/tests/shell/unit/ai_context_thresholds.bats b/tests/shell/unit/ai_context_thresholds.bats index 0cc606f..8117c69 100644 --- a/tests/shell/unit/ai_context_thresholds.bats +++ b/tests/shell/unit/ai_context_thresholds.bats @@ -101,3 +101,50 @@ payload() { printf '%s' "$(payload latch 72 200000)" | bash "$STATUSLINE" >/dev/null [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] } + +@test "a session sitting at green writes no breadcrumb" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + printf '%s' "$(payload idle 30 200000)" | bash "$STATUSLINE" >/dev/null + printf '%s' "$(payload idle 31 200000)" | bash "$STATUSLINE" >/dev/null + + [ ! -f "$CACHE_DIR/ai-context-alerts/idle.json" ] +} + +@test "dropping back to green resets both latches" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + state="$CACHE_DIR/ai-context-alerts/drop.json" + + printf '%s' "$(payload drop 70 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] + + printf '%s' "$(payload drop 10 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "green" ] + [ "$(jq -r '.stop_alerted' "$state")" = "green" ] + + # A later climb must be able to alert again. + printf '%s' "$(payload drop 70 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] +} + +@test "an unparseable payload degrades instead of erroring out" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c 'printf "not json at all" | bash "$1"' bash "$STATUSLINE" + [ "$status" -eq 0 ] + [[ "$output" == *"ctx"* ]] + [[ "$output" != *"jq:"* ]] +} + +@test "a model display name containing spaces is not split" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c ' + printf "%s" "{\"session_id\":\"spaced\",\"model\":{\"display_name\":\"Claude Opus 4\"},\"context_window\":{\"used_percentage\":30,\"context_window_size\":200000}}" \ + | bash "$1" + ' bash "$STATUSLINE" + [ "$status" -eq 0 ] + [[ "$output" == *"[Claude Opus 4]"* ]] + [[ "$output" == *"30%"* ]] +} From b85e3db26d706822b0a8c77e83cc88e913ed7097 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:26:49 -0600 Subject: [PATCH 07/11] test(qa): lint the Claude settings modify script 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. --- qa.just | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qa.just b/qa.just index 5dbde7e..d40356c 100644 --- a/qa.just +++ b/qa.just @@ -6,10 +6,10 @@ default: # Lint shell scripts and test files. lint-shell: @set -euo pipefail; \ - fmt_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/executable_ai-statusline" "{{ justfile_directory() }}/dot_local/bin/lib/ai-context-thresholds.sh" "{{ justfile_directory() }}/dot_claude/hooks/executable_context-threshold-stop.sh" "{{ justfile_directory() }}/.chezmoiscripts/run_after_25-cursor-statusline.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ + fmt_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/executable_ai-statusline" "{{ justfile_directory() }}/dot_local/bin/lib/ai-context-thresholds.sh" "{{ justfile_directory() }}/dot_claude/hooks/executable_context-threshold-stop.sh" "{{ justfile_directory() }}/dot_claude/modify_settings.json.tmpl" "{{ justfile_directory() }}/.chezmoiscripts/run_after_25-cursor-statusline.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ while IFS= read -r _file; do fmt_files+=("$_file"); done < <(find "{{ justfile_directory() }}/tests/shell" -type f \( -name '*.sh' -o -name '*.bash' -o -name '*.bats' \) 2>/dev/null | sort); \ mise exec --locked -- shfmt -d -i 2 -ci "${fmt_files[@]}"; \ - sc_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/lib/clipboard-image.sh" "{{ justfile_directory() }}/dot_local/bin/executable_ai-statusline" "{{ justfile_directory() }}/dot_local/bin/lib/ai-context-thresholds.sh" "{{ justfile_directory() }}/dot_claude/hooks/executable_context-threshold-stop.sh" "{{ justfile_directory() }}/.chezmoiscripts/run_after_25-cursor-statusline.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ + sc_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/lib/clipboard-image.sh" "{{ justfile_directory() }}/dot_local/bin/executable_ai-statusline" "{{ justfile_directory() }}/dot_local/bin/lib/ai-context-thresholds.sh" "{{ justfile_directory() }}/dot_claude/hooks/executable_context-threshold-stop.sh" "{{ justfile_directory() }}/dot_claude/modify_settings.json.tmpl" "{{ justfile_directory() }}/.chezmoiscripts/run_after_25-cursor-statusline.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ while IFS= read -r _file; do sc_files+=("$_file"); done < <(find "{{ justfile_directory() }}/tests/shell" -type f \( -name '*.sh' -o -name '*.bash' \) 2>/dev/null | sort); \ mise exec --locked -- shellcheck "${sc_files[@]}" From ce29d905548bd92fc917141cf6a6a61ab3d4099b Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:27:37 -0600 Subject: [PATCH 08/11] fix(cursor): do not fail chezmoi apply on an unreadable cli-config.json 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. --- .chezmoiscripts/run_after_25-cursor-statusline.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.chezmoiscripts/run_after_25-cursor-statusline.sh b/.chezmoiscripts/run_after_25-cursor-statusline.sh index 996853e..28b49c4 100755 --- a/.chezmoiscripts/run_after_25-cursor-statusline.sh +++ b/.chezmoiscripts/run_after_25-cursor-statusline.sh @@ -22,12 +22,21 @@ if [ "$current" = "$script" ] || [ "$current" = "~/.local/bin/ai-statusline" ]; exit 0 fi +# Merging a statusLine is optional, so a config this script cannot parse must +# not take `chezmoi apply` down with it. Bail out and leave the file alone. +if ! jq -e 'type == "object"' "$cfg" >/dev/null 2>&1; then + exit 0 +fi + tmp="$(mktemp)" -jq --arg cmd "$script" ' +if jq --arg cmd "$script" ' .statusLine = { "type": "command", "command": $cmd, "padding": 2 } -' "$cfg" >"$tmp" -mv "$tmp" "$cfg" +' "$cfg" >"$tmp" 2>/dev/null; then + mv "$tmp" "$cfg" +else + rm -f "$tmp" +fi From 688f9f53ed09b2042bf092323d83d96c21641eb3 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:33:55 -0600 Subject: [PATCH 09/11] perf(ai-cli): drop the breadcrumb on green instead of resetting it 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. --- dot_local/bin/executable_ai-statusline | 18 ++++++++++-------- tests/shell/unit/ai_context_thresholds.bats | 6 +++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/dot_local/bin/executable_ai-statusline b/dot_local/bin/executable_ai-statusline index b23fda0..fd4c9a7 100755 --- a/dot_local/bin/executable_ai-statusline +++ b/dot_local/bin/executable_ai-statusline @@ -87,12 +87,16 @@ if [ "$cur_rank" -gt "$prev_rank" ]; then fi # Persist only when it can matter. A session sitting at green re-renders -# constantly and has nothing new to record, so the common case does no work at -# all; yellow/red keeps pct fresh for the Stop hook. Dropping back below yellow -# clears the latches so a later climb can alert again. -if [ "$level" != "green" ] || [ "$prev_level" != "green" ] || [ -n "$new_ui_alerted" ]; then - reset_latches=false - [ "$level" = "green" ] && reset_latches=true +# constantly and has nothing to record, so the common case does no work at all. +if [ "$level" = "green" ]; then + # A missing breadcrumb already means "green, nothing alerted": both latches + # default to green here and the Stop hook short-circuits when the file is + # absent. Dropping it keeps later green refreshes off the jq read path and + # re-arms alerting for a later climb. + if [ "$prev_level" != "green" ]; then + rm -f "$state_path" + fi +else ai_context_state_merge "$state_path" "$(jq -nc \ --argjson pct "$pct" \ --arg level "$level" \ @@ -102,13 +106,11 @@ if [ "$level" != "green" ] || [ "$prev_level" != "green" ] || [ -n "$new_ui_aler --arg agent "$agent" \ --arg compact_cmd "$compact_cmd" \ --argjson now "${EPOCHSECONDS:-$(date +%s)}" \ - --argjson reset "$reset_latches" \ --arg ui_alerted "$new_ui_alerted" \ '{ pct:$pct, level:$level, window:$window, yellow:$yellow, red:$red, agent:$agent, compact_cmd:$compact_cmd, updated_at:$now } - + (if $reset then {ui_alerted:"green", stop_alerted:"green"} else {} end) + (if $ui_alerted == "" then {} else {ui_alerted:$ui_alerted} end)')" fi diff --git a/tests/shell/unit/ai_context_thresholds.bats b/tests/shell/unit/ai_context_thresholds.bats index 8117c69..33cdb9d 100644 --- a/tests/shell/unit/ai_context_thresholds.bats +++ b/tests/shell/unit/ai_context_thresholds.bats @@ -111,7 +111,7 @@ payload() { [ ! -f "$CACHE_DIR/ai-context-alerts/idle.json" ] } -@test "dropping back to green resets both latches" { +@test "dropping back to green clears the breadcrumb and re-arms alerting" { command -v jq >/dev/null 2>&1 || skip "jq not installed" state="$CACHE_DIR/ai-context-alerts/drop.json" @@ -119,9 +119,9 @@ payload() { printf '%s' "$(payload drop 70 200000)" | bash "$STATUSLINE" >/dev/null [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] + # Absence is the green state: no latch, and the Stop hook short-circuits. printf '%s' "$(payload drop 10 200000)" | bash "$STATUSLINE" >/dev/null - [ "$(jq -r '.ui_alerted' "$state")" = "green" ] - [ "$(jq -r '.stop_alerted' "$state")" = "green" ] + [ ! -f "$state" ] # A later climb must be able to alert again. printf '%s' "$(payload drop 70 200000)" | bash "$STATUSLINE" >/dev/null From 05b03f8a9c56bd6dac16fb096329743809fe22a6 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:34:32 -0600 Subject: [PATCH 10/11] perf(ai-cli): skip breadcrumb rewrites when yellow/red state is unchanged 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/.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. --- dot_local/bin/executable_ai-statusline | 13 +++++++++---- tests/shell/unit/ai_context_thresholds.bats | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/dot_local/bin/executable_ai-statusline b/dot_local/bin/executable_ai-statusline index fd4c9a7..a0b817e 100755 --- a/dot_local/bin/executable_ai-statusline +++ b/dot_local/bin/executable_ai-statusline @@ -64,12 +64,14 @@ compact_cmd="$(ai_context_compact_cmd "$agent")" state_path="$(ai_context_session_state_path "$session_id")" -# Single read of the breadcrumb: the BEL latch and the last level together. +# Single read of the breadcrumb: the BEL latch, the last level and the last +# pct, so an unchanged refresh can be detected without a second jq. prev_ui="green" prev_level="green" +prev_pct="" if [ -f "$state_path" ]; then - IFS=$'\x1f' read -r prev_ui prev_level < <( - jq -r '[(.ui_alerted // "green"), (.level // "green")] | join("\u001f")' \ + IFS=$'\x1f' read -r prev_ui prev_level prev_pct < <( + jq -r '[(.ui_alerted // "green"), (.level // "green"), (.pct // "" | tostring)] | join("\u001f")' \ "$state_path" 2>/dev/null ) || true [ -n "$prev_ui" ] || prev_ui="green" @@ -96,7 +98,10 @@ if [ "$level" = "green" ]; then if [ "$prev_level" != "green" ]; then rm -f "$state_path" fi -else +elif [ "$level" != "$prev_level" ] || [ "$pct" != "$prev_pct" ] || [ -n "$new_ui_alerted" ]; then + # Yellow/red only needs a write when something the Stop hook reads actually + # moved. Everything it consumes derives from level and pct, and updated_at + # has no reader, so an unchanged refresh can skip the rewrite entirely. ai_context_state_merge "$state_path" "$(jq -nc \ --argjson pct "$pct" \ --arg level "$level" \ diff --git a/tests/shell/unit/ai_context_thresholds.bats b/tests/shell/unit/ai_context_thresholds.bats index 33cdb9d..6f2a1d6 100644 --- a/tests/shell/unit/ai_context_thresholds.bats +++ b/tests/shell/unit/ai_context_thresholds.bats @@ -128,6 +128,23 @@ payload() { [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] } +@test "an unchanged yellow refresh does not rewrite the breadcrumb" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + state="$CACHE_DIR/ai-context-alerts/steady.json" + + printf '%s' "$(payload steady 70 200000)" | bash "$STATUSLINE" >/dev/null + before="$(stat -c %i "$state")" + + # ai_context_state_merge lands via mv, so any write changes the inode. + printf '%s' "$(payload steady 70 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(stat -c %i "$state")" = "$before" ] + + # A real change must still be recorded. + printf '%s' "$(payload steady 75 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.pct' "$state")" = "75" ] +} + @test "an unparseable payload degrades instead of erroring out" { command -v jq >/dev/null 2>&1 || skip "jq not installed" From ab07bcdb4ef7615312370165d1a234feeda8a5cb Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 8 Aug 2026 23:43:49 -0600 Subject: [PATCH 11/11] fix(ai-cli): strip control characters before splitting statusline fields 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. --- dot_local/bin/executable_ai-statusline | 5 +++-- tests/shell/unit/ai_context_thresholds.bats | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/dot_local/bin/executable_ai-statusline b/dot_local/bin/executable_ai-statusline index a0b817e..e685fb9 100755 --- a/dot_local/bin/executable_ai-statusline +++ b/dot_local/bin/executable_ai-statusline @@ -21,7 +21,8 @@ payload="$(cat)" # Both CLIs re-run this on every UI update, so the payload is parsed exactly # once. Fields are joined with US (0x1f): model names contain spaces, and tab # or newline would be IFS whitespace, which bash collapses -- an absent -# used_percentage would then shift every later field left. +# used_percentage would then shift every later field left. Control characters +# are stripped first so no value can smuggle in a separator of its own. model="" pct_raw="" window="" @@ -36,7 +37,7 @@ IFS=$'\x1f' read -r model pct_raw window session_id agent < <( (.session_id // "unknown"), (if has("cost") or has("rate_limits") or has("exceeds_200k_tokens") then "claude" else "cursor" end) - ] | map(tostring) | join("\u001f")' 2>/dev/null + ] | map(tostring | gsub("[[:cntrl:]]"; "")) | join("\u001f")' 2>/dev/null ) || true # An unparseable payload leaves everything empty; fall through to the dim diff --git a/tests/shell/unit/ai_context_thresholds.bats b/tests/shell/unit/ai_context_thresholds.bats index 6f2a1d6..13ad44b 100644 --- a/tests/shell/unit/ai_context_thresholds.bats +++ b/tests/shell/unit/ai_context_thresholds.bats @@ -154,6 +154,24 @@ payload() { [[ "$output" != *"jq:"* ]] } +@test "control characters in a model name cannot shift the parsed fields" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + # US (0x1f) is the field separator; a model name carrying it must not be able + # to fabricate pct/level or retarget the breadcrumb path. It has to travel as + # the JSON \u001f escape -- a raw control byte is not valid JSON. + us='\u001f' + evil="Evil${us}99${us}200000${us}hijacked${us}cursor" + body="{\"session_id\":\"real\",\"model\":{\"display_name\":\"$evil\"}," + body="$body\"context_window\":{\"used_percentage\":5,\"context_window_size\":200000}}" + + run bash -c 'printf "%s" "$1" | bash "$2"' bash "$body" "$STATUSLINE" + [ "$status" -eq 0 ] + [[ "$output" == *"5%"* ]] + [[ "$output" != *"99%"* ]] + [ ! -f "$CACHE_DIR/ai-context-alerts/hijacked.json" ] +} + @test "a model display name containing spaces is not split" { command -v jq >/dev/null 2>&1 || skip "jq not installed"