fix(plugin): default the status line to savings against session totals - #218
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-pluginwas deleted. Thisreopens it as a fresh PR against
main, with the priorities the user asked for inverted, plus amerge of the 24+ commits
maingained 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:
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/--keepaliveon the installed command — no new mechanism, theexisting
settings.py add --statusline CMDalready treats that command as its own and updatesit in place.
The payload contract — confirmed, not assumed
Grepped the installed CLI (
/home/vpcuser/.local/share/claude/versions/2.1.263) for thestatusLine payload literal:
...!1spreads the literalfalse— dead code where a conditionalmodel_usagefield wouldsit. The SDK's own schema documents
cost.session.model_usage(a map that would give an exactper-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_usagenever appeared;cost.total_cost_usdclimbed turn over turn against onesession_id;context_window.total_input_tokens/total_output_tokensmatchedcurrent_usage.input_tokens + cache_creation + cache_read/output_tokensexactly.Design decision: session cost =
cost.total_cost_usd(payload, confirmed session-scoped).Session tokens =
context_window.total_input_tokens + total_output_tokens(payload — the sizeof 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
/contextview).Neither is invented; the segment is omitted whenever the shapes don't hold.
Savings — a mismatch found and fixed. The existing
/api/statscall the old script alreadymade is process-wide across every project routed through one proxy (
dash/api.go'sscope():f.TenantAll = true // single-tenant: one deployment, one set of numbers) — confirmed live,unscoped
/api/statsdisagreed with what one session actually did. Pairing that with asession-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) — nothingnew added to the proxy.
statusline.pynow passes?session=<payload's session_id>, validatedagainst a UUID-shaped regex before it reaches a URL or a tempfile path (the on-disk response
cache is now keyed by
port + session_idtoo, fixing a latent bug where two terminals sharingone proxy on one port could read back each other's cached savings figure).
The toggle
Documented in
skills/statusline/SKILL.mdanddocs/how-to/install-plugin.md, both rewrittenfor 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 workerlog where every line is unique (
batch N,latency_msboth increment), so an exact-duplicate-linefolder (
dedup/collapse) has nothing byte-identical to act on.maskis the right component forthat 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 thegateway via
--anthropic-upstream, real credential read from this environment'ssettings.jsonenvblock at run time and never written to a file) and drove a real multi-turn Claude Codesession through it against the fixture: read the log, then four separate turns each forcing a
fresh
Bashtool call over it (wc -l,grep+sort,head/tail, agrep -c), so the file'sReadoutput aged out of the mask's keep-recent window. The session usedclaude -p --resume(headless, non-interactive) under a
settings.jsonpermission allow-list scoped to the fixturedirectory 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 actualuser'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:
— a genuine, uncached-tail masking of the aged
Readoutput, on a turn whose prompt-cache hadactually 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: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
statusLinehook at all (confirmed empirically: nostdin ever reached the tee wrapper across seven
-pcalls) and does not accumulatecost/durationacross separate-p --resumeprocesses (confirmed: two consecutive calls on the samesession reported independent, non-cumulative
total_cost_usd), thecost/context_windowfieldsbelow are assembled from this session's own real recorded numbers rather than taken verbatim off
Claude Code's stdin:
cost.total_cost_usdis this proxy's own real billing ledger for the session(
/api/statscost_usd, computed from actual upstream usage across all 19 real requests, not aclient-side guess), and
context_windowis Claude Code's own realusageobject for thesession's final real turn (
claude -p --output-format json). The savings figures are notconstructed at all: the unmodified script makes a live
GET /api/stats?session=...against thestill-running real proxy, so
$0.03/15.9k savedis the proxy computing it at render time, same asproduction.
--cache's countdown is likewise real, not a moved TTL: seeded from this proxy's own/api/keepalive/liveremaining_secondsfor the real session, read moments before rendering.--keepaliveneeded two real bugs chased down, not a stub: the mechanism silently refuses toretain anything without an audit sink (
k.h.rec == nil→ retire), which needs--dashboard-contenton 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}'sthinking_modefield), which needsMAX_THINKING_TOKENS=0on the client.With both fixed, two real pings fired on their own (
/api/keepalive/live:"pings": 2), cappedby the configured
keepalive_max_pings: 2— not fabricated, and noka 4pstub in sight: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 assertedBoth databases above hold genuinely-recorded traffic under distinct session ids:
5a5577ea-...(15 real requests, the successful session) and
07f414ad-...(49 real requests from a secondlive 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-copiedrequeststablesinto one database (
ATTACH DATABASE, real rows only, nothing invented) so a single/api/statscall can show what the OLD unscoped call actually returned versus what the fix returns for one
session:
requestsandsessionsgenuinely differ (64/2 vs 15/1) — proof the filter is real and doingsomething, not a no-op.
cost_usdhappens to be equal here only because the second session's 49requests all failed upstream (the outage below) at $0 real spend each; the same
?session=filter is what
total_saved_usd/saved_uniquego through, so a second session with real spendwould 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=== RUNlines(including subtests), 0 failures, 0 skips.
go test -count=1 -race ./...clean across the wholerepo.
make lintclean (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);--cacheand
--keepaliveeach 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 — itignores the query string, so a regression to the old unscoped URL would still pass every other
test); a malformed/injected
session_idproven rejected before it reaches a URL or a path; theper-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/statshas 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:
TestStatuslineScopesStatsToItsOwnSessionfailed:got query "", want "session=abc-123-session"TestStatuslineDefaultSegmentZeroTotalfailed:got "$0.00/0 saved of $0.00/0\n", want nothing--cache/--keepalivegate removed →TestStatuslineExtrasHiddenByDefaultfailed:the cache segment showed without --cache/the keep-alive segment showed without --keepaliveTestStatuslineRejectsAMalformedSessionIdfailed:got query "session=legit-id%26session%3Dsomeone-elses-session" — an unsanitised session_id reached the requestTestStatuslineCachesPerSessionfailed:session b: got "$0.01/100 saved...", want its own $9.99/9.0k saved — not session a's cached figureTestStatuslineDefaultSegmentRequiresStatsfailed:got "$0.00/0 saved of $0.41/187.0k", want nothingThe merge
mainhad 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 inmain()— this branch's--statuslineargument, andmain's newconfigsubcommand(
cmd_config, for reading configured plugin options off disk). Resolved by keeping both: the--statuslineflag stays on theadd/remove/showparsers, theconfigsubparser and itsdispatch entry are added alongside it.
context-guru-plugin/plugin_test.go: both sides appended new test functions after the samepoint. Git's line-based diff found the trailing
}/}of each side's LAST function textuallyidentical 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/gofmtimmediately after resolving (expected '}', found 'EOF'); fixed byrestoring the missing
\t}\n}and reordering so both sides' full functions are present, thenconfirmed clean with
go vet/gofmtand 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-guruor port 4000. Never read/var/lib/context-guru/cg.db,/etc/context-guru/**, or any tenant data. The gateway credentialwas 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 acheckpoint 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.