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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details><summary>OAuth and API behavior</summary>

Quota, profile, and extra-usage requests use Claude Code's OAuth credentials
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/api/state-dir.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
6 changes: 3 additions & 3 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Code Statusline — quota, context, cost. Live, in one bash file.</title>
<meta name="description" content="Live 5h/7d quota with reset times, per-model weekly limits, context pressure, session cost, prompt-cache expiry detection, git activity, and a usage-advisor second row in your Claude Code prompt. One bash file, 327 tests, no daemon, no telemetry.">
<meta name="description" content="Live 5h/7d quota with reset times, per-model weekly limits, context pressure, session cost, prompt-cache expiry detection, git activity, and a usage-advisor second row in your Claude Code prompt. One bash file, 334 tests, no daemon, no telemetry.">
<style>
:root {
--bg: #0b0c0e;
Expand Down Expand Up @@ -130,7 +130,7 @@
<h1>Claude Code Statusline</h1>
<p class="sub">Quota, context, cost, model, and git — live in your Claude Code prompt.
Know when the 5h window resets, how deep the weekly limit runs, and what this session costs, without leaving the terminal.</p>
<p class="facts">one bash file &middot; 327 tests &middot; deps: jq + curl &middot; no daemon &middot; no telemetry</p>
<p class="facts">one bash file &middot; 334 tests &middot; deps: jq + curl &middot; no daemon &middot; no telemetry</p>

<div class="term">
<div class="bar"><i></i><i></i><i></i><span>claude</span></div>
Expand Down Expand Up @@ -183,7 +183,7 @@ <h2>Built like it matters</h2>
<p>Corporate mitm proxy or a local tracer: it builds a combined CA bundle from <code>NODE_EXTRA_CA_CERTS</code> automatically, so fetches survive where plain curl dies.</p></div>
<div class="val"><h3>Documented wire contract</h3>
<p>The <a href="https://github.com/thevibeworks/claude-code-statusline/blob/main/docs/api/oauth-usage.md">observed <code>/api/oauth/usage</code> schema</a> is versioned in-repo and re-synced against CLI captures, so parser changes check against ground truth.</p></div>
<div class="val"><h3>327 tests on one bash file</h3>
<div class="val"><h3>334 tests on one bash file</h3>
<p>bats suite covering quota math, lock contention (20-contender single-winner), forecast pacing, ANSI rendering, and the installer. CI on every push.</p></div>
<div class="val"><h3>Nothing running in the background</h3>
<p>No daemon, no telemetry, no npm. It runs when Claude Code renders the prompt, reads stdin + one cached API call, prints a line, exits.</p></div>
Expand Down
4 changes: 2 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
> advisor second row that interprets the numbers (cap projections,
> expiring-surplus and underuse advice, fleet relief) and a
> `report` subcommand that ledgers what each closed window expired
> unused. 327 bats tests, deps are jq + curl, no daemon, no telemetry.
> unused. 334 bats tests, deps are jq + curl, no daemon, no telemetry.

Key facts for agents:
- Everything lives in `statusline.sh` (~3400 lines, plain bash). Install
Expand All @@ -33,7 +33,7 @@ Key facts for agents:

- [statusline.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/statusline.sh): the entire product
- [install.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/install.sh): installer (curl | bash entrypoint)
- [t/statusline.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/statusline.bats): 315 statusline tests
- [t/statusline.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/statusline.bats): 322 statusline tests
- [t/install.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/install.bats): 12 installer tests

## Optional
Expand Down
97 changes: 95 additions & 2 deletions statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,8 @@ debug_log() {

while [[ $# -gt 0 ]]; do
case $1 in
report)
subcommand="report"
report | check | session-summary)
subcommand="$1"
shift
;;
--days)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion t/helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
Expand Down
103 changes: 103 additions & 0 deletions t/statusline.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EOF
{"computed_at":$(date +%s),"days_history":$2,"recent_24h":$4,"recent_48h":0,
Expand Down
Loading