cdx-manager 0.21.6 → 0.23.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.
@@ -0,0 +1,207 @@
1
+ """cdx rescue: move a conversation stopped by a usage limit to an account that can go on.
2
+
3
+ Everything it does already exists -- `status` knows which accounts are spent,
4
+ the shared ranking knows which one is best, `handoff` carries a conversation
5
+ over. This command only puts them in one step, and asks before it does.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from datetime import datetime, timedelta, timezone
11
+
12
+ from ..cli_helpers import _json_failure, _json_success, _write_json
13
+ from ..cli_render import _info, _warn
14
+ from ..errors import CdxError
15
+ from ..session_ranking import rank_sessions
16
+ from ..status_view import _now_timestamp, _priority_reset_timestamp
17
+ from .launch import handle_handoff
18
+
19
+ RESCUE_USAGE = "Usage: cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]"
20
+ #: A conversation stopped longer ago than this is history, not something to rescue.
21
+ RECENT = timedelta(hours=24)
22
+
23
+
24
+ def _parse(rest):
25
+ parsed = {"source": None, "target": None, "yes": False, "dry_run": False, "json": False}
26
+ index = 0
27
+ while index < len(rest):
28
+ arg = rest[index]
29
+ if arg == "--to" and index + 1 < len(rest):
30
+ parsed["target"] = rest[index + 1]
31
+ index += 1
32
+ elif arg in ("--yes", "-y"):
33
+ parsed["yes"] = True
34
+ elif arg == "--dry-run":
35
+ parsed["dry_run"] = True
36
+ elif arg == "--json":
37
+ parsed["json"] = True
38
+ elif not arg.startswith("-") and parsed["source"] is None:
39
+ parsed["source"] = arg
40
+ else:
41
+ raise CdxError(RESCUE_USAGE)
42
+ index += 1
43
+ return parsed
44
+
45
+
46
+ def _recent(iso, now):
47
+ try:
48
+ when = datetime.fromisoformat(str(iso))
49
+ except (TypeError, ValueError):
50
+ return False
51
+ if when.tzinfo is None:
52
+ when = when.replace(tzinfo=timezone.utc)
53
+ return now - when <= RECENT
54
+
55
+
56
+ #: How much of a transcript's end is read to find how it stopped.
57
+ TAIL_BYTES = 256 * 1024
58
+
59
+
60
+ def ended_on_limit(transcript):
61
+ """Whether the conversation's last word is a usage-limit error.
62
+
63
+ Claude writes an API error record (`isApiErrorMessage`, `error: rate_limit`);
64
+ Codex an `error` event with `codex_error_info: usage_limit_exceeded`. A normal
65
+ reply after it means the conversation went on, so only the last of the two
66
+ counts. This catches a stop before `cdx status` shows the account at 0%.
67
+ """
68
+ try:
69
+ with open(transcript, "rb") as handle:
70
+ handle.seek(max(0, os.path.getsize(transcript) - TAIL_BYTES))
71
+ lines = handle.read().decode("utf-8", errors="replace").splitlines()
72
+ except (OSError, TypeError):
73
+ return False
74
+ stopped = False
75
+ for line in lines:
76
+ try:
77
+ record = json.loads(line)
78
+ except ValueError:
79
+ continue
80
+ if not isinstance(record, dict):
81
+ continue
82
+ payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
83
+ if record.get("isApiErrorMessage") and record.get("error") == "rate_limit":
84
+ stopped = True
85
+ elif payload.get("type") == "error" and payload.get("codex_error_info") == "usage_limit_exceeded":
86
+ stopped = True
87
+ elif record.get("type") == "assistant" and not record.get("isApiErrorMessage"):
88
+ stopped = False
89
+ elif payload.get("type") == "message" and payload.get("role") == "assistant":
90
+ stopped = False
91
+ return stopped
92
+
93
+
94
+ def stranded(service, rows, now=None):
95
+ """Sessions on a spent account whose last conversation can be handed off, newest first."""
96
+ now = now or datetime.now(timezone.utc)
97
+ found = []
98
+ for session in service["list_sessions"]():
99
+ row = rows.get(session["name"]) or {}
100
+ conversation = (session.get("conversation") or {}).get("id")
101
+ if not conversation or not _recent(row.get("last_launched_at"), now):
102
+ continue
103
+ if _rescued_since_launch(session, row):
104
+ continue
105
+ history = service["get_launch_history"](session["name"], limit=1)
106
+ last = history[0] if history else {}
107
+ cwd = last.get("cwd") or None
108
+ if not cwd or not os.path.isdir(cwd):
109
+ continue
110
+ spent = row.get("available_pct") == 0
111
+ if not spent and not ended_on_limit(last.get("provider_transcript_path")):
112
+ continue
113
+ found.append({
114
+ "session": session["name"], "provider": session["provider"], "conversation_id": conversation,
115
+ "cwd": cwd, "reset_at": row.get("reset_at"), "last_launched_at": row.get("last_launched_at"),
116
+ })
117
+ return sorted(found, key=lambda item: item["last_launched_at"] or "", reverse=True)
118
+
119
+
120
+ def _rescued_since_launch(session, row):
121
+ """Already handed off, and not launched again since: rescuing it twice forks it."""
122
+ try:
123
+ rescued = datetime.fromisoformat(str((session.get("conversation") or {}).get("rescuedAt")))
124
+ launched = datetime.fromisoformat(str(row.get("last_launched_at")))
125
+ return rescued >= launched
126
+ except (TypeError, ValueError):
127
+ return False
128
+
129
+
130
+ def pick_target(rows, source, provider):
131
+ """(target row, needs confirmation) for the best ready account, or (None, False).
132
+
133
+ `use` accounts first; an `ask-first` one only when no `use` account is
134
+ ready, and always behind a confirmation. `never` is not considered.
135
+ """
136
+ others = [row for row in rows.values() if row.get("session_name") != source]
137
+ ordered, _ = rank_sessions(others, _now_timestamp(), _priority_reset_timestamp,
138
+ provider=provider, require_ready=True)
139
+ if ordered:
140
+ return ordered[0], False
141
+ asked = [{**row, "policy": "use"} for row in others if row.get("policy") == "ask-first"]
142
+ ordered, _ = rank_sessions(asked, _now_timestamp(), _priority_reset_timestamp,
143
+ provider=provider, require_ready=True)
144
+ return (ordered[0], True) if ordered else (None, False)
145
+
146
+
147
+ def handle_rescue(rest, ctx):
148
+ parsed = _parse(rest)
149
+ service = ctx["service"]
150
+ rows = {row["session_name"]: row for row in service["get_status_rows"](force_refresh=False)}
151
+ candidates = stranded(service, rows)
152
+ if parsed["source"]:
153
+ candidates = [c for c in candidates if c["session"] == parsed["source"]]
154
+ if not candidates:
155
+ message = "No conversation stopped by a usage limit in the last 24 hours."
156
+ if parsed["json"]:
157
+ _write_json(ctx, _json_success("rescue", message, plan=None, stranded=[]))
158
+ else:
159
+ ctx["out"](f"{message}\n")
160
+ return 0
161
+ source = candidates[0]
162
+ if parsed["target"]:
163
+ target = rows.get(parsed["target"])
164
+ if not target or target.get("policy") == "never":
165
+ raise CdxError(f"Cannot rescue onto {parsed['target']}: unknown account or policy never.")
166
+ confirm = target.get("policy") == "ask-first"
167
+ else:
168
+ target, confirm = pick_target(rows, source["session"], source["provider"])
169
+ if not target:
170
+ message = f"No ready {source['provider']} account to take over {source['session']}."
171
+ if parsed["json"]:
172
+ _write_json(ctx, _json_failure("rescue", "no_ready_account", message, stranded=candidates))
173
+ return 1
174
+ raise CdxError(message)
175
+ plan = {**source, "target": target["session_name"], "target_policy": target.get("policy", "use")}
176
+ if parsed["json"] or parsed["dry_run"]:
177
+ if parsed["json"]:
178
+ _write_json(ctx, _json_success("rescue", "Rescue plan", plan=plan, stranded=candidates))
179
+ else:
180
+ ctx["out"](_describe(plan, ctx) + "\n")
181
+ return 0
182
+ ctx["out"](_describe(plan, ctx) + "\n")
183
+ if confirm or not parsed["yes"]:
184
+ if not ctx["stdin_is_tty"]:
185
+ raise CdxError("cdx rescue needs a terminal to confirm; pass --yes (not enough for an ask-first account).")
186
+ ask = ctx.get("options", {}).get("input") or input
187
+ question = f"Hand {source['session']} over to {target['session_name']}"
188
+ question += " (policy ask-first)" if confirm else ""
189
+ if ask(f"{question}? [y/N] ").strip().lower() not in ("y", "yes"):
190
+ raise CdxError("Rescue cancelled.", exit_code=130)
191
+ code = handle_handoff(
192
+ [source["session"], target["session_name"], "--source-conversation", source["conversation_id"]],
193
+ {**ctx, "cwd": source["cwd"]},
194
+ )
195
+ if code == 0:
196
+ service["mark_conversation_rescued"](source["session"], source["conversation_id"])
197
+ return code
198
+
199
+
200
+ def _describe(plan, ctx):
201
+ reset = f", back {plan['reset_at']}" if plan.get("reset_at") else ""
202
+ lines = [
203
+ _warn(f"{plan['session']} is out of usage{reset}", ctx["use_color"]),
204
+ f" conversation {plan['conversation_id']} in {plan['cwd']}",
205
+ _info(f"-> {plan['target']}" + (" (ask-first)" if plan["target_policy"] == "ask-first" else ""), ctx["use_color"]),
206
+ ]
207
+ return "\n".join(lines)
@@ -43,7 +43,7 @@ from ..provider_runtime import (
43
43
  )
44
44
  from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
45
45
  from ..run_failover import MAX_FAILOVER_TRANSITIONS, failover_reason
46
- from ..run_registry import RunRegistry, build_code_review_report
46
+ from ..run_registry import RunRegistry, build_code_review_report, final_message
47
47
  from ..run_usage import extract_run_observation
48
48
  from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
49
49
  from ..status_view import _now_timestamp, _priority_reset_timestamp
@@ -193,6 +193,7 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
193
193
  ("--model", "model"),
194
194
  ("--kind", "kind"),
195
195
  ("--permission", "permission"),
196
+ ("--label", "label"),
196
197
  ):
197
198
  value = parsed.get(key)
198
199
  if value:
@@ -414,7 +415,7 @@ def handle_runs(rest, ctx):
414
415
  raise CdxError(RUNS_USAGE)
415
416
  since = parsed.get("since")
416
417
  _reconcile_background_runs(ctx)
417
- runs = _run_registry(ctx).list(limit=parsed["limit"], since=since)
418
+ runs = _run_registry(ctx).list(limit=parsed["limit"], since=since, running=parsed.get("running", False))
418
419
  warnings = []
419
420
  if since is not None and parsed.get("limit_given"):
420
421
  warnings.append({
@@ -502,6 +503,7 @@ def handle_run_report(rest, ctx):
502
503
  _write_json(ctx, _json_success("run-report", "Run report loaded.", report={
503
504
  "run": run,
504
505
  "final_payload": run.get("final_payload"),
506
+ "final_message": final_message(run),
505
507
  "task_report": run.get("task_report"),
506
508
  "artifacts": run.get("artifacts") or {},
507
509
  "usage": run.get("usage"),
@@ -509,6 +511,17 @@ def handle_run_report(rest, ctx):
509
511
  }))
510
512
  return 0
511
513
 
514
+ def _env_max_runs(ctx):
515
+ """CDX_MAX_RUNS as the default --max-parallel; unset, invalid or <= 0 means no limit."""
516
+ value = {**os.environ, **(ctx.get("env") or {})}.get("CDX_MAX_RUNS")
517
+ try:
518
+ limit = int(value) if value else 0
519
+ except ValueError:
520
+ return None
521
+ # 0 or negative reads as "no limit", like unset, rather than a cap of 1.
522
+ return limit if limit > 0 else None
523
+
524
+
512
525
  def handle_run(rest, ctx):
513
526
  registry = None
514
527
  run_id = None
@@ -563,6 +576,15 @@ def handle_run(rest, ctx):
563
576
  if parsed.get("reasoning_effort"):
564
577
  launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
565
578
  launch_updates["power"] = parsed["reasoning_effort"]
579
+ # A detached child runs in the slot its parent already took.
580
+ max_parallel = None if detached_run_id else (parsed.get("max_parallel") or _env_max_runs(ctx))
581
+ if max_parallel:
582
+ # Before anything changes the session: a refused run must not leave
583
+ # Claude on a fresh conversation id that resume and rescue then
584
+ # point at. Finished native background runs are closed out first,
585
+ # since nothing else frees their slot.
586
+ _reconcile_background_runs(ctx)
587
+ _run_registry(ctx).check_capacity(max_parallel)
566
588
  # Claude needs its conversation id before the spec is built; Codex
567
589
  # records its own afterwards, from the rollout. Without this a headless
568
590
  # Claude run left the session with no identity and `cdx resume` fell
@@ -607,6 +629,8 @@ def handle_run(rest, ctx):
607
629
  "stdout_path": artifacts.get("stdout_path"),
608
630
  "stderr_path": artifacts.get("stderr_path"),
609
631
  },
632
+ label=parsed.get("label"),
633
+ max_parallel=max_parallel,
610
634
  )
611
635
  if parsed.get("detach"):
612
636
  # Delegate where the installed CLI can both start a background
@@ -4,6 +4,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
4
  """
