SLOPSHOPPER

token-scope

Token spend analytics and cost alerts for Claude Code. 16 report modes including weekly credit consumption against your plan cap, cache intelligence, tooling…

newstatustimer
v1.6.0MITupdated 2026-10-15nhangen/token-scope
A shopper browsing a rack in a slop shop
README

token-scope

Analytics for your Claude Code token spend.

Reads Claude Code's JSONL session files (and optionally ~/.claude/__store.db) in read-only mode and shows exactly where your tokens and money go — by tool, project, session, thinking blocks, cache efficiency, context bloat, and cost per turn.

Why

Claude Code users have no built-in visibility into token spend. token-scope fills that gap retroactively, with no proxy, no tokenizer, and no daemon. The data is already on disk.

Prerequisites

Bun 1.1.0+

curl -fsSL https://bun.sh/install | bash

Install

git clone https://github.com/nhangen/token-scope ~/ML-AI/claude/token-scope
cd ~/ML-AI/claude/token-scope
bun install
bun link          # makes `token-scope` available from any directory

Reports

Summary (default)

token-scope --since 7d
token-scope — Summary  (Apr 1, 2026 → now)
────────────────────────────────────────────────────────────
  Sessions: 236   Turns: 5,966

  Totals
  Output Tokens                1,617,245
  Cache Read Tokens            412,581,387
  Total Cost                   $401.77
  Avg Cost / Session           $1.70
  Avg Cost / Turn              $0.07

  Output Tokens by Tool
Tool              │ Turns  │ Output Tokens │ Cost %
──────────────────┼────────┼───────────────┼───────
(text only)       │  4,227 │       868,702 │  56.5%
Bash              │    900 │       272,348 │  22.3%
Agent             │    147 │       167,949 │   7.1%
Write             │     50 │       155,838 │   1.4%
...

  Output Tokens by Project
Project                  │ Sessions │ Turns │ Total Cost │ Avg/Session
─────────────────────────┼──────────┼───────┼────────────┼────────────
appoptinmonstertest      │        8 │ 1,140 │    $96.37  │     $12.05
token-scope              │        1 │   314 │    $17.03  │     $17.03
...

  Weekly Trend
Week     │ Sessions │ Turns  │ Output Tokens │ Total Cost
─────────┼──────────┼────────┼───────────────┼───────────
2026-W14 │      222 │  4,749 │     1,362,379 │    $198.71
2026-W13 │       16 │  1,217 │       254,866 │    $203.06

Tool drill-down

token-scope --tool bash --since 7d
token-scope — Tool: Bash
────────────────────────────────────────────────────────────
  Turns (dominant)             902
  Total Cost                   $89.78
  Share of All Cost            22.3%
  Distribution (p50/p95/max)   160 / 799 / 9,875

  Command Categories
Category        │ Turns │ Output Tokens │ Total Cost
────────────────┼───────┼───────────────┼───────────
Other           │   508 │       136,524 │    $57.43
File Inspection │   272 │        91,402 │    $16.41
Version Control │    54 │        10,612 │    $10.99
JS Tooling      │    22 │         6,773 │     $1.18
...

Sessions list

token-scope --sessions --since 7d
token-scope — Sessions  (last 7d)
────────────────────────────────────────────────────────────
Session ID     │ Project              │ Started         │ Duration   │ Turns │ Cost
───────────────┼──────────────────────┼─────────────────┼────────────┼───────┼──────────
be299042-801f- │ appoptinmonstertest  │ Apr 02 at 20:59 │ 123:47:33  │   682 │ $105.19
04442160-fea2- │ appoptinmonstertest  │ Apr 06 at 14:21 │  52:38:20  │   806 │  $47.90
07896aa6-5249- │ mtf-builder          │ Apr 07 at 17:45 │  07:01:05  │   141 │   $9.88
...

Session detail

token-scope --session be299042

Turn-by-turn breakdown with output tokens, cost, tool used, and thinking indicator for every turn in the session.


Spend (per-task token accounting)

token-scope --spend --session be299042            # whole session
token-scope --spend --session be299042 --turns 5..12   # isolate one task
token-scope --spend --turns 5..12                 # most-recent session

