cdx-manager 0.21.6 → 0.22.0

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.
@@ -268,7 +268,9 @@ def _codex_usage(handle):
268
268
  output_tokens=_number(usage.get("output_tokens")),
269
269
  reasoning_tokens=_number(usage.get("reasoning_output_tokens")) or None,
270
270
  )
271
- except (TypeError, ValueError):
271
+ except (AttributeError, TypeError, ValueError):
272
+ # AttributeError: a line that is valid JSON but not an object
273
+ # ([], "x", "message": "x") is skipped like any malformed line.
272
274
  continue
273
275
  return latest, model
274
276
 
@@ -277,12 +279,14 @@ def _claude_usage(handle):
277
279
  """Sum Claude's per-message usage across the transcript.
278
280
 
279
281
  Cache creation and cache read are kept apart rather than summed on the way
280
- in: they cost 1.25x and 0.1x of uncached input, so fusing them here would
281
- destroy the only information a cost-aware figure needs. Claude reports no
282
+ in: they cost 1.25x (2x for one-hour writes) and 0.1x of uncached input, so
283
+ fusing them here would destroy the only information a cost-aware figure
284
+ needs. Claude reports no
282
285
  separate reasoning count -- thinking is billed as output and is already
283
286
  inside `output_tokens` -- so that field stays absent by design.
284
287
  """
285
288
  totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
289
+ long_ttl = None
286
290
  seen = set()
287
291
  model = None
288
292
  for line in handle:
@@ -301,13 +305,18 @@ def _claude_usage(handle):
301
305
  totals["cache_creation_tokens"] += _number(usage.get("cache_creation_input_tokens"))
302
306
  totals["cache_read_tokens"] += _number(usage.get("cache_read_input_tokens"))
303
307
  totals["output_tokens"] += _number(usage.get("output_tokens"))
308
+ split = usage.get("cache_creation")
309
+ if isinstance(split, dict) and "ephemeral_1h_input_tokens" in split:
310
+ long_ttl = (long_ttl or 0) + _number(split["ephemeral_1h_input_tokens"])
304
311
  if isinstance(message.get("model"), str) and not message["model"].startswith("<"):
305
312
  model = message["model"]
306
- except (TypeError, ValueError):
313
+ except (AttributeError, TypeError, ValueError):
314
+ # AttributeError: a line that is valid JSON but not an object
315
+ # ([], "x", "message": "x") is skipped like any malformed line.
307
316
  continue
308
317
  if not seen and not any(totals.values()):
309
318
  return None, model
310
- return normalize_usage(**totals), model
319
+ return normalize_usage(**totals, cache_creation_1h_tokens=long_ttl), model
311
320
 
312
321
 
313
322
  def _number(value):