5
5
 
6
6
  import os
7
+ import re
7
8
  import threading
8
9
  import time
9
10
  from datetime import datetime
@@ -11,6 +12,7 @@ from datetime import datetime
11
12
  from ..claude_refresh import _refresh_claude_sessions
12
13
  from ..cli_args import (
13
14
  LAST_USAGE,
15
+ SESSIONS_USAGE,
14
16
  STATUS_USAGE,
15
17
  _filter_history_period,
16
18
  _has_history_period,
@@ -49,6 +51,7 @@ from ..provider_runtime import (
49
51
  _has_local_claude_auth,
50
52
  _probe_provider_auth_status,
51
53
  )
54
+ from ..rtk_savings import rtk_saved
52
55
  from ..run_usage import (
53
56
  USAGE_KEYS,
54
57
  estimate_cost,
@@ -57,6 +60,7 @@ from ..run_usage import (
57
60
  token_prices,
58
61
  weighted_usage,
59
62
  )
63
+ from ..session_status import session_paths
60
64
  from ..status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
61
65
 
62
66
 
@@ -268,7 +272,7 @@ def _summarize_stats(entries):
268
272
  name = entry.get("session_name") or "-"
269
273
  row = rows.setdefault(name, {
270
274
  "session_name": name,
271
- "provider": entry.get("provider") or "-",
275
+ "provider": set(),
272
276
  "launches": 0,
273
277
  "successes": 0,
274
278
  "failures": 0,
@@ -284,6 +288,7 @@ def _summarize_stats(entries):
284
288
  "input_tokens": 0,
285
289
  "cached_input_tokens": 0,
286
290
  "cache_creation_tokens": 0,
291
+ "cache_creation_1h_tokens": 0,
287
292
  "cache_read_tokens": 0,
288
293
  "output_tokens": 0,
289
294
  "reasoning_tokens": 0,
@@ -291,6 +296,8 @@ def _summarize_stats(entries):
291
296
  "last_started_at": None,
292
297
  })
293
298
  row["launches"] += 1
299
+ if entry.get("provider"):
300
+ row["provider"].add(entry["provider"])
294
301
  if entry.get("status") == "success":
295
302
  row["successes"] += 1
296
303
  elif entry.get("status") == "failed":
@@ -338,8 +345,10 @@ def _summarize_stats(entries):
338
345
  started = entry.get("started_at")
339
346
  if started and (not row["last_started_at"] or started > row["last_started_at"]):
340
347
  row["last_started_at"] = started
341
- row["provider"] = entry.get("provider") or row["provider"]
342
348
  for row in rows.values():
349
+ # Every provider seen, not the last one: a project or model row often
350
+ # spans several.
351
+ row["provider"] = "+".join(sorted(row["provider"])) or "-"
343
352
  # Derive from the summed parts rather than summing each run's stored
344
353
  # total. Records written before the definition was settled carry a
345
354
  # total that excluded cache, and adding those to correct ones produced
@@ -472,14 +481,43 @@ def _format_token_count(value):
472
481
  return f"{amount / 1000:.1f}K"
473
482
  return str(amount)
474
483
 
475
- def _format_stats(rows, totals, period=None, use_color=False, active_sessions=None):
484
+ def _format_rtk_saved(rtk):
485
+ saved = _format_token_count(rtk["saved_tokens"])
486
+ share = f" ({rtk['saved_tokens'] * 100 // rtk['input_tokens']}% of their output)" if rtk["input_tokens"] else ""
487
+ return f"rtk saved {saved} tokens on {rtk['commands']:,} commands{share}."
488
+
489
+
490
+ def _stats_group(entry, by):
491
+ """The project (launch folder) or the model a run is counted under."""
492
+ if by == "project":
493
+ return str(entry.get("cwd") or "").rstrip("/\\") or "-"
494
+ return entry.get("usage_model") or "-"
495
+
496
+
497
+ def _project_labels(paths):
498
+ """The shortest trailing part of each folder that still tells it apart, so
499
+ /a/app and /b/app read as a/app and b/app rather than both as app."""
500
+ parts = {path: [p for p in re.split(r"[/\\]", path) if p] for path in paths}
501
+ labels = {}
502
+ for path, segments in parts.items():
503
+ for n in range(1, len(segments) + 1):
504
+ label = "/".join(segments[-n:])
505
+ if not any(other != path and "/".join(rest[-n:]) == label for other, rest in parts.items()):
506
+ break
507
+ else:
508
+ label = path
509
+ labels[path] = label
510
+ return labels
511
+
512
+
513
+ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=None, group_label="SESSION"):
476
514
  from ..cli_render import _format_relative_age
477
515
 
478
516
  active_sessions = active_sessions or set()
479
517
  if not rows:
480
518
  return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
481
519
  table = [[_style(value, "1", use_color) for value in [
482
- "SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "COST~", "USD~", "TIME", "LAST"
520
+ group_label, "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "COST~", "USD~", "TIME", "LAST"
483
521
  ]]]
484
522
  for row in rows:
485
523
  session_name = row["session_name"]
@@ -596,8 +634,18 @@ def handle_stats(rest, ctx):
596
634
  parsed = _parse_stats_args(rest, now=now)
597
635
  entries = ctx["service"]["get_launch_history"](parsed["name"], limit=0)
598
636
  entries = _filter_history_period(entries, parsed["period"])
637
+ if parsed["by"] != "session":
638
+ # Same accounting, another key: each run is regrouped, never re-priced.
639
+ entries = [{**entry, "session_name": _stats_group(entry, parsed["by"])} for entry in entries]
599
640
  rows = _summarize_stats(entries)
641
+ if parsed["by"] == "project":
642
+ labels = _project_labels([row["session_name"] for row in rows])
643
+ for row in rows:
644
+ row["project"] = row["session_name"]
645
+ row["session_name"] = labels[row["project"]]
600
646
  totals = _stats_totals(rows)
647
+ # rtk cannot tell which session a command ran in, so only the unfiltered view shows it.
648
+ rtk = None if parsed["name"] else rtk_saved(ctx["service"]["base_dir"], parsed["period"], env=ctx.get("env"))
601
649
  message = (
602
650
  f"Calculated stats for {parsed['name']}"
603
651
  if parsed["name"]
@@ -610,6 +658,8 @@ def handle_stats(rest, ctx):
610
658
  period=_public_history_period(parsed["period"]),
611
659
  stats=rows,
612
660
  totals=totals,
661
+ group_by=parsed["by"],
662
+ rtk=rtk,
613
663
  accounting={
614
664
  "launches_unit": "history_attempts",
615
665
  "period_policy": "whole_run_overlap",
@@ -619,8 +669,10 @@ def handle_stats(rest, ctx):
619
669
  },
620
670
  ))
