cdx-manager 0.19.3 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.19.3-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.20.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
10
10
 
@@ -86,7 +86,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
86
86
  - **Multiple providers, one tool.** Register as many Codex, Claude, Antigravity, or Ollama sessions as you need. Codex and Claude get isolated auth environments; Antigravity is launchable through `agy` with OS-keyring auth; Ollama runs local models through `ollama run`.
87
87
  - **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
88
88
  - **Quick relaunch.** `cdx last` reopens the most recently launched assistant profile.
89
- - **Resume by identity, not by recency.** Each session records the provider's own conversation id — imposed at launch for Claude, read back from the rollout for Codex — so `cdx resume <name>` reopens *that* conversation regardless of your working directory or of which session ran last. `cdx can-resume --json` reports the strategy and where the id came from, so an automated caller can tell a named resume from a best-effort one.
89
+ - **Resume by identity, not by recency.** Each session records the provider's own conversation id — imposed at launch for Claude, read back from the rollout for Codex — so `cdx resume <name>` reopens *that* conversation regardless of your working directory or of which session ran last. The conversation is looked up before it is named: a recorded id whose transcript the provider never wrote degrades to the provider's own fallback (`--continue`, or `resume --last`) and reports `reason: conversation_not_found`, rather than naming a conversation that does not exist and letting the provider exit with an error. `cdx can-resume --json` reports the strategy and where the id came from, so an automated caller can tell a named resume from a best-effort one.
90
90
  - **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
91
91
  - **Persistent launch settings.** Pin per-session power, permission, and fast-mode preferences once; `cdx` reapplies them on every launch until you unset them.
92
92
  - **Spend ceiling and model fallback.** `cdx set <name> --budget 5` caps what a headless run may spend, and `--fallback-model sonnet,haiku` lets a run continue on the next model when the first is overloaded. Both are Claude Code flags that the provider accepts with `--print` only, so they apply to `cdx run` and not to interactive launches; `cdx configs` says so.
@@ -537,7 +537,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
537
537
  | `cdx run-tail <run_id> [--lines N] --json` | Last lines of a run's own output, while it is still running or after it finished |
538
538
  | `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
539
539
  | `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
540
- | `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known headless token usage by session |
540
+ | `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known token usage by session, for headless and interactive runs alike |
541
541
  | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status |
542
542
  | `cdx status --small [--refresh\|--cached] [--timeout SECONDS]` / `cdx status -s [--refresh\|--cached] [--timeout SECONDS]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
543
543
  | `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
@@ -708,7 +708,49 @@ cdx run \
708
708
 
709
709
  The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
710
710
 