@@ -322,6 +331,7 @@ def _number(value):
322
331
  MEASURED_KEYS = (
323
332
  "input_tokens",
324
333
  "cache_creation_tokens",
334
+ "cache_creation_1h_tokens",
325
335
  "cache_read_tokens",
326
336
  "output_tokens",
327
337
  "reasoning_tokens",
@@ -353,6 +363,11 @@ def usage_delta(cumulative, previous):
353
363
  if now is None:
354
364
  parts[key] = None
355
365
  continue
366
+ if before is None and key == "cache_creation_1h_tokens" and previous.get("cache_creation_tokens"):
367
+ # The previous figure predates the split: its writes are unknown,
368
+ # not zero, so this run's share cannot be told apart.
369
+ parts[key] = None
370
+ continue
356
371
  difference = now - (before or 0)
357
372
  if difference < 0:
358
373
  return None
@@ -72,12 +72,13 @@ def supports_native_background(provider, env=None, spawn_sync=None):
72
72
  1. A delegated run cannot honour the `cdx run --json` contract. The
73
73
  in-house path runs `claude --print --output-format json` and gets a
74
74
  structured result, token usage, and a final payload. `--bg` runs the
75
- interactive TUI; `claude logs <id>` returns an ANSI screen dump. So
76
- usage accounting, the final payload, and the rate-limit classification
77
- that `--failover` depends on all have no source.
78
- 2. Nothing yet drives a delegated run to a terminal status. `agent_terminal_status`
79
- exists but no caller consults the provider, so the record relies on pid
80
- liveness and a blocked agent reads as running indefinitely.
75
+ interactive TUI; `claude logs <id>` returns an ANSI screen dump. Usage
76
+ and the final text are read back from the session transcript, but the
77
+ rate-limit classification that `--failover` depends on has no source.
78
+ 2. A delegated run only reaches a terminal status when a caller reads it:
79
+ `_reconcile_background_runs` in commands/runs.py consults the
80
+ transcript, then `agent_terminal_status`, on `cdx runs`, `run-status`
81
+ and `run-report`. Until one of those runs, the record stays `running`.
81
82
  3. cdx's own launch spec makes the agent block. A hand-run
82
83
  `claude --bg --permission-mode bypassPermissions "..."` reaches
83
84
  `state: done`; the same task launched through cdx sits at
@@ -223,6 +224,7 @@ def read_transcript_outcome(path):
223
224
  if not path:
224
225
  return outcome
225
226
  totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
227
+ long_ttl = None
226
228
  # This path had no de-duplication at all, so a transcript that records the
227
229
  # same billed response more than once inflated the total outright.
228
230
  counted = set()
@@ -268,10 +270,13 @@ def read_transcript_outcome(path):
268
270
  totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
269
271
  totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
270
272
  totals["output_tokens"] += usage.get("output_tokens") or 0
273
+ split = usage.get("cache_creation")
274
+ if isinstance(split, dict) and "ephemeral_1h_input_tokens" in split:
275
+ long_ttl = (long_ttl or 0) + (split["ephemeral_1h_input_tokens"] or 0)
271
276
  except OSError:
272
277
  return outcome
273
278
  if seen:
274
- outcome["usage"] = normalize_usage(**totals)
279
+ outcome["usage"] = normalize_usage(**totals, cache_creation_1h_tokens=long_ttl)
275
280
  if len(models) == 1:
276
281
  outcome["usage_model"] = next(iter(models))
277
282
  return outcome
@@ -150,6 +150,10 @@ CLAUDE_PARENT_SESSION_ENV = (
150
150
  "CLAUDE_CODE_MESSAGING_SOCKET",
151
151
  "CLAUDE_CODE_MESSAGING_TOKEN",
152
152
  "CLAUDE_PID",
153
+ # Credentials: a profile launched from inside another profile's session must
154
+ # not inherit that account's token. The profile's own token is set after.
155
+ "CLAUDE_CODE_OAUTH_TOKEN",
156
+ "ANTHROPIC_API_KEY",
153
157
  )
154
158
  CLAUDE_CLI_MODEL_ALIASES = {
155
159
  "claude-sonnet": "sonnet",
@@ -208,6 +212,40 @@ def _shared_tool_cache_overrides(env):
208
212
  }
209
213
 
210
214
 
215
+ def operator_rtk_history_path(env=None):
216
+ """The rtk history database of the operator's real home.
217
+
218
+ rtk files its savings under the data dir of HOME, and a profile launch
219
+ swaps HOME, so each account kept its own history.db and `rtk gain` in the
220
+ operator's shell saw none of what the agents saved. Read from the passwd
221
+ entry rather than HOME, which is already a profile home when cdx runs
222
+ inside a session.
223
+ """
224
+ env = os.environ if env is None else env
225
+ if os.name == "nt":
226
+ base = env.get("LOCALAPPDATA")
227
+ return os.path.join(base, "rtk", "history.db") if base else None
228
+ import pwd
229
+
230
+ try:
231
+ home = pwd.getpwuid(os.getuid()).pw_dir
232
+ except KeyError:
233
+ # A uid without a passwd entry (common in containers): no better
234
+ # answer than HOME.
235
+ home = env.get("HOME") or os.path.expanduser("~")
236
+ if sys.platform == "darwin":
237
+ return os.path.join(home, "Library", "Application Support", "rtk", "history.db")
238
+ data_home = env.get("XDG_DATA_HOME") or os.path.join(home, ".local", "share")
239
+ return os.path.join(data_home, "rtk", "history.db")
240
+
241
+
242
+ def _shared_rtk_history(env):
243
+ """Point every profile's rtk at one history, unless the operator chose one."""
244
+ path = None if env.get("RTK_DB_PATH") else operator_rtk_history_path(env)
245
+ if path:
246
+ env["RTK_DB_PATH"] = path
247
+
248
+
211
249
  def _anthropic_profile_name():
212
250
  return "default"
213
251
 
@@ -542,6 +580,9 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
542
580
  script_args = env.get("CDX_SCRIPT_ARGS")
543
581
  transcript_path = _build_launch_transcript_path(session)
544
582
  os.makedirs(os.path.dirname(transcript_path), exist_ok=True)
583
+ if os.name != "nt":
584
+ # script(1) keeps an existing file's mode, and a transcript can hold a token.
585
+ os.close(os.open(transcript_path, os.O_CREAT | os.O_WRONLY, 0o600))
545
586
  if script_args:
546
587
  args = shlex.split(script_args)
547
588
  if "{transcript}" in args:
@@ -674,12 +715,15 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
674
715
  env = {**os.environ, **env_override}
675
716
  for key, value in _shared_tool_cache_overrides(env).items():
676
717
  env.setdefault(key, value)
718
+ _shared_rtk_history(env)
677
719
  env.update(launch_notify_env(session, notifications_enabled(session)))
678
720
  env.update(_scrub_env(session))
679
721
  preference_args = _launch_preference_args(session, env=env)
680
722
  if session["provider"] == PROVIDER_CLAUDE:
681
723
  launch = session.get("launch") or {}
682
- args = ["--name", session["name"]]
724
+ # No --name: it would title every conversation after the account in
725
+ # the /resume picker. The account already shows in cdx's terminal title.
726
+ args = []
683
727
  conversation_id = _conversation_id(session)
684
728
  if conversation_id:
685
729
  args += ["--session-id", conversation_id]
@@ -850,6 +894,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
850
894
  cwd = cwd or os.getcwd()
851
895
  env_override = env_override or {}
852
896
  env = {**os.environ, **env_override}
897
+ _shared_rtk_history(env)
853
898
  capability = get_resume_capability(session, cwd=cwd)
854
899
  if not capability["resumable"]:
855
900
  raise CdxError(f"Provider {session.get('provider')} does not support native resume through cdx.")
@@ -869,7 +914,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
869
914
  if capability["strategy"] == "provider_conversation_id"
870
915
  else ["--continue"]
871
916
  )
