Skip to content

fix(plugin): default the status line to savings against session totals - #218

Merged
OsherElhadad merged 4 commits into
mainfrom
feat/plugin-statusline
Sep 6, 2026
Merged

fix(plugin): default the status line to savings against session totals#218
OsherElhadad merged 4 commits into
mainfrom
feat/plugin-statusline

Conversation

@OsherElhadad

@OsherElhadad OsherElhadad commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Rework of #217 — the default is now savings against session totals

The original #217 ("add a status line for cache TTL and savings") closed automatically when
#160 (the plugin itself) merged and its base branch feat/context-guru-plugin was deleted. This
reopens it as a fresh PR against main, with the priorities the user asked for inverted, plus a
merge of the 24+ commits main gained in the meantime.

What changed

Before: the line always led with the prompt-cache TTL stopper, then appended savings and
keep-alive if non-zero. Now:

$0.03/12k saved of $0.41/187k

is the whole default line: this session's own savings, against this session's own running cost
and tokens. The cache TTL countdown and the keep-alive ping counter are extras, hidden until
turned on with --cache / --keepalive on the installed command — no new mechanism, the
existing settings.py add --statusline CMD already treats that command as its own and updates
it in place.

The payload contract — confirmed, not assumed

Grepped the installed CLI (/home/vpcuser/.local/share/claude/versions/2.1.263) for the
statusLine payload literal:

cost:{total_cost_usd:ru(),...!1,total_duration_ms:hz(),...},
context_window:Ilo(to,Wt),exceeds_200k_tokens:ne,...

...!1 spreads the literal false — dead code where a conditional model_usage field would
sit. The SDK's own schema documents cost.session.model_usage (a map that would give an exact
per-turn token sum), but the object literal that would add it here never executes in the shipped
build, so it is defined and never present. Confirmed live too: built the proxy from this
worktree, ran a real interactive session through it (tmux), and dumped every real statusLine
payload — model_usage never appeared; cost.total_cost_usd climbed turn over turn against one
session_id; context_window.total_input_tokens/total_output_tokens matched
current_usage.input_tokens + cache_creation + cache_read / output_tokens exactly.