Isolates the Claude (billed) token spend — output, input, cache-read, cache-write, and a derived cost — for one session, sliced to a turn range so a single task can be measured. Rolls up subagent (Task/Agent) overhead so PM-loop cost (auditors, explorers) is visible rather than hidden. --session picks the session (defaults to the most recent); --turns N..M (also N.., ..M, N) is a 1-indexed inclusive slice; --since acts as a within-session timestamp floor when set.

Notes: subagent totals are session-wide in v1 (not scoped to the turn range), and require the JSONL source (subagent transcripts aren't in the SQLite store). Turns on a model with no known pricing still count their tokens; only their cost is excluded. Local (ollama) authoring runs off-transcript and is not counted — so a delegated task's spend shows just the thin Claude PM overhead. Model labels in displayed turns pass the credential screen: credential-shaped values print as [redacted], and --json reports the number of withheld turn labels in redacted_labels (the text report adds a footer only when any are redacted).


Savings (ollama delegation ROI)

token-scope --savings                                  # all delegation runs
token-scope --savings --session be299042               # one delegation session
token-scope --savings --counterfactual-model claude-sonnet-5   # value against a cheaper tier
token-scope --savings --session be299042 --pm-turns 4..9   # net vs. just the delegation's PM turns
token-scope --savings --session be299042 --pm-cost 0.87    # net vs. a measured PM figure (subagent PM)
token-scope --savings --ledger /path/to/runs.jsonl     # explicit ledger location
token-scope --savings --escalations /path/to/escalations.jsonl  # explicit escalation sidecar

Answers the "did delegating authorship to a local ollama model actually save money?" question. Reads the ollama-agent run ledger ($XDG_STATE_HOME/ollama-agent/runs.jsonl, or $OLLAMA_AGENT_LEDGER) — the bridge records each run's local token counts (ground truth from ollama's eval_count/prompt_eval_count) plus the Claude session that spawned it. token-scope prices them; the bridge never guesses cost.

For each delegation session it reports the net savings:

Net = Counterfactual − PM overhead
  • Counterfactual — the ollama token volume valued at Claude prices (default claude-opus-4-8): an estimate of what Claude authoring the same work would have cost. ollama and Claude tokenize differently, so this is a proxy, not a measured figure.
  • PM overhead — the actual Claude billed spend of the session that ran the delegation: the real cost of Claude playing project-manager (writing tests, auditing, steering). By default this is the whole session (direct + subagent rollup). That only makes sense for a dedicated delegation session — in a long mixed session, unrelated work swamps the delegation and net reads hugely negative. Use --pm-turns N..M (requires --session) to scope PM overhead to just the delegation's orchestration turns — the only way to get a meaningful per-task net from transcripts. The ledger has no delegation-start marker, so the turn window can't be auto-derived; you isolate it, exactly as with --spend --turns. (Turn-scoped PM excludes session-wide subagent cost, so it's a floor.)
  • Measured PM — when the PM was a subagent (the recommended pattern: a lean Haiku agent writes the oracles and fires the bridge), neither scope can isolate its cost — subagent spend is session-wide in v1, and turn slices cover direct turns only. Measure it out-of-band (e.g. the subagent-bucket delta between two --spend runs) and pass it as --pm-cost <usd> (requires --session, mutually exclusive with --pm-turns). The report takes the figure on trust and labels the scope measured (caller). Because no transcript lookup happens, this also attributes sessions that ran on another machine.
Escalated runs are not savings

When a spec hits the turn cap twice, ollama-delegate no longer stops — it hands the same spec to a higher-tier author (a Sonnet subagent in Claude Code, gpt-5.6-terra/-luna in Codex). That author is billed for the job. Pricing the local attempts as "what Claude would have cost" then counts one job twice: once as a counterfactual saving and once as real spend.

So token-scope reads a sidecar written by llm-tools' ~/.claude/scripts/ollama-record-escalation.sh:

$XDG_STATE_HOME/ollama-agent/escalations.jsonl   (or $OLLAMA_AGENT_ESCALATIONS, or --escalations)

It is a sibling of runs.jsonl rather than a field on it — that file is written by claude-ceo's bridge, and a new field there risks the parsing this report depends on.

A ledger run is superseded when a record names its run_id, ran in the same cwd, the run itself failed, and it falls in the OLLAMA_ATTEMPT_GAP window (default 4h) before the record. All four matter: a label is a ticket number and gets reused, a later run on the same ticket that succeeded saved real work, and a run outside the window belongs to an earlier cycle. Three shapes cannot be decided and all three stay in the counterfactual, over-stating the saving rather than inventing an exclusion: a run with no timestamp, a legacy run with no cwd, and a run whose reason/completed/verified are all unrecorded (the ledger defines that as "not recorded — never a claim about the run", and the matcher reads it as "succeeded"). A record with an empty cwd is read as having none, so it matches nothing — the recorder refuses to write one and calls such a record not discountable at all.

Superseded runs are excluded from the counterfactual — unlike runs that merely failed, which stay in it and are footnoted — but their tokens remain in the ledger totals, so no spend is hidden. --json adds superseded_run_count, superseded_input, superseded_output, and superseded_excluded_usd (the dollar figure the exclusion removed) whenever the report found any.

A sidecar that fails to load is never reported as one that legitimately held nothing. That distinction is the whole point of the feature: both produce zero exclusions, and zero exclusions is a full, confident, wrong counterfactual — the double-count #81 removed, back again. So --json always carries escalations_path, escalations_status (ok/absent/unreadable), escalations_records, escalations_skipped_lines, escalations_unmatched, and attempt_gap_seconds, whether or not anything was superseded. An unreadable file warns on stderr; so does a missing file whose path you named yourself, since a path you typed is an assertion that it is there.

Records that name a run the ledger does not contain are footnoted rather than dropped. Matching keys on cwd as well as run_id, so a worktree that moved after the escalation breaks every record naming it — silently restoring the double-count if nothing counts the misses. A record whose run is present but ineligible (it succeeded, or it cannot be dated) is doing its job by not firing and is not counted, and the footnote is suppressed on a --session/--since report, where scoping leaves most records naming nothing by construction.

The same disclosure covers the ledger itself: ledger_status and ledger_skipped_lines sit beside ledger_path, and an unreadable ledger warns on stderr. It is the primary source of every figure in the report, so disclosing the sidecar's load status and not the ledger's would advertise an integrity the report does not have.

A positive net means delegation saved money. Note the economics: for a small task the counterfactual is tiny, so a single expensive PM turn can exceed it (net negative) — delegation pays off as the authored task grows large relative to the fixed PM overhead. Runs with no attributable Claude session (null session_id, or a session not in the local transcripts) are counted for token volume but excluded from the net headline. --since floors by ledger timestamp when set.


Project filter

token-scope --project wp-content --since 30d

Filters all reports to sessions where wp-content appears in the working directory path.


Cache economics & efficiency

token-scope --cache --since 30d
token-scope --cache-misses --since 30d      # focus on miss classification and gap histogram
token-scope --cache-sim --since 30d         # focus on policy simulations
token-scope --cache --json                  # machine-readable full analysis
token-scope — Cache Economics & Efficiency
────────────────────────────────────────────────────────────
  Projects                     20
  Overall Cache Hit %          96.2% (read / (read + write + input))
  Read Share (non-write)       99.8% (legacy read / (read + input))
  Est. Total Savings           $10159.6300
  Rebuild Miss Cost            $143.2100
  Reader vs Tier-Priced        $412.6000 vs $427.7000 (Δ $-15.1000)

  Input Token & Cost Mix
Category         │         Tokens │   Token % │    Cost ($) │   Cost % │        Credits │  Credits %
─────────────────┼────────────────┼───────────┼─────────────┼──────────┼────────────────┼───────────
Cache Read       │    895,151,000 │     96.2% │     $268.5500 │    62.8% │     89,515,100 │      51.1%
Cache Write      │     24,800,000 │      2.7% │      $93.0000 │    21.7% │     31,000,000 │      17.7%
Uncached Input   │     10,500,000 │      1.1% │      $66.1500 │    15.5% │     54,500,000 │      31.2%

  By Project
Project                      │ Sessions │ Turns │ Cache Hit % │ Read Share │ Cache Reads │ Est. Savings
─────────────────────────────┼──────────┼───────┼─────────────┼────────────┼─────────────┼─────────────
public                       │       12 │ 3,431 │       97.1% │     100.0% │ 405,001,821 │   $3974.1500
wp-content-6879-hubspot-v3   │        2 │   449 │       95.4% │     100.0% │ 130,063,498 │   $1700.5500
wp-content                   │        4 │   863 │       96.8% │     100.0% │ 122,866,030 │   $1513.6800
...

  Miss Classification & Extra Rebuild Costs
Loop           │ Classification   │   Requests │   Extra Cost │ Note
───────────────┼──────────────────┼────────────┼──────────────┼─────────────────────────────────────
Main session   │ Expiry (>1h gap) │         18 │    $112.4000 │ Idle pause exceeded 1h TTL; rebuilt
Main session   │ Growth (no gap)  │      4,720 │      $0.0000 │ Normal incremental context expansion
Main session   │ Invalidation     │          4 │     $18.2000 │ Prefix invalidated (model/tools)
Main session   │ Cold initial     │         24 │      $0.0000 │ Session start / first request
Subagents      │ Cold starts      │         82 │      $0.0000 │ Subagent first turn (5m TTL)
Subagents      │ Expiry (>5m gap) │          6 │     $12.2000 │ Subagent pause exceeded 5m TTL
Subagents      │ Growth (no gap)  │        190 │      $0.0000 │ Subagent context growth
Subagents      │ Invalidation     │          1 │      $0.4100 │ Subagent prefix invalidated

  Request Gap Distribution (Main Loop)
  Bucket       │   Count │   Share │ Distribution
  ─────────────┼─────────┼─────────┼──────────────────────────────
  < 1m         │    3412 │   71.8% │ █████████████████
  1m – 5m      │     980 │   20.6% │ █████
  ─────────────── 5m TTL (subagents / 5m policy / overage) ───────────────
  5m – 15m     │     210 │    4.4% │ █
  15m – 30m    │      95 │    2.0% │ 
  30m – 60m    │      38 │    0.8% │ 
  ─────────────── 1h TTL (default main-session policy) ───────────────────
  1h – 2h      │      12 │    0.3% │ 
  2h – 4h      │       4 │    0.1% │ 
  > 4h         │       2 │    0.0% │ 

  What Would Have Saved (Simulations)
  # │ Policy Change                              │   Est. Savings/Wk │   Credits Saved │ Simulated Cost
────┼────────────────────────────────────────────┼───────────────────┼─────────────────┼───────────────
  1 │ Run /compact before breaks > 1 hour        │         ~$24.50/wk│     ~6,125,000/wk│      $403.2000
  2 │ Start a fresh session instead of resuming after > 24h │         ~$18.20/wk│     ~4,550,000/wk│      $409.5000
  3 │ Lower auto-compact threshold to 35% of context window│         ~$11.40/wk│     ~2,850,000/wk│      $416.3000
  4 │ Switch main session to 5m TTL              │     +$8.90/wk cost│      +2,225,000/wk│      $436.6000
Formulas & Definitions
  • Corrected Hit Rate: $$\text{Hit Rate} = \frac{\text{cache\_read\_tokens}}{\text{cache\_read\_tokens} + \text{cache\_write\_tokens} + \text{input\_tokens}}$$ Unlike the legacy formula $\frac{\text{cache\_read}}{\text{cache\_read} + \text{input}}$ (which omitted cache creation writes and made rebuild costs invisible), the corrected hit rate accounts for all input tokens processed. The legacy number remains available labeled as Read Share (non-write) for continuity.
  • Token & Cost Mix: Evaluated at model prices (and credit weights matching --credits):
  • Cache Read: 0.1x base input rate (90% discount).
  • Cache Write (5m TTL): 1.25x base input rate (Anthropic 5-minute standard write rate).
  • Extended Cache Write (1h TTL): 2.0x base input rate (Anthropic extended caching rate). The split comes from usage.cache_creation.ephemeral_1h_input_tokens; writes with no split are priced at the 5m rate.
  • Uncached Input: 1.0x base rate.
  • Credits: Weighted as 0.1x read, 1.25x write (2.0x for 1h-tier writes), 1.0x uncached input, and 5.0x output.
  • Miss Classification: Each request with a cache write is classified as below, on both loops; requests with no write are hit and are excluded from miss counts and items:
  • expiry: Time gap since the previous request on the same loop $> \text{TTL}$ (1h default for main session, 5m for subagents). The cache expired due to idle time; the previous context prefix had to be rewritten. $$\text{Rebuild Prefix} = \min(\text{prev\_context}, \text{cache\_write\_tokens})$$ $$\text{Expiry Cost} = \frac{\text{Rebuild Prefix} \times (P_{\text{write}} - P_{\text{read}})}{1,000,000}$$ where $P_{\text{write}}$ is the turn's blended write rate across its 5m and 1h tiers.
  • growth: Gap $\le \text{TTL}$, and write corresponds to new content entering context ($\text{cache\_read} \approx \text{prev\_context}$). Extra miss cost is $\$0.00$.
  • invalidation: Gap $\le \text{TTL}$, but prefix read was missed and write is far larger than new content (model switch, system prompt / tools change, or compaction). Priced with the same rebuild formula as expiry; the summary's Rebuild Miss Cost is expiry plus invalidation across both loops.
  • cold: First turn of a main session or subagent loop.
  • Reconciliation: The baseline is token-priced with the 5m/1h write tiers, the same rates every simulation uses. Reader vs Tier-Priced compares that against the cost the reader reports per turn, which prices every write at the 5m rate; a negative delta means the reader under-counts 1h-tier writes.
  • Unpriced models: Turns on a model with no pricing entry still count toward the token mix and hit rate but are left out of the dollar cost mix, rebuild costs, and simulation savings (on SQLite, the baseline still includes their stored reader cost). Model-independent weighted-token credits, including simulation credit deltas, are computed directly from tokens and remain included. The report names unpriced models in a footnote, and JSON carries summary.unpriced_turns and summary.unpriced_models.
  • Policy Simulations ("What Would Have Saved"): Replays actual user transcripts over the analyzed window under counterfactual behaviors:
  • TTL 5m vs 1h: Evaluates trade-off between cheaper write rate (1.25x vs 2.0x, applied only to tokens actually billed at the 1h tier) and expiry rebuilds on pauses between 5m and 60m.
  • /compact before breaks > 1h: Measures cost of compacting on a warm cache before an idle break long enough to expire the 1h cache vs paying a full-context rebuild upon return. Overlaps sim 4 on the same expiries, so the two savings are not additive.
  • Auto-compact threshold (35%): Evaluates per-turn read volume reduction across deep sessions.
  • Resume stale session (>24h) vs starting fresh: Compares rebuild and carryover costs against a fresh session baseline.

Session efficiency

token-scope --efficiency --since 30d
token-scope — Session Efficiency
────────────────────────────────────────────────────────────
  Total Sessions               1,162

  Cost Per Turn by Session Length
Turn Bucket │ Sessions │ Avg Turns │ Avg Per-Turn Cost │ Avg Session Cost
────────────┼──────────┼───────────┼──────────────────┼─────────────────
1–5         │      962 │       2.7 │           $0.010 │           $0.027
6–15        │      130 │       7.7 │           $0.025 │           $0.191
16–30       │       28 │      20.1 │           $0.034 │           $0.678
31–50       │       13 │      40.5 │           $0.077 │           $3.105
51+         │       29 │     315.6 │           $0.192 │          $60.626

Longer sessions accumulate more context, driving up per-turn cost. A 51+ turn session costs ~20× more per turn than a 1–5 turn session.


Context bloat

token-scope --context --since 30d
token-scope — Context Bloat Analysis
────────────────────────────────────────────────────────────
  Sessions Analyzed            20

  Sessions Ranked by Bloat (early = avg of first 3 turns, late = avg of last 3 turns)
Session        │ Project           │ Turns │ Early Avg Input │ Late Avg Input │ Bloat
───────────────┼───────────────────┼───────┼─────────────────┼────────────────┼──────
cbb12eb7-b13b- │ HW                │   347 │               3 │              3 │  1.0×
58279086-c21b- │ observer-sessions │    23 │              10 │             10 │  1.0×
...

Sessions with ≥6 turns, ranked by how much input token count grows from early turns to late turns. High bloat ratios indicate context accumulation driving up costs.


Thinking analysis

token-scope --thinking --since 7d
token-scope — Thinking Analysis
────────────────────────────────────────────────────────────
  ~Total Thinking Tokens (est)  ~126,832
  ~Thinking % of Output         ~7.8%
  Turns with Thinking           2,055 (34.4% of all turns)
  Sessions with Thinking        232 (98.3%)

  By Project
Project          │ Thinking Sessions │ Thinking Turns │ ~Total Thinking Tokens
─────────────────┼───────────────────┼────────────────┼───────────────────────
observer-sessions│               202 │          1,899 │               ~117,274
3body            │                 1 │             20 │                 ~8,159
wp-content       │                 2 │             16 │                   ~155
...

Thinking token estimates use a character-ratio proxy (±15–30% error). All thinking figures are prefixed with ~.


Tooling analysis

token-scope --tools --since 30d
token-scope — Tooling Analysis
────────────────────────────────────────────────────────────
  Total Tool Calls             4,716
  Distinct Tools               23
  Layers Active                4 of 5
  Unclassified Tools           0

  Cost by Layer
Layer        │ Calls  │ Attributed Cost │ Cost %  │ Avg/Call
─────────────┼────────┼─────────────────┼─────────┼──────────
Built-in     │  3,890 │        $342.18  │   85.2% │   $0.088
MCP          │     61 │         $12.40  │    3.1% │   $0.203
Plugin       │     17 │          $4.80  │    1.2% │   $0.282
Skill        │     67 │          $3.42  │    0.9% │   $0.051
Meta         │    241 │          $5.26  │    1.3% │   $0.022
(no tool)    │    440 │         $33.57  │    8.4% │   $0.076

Classifies every tool call into five layers and attributes cost proportionally by input payload size. Plugins are MCP servers provided by Claude Code plugins (mcp__plugin_*). Hooks are invisible (shell commands, not tool_use blocks).


Context contributors

Source 1 files
hooks/register.ts 188 lines
1import type { Register, Timer } from "claude-code";
2
3export function isEnvTrue(val: string | undefined): boolean {
4  if (!val) return false;
5  const s = val.trim().toLowerCase();
6  return s !== "" && s !== "0" && s !== "false" && s !== "no" && s !== "off";
7}
8
9export function parseTtl(val: unknown): number | undefined {
10  let ms: number | undefined;
11  if (typeof val === "number") {
12    ms = val * 1000;
13  } else if (typeof val === "string") {
14    const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(val.trim().toLowerCase());
15    if (match) {
16      const n = parseFloat(match[1]!);
17      const unit = match[2] ?? "s";
18      ms = n * { ms: 1, s: 1000, m: 60 * 1000, h: 60 * 60 * 1000 }[unit]!;
19    }
20  }
21  if (ms === undefined || !Number.isFinite(ms) || ms <= 0) return undefined;
22  return Math.round(ms);
23}
24
25export function resolveTtl(
26  env: Record<string, string | undefined> = {},
27  settings?: Record<string, unknown>
28): number | null {
29  if (isEnvTrue(env["DISABLE_PROMPT_CACHING"])) {
30    return null;
31  }
32  if (isEnvTrue(env["FORCE_PROMPT_CACHING_5M"])) {
33    return 5 * 60 * 1000;
34  }
35  for (const candidate of [env["CLAUDE_CODE_PROMPT_CACHE_TTL"], settings?.["promptCacheTtl"]]) {
36    const parsed = parseTtl(candidate);
37    if (parsed !== undefined) return parsed;
38  }
39  return 60 * 60 * 1000;
40}
41
42export function isOptedOut(
43  options?: Record<string, unknown>,
44  env: Record<string, string | undefined> = {}
45): boolean {
46  if (options?.["cacheTimer"] === false) return true;
47  const timer = env["TOKEN_SCOPE_CACHE_TIMER"];
48  if (timer !== undefined && timer.trim() !== "" && !isEnvTrue(timer)) return true;
49  if (isEnvTrue(env["TOKEN_SCOPE_DISABLE_CACHE_TIMER"])) return true;
50  return false;
51}
52
53export function isOverageFallback(
54  gap: number,
55  effectiveTtl: number,
56  read: number,
57  write: number
58): boolean {
59  const fiveMinMs = 5 * 60 * 1000;
60  if (gap <= fiveMinMs || gap >= effectiveTtl) return false;
61  return read <= 100 && write >= 1000;
62}
63
64export function label(
65  now: number,
66  lastAt: number | undefined,
67  hit: number | undefined,
68  ttlMs: number | null | undefined = 60 * 60 * 1000,
69  fallback5m: boolean = false
70): string | undefined {
71  if (lastAt === undefined) return undefined;
72  if (ttlMs === null || ttlMs === undefined || ttlMs <= 0) return undefined;
73  const left = ttlMs - (now - lastAt);
74  if (left <= 0) return "cache cold";
75  const s = Math.ceil(left / 1000);
76  const mmss = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
77  const tag = fallback5m ? " (5m)" : "";
78  return hit === undefined ? `cache warm ${mmss}${tag}` : `cache warm ${mmss}${tag} · hit ${hit}%`;
79}
80
81export const register: Register = (on, options) => {
82  const opts = options as Record<string, unknown> | undefined;
83
84  let timer: Timer | undefined;
85  let effectiveTtl = 60 * 60 * 1000;
86  let is5mFallback = false;
87  let lastAt: number | undefined;
88  let hit: number | undefined;
89  let isEnabled = true;
90
91  on("session.start", async ($, e, next) => {
92    timer?.cancel();
93    timer = undefined;
94    lastAt = undefined;
95    hit = undefined;
96    is5mFallback = false;
97
98    if (isOptedOut(opts)) {
99      isEnabled = false;
100      $.ui.status(undefined);
101      return next(e);
102    }
103
104    // String literals, not a loop over names: claude plugin validate scans for them.
105    const reads = await Promise.allSettled([
106      $.env.get("DISABLE_PROMPT_CACHING"),
107      $.env.get("FORCE_PROMPT_CACHING_5M"),
108      $.env.get("CLAUDE_CODE_PROMPT_CACHE_TTL"),
109      $.env.get("TOKEN_SCOPE_DISABLE_CACHE_TIMER"),
110      $.env.get("TOKEN_SCOPE_CACHE_TIMER"),
111    ]);
112    const keys = [
113      "DISABLE_PROMPT_CACHING",
114      "FORCE_PROMPT_CACHING_5M",
115      "CLAUDE_CODE_PROMPT_CACHE_TTL",
116      "TOKEN_SCOPE_DISABLE_CACHE_TIMER",
117      "TOKEN_SCOPE_CACHE_TIMER",
118    ];
119    const env: Record<string, string | undefined> = {};
120    reads.forEach((read, i) => {
121      if (read.status === "fulfilled") env[keys[i]!] = read.value;
122    });
123
124    if (isOptedOut(opts, env)) {
125      isEnabled = false;
126      $.ui.status(undefined);
127      return next(e);
128    }
129
130    let settings: Record<string, unknown> | undefined;
131    try {
132      settings = (await $.settings.read()) as Record<string, unknown>;
133    } catch {
134      // A failed settings read falls back to the env or default TTL rather than disabling the timer.
135    }
136
137    const ttl = resolveTtl(env, settings);
138    if (ttl === null) {
139      isEnabled = false;
140      $.ui.status(undefined);
141      return next(e);
142    }
143
144    isEnabled = true;
145    effectiveTtl = ttl;
146
147    timer = $.clock.every(1000, () => {
148      $.clock
149        .now()
150        .then(now => $.ui.status(label(now, lastAt, hit, effectiveTtl, is5mFallback)))
151        .catch(() => {});
152    });
153
154    return next(e);
155  });
156
157  // /clear ends the conversation without a following session.start. The 5m fallback is
158  // account-level usage overage, so it outlives the conversation.
159  on("session.end", ($, e, next) => {
160    lastAt = undefined;
161    hit = undefined;
162    if (isEnabled) $.ui.status(undefined);
163    return next(e);
164  });
165
166  on("turn.step", async function* ($, e, next) {
167    const r: any = yield* next(e);
168    if (!isEnabled || e.agentId !== undefined || !r?.usage) return r;
169    const u = r.usage;
170    const now = await $.clock.now();
171
172    if (lastAt !== undefined && !is5mFallback && effectiveTtl > 5 * 60 * 1000) {
173      const gap = now - lastAt;
174      const read = u.cache_read_input_tokens ?? 0;
175      const write = u.cache_creation_input_tokens ?? 0;
176      if (isOverageFallback(gap, effectiveTtl, read, write)) {
177        effectiveTtl = 5 * 60 * 1000;
178        is5mFallback = true;
179      }
180    }
181
182    lastAt = now;
183    const total = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
184    hit = total > 0 ? Math.round((100 * (u.cache_read_input_tokens ?? 0)) / total) : undefined;
185    return r;
186  });
187};
188