872
- args += ["--name", session["name"]]
917
+ # No --name: on a resume it would overwrite the user's /rename.
873
918
  if launch.get("model"):
874
919
  args += ["--model", _claude_cli_model(launch["model"])]
875
920
  args += _launch_config_args(session)
@@ -926,6 +971,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
926
971
  _validate_initial_prompt(initial_prompt)
927
972
  cwd = cwd or os.getcwd()
928
973
  env = {**os.environ, **(env_override or {})}
974
+ _shared_rtk_history(env)
929
975
  # Headless runs read the same home, and so the same hooks, as an interactive
930
976
  # launch. Their caller already learns of completion from the return value.
931
977
  env.update(launch_notify_env(session, enabled=False))
@@ -937,7 +983,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
937
983
  model = launch.get("model")
938
984
 
939
985
  if session["provider"] == PROVIDER_CLAUDE:
940
- args = ["--print", "--output-format", "json", "--name", session["name"]]
986
+ args = ["--print", "--output-format", "json"]
941
987
  conversation_id = _conversation_id(session)
942
988
  if conversation_id:
943
989
  args += ["--session-id", conversation_id]
package/src/retro.py ADDED
@@ -0,0 +1,131 @@
1
+ """Session retrospectives: what the user had to correct, so the next session knows it.
2
+
3
+ With `cdx set <name> --retro on`, the end of an interactive session starts a
4
+ detached, read-only `cdx run --kind retro` on a ready account. It reads the
5
+ session's transcript and reports the corrections the user made and the lessons
6
+ worth keeping, each with where it belongs (memory, runbook, skill, CLAUDE.md).
7
+ It changes nothing: `cdx retro` shows the reports, and the user decides.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+
15
+ #: A session with fewer prompts than this rarely holds a correction worth a model call.
16
+ MIN_USER_PROMPTS = 3
17
+
18
+ PROMPT = """You are writing a retrospective of one finished coding-agent session. Do not modify any file.
19
+
20
+ Transcript (JSONL, possibly large; read it in bounded portions, never all at once): {transcript}
21
+ Project folder: {cwd}
22
+
23
+ Find the moments where the user corrected, redirected or overruled the agent, and the
24
+ non-obvious facts the session had to discover. Ignore routine back-and-forth.
25
+
26
+ Answer in Markdown with exactly these sections:
27
+ ## Corrections
28
+ One bullet per correction: what the agent did, what the user wanted, in one line each.
29
+ ## Lessons
30
+ One bullet per lesson worth keeping for future sessions, each starting with where it belongs:
31
+ `memory:`, `runbook:`, `skill:` or `CLAUDE.md:`, followed by the exact text to add.
32
+ ## Verdict
33
+ One line: "nothing to learn" if both sections are empty, else the single most useful lesson.
34
+ """
35
+
36
+
37
+ def enabled(session):
38
+ return (session.get("launch") or {}).get("retro") is True
39
+
40
+
41
+ def start(session, cwd, transcript, env=None, popen=subprocess.Popen):
42
+ """Start the retro run in the background; returns the argv, or None when skipped."""
43
+ env = {**os.environ, **(env or {})}
44
+ if not enabled(session) or not transcript or not os.path.isfile(transcript):
45
+ return None
46
+ if env.get("CDX_RETRO") == "1" or _user_prompts(transcript) < MIN_USER_PROMPTS:
47
+ return None
48
+ executable = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
49
+ argv = [
50
+ executable, "run", "--provider", session["provider"], "--cwd", cwd, "--kind", "retro",
51
+ "--permission", "read-only", "--power", "low", "--detach", "--json",
52
+ "--prompt", PROMPT.format(transcript=transcript, cwd=cwd),
53
+ ]
54
+ env["CDX_RETRO"] = "1" # a retro never asks for a retro of itself
55
+ try:
56
+ popen(argv, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
57
+ stderr=subprocess.DEVNULL, start_new_session=True)
58
+ except OSError:
59
+ return None
60
+ return argv
61
+
62
+
63
+ def _user_prompts(transcript):
64
+ """Prompts the user typed: Claude `user` records that are not tool results, Codex `user_message` events.
65
+
66
+ Size is no proxy: one long handoff prompt makes a large transcript of a session
67
+ that never started.
68
+ """
69
+ count = 0
70
+ try:
71
+ with open(transcript, encoding="utf-8", errors="replace") as handle:
72
+ for line in handle:
73
+ if ('"type":"user"' in line and '"tool_result"' not in line) or '"type":"user_message"' in line:
74
+ count += 1
75
+ if count >= MIN_USER_PROMPTS:
76
+ break
77
+ except OSError:
78
+ return 0
79
+ return count
80
+
81
+
82
+ def reports(registry, limit=5):
83
+ """The newest retro runs, newest first, with their text when finished."""
84
+ found = []
85
+ for run in registry.list(limit=200):
86
+ if run.get("kind") != "retro":
87
+ continue
88
+ payload = run.get("final_payload") if isinstance(run.get("final_payload"), dict) else {}
89
+ found.append({"run_id": run.get("run_id"), "status": run.get("status"), "cwd": run.get("cwd"),
90
+ "started_at": run.get("started_at"), "session": run.get("session"),
91
+ "report": _result_text(payload.get("stdout_path"))})
92
+ if len(found) >= limit:
93
+ break
94
+ return found
95
+
96
+
97
+ def _result_text(stdout_path):
98
+ """The answer of a finished run: Claude's `result`, else where to read it."""
99
+ if not stdout_path or not os.path.isfile(stdout_path):
100
+ return None
101
+ try:
102
+ with open(stdout_path, encoding="utf-8") as handle:
103
+ document = json.load(handle)
104
+ except (OSError, ValueError):
105
+ return f"(see {stdout_path})"
106
+ result = document.get("result") if isinstance(document, dict) else None
107
+ return result if isinstance(result, str) else f"(see {stdout_path})"
108
+
109
+
110
+ def handle_retro(rest, ctx):
111
+ from .cli_helpers import _json_success, _write_json
112
+ from .errors import CdxError
113
+ from .run_registry import RunRegistry
114
+
115
+ json_flag = "--json" in rest
116
+ args = [arg for arg in rest if arg != "--json"]
117
+ limit = 5
118
+ if args[:1] == ["--limit"] and len(args) == 2 and args[1].isdigit():
119
+ limit = int(args[1])
120
+ elif args:
121
+ raise CdxError("Usage: cdx retro [--limit N] [--json]")
122
+ found = reports(RunRegistry(ctx["service"]["base_dir"]), limit=limit)
123
+ if json_flag:
124
+ _write_json(ctx, _json_success("retro", f"{len(found)} retro(s)", retros=found))
125
+ return 0
126
+ if not found:
127
+ ctx["out"]("No session retro yet. Turn them on with `cdx set <name> --retro on`.\n")
128
+ return 0
129
+ for item in found:
130
+ ctx["out"](f"## {item['started_at']} · {item['cwd']} · {item['status']}\n{item['report'] or '(still running)'}\n\n")
131
+ return 0
@@ -0,0 +1,68 @@
1
+ """What rtk saved, for the period `cdx stats` reports on.
2
+
3
+ Launches share the operator's rtk history (RTK_DB_PATH), but profiles used before
4
+ that kept their own `history.db` under their home; both are read so older
5
+ savings are not lost. rtk itself does the reading (`rtk gain --daily --format
6
+ json`), so this never depends on its database schema.
7
+ """
8
+
9
+ import glob
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ from datetime import datetime
15
+
16
+ from .provider_runtime import operator_rtk_history_path
17
+
18
+ _PROFILE_DBS = (
19
+ ("*", "Library", "Application Support", "rtk", "history.db"),
20
+ ("*", "*-home", "Library", "Application Support", "rtk", "history.db"),
21
+ ("*", ".local", "share", "rtk", "history.db"),
22
+ ("*", "*-home", ".local", "share", "rtk", "history.db"),
23
+ )
24
+
25
+
26
+ def history_databases(base_dir, env=None):
27
+ """Every rtk history this operator's sessions wrote to, the shared one first."""
28
+ env = os.environ if env is None else env
29
+ found = []
30
+ for path in [env.get("RTK_DB_PATH") or operator_rtk_history_path(env)] + [
31
+ match for pattern in _PROFILE_DBS
32
+ for match in sorted(glob.glob(os.path.join(glob.escape(os.path.join(base_dir, "profiles")), *pattern)))
33
+ ]:
34
+ if path and os.path.isfile(path) and os.path.realpath(path) not in {os.path.realpath(p) for p in found}:
35
+ found.append(path)
36
+ return found
37
+
38
+
39
+ def _in_period(day, period):
40
+ for key, keep in (("from", lambda d, edge: d >= edge), ("to", lambda d, edge: d <= edge)):
41
+ edge = (period or {}).get(key)
42
+ if edge and not keep(day, datetime.fromisoformat(edge).date().isoformat()):
43
+ return False
44
+ return True
45
+
46
+
47
+ def rtk_saved(base_dir, period=None, env=None, run=subprocess.run):
48
+ """{commands, input_tokens, saved_tokens, databases} over the period, or None without rtk."""
49
+ env = {**os.environ, **(env or {})}
50
+ rtk = shutil.which("rtk", path=env.get("PATH"))
51
+ databases = history_databases(base_dir, env)
52
+ if not rtk or not databases:
53
+ return None
54
+ totals = {"commands": 0, "input_tokens": 0, "saved_tokens": 0, "databases": 0}
55
+ for database in databases:
56
+ try:
57
+ result = run([rtk, "gain", "--daily", "--format", "json"], env={**env, "RTK_DB_PATH": database},
58
+ capture_output=True, text=True, timeout=15, check=False)
59
+ days = json.loads(result.stdout).get("daily") or []
60
+ except (OSError, ValueError, AttributeError, subprocess.TimeoutExpired):
61
+ continue
62
+ totals["databases"] += 1
63
+ for day in days:
64
+ if isinstance(day, dict) and _in_period(str(day.get("date")), period):
65
+ totals["commands"] += int(day.get("commands") or 0)
66
+ totals["input_tokens"] += int(day.get("input_tokens") or 0)
67
+ totals["saved_tokens"] += int(day.get("saved_tokens") or 0)
68
+ return totals if totals["databases"] else None
@@ -219,15 +219,41 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
219
219
  }