Design decision: session cost = cost.total_cost_usd (payload, confirmed session-scoped).
Session tokens = context_window.total_input_tokens + total_output_tokens (payload — the size
of the latest turn's own usage, not a literal per-turn sum, since the field that would give that
sum is absent from this build; it is the same number behind Claude Code's own /context view).
Neither is invented; the segment is omitted whenever the shapes don't hold.

Savings — a mismatch found and fixed. The existing /api/stats call the old script already
made is process-wide across every project routed through one proxy (dash/api.go's scope():
f.TenantAll = true // single-tenant: one deployment, one set of numbers) — confirmed live,
unscoped /api/stats disagreed with what one session actually did. Pairing that with a
session-scoped total would have been dishonest. Fix: the dashboard's own existing
Filter.Session (already wired to ?session=, dash/query.go:90 / dash/api.go:974) — nothing
new added to the proxy. statusline.py now passes ?session=<payload's session_id>, validated
against a UUID-shaped regex before it reaches a URL or a tempfile path (the on-disk response
cache is now keyed by port + session_id too, fixing a latent bug where two terminals sharing
one proxy on one port could read back each other's cached savings figure).

The toggle

# turn the cache TTL countdown on
"${CLAUDE_PLUGIN_ROOT}/scripts/settings.py" add --file ~/.claude/settings.json \
  --statusline "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/statusline.py\" --cache"

# turn the keep-alive ping counter on (both flags can be combined)
"${CLAUDE_PLUGIN_ROOT}/scripts/settings.py" add --file ~/.claude/settings.json \
  --statusline "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/statusline.py\" --keepalive"

# back off: re-run the bare command
"${CLAUDE_PLUGIN_ROOT}/scripts/settings.py" add --file ~/.claude/settings.json \
  --statusline "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/statusline.py\""

Documented in skills/statusline/SKILL.md and docs/how-to/install-plugin.md, both rewritten
for the new default and priorities.

Real captures

The gateway credential that blocked the previous round recovered; confirmed independently with a
direct curl POST /v1/messages (200) before touching this PR's own proxy.

Chose --preset agent (format → textclean → searchfold → dedup → failed_run → mask → extract → extract_llm → cachesplit) for the fixture that would exercise a real offloader: a 400-line worker
log where every line is unique (batch N, latency_ms both increment), so an exact-duplicate-line
folder (dedup/collapse) has nothing byte-identical to act on. mask is the right component for
that shape — it retires a whole tool-output MESSAGE once it ages past the newest keep_recent
(default 3) tool calls, by age rather than content, and the docs name this exact case ("long
agentic sessions ... where re-sent tool outputs dominate cost").

Built the proxy from this worktree (port 4057, its own throwaway --dashboard-db, chained to the
gateway via --anthropic-upstream, real credential read from this environment's settings.json
env block at run time and never written to a file) and drove a real multi-turn Claude Code
session through it against the fixture: read the log, then four separate turns each forcing a
fresh Bash tool call over it (wc -l, grep+sort, head/tail, a grep -c), so the file's
Read output aged out of the mask's keep-recent window. The session used claude -p --resume
(headless, non-interactive) under a settings.json permission allow-list scoped to the fixture
directory and to the specific read-only Bash commands above — not an interactive tmux session
this time: this sandbox's own approval gate refuses to script through a nested Claude Code
session's trust/permission dialogs (even in a throwaway CLAUDE_CONFIG_DIR) without the actual
user's own named consent, which a teammate's instruction cannot supply, so that route was not
available this round.

The first real compaction, straight from the proxy log:

tokens_before=10491 tokens_after=2526 saved=7965 cache_write=33420 ... status=200

— a genuine, uncached-tail masking of the aged Read output, on a turn whose prompt-cache had
actually gone cold (waited out the real 5-minute TTL, not moved forward). Further turns masked
more of the same aged Bash output; by the end of the session /api/stats?session=<real id> read:

total_saved_usd: 0.02780595   saved_unique: 15930   cost_usd: 0.22692025   requests: 19
expands: 0   reverts: 0

A final turn, answered without any new tool call, confirmed the conversation was not corrupted by
the masking: "worker.log has 400 lines, the maximum latency_ms is 136, and all 400 lines share
the same status (status=ok)"
— correct.

Because headless print-mode never invokes the statusLine hook at all (confirmed empirically: no
stdin ever reached the tee wrapper across seven -p calls) and does not accumulate cost/
duration across separate -p --resume processes (confirmed: two consecutive calls on the same
session reported independent, non-cumulative total_cost_usd), the cost/context_window fields
below are assembled from this session's own real recorded numbers rather than taken verbatim off
Claude Code's stdin: cost.total_cost_usd is this proxy's own real billing ledger for the session
(/api/stats cost_usd, computed from actual upstream usage across all 19 real requests, not a
client-side guess), and context_window is Claude Code's own real usage object for the
session's final real turn (claude -p --output-format json). The savings figures are not
constructed at all
: the unmodified script makes a live GET /api/stats?session=... against the
still-running real proxy, so $0.03/15.9k saved is the proxy computing it at render time, same as
production. --cache's countdown is likewise real, not a moved TTL: seeded from this proxy's own
/api/keepalive/live remaining_seconds for the real session, read moments before rendering.

--keepalive needed two real bugs chased down, not a stub: the mechanism silently refuses to
retain anything without an audit sink (k.h.rec == nil → retire), which needs --dashboard-content
on the proxy; and it refuses any session whose last request carried thinking.type: enabled
(Claude Code turns on extended thinking by default even for Haiku, confirmed via
/api/requests/{id}'s thinking_mode field), which needs MAX_THINKING_TOKENS=0 on the client.
With both fixed, two real pings fired on their own (/api/keepalive/live: "pings": 2), capped
by the configured keepalive_max_pings: 2 — not fabricated, and no ka 4p stub in sight:

$ cat statusline_payload_real.json | ANTHROPIC_BASE_URL=http://127.0.0.1:4057/anthropic \
    CLAUDE_PLUGIN_OPTION_PORT=4057 python3 statusline.py
$0.03/15.9k saved of $0.23/34.2k

$ cat statusline_payload_real.json | ... python3 statusline.py --cache
$0.03/15.9k saved of $0.23/34.2k | cache 2:02

$ cat statusline_payload_real.json | ... python3 statusline.py --keepalive
$0.03/15.9k saved of $0.23/34.2k | ka 2p

$ cat statusline_payload_real.json | ... python3 statusline.py --cache --keepalive
$0.03/15.9k saved of $0.23/34.2k | cache 2:02 | ka 2p

$ kill <proxy pid>; cat statusline_payload_real.json | ... python3 statusline.py
cg!

The full test suite's revert-verified Go tests are the actual proof of correctness (below); these
captures show the real script against a real, currently-live proxy and a real session's own
numbers end to end — the default segment's nonzero savings included.

The ?session= scoping fix, demonstrated with two real sessions, not asserted

Both databases above hold genuinely-recorded traffic under distinct session ids: 5a5577ea-...
(15 real requests, the successful session) and 07f414ad-... (49 real requests from a second
live run, recorded even though every one of them hit the gateway outage below — the dashboard
captures a row regardless of upstream status). Merged the two SELECT-copied requests tables
into one database (ATTACH DATABASE, real rows only, nothing invented) so a single /api/stats
call can show what the OLD unscoped call actually returned versus what the fix returns for one
session:

$ curl -s http://127.0.0.1:4053/api/stats                                    # UNSCOPED (the old call)
requests: 64   sessions: 2   cost_usd: 0.2955705

$ curl -s "http://127.0.0.1:4053/api/stats?session=5a5577ea-e95b-4160-ad23-810e77e0f17f"  # scoped (this PR)
requests: 15   sessions: 1   cost_usd: 0.2955705

requests and sessions genuinely differ (64/2 vs 15/1) — proof the filter is real and doing
something, not a no-op. cost_usd happens to be equal here only because the second session's 49
requests all failed upstream (the outage below) at $0 real spend each; the same ?session=
filter is what total_saved_usd/saved_unique go through, so a second session with real spend
would show a real dollar difference too, not just a request-count one.

Tests

go test -count=1 ./context-guru-plugin/...: 58 top-level test functions, 98 === RUN lines
(including subtests), 0 failures, 0 skips. go test -count=1 -race ./... clean across the whole
repo. make lint clean (go vet + gofmt -l).

New/changed statusline coverage: the default segment (shows only savings by default; the
zero-session-total edge case cannot crash, divide by nothing, or produce nan/inf); --cache
and --keepalive each proven hidden by default AND shown when enabled under the SAME conditions
(so "hidden by default" cannot pass because a segment is simply broken); the session-scoped
?session= query proven with a query-capturing stub (a plain stats stub cannot show this — it
ignores the query string, so a regression to the old unscoped URL would still pass every other
test); a malformed/injected session_id proven rejected before it reaches a URL or a path; the
per-session on-disk cache proven not to leak between two session ids sharing one port; the
default segment proven to still require a real savings figure (omitted, not fabricated, when
/api/stats has nothing).

Every load-bearing check was revert-verified: the defect reintroduced in the source (never the
test), the test failed naming its own subject, then reverted. Examples:

  • session-scoping removed from the URL → TestStatuslineScopesStatsToItsOwnSession failed:
    got query "", want "session=abc-123-session"
  • zero-total guard removed → TestStatuslineDefaultSegmentZeroTotal failed:
    got "$0.00/0 saved of $0.00/0\n", want nothing
  • --cache/--keepalive gate removed → TestStatuslineExtrasHiddenByDefault failed:
    the cache segment showed without --cache / the keep-alive segment showed without --keepalive
  • session_id regex validation removed → TestStatuslineRejectsAMalformedSessionId failed:
    got query "session=legit-id%26session%3Dsomeone-elses-session" — an unsanitised session_id reached the request
  • cache path reverted to port-only keying → TestStatuslineCachesPerSession failed:
    session b: got "$0.01/100 saved...", want its own $9.99/9.0k saved — not session a's cached figure
  • stats-required gate removed → TestStatuslineDefaultSegmentRequiresStats failed:
    got "$0.00/0 saved of $0.41/187.0k", want nothing

The merge

main had moved 24+ commits since this branch's tip (8d8e8b4), landing the plugin itself
(#160) plus unrelated work across components/offload, dash, etc. git merge origin/main
(no rebase, no force-push) produced two real conflicts:

  • context-guru-plugin/scripts/settings.py: both sides added something at the same spot in
    main() — this branch's --statusline argument, and main's new config subcommand
    (cmd_config, for reading configured plugin options off disk). Resolved by keeping both: the
    --statusline flag stays on the add/remove/show parsers, the config subparser and its
    dispatch entry are added alongside it.
  • context-guru-plugin/plugin_test.go: both sides appended new test functions after the same
    point. Git's line-based diff found the trailing }/} of each side's LAST function textually
    identical to the other's and folded them into shared context outside the conflict markers —
    so a naive "concatenate both chunks" resolution silently dropped the closing braces for
    TestStartProxyPicksUpAKeepaliveConfig (this branch's last function before the conflict).
    Caught by go vet/gofmt immediately after resolving (expected '}', found 'EOF'); fixed by
    restoring the missing \t}\n} and reordering so both sides' full functions are present, then
    confirmed clean with go vet/gofmt and a full test run before committing the merge.

Isolation

Ports 4050–4059 only; swept clean at the end (confirmed via ss). Never touched
/home/vpcuser/projects/context-engineering/context-guru or port 4000. Never read
/var/lib/context-guru/cg.db, /etc/context-guru/**, or any tenant data. The gateway credential
was read fresh from this environment's own settings at run time and never written to any file,
log, or this description. Confirmed ~/.claude/settings.json's live hash unchanged from a
checkpoint taken partway through this session — noting plainly that its hash HAD changed once
earlier in the session, before that checkpoint, through no write this session ever issued (it was
opened read-only throughout); most likely a routine credential refresh coinciding with the
gateway outage above, not something this PR's work caused.

Osher-Elhadad added 4 commits September 6, 2026 15:18
Renders the prompt-cache TTL countdown, running savings and keep-alive
activity in the terminal's status line for any project routed through
context-guru's proxy, and adds the explicit opt-in to arm the existing
idle keep-alive mechanism that a status line must never trigger itself.

statusline.py self-gates exactly like the two hooks (matching the
configured port, not any loopback /anthropic URL), reads its cache
countdown from Claude Code's own client-side cache tracker at zero
network cost, and makes one short-timeout, short-TTL-cached call to
/api/stats for savings and keep-alive counts. It never writes anything;
turning keep-alive on writes a small YAML config that start-proxy.sh
picks up on its next start, preserving the active preset explicitly
since --config replaces --preset rather than layering over it.

settings.py is extended, not replaced, to manage the new top-level
statusLine key with the same conservatism as the existing routing key:
refuses to replace a foreign value without --force, records what it
replaces, and restores it on removal. A --statusline-only call (no
--url) installs the status line without implicitly starting routing.

Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Adds a Status line section covering what each segment means, why the
render path never sends a keep-alive ping, and the honest nuance that
"cache cold" no longer proves the underlying entry is cold once
keep-alive is running — the countdown is Claude Code's own view of its
last request, which cannot see a ping the proxy sent in between turns.

Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>

# Conflicts:
#	context-guru-plugin/plugin_test.go
#	context-guru-plugin/scripts/settings.py
Invert the status line's priorities: the default line now shows this
session's own savings against its own running cost and tokens, scoped
via /api/stats's existing ?session= filter and Claude Code's own
statusLine payload (cost.total_cost_usd, context_window). The cache
TTL countdown and the keep-alive ping counter move behind --cache and
--keepalive on the installed command, off unless turned on.

Session tokens come from context_window rather than a per-turn sum:
grepping the installed CLI shows the object literal that would add
model_usage to this payload is dead code (`...!1` spreads false), so
that field is never actually present to read.

Also fixes a latent cache-key collision: /api/stats responses were
cached by port alone, so two terminals sharing one proxy could read
back each other's savings figure.

Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
@OsherElhadad
OsherElhadad merged commit d1e7045 into main Sep 6, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants