From 6b4dc6e7a92b415b1658caa96978307646750a19 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 28 Jul 2026 23:02:23 -0700 Subject: [PATCH 1/2] check + session-summary: the advisor, wired into the host's plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The statusline never runs when you're away — exactly when expiring capacity needs a voice. Instead of a daemon, `check` exposes the advisor's judgment as an exit code (0 calm / 1 opportunity / 2 pressure / 3 unknown-or-stale) with the plain verdict on stdout, for tmux segments, cron notifiers, and scripts. Model context for the scoped clauses comes from the last logged snapshot — the first consumer of the widened `model` field. `session-summary` gives one-line session retrospectives from the usage log, designed as a SessionEnd hook (hook JSON on stdin, only session_id read; bare runs fall back to the last logged session). Window deltas are positive-delta sums — the profile builder's rule — so a session straddling a 5h reset still reports what it consumed. Co-Authored-By: Claude Fable 5 --- statusline.sh | 97 ++++++++++++++++++++++++++++++++++++++++++- t/helpers.bash | 2 +- t/statusline.bats | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/statusline.sh b/statusline.sh index 2665740..53e1e66 100755 --- a/statusline.sh +++ b/statusline.sh @@ -238,8 +238,8 @@ debug_log() { while [[ $# -gt 0 ]]; do case $1 in - report) - subcommand="report" + report | check | session-summary) + subcommand="$1" shift ;; --days) @@ -2124,6 +2124,97 @@ run_usage_report() { return 0 } +# `statusline.sh check` — the advisor as an exit code, for tmux segments, +# cron notifiers, and scripts (`check || notify`). Prints the plain-text +# advisor verdict (or "calm"/"unknown: ...") and exits: +# 0 calm 1 opportunity (+) 2 pressure (!) 3 unknown/stale +# We provide the judgment; the host provides the plumbing — no daemon. +# Model context comes from the last logged snapshot, so scoped clauses +# know which model this account was last running. +run_check() { + local uc="$CLAUDE_ACCOUNT_DIR/usage.cache" + if [ ! -f "$uc" ]; then + echo "unknown: no usage.cache under $CLAUDE_ACCOUNT_DIR" + return 3 + fi + local fetched age + fetched=$(jq -r '.fetched_at // 0' "$uc" 2>/dev/null) + age=$(( $(date +%s) - ${fetched:-0} )) + if [ "$age" -gt 3600 ] 2>/dev/null; then + # The state-dir contract's staleness rule: >1h old means no active + # session feeds this account — the numbers describe the past. + echo "unknown: usage.cache ${age}s stale (no active session feeding this account)" + return 3 + fi + local last_model="" + [ -f "$CLAUDE_ACCOUNT_DIR/usage.jsonl" ] && \ + last_model=$(tail -1 "$CLAUDE_ACCOUNT_DIR/usage.jsonl" 2>/dev/null | jq -r '.model // empty' 2>/dev/null) + local line plain + line=$(build_advisor_line "$(cat "$uc")" auto "$last_model") + if [ -z "$line" ]; then + echo "calm" + return 0 + fi + plain=$(printf '%b' "$line" | sed 's/\x1b\[[0-9;]*m//g') + printf '%s\n' "$plain" + case "$plain" in + "! "*) return 2 ;; + "+ "*) return 1 ;; + esac + return 0 +} + +# `statusline.sh session-summary` — one-line session retrospective, designed +# as a SessionEnd hook (pipe the hook JSON in; only session_id is read). +# Falls back to the last session in the log for manual runs. Window deltas +# are positive-delta sums (resets ignored), the profile builder's rule, so +# a session that straddles a 5h reset still reports what it consumed. +run_session_summary() { + local sid="" + [ ! -t 0 ] && sid=$(cat 2>/dev/null | jq -r '.session_id // empty' 2>/dev/null) + local jsonl="$CLAUDE_ACCOUNT_DIR/usage.jsonl" + if [ -z "$sid" ] && [ -f "$jsonl" ]; then + sid=$(jq -r 'select((.type // "") == "usage") | .session_id // empty' "$jsonl" 2>/dev/null | tail -1) + fi + if [ -z "$sid" ]; then + echo "session-summary: no session id (pipe hook JSON, or log some usage first)" + return 1 + fi + if [ ! -f "$jsonl" ] && [ ! -f "${jsonl}.1" ]; then + echo "session-summary: no usage log at $jsonl" + return 1 + fi + local out + out=$( { cat "${jsonl}.1" 2>/dev/null; cat "$jsonl" 2>/dev/null; } | jq -r --arg s "$sid" ' + select((.type // "") == "usage" and (.session_id // "") == $s) + | [.timestamp, (.five_hour.utilization // ""), + (.seven_day.utilization // ""), (.model // "")] + | @tsv' 2>/dev/null \ + | sort -n | awk -F'\t' -v sid="${sid:0:8}" ' + { + n++ + if (first == "") first = $1 + last = $1 + if ($2 != "") { if (pf != "" && $2 > pf) df += $2 - pf; pf = $2 } + if ($3 != "") { if (ps != "" && $3 > ps) ds += $3 - ps; ps = $3 } + if ($4 != "") model = $4 + } + END { + if (n == 0) exit 0 + secs = last - first + h = int(secs / 3600); m = int((secs % 3600) / 60) + dur = (h > 0 ? h "h" m "m" : m "m") + printf "session %s: %s, 5h +%.0fpts, 7d +%.0fpts%s\n", \ + sid, dur, df, ds, (model != "" ? ", " model : "") + }') + if [ -z "$out" ]; then + echo "session-summary: no samples for session ${sid:0:8}" + return 1 + fi + printf '%s\n' "$out" + return 0 +} + # Generalized change flash: signed delta between the current value and the # last value THIS session rendered, held for QUOTA_BUMP_NOTICE_SECS after the # change so the refresh right after a jump still shows it. One JSON state @@ -3085,6 +3176,8 @@ build_1m_tag() { if [ -n "$subcommand" ]; then case "$subcommand" in report) run_usage_report "${report_days:-28}"; exit $? ;; + check) run_check; exit $? ;; + session-summary) run_session_summary; exit $? ;; esac exit 0 fi diff --git a/t/helpers.bash b/t/helpers.bash index 45e9ec9..4e35b7a 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -64,7 +64,7 @@ debug_log() { # Source individual functions by extracting them from statusline.sh. # This is deliberate: we test the actual production code, not copies. eval "$(awk ' - /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|log_usage_snapshot|detect_session_boundary|run_usage_report)\(\)/ { capture=1 } + /^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|log_usage_snapshot|detect_session_boundary|run_usage_report|run_check|run_session_summary)\(\)/ { capture=1 } capture { print } capture && /^}$/ { capture=0 } ' "$SCRIPT_DIR/statusline.sh")" diff --git a/t/statusline.bats b/t/statusline.bats index b375aef..22e9188 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -762,6 +762,109 @@ _write_ledger_fixture() { # dir rm -rf "$tmpdir" } +# --- check + session-summary subcommands ----------------------------------- + +@test "run_check: calm cache prints calm and exits 0" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + reset_5h=$(date -u -d '+45 minutes' '+%Y-%m-%dT%H:%M:%SZ') + reset_7d=$(date -u -d '+5 days' '+%Y-%m-%dT%H:%M:%SZ') + printf '{"fetched_at":%s,"five_hour":{"utilization":20,"resets_at":"%s"},"seven_day":{"utilization":20,"resets_at":"%s"}}' \ + "$(date +%s)" "$reset_5h" "$reset_7d" > "$tmpdir/usage.cache" + run run_check + [ "$status" -eq 0 ] + [ "$output" = "calm" ] + rm -rf "$tmpdir" +} + +@test "run_check: pressure exits 2 with the plain advisor text" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + # 85% only 2h into the window: hot pace, caps before reset (pressure) + reset_5h=$(date -u -d '+3 hours' '+%Y-%m-%dT%H:%M:%SZ') + printf '{"fetched_at":%s,"five_hour":{"utilization":85,"resets_at":"%s"},"seven_day":{"utilization":10}}' \ + "$(date +%s)" "$reset_5h" > "$tmpdir/usage.cache" + run run_check + [ "$status" -eq 2 ] + [[ "$output" == "! 5h caps"* ]] + rm -rf "$tmpdir" +} + +@test "run_check: expiring surplus exits 1 (opportunity)" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + # 5h on pace (40% at 60% elapsed), 7d resets in 10h with 56% unused + reset_5h=$(date -u -d '+2 hours' '+%Y-%m-%dT%H:%M:%SZ') + reset_7d=$(date -u -d '+10 hours' '+%Y-%m-%dT%H:%M:%SZ') + printf '{"fetched_at":%s,"five_hour":{"utilization":40,"resets_at":"%s"},"seven_day":{"utilization":44,"resets_at":"%s"}}' \ + "$(date +%s)" "$reset_5h" "$reset_7d" > "$tmpdir/usage.cache" + run run_check + [ "$status" -eq 1 ] + [[ "$output" == "+ 7d resets"* ]] + rm -rf "$tmpdir" +} + +@test "run_check: missing or stale cache exits 3" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + run run_check + [ "$status" -eq 3 ] + [[ "$output" == "unknown: no usage.cache"* ]] + printf '{"fetched_at":%s,"five_hour":{"utilization":85}}' "$(( $(date +%s) - 7200 ))" > "$tmpdir/usage.cache" + run run_check + [ "$status" -eq 3 ] + [[ "$output" == *"stale"* ]] + rm -rf "$tmpdir" +} + +_write_session_fixture() { # dir + local dir="$1" now + now=$(date +%s) + : > "$dir/usage.jsonl" + printf '{"type":"usage","session_id":"S1","timestamp":%s,"five_hour":{"utilization":10},"seven_day":{"utilization":5}}\n' "$(( now - 4000 ))" >> "$dir/usage.jsonl" + printf '{"type":"usage","session_id":"S1","timestamp":%s,"five_hour":{"utilization":40},"seven_day":{"utilization":7}}\n' "$(( now - 2000 ))" >> "$dir/usage.jsonl" + printf '{"type":"usage","session_id":"S1","timestamp":%s,"five_hour":{"utilization":70},"seven_day":{"utilization":9},"model":"claude-fable-5"}\n' "$(( now - 300 ))" >> "$dir/usage.jsonl" +} + +@test "run_session_summary: summarizes the piped hook session" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_session_fixture "$tmpdir" + out=$(printf '{"session_id":"S1"}' | run_session_summary) + # 3700s span, +60 five-points, +4 seven-points, model from the last sample + [ "$out" = "session S1: 1h1m, 5h +60pts, 7d +4pts, claude-fable-5" ] + rm -rf "$tmpdir" +} + +@test "run_session_summary: falls back to the last logged session; unknown exits 1" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_session_fixture "$tmpdir" + out=$(run_session_summary < /dev/null) + [[ "$out" == "session S1:"* ]] + if printf '{"session_id":"nope"}' | run_session_summary > /dev/null; then + false + fi + rm -rf "$tmpdir" +} + +@test "integration: check and session-summary run end-to-end" { + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/data" + reset_5h=$(date -u -d '+45 minutes' '+%Y-%m-%dT%H:%M:%SZ') + reset_7d=$(date -u -d '+5 days' '+%Y-%m-%dT%H:%M:%SZ') + printf '{"fetched_at":%s,"five_hour":{"utilization":20,"resets_at":"%s"},"seven_day":{"utilization":20,"resets_at":"%s"}}' \ + "$(date +%s)" "$reset_5h" "$reset_7d" > "$tmpdir/data/usage.cache" + _write_session_fixture "$tmpdir/data" + run env HOME="$tmpdir" CLAUDE_DATA_DIR="$tmpdir/data" bash "$SCRIPT_DIR/statusline.sh" check + [ "$status" -eq 0 ] + [ "$output" = "calm" ] + run bash -c "printf '{\"session_id\":\"S1\"}' | env HOME='$tmpdir' CLAUDE_DATA_DIR='$tmpdir/data' bash '$SCRIPT_DIR/statusline.sh' session-summary" + [ "$status" -eq 0 ] + [[ "$output" == "session S1:"* ]] + rm -rf "$tmpdir" +} + _write_profile_cache() { # $1=dir $2=days_history $3=rate_all_days $4=recent24 cat > "$1/forecast.cache" < Date: Tue, 28 Jul 2026 23:02:23 -0700 Subject: [PATCH 2/2] v0.21.0: check/session-summary release surfaces README scripting section (cron + tmux + SessionEnd hook recipes), CHANGELOG entry, counts synced to 334 tests. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 +++++++++++++++++++++++ README.md | 43 ++++++++++++++++++++++++++++++++++++++++++- docs/api/state-dir.md | 2 +- docs/index.html | 6 +++--- llms.txt | 4 ++-- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e4cbb..3fc9aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## v0.21.0 — 2026-07-28 — the advisor, wired into your world + +**New: `statusline.sh check` — the advisor as an exit code.** The +statusline never runs when you're away, which is exactly when expiring +capacity needs a voice. Instead of a daemon (still no daemon, ever), +`check` prints the plain-text advisor verdict and exits 0 calm / 1 +opportunity / 2 pressure / 3 unknown-or-stale — you wire it into tmux, +cron, or CI. We provide the judgment; the host provides the plumbing. +Model context for the scoped clauses comes from the last logged +snapshot — the first consumer of v0.20's widened `model` field. + +**New: `statusline.sh session-summary` — one-line session +retrospectives.** Designed as a `SessionEnd` hook (reads the hook JSON +on stdin; falls back to the last logged session for manual runs): + +``` +session 8f3c02aa: 3h12m, 5h +34pts, 7d +4pts, claude-fable-5 +``` + +Window deltas are positive-delta sums (the profile builder's rule), so +a session that straddles a 5h reset still reports what it actually +consumed. 334 tests (322 statusline + 12 installer). + ## v0.20.0 — 2026-07-28 — the waste ledger: see what you paid for and didn't use **New: `statusline.sh report [--days N]` — the waste ledger.** The diff --git a/README.md b/README.md index 1216380..f3f534f 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,47 @@ its reset, so usage from other devices after your last local render is invisible, and a week you never opened a session in never appears at all. The ledger reports what the log observed, nothing more. +## Scripting: `check` and `session-summary` + +The statusline never runs when you're away — exactly when expiring +capacity needs a voice. Instead of shipping a daemon, `check` exposes +the advisor's judgment as an exit code; you provide the plumbing (tmux +segment, cron, CI): + +```bash +~/.claude/statusline.sh check +# stdout: the plain advisor text, or "calm" / "unknown: ..." +# exit 0 calm | 1 opportunity (+) | 2 pressure (!) | 3 unknown/stale +``` + +```bash +# cron: nudge yourself when paid capacity is about to expire unused +*/30 * * * * ~/.claude/statusline.sh check; [ $? -eq 1 ] && notify-send "$(~/.claude/statusline.sh check)" + +# tmux: advisor verdict in the status bar +set -g status-right '#(~/.claude/statusline.sh check)' +``` + +`session-summary` is the same idea for session retrospectives — one +line per session, built from the usage log, designed as a `SessionEnd` +hook (it reads the hook JSON on stdin): + +```jsonc +// settings.json +"hooks": { + "SessionEnd": [{"hooks": [{"type": "command", + "command": "~/.claude/statusline.sh session-summary >> ~/.claude/statusline/session-summaries.log"}]}] +} +``` + +```text +session 8f3c02aa: 3h12m, 5h +34pts, 7d +4pts, claude-fable-5 +``` + +Run it bare and it summarizes the last session in the log. Window +deltas are positive-delta sums, so a session that straddles a 5h reset +still reports what it actually consumed. +
OAuth and API behavior Quota, profile, and extra-usage requests use Claude Code's OAuth credentials @@ -362,7 +403,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior. npm exec --yes bats -- t/ ``` -327 tests across `t/statusline.bats` (315 statusline + integration) and +334 tests across `t/statusline.bats` (322 statusline + integration) and `t/install.bats` (12 installer). CI runs on push and PR to `main`. ## Project Structure diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index 58a7536..da468bf 100644 --- a/docs/api/state-dir.md +++ b/docs/api/state-dir.md @@ -7,7 +7,7 @@ is the ONLY writer; everything here is safe to read concurrently. - Contract version: **1** (bump on any breaking layout/field change; this file is the changelog) -- Synced with: statusline.sh v0.20.0 +- Synced with: statusline.sh v0.21.0 - Permissions: the script runs under `umask 077` — files are owner-only. Caches hold account PII (email, uuid, org names). diff --git a/docs/index.html b/docs/index.html index c08c1b5..c6b7ffb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ Claude Code Statusline — quota, context, cost. Live, in one bash file. - +