621
671
  return 0
622
- active_sessions = _active_session_names(ctx)
623
- ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
672
+ active_sessions = _active_session_names(ctx) if parsed["by"] == "session" else set()
673
+ ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions, group_label=parsed['by'].upper())}\n")
674
+ if rows and rtk and rtk["commands"]:
675
+ ctx["out"](f"{_dim(_format_rtk_saved(rtk), ctx['use_color'])}\n")
624
676
  return 0
625
677
 
626
678
  def _resolve_last_launch_session(ctx):
@@ -876,3 +928,39 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out", rows=None):
876
928
  rows_by_name = _rows_by_session(rows)
877
929
  for item in refresh_errors:
878
930
  write(f"{_warn(_format_refresh_warning(item, rows_by_name), ctx['use_color'])}\n")
931
+
932
+
933
+ def handle_sessions(rest, ctx):
934
+ """Interactive sessions cdx launched that are still running, or every account.
935
+
936
+ Read from the runtime cdx records at launch, so a provider started outside
937
+ cdx is not listed. A runtime whose process has died is reported stale and
938
+ dropped by the same check `cdx status` uses for `active`. `--all` adds the
939
+ idle enabled accounts, so a caller can resolve any account's directories.
940
+ """
941
+ json_flag, args = _parse_json_flag(rest)
942
+ include_all = "--all" in args
943
+ args = [arg for arg in args if arg != "--all"]
944
+ if not json_flag or args:
945
+ raise CdxError(SESSIONS_USAGE)
946
+ service = ctx["service"]
947
+ rows = []
948
+ for session in service["list_sessions"]():
949
+ if session.get("enabled", True) is False:
950
+ continue
951
+ runtime = service["active_session_runtime"](session["name"]) or {}
952
+ if not runtime and not include_all:
953
+ continue
954
+ rows.append({
955
+ "session": session["name"],
956
+ "provider": session["provider"],
957
+ "active": bool(runtime),
958
+ "pid": runtime.get("pid"),
959
+ "cwd": runtime.get("cwd"),
960
+ "started_at": runtime.get("startedAt"),
961
+ "runtime_id": runtime.get("runId"),
962
+ "conversation_id": (session.get("conversation") or {}).get("id"),
963
+ **session_paths(session),
964
+ })
965
+ _write_json(ctx, _json_success("sessions", "Sessions loaded.", sessions=rows))
966
+ return 0
package/src/handoff.py CHANGED
@@ -48,7 +48,7 @@ def inspect_transcript(path, provider, extent=None):
48
48
  Never flatten messages: the descriptor points to the original tool evidence.