220
220
 
221
221
 
222
+ def _check_capacity(data, max_parallel):
223
+ _refresh_stale_runs(data)
224
+ running = sum(1 for run in data["runs"] if run.get("status") == "running")
225
+ if running >= max_parallel:
226
+ raise CdxError(
227
+ f"{running} run(s) already running, the limit is {max_parallel}. "
228
+ "Wait for one to finish (cdx runs --json) or raise --max-parallel.",
229
+ code="too_many_runs",
230
+ )
231
+
232
+
222
233
  class RunRegistry:
223
234
  def __init__(self, base_dir):
224
235
  self.path = registry_path(base_dir)
225
236
 
226
- def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None):
237
+ def check_capacity(self, max_parallel):
238
+ """Raise too_many_runs now, before a refused run changes any session state.
239
+
240
+ `start()` checks again under the same lock, so a run that slips in
241
+ between is still refused there.
242
+ """
243
+ with _registry_lock(self.path):
244
+ _check_capacity(_read_registry(self.path), max_parallel)
245
+
246
+ def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None, label=None, max_parallel=None):
227
247
  with _registry_lock(self.path):
228
248
  data = _read_registry(self.path)
229
249
  data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
250
+ if max_parallel:
251
+ # Counted under the lock and without this run's own record, which a
252
+ # detached child writes a second time under the same id.
253
+ _check_capacity(data, max_parallel)
230
254
  record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