711
- Known headless and completed interactive token usage is persisted in launch history and can be aggregated later. Interactive records are best-effort local data: Codex reads its latest cumulative rollout snapshot and Claude reads its assistant usage records. Cached input is retained separately from the provider's total, so it is never counted twice.
711
+ Known headless and completed interactive token usage is persisted in launch history and can be aggregated later. Interactive records are best-effort local data: Codex reads its latest cumulative rollout snapshot, Claude reads its assistant usage records, and Ollama — which writes no transcript of its own — is launched with `--verbose` so its per-response token counts land in the terminal capture cdx already takes. Antigravity reports no usage: its history is schema-less binary protobuf and its logs carry no usage line, so those sessions show no tokens by nature rather than by omission.
712
+
713
+ Every usage record, whichever path produced it, uses one set of definitions:
714
+
715
+ | Field | Meaning |
716
+ | --- | --- |
717
+ | `input_tokens` | Uncached input **only**. Codex reports a cache-inclusive count natively; cdx reduces it to the remainder so the field means the same thing on every provider. |
718
+ | `cache_creation_tokens` | Tokens written to the provider's prompt cache. |
719
+ | `cache_read_tokens` | Tokens served from it. Kept apart from cache creation because the two cost roughly 1.25x and 0.1x of uncached input, a difference no summed figure can recover. |
720
+ | `cached_input_tokens` | `cache_creation_tokens + cache_read_tokens`, derived. The single CACHE column in `cdx stats` shows this. |
721
+ | `output_tokens` | Generated tokens. |
722
+ | `reasoning_tokens` | Reasoning the provider counts separately. Codex reports one; Claude does not, because it bills thinking as output — those tokens are already inside `output_tokens`, so an empty REASON column for Claude is correct rather than missing. |
723
+ | `total_tokens` | `input + cache_creation + cache_read + output`. Cache is typically the bulk of real consumption, so a total that left it out would not be slightly low — it would be a different quantity. |
724
+
725
+ Cached input is counted once: it is excluded from `input_tokens` and reported in the cache fields, never in both. `null` means the provider did not report the figure, which is not the same as zero.
726
+
727
+ `cdx stats` also shows a **`COST~`** column and ranks sessions by it. It weights each token class by what it costs relative to one uncached input token — cache read 0.1x, cache write 1.25x, output 5x — and reports the result in uncached-input-equivalent tokens. These are ratios rather than prices: across the current model lineup output is 5x input on every model and the cache multipliers do not vary by model either, so the figure needs no per-model price table and does not go stale when prices change. It is **not** a currency amount, and it is not comparable across providers whose own prices differ. The cache-write multiplier assumes the five-minute cache TTL; a one-hour TTL costs 2x and nothing in a transcript says which was used, so long-TTL writes are slightly under-weighted.
728
+
729
+ Ranking on the raw total would put a session that mostly replayed a cache above one that spent far more on generation, because cache reads are typically the overwhelming majority of tokens consumed while costing a fiftieth of an output token.
730
+
731
+ The **`USD~`** column turns that into money for runs whose serving model cdx recorded. It needs one number per model — the list price per million uncached input tokens — because `COST~` has already absorbed every other rate. Prices live in configuration, not in code: set `CDX_TOKEN_PRICES` to a JSON object mapping model id to dollars per million tokens to override the built-in table, whose last review date is printed with the totals. A run whose model cdx never saw stays unpriced rather than being charged at a default tier, and the totals line says how many runs were actually priced.
732
+
733
+ The table carries two numbers per model — uncached input and output per million tokens — and derives the cache rates from the input rate: a cache read is 0.1x it and a cache write 1.25x, a relationship that holds on both vendors. Each token class is then priced at its own rate rather than through the weighted figure, because the weighted shortcut is only correct while every model shares one output ratio, and they do not: Anthropic bills output at 5x input, OpenAI at 6x.
734
+
735
+ Three caveats stated rather than hidden. A run is priced at the model serving its most recent record, so a run that switched models mid-way is priced at the newer one. The figure is an estimate at public list prices — not an invoice. And OpenAI's long-context tier, which roughly doubles both rates above a 272K-token request, is **not** modelled: cdx records tokens per run rather than per request, so it cannot tell which requests crossed that line, and long-context Codex work is under-costed.
736
+
737
+ A model absent from the table stays unpriced rather than being charged a default tier, and the totals line names it — which is exactly the key `CDX_TOKEN_PRICES` wants:
738
+
739
+ ```
740
+ Totals: 26 runs, ... ; no price for gpt-6-unreleased)
741
+ CDX_TOKEN_PRICES='{"gpt-6-unreleased": {"input": 3, "output": 18}}' cdx stats
742
+ ```
743
+
744
+ **Prices are the only part of this that rots on its own.** A test fails once the table has gone 90 days without review, and `scripts/check_token_prices.py` compares it against published pricing — outside the test suite, since a check that needs the network fails for reasons nobody wants in CI. A row it cannot confirm is reported as unverified, never as passing. When it finds a difference, follow [`docs/token-prices-runbook.md`](docs/token-prices-runbook.md) — which also records where each vendor's numbers actually live, and which pages refuse to be read by a script.
745
+
746
+ Runs recorded before these definitions existed carry figures that are fictitious rather than imprecise — one real session reported 206.6M cached tokens for a period in which its own conversation consumed 33,608, because the old reader picked whichever transcript had the newest modification time and re-read all of it on every launch. They cannot be recomputed: a run's true usage is the increment its transcript gained while it ran, and nothing recorded that. They are therefore excluded from every total automatically, and the totals line says how many runs that covered — so a period containing only such runs reads as absent rather than as a session that spent nothing. The records themselves are left alone, which keeps them recoverable if the marker ever proves wrong; `cdx repair` clears them from the file for anyone who would rather they were gone:
747
+
748
+ ```
749
+ cdx repair --dry-run # says how many records carry unvouched usage
750
+ cdx repair # drops the figures; runs, durations and timestamps stay
751
+ ```
752
+
753
+ Records that *are* vouched for but predate the split — headless runs, and anything recorded between the definition landing and the model being captured — are shown as faithfully as their data allows: they carry no creation/read split and no model, so their cached tokens are read as cache reads for weighting and they stay unpriced. `cdx stats` derives each row's `TOTAL` from the columns it displays, so a row always adds up even while history spans both eras.
712
754
 
713
755
  ```bash
714
756
  cdx stats --since 7d --json
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.19.3
1
+ 0.20.1
@@ -0,0 +1,71 @@
1
+ # CDX Manager 0.20.0
2
+
3
+ ## Highlights
4
+
5
+ - `cdx stats` reported token figures that were wrong by up to four orders of magnitude. Every number in that table has been rebuilt on one definition, verified against an independent reader of the same transcripts.
6
+ - Sessions are ranked by what they cost rather than by how many tokens they moved, and priced in currency where the serving model is known.
7
+ - Ollama sessions report real token usage instead of a permanent zero.
8
+ - `cdx resume` works again for Claude, and stops promising resumes that would fail.
9
+
10
+ ## Changes
11
+
12
+ ### One definition for token usage
13
+
14
+ Three code paths read provider usage — headless stdout, an interactive transcript, and a native background close-out — and each computed the fields its own way. They disagreed in every direction at once: one dropped cache from the total, one counted it twice, one omitted the cached field entirely. All three wrote into the same launch history and the same column, so the table mixed definitions without saying so.
15
+
16
+ `src/run_usage.py` now owns the definition and all three normalize to it. `input_tokens` is the uncached remainder on every provider; cache creation and cache read are separate fields because they cost 1.25x and 0.1x of uncached input and no summed figure can recover that split; the total covers all four token classes. The run registry normalizes on the way in, so `cdx run --json`, `cdx run-status` and `cdx run-report` publish the same shape.
17
+
18
+ An independent reader of the same transcripts now agrees with cdx exactly, field by field.
19
+
20
+ ### Each run is billed for its own tokens
21
+
22
+ Both interactive readers report a cumulative figure and every run stored it, so a resumed session was re-billed for its entire history on each resume — one observed session's cache read went from 2.6M to 7.6M on a single additional launch, and `cdx stats` summed that inflation again.
23
+
24
+ A run now stores the increment over the previous run on the same transcript. A shrunk, rotated or replaced transcript reports absence rather than arithmetic, and the first run against a transcript that predates it leaves a baseline without claiming history it never measured.
25
+
26
+ ### Each billed response is counted once
27
+
28
+ A Claude transcript records the same billed API response under several record ids. Counting per record inflated one real transcript by 1.55x — 2741 rows for 1765 distinct responses. De-duplication now keys on the API response itself.
29
+
30
+ ### The session's own transcript
31
+
32
+ Usage was read from whichever transcript in the provider home had the newest modification time, so a run could be billed against an unrelated project's file — sessions reported three runs and eighteen output tokens. Resolution is now by the conversation id cdx already holds, and a scan that cannot conclude reports absence instead of whichever candidate it had reached.
33
+
34
+ ### Ranked by cost, and priced
35
+
36
+ A raw token total ranks a cache read equal to an output token worth fifty times more, and cache reads are typically the overwhelming majority of tokens — so the old ranking was very nearly a measure of cache reads alone.
37
+
38
+ `cdx stats` gains a `COST~` column and sorts on it, weighting each token class by its cost relative to an uncached input token. A `USD~` column prices runs whose serving model cdx recorded, from a table carrying two numbers per model. A model absent from that table stays unpriced rather than charged a default tier, and the totals line names it.
39
+
40
+ `CDX_TOKEN_PRICES` overrides the table without a release. `scripts/check_token_prices.py` compares it against published pricing, a test fails once it has gone ninety days unreviewed, and `docs/token-prices-runbook.md` records where each vendor's numbers actually live.
41
+
42
+ ### Ollama token usage
43
+
44
+ Ollama writes no transcript of its own, so it is now launched with `--verbose` and its per-response counts are parsed from the terminal capture cdx already takes — after the same normalization the handoff reader applies, because a real capture carries cursor sequences between individual words.
45
+
46
+ The provider-flag health check now covers flags cdx passes on its own initiative, not only permission mappings.
47
+
48
+ ### Resume
49
+
50
+ `cdx <session> --resume` minted a fresh conversation id and then asked the provider to resume it, so every Claude resume failed with `No conversation found with session ID`. A resume now carries the conversation it is resuming.
51
+
52
+ Resumability is a checked fact rather than a stored string: a recorded conversation the provider never wrote degrades to `--continue` or `resume --last` and reports `conversation_not_found`, instead of being named and failing. What `cdx can-resume --json` reports and what `cdx resume` runs now read the same decision.
53
+
54
+ ### Dropping figures cdx cannot vouch for
55
+
56
+ Launch history written before these definitions is fictitious rather than imprecise, and cannot be recomputed: a run's true usage is the increment its transcript gained while it ran, and nothing recorded that. They are excluded from every total automatically from this release, with the totals line naming how many runs that covered — no command to remember, and no upgrade that leaves the fiction on screen. The stored records are left intact so they stay recoverable; `cdx repair` clears them from the file for anyone who wants that.
57
+
58
+ ## Upgrading
59
+
60
+ Usage records gain `cache_creation_tokens` and `cache_read_tokens`; existing fields keep their names and `cached_input_tokens` remains their sum. `cdx stats --json` gains `weighted_tokens`, `cost_usd`, `priced_runs`, `unpriced_models` and `unvouched_runs`. Nothing was removed.
61
+
62
+ `cdx can-resume --json` can now report `reason: conversation_not_found`. `resumable` stays `true` in every case that used to return it; only the strategy changes.
63
+
64
+ Token totals will drop, in some cases sharply — one real session fell from 206.6M to 361.3K. That is the fiction leaving, not data loss: the totals line reports how many runs were excluded, and `cdx repair --dry-run` says how many stored records carry such usage.
65
+
66
+ ## Known limits
67
+
68
+ - Historical figures are excluded rather than corrected; recomputation is not possible, so those runs report no usage at all.
69
+ - OpenAI's long-context tier, which roughly doubles both rates above a 272K-token request, is not modelled: cdx records tokens per run rather than per request, so long-context Codex work is under-costed.
70
+ - A run that switched models mid-way is priced at the model serving its most recent record.
71
+ - OpenAI prices were taken from third-party aggregators; `openai.com` refuses automated fetches. This is why the drift check exists.
@@ -0,0 +1,6 @@
1
+ # CDX Manager 0.20.1
2
+
3
+ ## Fixes
4
+
5
+ - `cdx status --refresh` no longer reports a false "OAuth access token has expired" alert for a profile that is still logged in. The persisted access token's `expiresAt` is now checked offline before the quota probe runs; once it is known to be expired, the probe is skipped instead of reproducing a predictable 401 (`claude auth status` does not refresh it — only a real Claude Code invocation does). (#11)
6
+ - The stale-quota/local-auth-valid warning path now also recognizes the provider's current `OAuth access token has expired` message, not just the older `invalid authentication credentials` phrasing.
@@ -298,6 +298,28 @@
298
298
  "v0.19.2": {
299
299
  "github_tarball_sha256": "d644666c727b2a29fa022700da1c23483173a2110606b994d200f72feb959c3b",
300
300
  "github_zip_sha256": "bca42637ddab4502e2dd6d9d63239eca322d953f8d910d8ada209fcfdd2f98d4"
301
+ },
302
+ "v0.19.3": {
303
+ "github_tarball_sha256": "23e264e7c7f87b1da7052fa47c67e54454e4eee0a27d121565db155324845a06",
304
+ "github_zip_sha256": "5b6eb81fc09581ba25584ad98a99141b1e50433ad76c7261ba4696abf601f274",
305
+ "tray_assets": {
306
+ "aarch64-apple-darwin": "bf289a773c32549cd78028b57dcdfd3d60c6436b038899ce160e84e1f88e9a9d",
307
+ "aarch64-unknown-linux-musl": "4e29448f0acae916ce8b57132d5dfd24dd89c28184f21993bc6346a9aa82ec2f",
308
+ "x86_64-apple-darwin": "a3931de7c38beaba4725940c29692c975f0d078c3a1e7d0ed610061cee6cab81",
309
+ "x86_64-pc-windows-msvc": "c5a29140bb9d634f2533084084ca10cb90ca64d34cac1b664998d305b934a7fb",
310
+ "x86_64-unknown-linux-musl": "d1850f3acdede7c2cdcd31b5ca6d5e2372535a32bf75c0e6c8fd0100af7813f2"
311
+ }
312
+ },
313
+ "v0.20.0": {
314
+ "github_tarball_sha256": "8222afa4f8e5fef0291e180450c1b24fb623a6543104f62bd430d27b2157c75b",
315
+ "github_zip_sha256": "c09ab617e1af92bf7c3690fe8d21e497cb772ea270eb8b83ea413b1718d978cd",
316
+ "tray_assets": {
317
+ "aarch64-apple-darwin": "8b6d93b6d67d2b0119c544beca251b5dcaede56b770fc8bed741c36a2f2d868a",
318
+ "aarch64-unknown-linux-musl": "f4bb1bd9a8980f403a7d5af9724dfae4b63345323019e212fe257c4689f7480d",
319
+ "x86_64-apple-darwin": "1e90758238f87c73bf1dadf7d9c3efe2967ab6f65b484d9fd7ed4e3cceae671f",
320
+ "x86_64-pc-windows-msvc": "e88d296e706ae90624a1f82bf14d36d67787d5f73bbb04986c1ca82293178c68",
321
+ "x86_64-unknown-linux-musl": "cf8ec3f5a56bf50ba42d2dfa44f05ecc4471c5a5a819ca8b5180c6d9eff75ff1"
322
+ }
301
323
  }
302
324
  }
303
325
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.19.3",
3
+ "version": "0.20.1",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.19.3"
7
+ version = "0.20.1"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -129,6 +129,32 @@ def _parse_reset_epoch(value):
129
129
  return parsed.timestamp()
130
130
 
131
131
 
132
+ CLAUDE_ACCESS_TOKEN_EXPIRY_SKEW_SECONDS = 60
133
+
134
+
135
+ def _current_epoch_seconds():
136
+ return datetime.now(timezone.utc).timestamp()
137
+
138
+
139
+ def _parse_epoch_seconds(value):
140
+ if value in (None, ""):
141
+ return None
142
+ try:
143
+ num = float(value)
144
+ except (TypeError, ValueError):
145
+ return _parse_reset_epoch(value)
146
+ # Claude CLI persists expiresAt in milliseconds since epoch.
147
+ return num / 1000 if num > 1e12 else num
148
+
149
+
150
+ def _access_token_expired(creds, now=None):
151
+ expires_at = _parse_epoch_seconds(creds.get("expiresAt")) if isinstance(creds, dict) else None
152
+ if expires_at is None:
153
+ return False
154
+ now = _current_epoch_seconds() if now is None else now
155
+ return now >= expires_at - CLAUDE_ACCESS_TOKEN_EXPIRY_SKEW_SECONDS
156
+
157
+
132
158
  def _safe_float(value):
133
159
  if value is None:
134
160
  return None
@@ -241,4 +267,11 @@ def refresh_claude_session_status(session, auth_refresher=None):
241
267
  creds = _read_claude_credentials(auth_home)
242
268
  if not creds or not creds.get("accessToken"):
243
269
  return None
270
+ if _access_token_expired(creds):
271
+ # ponytail: `claude auth status` doesn't refresh an expired access
272
+ # token (verified no-op) and only a real Claude Code invocation does.
273
+ # Probing anyway just reproduces a 401 we can already predict
274
+ # offline, so skip it like the "no credentials" case above and leave
275
+ # the cached quota as stale rather than raising a false alert.
276
+ return None
244
277
  return fetch_claude_rate_limit_headers(creds["accessToken"])
@@ -25,8 +25,8 @@ from ..cli_render import _dim, _info, _success, _warn
25
25
  from ..config import PROVIDER_CODEX
26
26
  from ..context_store import install_context_for_session, write_context
27
27
  from ..errors import CdxError
28
- from ..interactive_usage import extract_interactive_usage
29
- from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
28
+ from ..interactive_usage import extract_interactive_usage, transcript_predates_run, usage_delta
29
+ from ..provider_runtime import _conversation_id, _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
30
30
 
31
31
 
32
32
  def _parse_launch_args(args):
@@ -199,7 +199,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
199
199
  f"Provider {session['provider']} does not support native resume through cdx."
200
200
  )
201
201
  cwd = _launch_directory(session, ctx, directory, json_flag)
202
- session = ctx["service"]["launch_session"](command)
202
+ session = ctx["service"]["launch_session"](command, resume=resume)
203
203
  capability = _resume_capability_for_session(session, ctx) if resume else None
204
204
  if not json_flag and not ctx["stdin_is_tty"]:
205
205
  ctx["out"](f"Using directory: {cwd}\n")
@@ -265,7 +265,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
265
265
  )
266
266
  except CdxError as error:
267
267
  run_info = getattr(error, "run_info", {}) or {}
268
- _attach_interactive_usage(session, run_info)
268
+ run_info = _attach_interactive_usage(
269
+ session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
270
+ )
269
271
  if runtime_run_id:
270
272
  ctx["service"]["finish_session_runtime"](
271
273
  session["name"],
@@ -286,7 +288,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
286
288
  "action": "resume" if resume else "launch",
287
289
  "cwd": cwd,
288
290
  "exit_code": 0,
289
- **_attach_interactive_usage(session, run_info),
291
+ **_attach_interactive_usage(
292
+ session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
293
+ ),
290
294
  })
291
295
  if json_flag:
292
296
  extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
@@ -296,18 +300,68 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
296
300
  return 0
297
301
 
298
302
 
299
- def _attach_interactive_usage(session, run_info):
300
- """Attach best-effort local provider usage without affecting launch outcome."""
303
+ def _attach_interactive_usage(session, run_info, history=None):
304
+ """Attach this run's own token usage without affecting launch outcome.
305
+
306
+ Both interactive readers report a cumulative figure, so what a run *stores*
307
+ has to be the increment over the previous run on the same transcript --
308
+ otherwise a resumed session re-bills its whole history on every resume, and
309
+ `cdx stats` sums that inflation again.
310
+
311
+ The cumulative is kept beside the delta as `usage_cumulative`: it is the
312
+ baseline the next run differences against, and without it a single run that
313
+ recorded nothing would break the chain for every run after it.
314
+ """
301
315
  run_info = dict(run_info or {})
302
- usage, provider_transcript = extract_interactive_usage(
303
- session.get("provider"), _get_auth_home(session), run_info.get("started_at")
316
+ started_at = run_info.get("started_at")
317
+ cumulative, provider_transcript, match, model = extract_interactive_usage(
318
+ session.get("provider"), _get_auth_home(session), started_at, _conversation_id(session),
319
+ # Ollama writes no provider transcript; its only record is the PTY
320
+ # capture this run already took.
321
+ run_info.get("transcript_path"),
304
322
  )
305
- if usage:
306
- run_info["usage"] = usage
307
323
  if provider_transcript:
308
324
  run_info["provider_transcript_path"] = provider_transcript
325
+ # How the transcript was found travels with the number. A recency match
326
+ # is a guess that happened to be the newest file; recording it the same
327
+ # way as an id match would let a wrong attribution read as a
328
+ # measurement, which is the failure this replaced.
329
+ run_info["provider_transcript_match"] = match
330
+ if not cumulative:
331
+ return run_info
332
+
333
+ run_info["usage_cumulative"] = cumulative
334
+ if model:
335
+ # Which model actually served the run, read from the transcript rather
336
+ # than inferred from the session's configured launch settings -- where
337
+ # `model` is optional and usually unset, because the session takes the
338
+ # provider's default.
339
+ run_info["usage_model"] = model
340
+ previous = _previous_cumulative(history, provider_transcript)
341
+ if previous is not None:
342
+ delta = usage_delta(cumulative, previous)
343
+ if delta is not None:
344
+ run_info["usage"] = delta
345
+ # A None delta means the transcript shrank, rotated, or was replaced.
346
+ # The baseline above still updates, so the next run recovers.
347
+ elif not transcript_predates_run(provider_transcript, started_at):
348
+ # First sighting of a transcript this run created: all of it is ours.
349
+ run_info["usage"] = cumulative
309
350
  return run_info
310
351
 
352
+
353
+ def _previous_cumulative(history, transcript_path):
354
+ """The cumulative the last run on this same transcript left behind."""
355
+ if not history or not transcript_path:
356
+ return None
357
+ for entry in history:
358
+ if entry.get("provider_transcript_path") != transcript_path:
359
+ continue
360
+ cumulative = entry.get("usage_cumulative")
361
+ if isinstance(cumulative, dict):
362
+ return cumulative
363
+ return None
364
+
311
365
  def handle_handoff(rest, ctx):
312
366
  json_flag, args = _parse_json_flag(rest)
313
367
  if len(args) not in (1, 2):
@@ -41,6 +41,14 @@ from ..commands.launch import handle_launch
41
41
  from ..config import PROVIDER_CLAUDE
42
42
  from ..errors import CdxError
43
43
  from ..provider_runtime import AUTH_PROBE_AUTHENTICATED, AUTH_PROBE_DEGRADED, _probe_provider_auth_status
44
+ from ..run_usage import (
45
+ USAGE_KEYS,
46
+ estimate_cost,
47
+ is_vouched,
48
+ normalize_usage,
49
+ token_prices,
50
+ weighted_usage,
51
+ )
44
52
  from ..status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
45
53
 
46
54
 
@@ -174,6 +182,12 @@ def _format_launch_configs(sessions, use_color=False):
174
182
  lines.append(_dim(_format_launch_settings_hint(), use_color))
175
183
  return "\n".join(lines)
176
184
 
185
+ def _format_usd(amount):
186
+ if not amount:
187
+ return "-"
188
+ return f"${amount:,.2f}" if amount >= 0.01 else "<$0.01"
189
+
190
+
177
191
  def _format_duration_ms(value):
178
192
  if value is None:
179
193
  return "-"
@@ -252,8 +266,15 @@ def _summarize_stats(entries):
252
266
  "failures": 0,
253
267
  "duration_ms": 0,
254
268
  "usage_runs": 0,
269
+ "cost_usd": 0.0,
270
+ "weighted_tokens": 0,
271
+ "priced_runs": 0,
272
+ "unpriced_models": set(),
273
+ "unvouched_runs": 0,
255
274
  "input_tokens": 0,
256
275
  "cached_input_tokens": 0,
276
+ "cache_creation_tokens": 0,
277
+ "cache_read_tokens": 0,
257
278
  "output_tokens": 0,
258
279
  "reasoning_tokens": 0,
259
280
  "total_tokens": 0,
@@ -269,21 +290,103 @@ def _summarize_stats(entries):
269
290
  except (TypeError, ValueError):
270
291
  pass
271
292
  usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
293
+ if usage and not is_vouched(usage):
294
+ # Written before the definition existed, so the figures are
295
+ # fictitious rather than merely old. Excluded from every total and
296
+ # counted, so the exclusion is visible instead of silent.
297
+ row["unvouched_runs"] += 1
298
+ usage = {}
272
299
  parsed_usage = {
273
300
  key: _token_value(usage, key)
274
- for key in ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
301
+ for key in USAGE_KEYS
275
302
  }
276
303
  if any(value is not None for value in parsed_usage.values()):
277
304
  row["usage_runs"] += 1
278
305
  for key, value in parsed_usage.items():
279
306
  row[key] += value or 0
307
+ # Priced per run, against the model that served it. A run whose
308
+ # model cdx never saw stays unpriced rather than being charged at a
309
+ # default tier, and `priced_runs` says how much of the figure is
310
+ # actually covered.
311
+ # Weighted per entry, not on the summed row: the output ratio is
312
+ # the one multiplier that differs between vendors, and a session's
313
+ # runs can span models.
314
+ row["weighted_tokens"] += weighted_usage(
315
+ entry.get("usage"), entry.get("usage_model")) or 0
316
+ cost = estimate_cost(entry.get("usage"), entry.get("usage_model"))
317
+ if cost is not None:
318
+ row["cost_usd"] += cost
319
+ row["priced_runs"] += 1
320
+ elif entry.get("usage_model"):
321
+ # Named the model but could not price it. Recording which one
322
+ # turns an unexplained "-" into something actionable: that
323
+ # string is exactly what CDX_TOKEN_PRICES needs a key for.
324
+ row["unpriced_models"].add(entry["usage_model"])
280
325
  started = entry.get("started_at")
281
326
  if started and (not row["last_started_at"] or started > row["last_started_at"]):
282
327
  row["last_started_at"] = started
283
328
  row["provider"] = entry.get("provider") or row["provider"]
329
+ for row in rows.values():
330
+ # Derive from the summed parts rather than summing each run's stored
331
+ # total. Records written before the definition was settled carry a
332
+ # total that excluded cache, and adding those to correct ones produced
333
+ # a row that contradicted itself -- CACHE in the hundreds of millions
334
+ # beside a TOTAL in the low millions.
335
+ #
336
+ # Those records also lack the creation/read split, so the fused
337
+ # `cached_input_tokens` is the one field both eras share. Where the
338
+ # split is missing it is read as cache read: that is what the
339
+ # overwhelming majority of cached tokens are, and weighting the whole
340
+ # of it at the creation rate would overstate legacy sessions by more
341
+ # than reading it as reads understates them.
342
+ split = row["cache_creation_tokens"] + row["cache_read_tokens"]
343
+ legacy_cache = max(0, row["cached_input_tokens"] - split)
344
+ derived = normalize_usage(
345
+ input_tokens=row["input_tokens"],
346
+ cache_creation_tokens=row["cache_creation_tokens"],
347
+ cache_read_tokens=row["cache_read_tokens"] + legacy_cache,
348
+ output_tokens=row["output_tokens"],
349
+ )
350
+ row["total_tokens"] = derived["total_tokens"] or 0
351
+ if not row["weighted_tokens"]:
352
+ # Records written before the model was recorded: weigh the row as a
353
+ # whole, at the default ratio, rather than reporting nothing.
354
+ row["weighted_tokens"] = weighted_usage(derived) or 0
355
+ row["unpriced_models"] = sorted(row["unpriced_models"])
356
+ for row in rows.values():
357
+ # Derive from the summed parts rather than summing each run's stored
358
+ # total. Records written before the definition was settled carry a
359
+ # total that excluded cache, and adding those to correct ones produced
360
+ # a row that contradicted itself -- CACHE in the hundreds of millions
361
+ # beside a TOTAL in the low millions.
362
+ #
363
+ # Those records also lack the creation/read split, so the fused
364
+ # `cached_input_tokens` is the one field both eras share. Where the
365
+ # split is missing it is read as cache read: that is what the
366
+ # overwhelming majority of cached tokens are, and weighting the whole
367
+ # of it at the creation rate would overstate legacy sessions by more
368
+ # than reading it as reads understates them.
369
+ split = row["cache_creation_tokens"] + row["cache_read_tokens"]
370
+ legacy_cache = max(0, row["cached_input_tokens"] - split)
371
+ derived = normalize_usage(
372
+ input_tokens=row["input_tokens"],
373
+ cache_creation_tokens=row["cache_creation_tokens"],
374
+ cache_read_tokens=row["cache_read_tokens"] + legacy_cache,
375
+ output_tokens=row["output_tokens"],
376
+ )
377
+ row["total_tokens"] = derived["total_tokens"] or 0
378
+ if not row["weighted_tokens"]:
379
+ # Records written before the model was recorded: weigh the row as a
380
+ # whole, at the default ratio, rather than reporting nothing.
381
+ row["weighted_tokens"] = weighted_usage(derived) or 0
382
+ row["unpriced_models"] = sorted(row["unpriced_models"])
284
383
  return sorted(
285
384
  rows.values(),
286
- key=lambda item: (item["total_tokens"], item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
385
+ # Ranked by what a session cost, not by how many tokens it moved: a
386
+ # cache read is 0.1x an uncached input token and an output token is 5x,
387
+ # so a raw total sorts a cache-heavy session above one that spent far
388
+ # more on generation.
389
+ key=lambda item: (item["weighted_tokens"], item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
287
390
  reverse=True,
288
391
  )
289
392
 
@@ -295,11 +398,12 @@ def _stats_totals(rows):
295
398
  "failures": sum(row["failures"] for row in rows),
296
399
  "duration_ms": sum(row["duration_ms"] for row in rows),
297
400
  "usage_runs": sum(row["usage_runs"] for row in rows),
298
- "input_tokens": sum(row["input_tokens"] for row in rows),
299
- "cached_input_tokens": sum(row["cached_input_tokens"] for row in rows),
300
- "output_tokens": sum(row["output_tokens"] for row in rows),
301
- "reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
302
- "total_tokens": sum(row["total_tokens"] for row in rows),
401
+ **{key: sum(row[key] for row in rows) for key in USAGE_KEYS},
402
+ "weighted_tokens": sum(row["weighted_tokens"] for row in rows),
403
+ "cost_usd": sum(row["cost_usd"] for row in rows),
404
+ "priced_runs": sum(row["priced_runs"] for row in rows),
405
+ "unpriced_models": sorted({m for row in rows for m in row["unpriced_models"]}),
406
+ "unvouched_runs": sum(row["unvouched_runs"] for row in rows),
303
407
  }
304
408
 
305
409
  def _format_history_period(period):
@@ -391,7 +495,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
391
495
  if not rows:
392
496
  return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
393
497
  table = [[_style(value, "1", use_color) for value in [
394
- "SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "TIME", "LAST"
498
+ "SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "COST~", "USD~", "TIME", "LAST"
395
499
  ]]]
396
500
  for row in rows:
397
501
  session_name = row["session_name"]
@@ -405,7 +509,10 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
405
509
  _style(_format_token_count(row["cached_input_tokens"]), "96" if row["cached_input_tokens"] else "2", use_color),
406
510
  _style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
407
511
  _style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
408
- _style(_format_token_count(row["total_tokens"]), "1;96" if row["total_tokens"] else "2", use_color),
512
+ _style(_format_token_count(row["total_tokens"]), "96" if row["total_tokens"] else "2", use_color),
513
+ _style(_format_token_count(row["weighted_tokens"]), "1;95" if row["weighted_tokens"] else "2", use_color),
514
+ _style(_format_usd(row["cost_usd"]) if row["priced_runs"] else "-",
515
+ "1;33" if row["priced_runs"] else "2", use_color),
409
516
  _style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
410
517
  _dim(_format_relative_age(row.get("last_started_at")), use_color),
411
518
  ])
@@ -419,8 +526,18 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
419
526
  _dim(
420
527
  "Totals: "
421
528
  f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
422
- f"{_format_token_count(totals['total_tokens'])} tokens, "
423
- f"{_format_duration_ms(totals['duration_ms'])}.",
529
+ f"{_format_token_count(totals['total_tokens'])} tokens "
530
+ f"({_format_token_count(totals['weighted_tokens'])} cost-equivalent"
531
+ + (f", {_format_usd(totals['cost_usd'])} at list on {totals['priced_runs']} priced run"
532
+ f"{'s' if totals['priced_runs'] != 1 else ''} [{token_prices()[1]}]"
533
+ if totals["priced_runs"] else ", no run priced")
534
+ + (f"; no price for {', '.join(totals['unpriced_models'])}"
535
+ if totals["unpriced_models"] else "") + "), "
536
+ + (f"{totals['unvouched_runs']} run"
537
+ f"{'s' if totals['unvouched_runs'] != 1 else ''} excluded "
538
+ f"(recorded before cdx could measure them; cdx repair clears these), "
539
+ if totals["unvouched_runs"] else "")
540
+ + f"{_format_duration_ms(totals['duration_ms'])}.",
424
541
  use_color,
425
542
  ),
426
543
  ])
@@ -673,7 +790,9 @@ def _rows_by_session(rows):
673
790
 
674
791
  def _is_invalid_claude_usage_auth(error):
675
792
  text = str(error or "").lower()
676
- return "http 401" in text and "invalid authentication credentials" in text
793
+ if "http 401" not in text:
794
+ return False
795
+ return "invalid authentication credentials" in text or "oauth access token has expired" in text
677
796
 
678
797
  def _has_valid_local_claude_auth(item, rows_by_name):
679
798
  session = item.get("session")
package/src/health.py CHANGED
@@ -12,6 +12,7 @@ from .cli_render import _pad_table, _style
12
12
  from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
13
13
  from .provider_runtime import (
14
14
  HEADLESS_CODEX_PERMISSION_ARGS,
15
+ LAUNCH_FEATURE_ARGS,
15
16
  LAUNCH_PERMISSION_ARGS,
16
17
  codex_auth_diagnostic,
17
18
  )
@@ -173,12 +174,17 @@ def _extract_version(text):
173
174
 
174
175
 
175
176
  def _mapped_provider_flags(provider):
176
- """Every distinct flag cdx would pass to this provider, by permission.
177
+ """Every distinct flag cdx would pass to this provider, by reason.
177
178
 
178
179
  Read off the same mappings the launch specs use, so a permission added
179
180
  there is automatically covered here rather than quietly unverified.
180
181
  Values (a sandbox name, a config expression) are not flags and are not
181
182
  checked — only the options themselves.
183
+
184
+ Covers flags cdx passes on its own initiative as well as permission
185
+ mappings. Issue #8 was a mapped flag the CLI never had; a flag cdx invents
186
+ for a feature of its own is exactly as capable of not existing, and used to
187
+ pass through here unverified because it belonged to no permission.
182
188
  """
183
189
  mappings = {}
184
190
  interactive = LAUNCH_PERMISSION_ARGS.get(provider)
@@ -186,6 +192,9 @@ def _mapped_provider_flags(provider):
186
192
  # No entry at all means nobody declared this provider's mapping, which
187
193
  # is different from declaring that it has none.
188
194
  return {}
195
+ for flag in LAUNCH_FEATURE_ARGS.get(provider, []):
196
+ if flag.startswith("-"):
197
+ mappings.setdefault("cdx feature flags", set()).add(flag)
189
198
  for permission, args in interactive.items():
190
199
  mappings.setdefault(permission, set()).update(
191
200
  arg for arg in args if arg.startswith("-")