49
49
  The digest binds that evidence to the preparation boundary, even after append.
50
50
  """
51
- ids, workspaces = set(), set()
51
+ ids, workspaces = set(), []
52
52
  last_event = None
53
53
  digest = hashlib.sha256()
54
54
  consumed = 0
@@ -88,7 +88,9 @@ def inspect_transcript(path, provider, extent=None):
88
88
  if cwd:
89
89
  if not isinstance(cwd, str) or not os.path.isabs(cwd):
90
90
  raise ValueError('invalid workspace')
91
- workspaces.add(os.path.realpath(cwd))
91
+ real = os.path.realpath(cwd)
92
+ if real not in workspaces:
93
+ workspaces.append(real)
92
94
  stamp = record.get('timestamp')
93
95
  if isinstance(stamp, str):
94
96
  try:
@@ -102,24 +104,33 @@ def inspect_transcript(path, provider, extent=None):
102
104
  consumed = handle.tell()
103
105
  except (OSError, ValueError) as error:
104
106
  raise CdxError(f'Cannot use transcript {path}: {error}') from error
105
- if len(ids) != 1 or len(workspaces) != 1:
107
+ if len(ids) != 1 or not workspaces:
106
108
  raise CdxError(f'Transcript needs one conversation identity and workspace: {path}')
109
+ # A session that moved between folders (a worktree, a subfolder) keeps
110
+ # every one of them; it starts in the first, and may be handed off in any.
107
111
  return {
108
112
  'path': os.path.abspath(path), 'provider': provider, 'conversation_id': ids.pop(),
109
- 'workspace': workspaces.pop(), 'last_event_at': last_event,
113
+ 'workspace': workspaces[0], 'workspaces': workspaces, 'last_event_at': last_event,
110
114
  'extent_bytes': consumed, 'sha256': digest.hexdigest(), 'format': 'jsonl',
111
115
  'mode': 'native',
112
116
  }
113
117
 
114
118
 
119
+ def _in_workspace(info, workspace):
120
+ """The transcript as seen from `workspace`, or None if it never worked there."""
121
+ if workspace not in info['workspaces']:
122
+ return None
123
+ return {**info, 'workspace': workspace}
124
+
125
+
115
126
  def source_candidates(session, workspace):
116
127
  candidates = []
117
128
  for path in _native_paths(session):
118
129
  try:
119
- info = inspect_transcript(path, session['provider'])
130
+ info = _in_workspace(inspect_transcript(path, session['provider']), workspace)
120
131
  except CdxError:
121
132
  continue
122
- if info['workspace'] == workspace:
133
+ if info:
123
134
  candidates.append(info)
124
135
  # Event time helps humans choose; it is never an automatic selection rule.
125
136
  return sorted(candidates, key=lambda c: (c['last_event_at'] or '', c['path']), reverse=True)[:CANDIDATE_LIMIT]
@@ -163,11 +174,11 @@ def resolve_source(session, workspace, conversation_id=None, terminal_path=None,
163
174
  if path not in allowed:
164
175
  raise HandoffSourceError('Native transcript must be a top-level source file in the source profile.')
165
176
  try:
166
- info = inspect_transcript(path, session['provider'])
177
+ info = _in_workspace(inspect_transcript(path, session['provider']), workspace)
167
178
  except CdxError as error:
168
179
  raise HandoffSourceError(str(error)) from error
169
- if info['workspace'] != workspace:
170
- raise HandoffSourceError('Native transcript workspace does not match the target workspace.')
180
+ if not info:
181
+ raise HandoffSourceError('Native transcript never worked in the target workspace.')
171
182
  info['provenance'] = 'explicit_native_path'
172
183
  return info
173
184
  if selected:
@@ -177,10 +188,10 @@ def resolve_source(session, workspace, conversation_id=None, terminal_path=None,
177
188
  matches = []
178
189
  for path in paths:
179
190
  try:
180
- info = inspect_transcript(path, session['provider'])
191
+ info = _in_workspace(inspect_transcript(path, session['provider']), workspace)
181
192
  except CdxError:
182
193
  continue
183
- if info['conversation_id'] == selected and info['workspace'] == workspace:
194
+ if info and info['conversation_id'] == selected:
184
195
  matches.append(info)
185
196
  if len(matches) > 1:
186
197
  raise HandoffSourceError(f'Conversation {selected} has multiple native transcripts; select --source-native-transcript PATH.', matches)
@@ -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