255
+ if label:
256
+ record["label"] = label
231
257
  data["runs"].insert(0, record)
232
258
  _prune_finished_runs(data)
233
259
  _write_registry(self.path, data)
package/src/run_usage.py CHANGED
@@ -14,6 +14,11 @@ The definitions below are therefore normative, not a suggestion:
14
14
  natively; Codex does not, so its cache-inclusive count
15
15
  is reduced here rather than left for a reader to guess.
16
16
  cache_creation_tokens Tokens written to the provider's prompt cache.
17
+ cache_creation_1h_tokens
18
+ The part of cache_creation_tokens written with
19
+ Anthropic's one-hour TTL, which costs 2x instead of
20
+ 1.25x. A subset, never added to a total; absent when
21
+ the provider does not report the split.
17
22
  cache_read_tokens Tokens served from it.
18
23
  cached_input_tokens cache_creation + cache_read. Kept because it is a
19
24
  published field of `cdx run --json`, and because the
@@ -31,8 +36,8 @@ The definitions below are therefore normative, not a suggestion:
31
36
  quantity.
32
37
 
33
38
  Cache creation and cache read are separate fields because they cost very
34
- differently -- 1.25x and 0.1x of uncached input -- and a weighted figure cannot
35
- recover the split once they are summed.
39
+ differently -- 1.25x (2x for a one-hour TTL) and 0.1x of uncached input -- and
40
+ a weighted figure cannot recover the split once they are summed.
36
41
 
37
42
  Absence stays absent: `None` means "not reported", which is not zero. Only
38
43
  `normalize_usage` may derive `cached_input_tokens` and `total_tokens`, so no
@@ -49,6 +54,7 @@ USAGE_KEYS = (
49
54
  "input_tokens",
50
55
  "cached_input_tokens",
51
56
  "cache_creation_tokens",
57
+ "cache_creation_1h_tokens",
52
58
  "cache_read_tokens",
53
59
  "output_tokens",
54
60
  "reasoning_tokens",
@@ -64,7 +70,7 @@ def empty_usage():
64
70
 
65
71
 
66
72
  def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_tokens=None,
67
- output_tokens=None, reasoning_tokens=None):
73
+ output_tokens=None, reasoning_tokens=None, cache_creation_1h_tokens=None):
68
74
  """Build a usage record from the four measured quantities.
69
75
 
70
76
  The derived fields are computed here and only here. A record whose parts are
@@ -80,6 +86,11 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
80
86
  }
81
87
  if all(value is None for value in parts.values()):
82
88
  return empty_usage()
89
+ long_ttl = _non_negative(cache_creation_1h_tokens)
90
+ if long_ttl is not None:
91
+ # A subset of the writes; more than the writes is not a subset.
92
+ long_ttl = min(long_ttl, parts["cache_creation_tokens"] or 0)
93
+ parts["cache_creation_1h_tokens"] = long_ttl
83
94
  cached = _sum_present(parts["cache_creation_tokens"], parts["cache_read_tokens"])
84
95
  total = _sum_present(
85
96
  parts["input_tokens"],
@@ -94,12 +105,13 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
94
105
  #:
95
106
  #: Defaults for rows without an explicit cache-read price. Newer models can
96
107
  #: discount reads further; both stats and currency use the table's rate.
97
- #: Anthropic's one-hour cache TTL costs 2x instead; nothing in a transcript
98
- #: says which TTL was used, so long-TTL writes are under-weighted. Cache writes
99
- #: are a small share of a cache-heavy session, so that error is bounded --
100
- #: stated here rather than hidden behind a single averaged number.
108
+ #: Anthropic's one-hour cache TTL costs 2x instead. Claude transcripts report
109
+ #: that split as `cache_creation.ephemeral_1h_input_tokens`; Claude Code writes
110
+ #: most of its cache that way, so pricing every write at 1.25x understated
111
+ #: them by up to 1.6x. A record without the split keeps the 1.25x rate.
101
112
  CACHE_READ_MULTIPLIER = 0.1
102
113
  CACHE_WRITE_MULTIPLIER = 1.25
114
+ CACHE_WRITE_1H_MULTIPLIER = 2.0
103
115
 
104
116
  #: What an output token costs relative to an uncached input one, when the model
105
117
  #: is unknown. Current model ratios vary, so an unknown model is priced at the
@@ -144,7 +156,8 @@ def weighted_usage(usage, model=None, prices=None):
144
156
  That is not the ranking anyone reading `cdx stats` is looking for.
145
157
 
146
158
  Output and cache-read ratios come from the model that served the run.
147
- Cache writes retain the five-minute TTL assumption.
159
+ Cache writes use the one-hour rate for the part the provider reported as
160
+ one-hour, the five-minute rate for the rest.
148
161
 
149
162
  `reasoning_tokens` is deliberately excluded: Codex reports it as a subset
150
163
  of its output, and counting it would bill those tokens twice.
@@ -154,12 +167,13 @@ def weighted_usage(usage, model=None, prices=None):
154
167
  weights = {
155
168
  "input_tokens": 1.0,
156
169
  "cache_creation_tokens": CACHE_WRITE_MULTIPLIER,
170
+ "cache_creation_1h_tokens": CACHE_WRITE_1H_MULTIPLIER - CACHE_WRITE_MULTIPLIER,
157
171
  "cache_read_tokens": cache_read_multiplier(model, prices),
158
172
  "output_tokens": output_multiplier(model, prices),
159
173
  }
160
174
  present = []
161
175
  for key, weight in weights.items():
162
- value = usage.get(key)
176
+ value = _long_ttl_writes(usage) if key == "cache_creation_1h_tokens" else usage.get(key)
163
177
  if value is not None:
164
178
  if weight is None and value:
165
179
  return None
@@ -297,9 +311,20 @@ def estimate_cost(usage, model, prices=None):
297
311
  ]
298
312
  if not present:
299
313
  return None
314
+ long_ttl = _long_ttl_writes(usage) or 0
315
+ present.append(rate["input"] * (CACHE_WRITE_1H_MULTIPLIER - CACHE_WRITE_MULTIPLIER) * long_ttl)
300
316
  return sum(present) / 1_000_000
301
317
 
302
318
 
319
+ def _long_ttl_writes(usage):
320
+ """One-hour cache writes, bounded by the writes they are a part of."""
321
+ long_ttl = usage.get("cache_creation_1h_tokens")
322
+ if not isinstance(long_ttl, (int, float)) or isinstance(long_ttl, bool) or long_ttl <= 0:
323
+ return None
324
+ writes = usage.get("cache_creation_tokens")
325
+ return min(long_ttl, writes) if isinstance(writes, (int, float)) and not isinstance(writes, bool) else None
326
+
327
+
303
328
  def claude_usage_dedup_key(record):
304
329
  """What identifies one *billed* Claude API response in a transcript.
305
330
 
@@ -362,6 +387,7 @@ def coerce_usage(usage):
362
387
  cache_read_tokens=usage.get("cache_read_tokens"),
363
388
  output_tokens=usage.get("output_tokens"),
364
389
  reasoning_tokens=usage.get("reasoning_tokens"),
390
+ cache_creation_1h_tokens=usage.get("cache_creation_1h_tokens"),
365
391
  )
366
392
  if record["cached_input_tokens"] is None:
367
393
  # A legacy record carrying only the fused figure: keep it visible rather
@@ -493,6 +519,7 @@ def _usage_from_dict(value):
493
519
  return normalize_usage(
494
520
  input_tokens=input_tokens,
495
521
  cache_creation_tokens=cache_creation,
522
+ cache_creation_1h_tokens=_nested_int(usage, "cache_creation", "ephemeral_1h_input_tokens"),
496
523
  cache_read_tokens=cache_read,
497
524
  output_tokens=_int_value(usage.get("output_tokens"), usage.get("completion_tokens")),
498
525
  reasoning_tokens=_int_value(
@@ -106,13 +106,16 @@ def _collect_auth_files(session_root, provider, session_name=None, progress_call
106
106
  })
107
107
  return {"files": files, "file_count": len(files), "bytes": total_bytes}
108
108
 
109
- def _validate_import_profile_files(payload, imported_sessions):
109
+ def _validate_import_profile_files(payload, imported_sessions, include_auth=True):
110
110
  profiles = payload.get("profiles") or {}
111
111
  if not isinstance(profiles, dict):
112
112
  raise CdxError("Bundle contains invalid profile data.")
113
+ if any(profiles.values()) and not include_auth:
114
+ raise CdxError("Bundle contains profile files but no auth payload.")
113
115
  decoded_profiles = {}
114
116
  for session_payload in imported_sessions:
115
117
  name = session_payload["name"]
118
+ allowed_paths = _auth_bundle_paths(_normalize_provider(session_payload["provider"]))
116
119
  files = []
117
120
  profile_items = profiles.get(name, [])
118
121
  if not isinstance(profile_items, list):
@@ -121,6 +124,8 @@ def _validate_import_profile_files(payload, imported_sessions):
121
124
  if not isinstance(item, dict):
122
125
  raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
123
126
  rel_path = _safe_relpath(item.get("path"))
127
+ if rel_path not in allowed_paths:
128
+ raise CdxError(f"Bundle contains an unexpected file for session {name}: {rel_path}")
124
129
  data_b64 = item.get("data_b64")
125
130
  if not isinstance(data_b64, str):
126
131
  raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
@@ -288,9 +293,14 @@ def import_bundle(base_dir, store, file_path,
288
293
  )
289
294
 
290
295
  for session_payload in imported_sessions:
291
- _validate_new_session_name(session_payload["name"])
296
+ if session_payload["name"] not in existing:
297
+ _validate_new_session_name(session_payload["name"])
292
298
  _normalize_provider(session_payload["provider"])
293
- decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
299
+ # A rollback removes the profile folder, so never adopt one we did not create.
300
+ if session_payload["name"] not in existing and os.path.lexists(_get_session_root(base_dir, session_payload["name"])):
301
+ raise CdxError(f"Session profile already exists: {session_payload['name']}")
302
+ decoded_profiles = _validate_import_profile_files(payload, imported_sessions,
303
+ include_auth=bool(decoded["meta"].get("include_auth")))
294
304
 
295
305
  for session_payload in imported_sessions:
296
306
  name = session_payload["name"]
@@ -318,7 +328,8 @@ def import_bundle(base_dir, store, file_path,
318
328
  bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
319
329
  item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
320
330
  )
321
- if bundle_carries_credential:
331
+ # Force replaces the login even when the bundle has none to offer.
332
+ if bundle_carries_credential or (force and provider == PROVIDER_CLAUDE):
322
333
  local_credential = read_keychain_credentials(old_record["authHome"])
323
334
  recovery_dir = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
324
335
  backup_root = os.path.join(recovery_dir, "profile")
@@ -405,7 +416,7 @@ def import_bundle(base_dir, store, file_path,
405
416
  # Decrypted credentials: 0o600 from creation, no umask window.
406
417
  atomic_write(dest_path, item["content"], mode=0o600)
407
418
  imported_paths.append(item["path"])
408
- if provider == PROVIDER_CLAUDE and CLAUDE_CREDENTIALS_BUNDLE_PATH in imported_paths:
419
+ if provider == PROVIDER_CLAUDE and (force and old_record or CLAUDE_CREDENTIALS_BUNDLE_PATH in imported_paths):
409
420
  delete_keychain_credentials(auth_home)
410
421
  _restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
411
422
  except Exception: