cdx-manager 0.20.13 → 0.21.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.
package/src/handoff.py CHANGED
@@ -11,7 +11,6 @@ from uuid import uuid4
11
11
  from .context_store import get_context_path, read_context
12
12
  from .errors import CdxError
13
13
  from .fs_utils import atomic_write
14
- from .interactive_usage import conversation_transcript
15
14
  from .provider_runtime import _get_auth_home, _list_launch_transcript_paths
16
15
 
17
16
  CANDIDATE_LIMIT = 20
@@ -25,13 +24,19 @@ class HandoffSourceError(CdxError):
25
24
 
26
25
  def _native_paths(session):
27
26
  home = _get_auth_home(session)
27
+ root = os.path.realpath(home)
28
+ def owned(path):
29
+ try:
30
+ return os.path.commonpath((root, os.path.realpath(path))) == root
31
+ except ValueError:
32
+ return False
28
33
  if session['provider'] == 'codex':
29
- paths = glob.glob(os.path.join(home, 'sessions', '**', '*.jsonl'), recursive=True)
30
- return [p for p in paths if 'subagents' not in os.path.relpath(p, home).split(os.sep)]
34
+ paths = glob.glob(os.path.join(glob.escape(home), 'sessions', '**', '*.jsonl'), recursive=True)
35
+ return [p for p in paths if owned(p) and 'subagents' not in os.path.relpath(p, home).split(os.sep)]
31
36
  if session['provider'] == 'claude':
32
37
  return sorted(set(
33
- p for root in (('.claude', 'projects'), ('projects',))
34
- for p in glob.glob(os.path.join(home, *root, '*', '*.jsonl'))
38
+ p for layout in (('.claude', 'projects'), ('projects',))
39
+ for p in glob.glob(os.path.join(glob.escape(home), *layout, '*', '*.jsonl')) if owned(p)
35
40
  ))
36
41
  return []
37
42
 
@@ -119,7 +124,7 @@ def source_candidates(session, workspace):
119
124
  return sorted(candidates, key=lambda c: (c['last_event_at'] or '', c['path']), reverse=True)[:CANDIDATE_LIMIT]
120
125
 
121
126
 
122
- def resolve_source(session, workspace, conversation_id=None, terminal_path=None):
127
+ def resolve_source(session, workspace, conversation_id=None, terminal_path=None, native_path=None):
123
128
  workspace = os.path.realpath(workspace)
124
129
  if terminal_path:
125
130
  path = os.path.realpath(terminal_path)
@@ -150,26 +155,37 @@ def resolve_source(session, workspace, conversation_id=None, terminal_path=None)
150
155
  reason = 'No recorded conversation identity.'
151
156
  if session['provider'] not in ('codex', 'claude'):
152
157
  raise HandoffSourceError('No supported native transcript for this provider. Select a known launch log with --source-transcript PATH for degraded recovery.')
158
+ paths = _native_paths(session)
159
+ if native_path:
160
+ path = os.path.realpath(native_path)
161
+ allowed = {os.path.realpath(p) for p in paths}
162
+ if path not in allowed:
163
+ raise HandoffSourceError('Native transcript must be a top-level source file in the source profile.')
164
+ try:
165
+ info = inspect_transcript(path, session['provider'])
166
+ except CdxError as error:
167
+ raise HandoffSourceError(str(error)) from error
168
+ if info['workspace'] != workspace:
169
+ raise HandoffSourceError('Native transcript workspace does not match the target workspace.')
170
+ info['provenance'] = 'explicit_native_path'
171
+ return info
153
172
  if selected:
154
173
  if not isinstance(selected, str) or not re.fullmatch(r'[A-Za-z0-9_-]+', selected):
155
174
  raise HandoffSourceError('Invalid source conversation ID.')
156
- path = conversation_transcript(session['provider'], _get_auth_home(session), selected)
157
- # The native resolver may walk nested subagent directories; only top-level
158
- # conversations are eligible for handoff, even when explicitly named.
159
- paths = _native_paths(session)
160
- if not path:
161
- matches = [p for p in paths if os.path.basename(p) == selected + '.jsonl']
162
- path = matches[0] if len(matches) == 1 else None
163
175
  reason = f'Conversation {selected} is missing or is not a top-level source.'
164
- if path in paths:
176
+ matches = []
177
+ for path in paths:
165
178
  try:
166
179
  info = inspect_transcript(path, session['provider'])
167
- if info['conversation_id'] != selected or info['workspace'] != workspace:
168
- raise CdxError(f'Conversation identity or workspace mismatch: {selected}')
169
- info['provenance'] = 'explicit_identity' if conversation_id else 'recorded_identity'
170
- return info
171
- except CdxError as error:
172
- reason = str(error)
180
+ except CdxError:
181
+ continue
182
+ if info['conversation_id'] == selected and info['workspace'] == workspace:
183
+ matches.append(info)
184
+ if len(matches) > 1:
185
+ raise HandoffSourceError(f'Conversation {selected} has multiple native transcripts; select --source-native-transcript PATH.', matches)
186
+ if matches:
187
+ matches[0]['provenance'] = 'explicit_identity' if conversation_id else 'recorded_identity'
188
+ return matches[0]
173
189
  candidates = source_candidates(session, workspace)
174
190
  raise HandoffSourceError(
175
191
  reason + ' Select the intended ID with --source-conversation ID; no recency fallback is used.',
@@ -219,13 +219,14 @@ def read_transcript_outcome(path):
219
219
  records the same facts here. Usage is summed across assistant messages
220
220
  because a session can hold several turns.
221
221
  """
222
- outcome = {"result": None, "usage": None}
222
+ outcome = {"result": None, "usage": None, "usage_model": None}
223
223
  if not path:
224
224
  return outcome
225
225
  totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
226
226
  # This path had no de-duplication at all, so a transcript that records the
227
227
  # same billed response more than once inflated the total outright.
228
228
  counted = set()
229
+ models = set()
229
230
  seen = False
230
231
  try:
231
232
  with open(path, encoding="utf-8", errors="replace") as handle:
@@ -261,6 +262,8 @@ def read_transcript_outcome(path):
261
262
  # interactive one filled the same column under opposite
262
263
  # definitions.
263
264
  seen = True
265
+ if isinstance(message.get("model"), str) and message["model"]:
266
+ models.add(message["model"])
264
267
  totals["input_tokens"] += usage.get("input_tokens") or 0
265
268
  totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
266
269
  totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
@@ -269,4 +272,6 @@ def read_transcript_outcome(path):
269
272
  return outcome
270
273
  if seen:
271
274
  outcome["usage"] = normalize_usage(**totals)
275
+ if len(models) == 1:
276
+ outcome["usage_model"] = next(iter(models))
272
277
  return outcome
@@ -515,7 +515,7 @@ def _list_launch_transcript_paths(session, glob_fn=None):
515
515
  log_dir = _get_launch_transcript_dir(session)
516
516
  if not os.path.isdir(log_dir):
517
517
  return []
518
- paths = set(glob_fn(os.path.join(log_dir, "cdx-session*.log")))
518
+ paths = set(glob_fn(os.path.join(glob.escape(log_dir), "cdx-session*.log")))
519
519
  legacy = _get_launch_transcript_path(session)
520
520
  if os.path.exists(legacy):
521
521
  paths.add(legacy)
@@ -28,7 +28,7 @@ REGISTRY_LOCK_TIMEOUT_SECONDS = 120
28
28
  _LOCK_RETRY_SECONDS = 0.05
29
29
 
30
30
 
31
- def _acquire_windows_lock(handle, timeout_seconds):
31
+ def _acquire_windows_lock(handle, timeout_seconds, label="run registry"):
32
32
  """Block until the byte is locked, or give up with a nameable error.
33
33
 
34
34
  Uses the non-blocking form and does the waiting here, so the wait is
@@ -47,14 +47,14 @@ def _acquire_windows_lock(handle, timeout_seconds):
47
47
  except OSError:
48
48
  if time.monotonic() >= deadline:
49
49
  raise CdxError(
50
- "Timed out waiting for the run registry lock after "
50
+ f"Timed out waiting for the {label} lock after "
51
51
  f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
52
52
  75,
53
53
  ) from None
54
54
  time.sleep(_LOCK_RETRY_SECONDS)
55
55
 
56
56
 
57
- def _acquire_posix_lock(handle, timeout_seconds):
57
+ def _acquire_posix_lock(handle, timeout_seconds, label="run registry"):
58
58
  """Acquire a POSIX advisory lock within the same bounded wait as Windows."""
59
59
  import fcntl
60
60
 
@@ -66,7 +66,7 @@ def _acquire_posix_lock(handle, timeout_seconds):
66
66
  except OSError:
67
67
  if time.monotonic() >= deadline:
68
68
  raise CdxError(
69
- "Timed out waiting for the run registry lock after "
69
+ f"Timed out waiting for the {label} lock after "
70
70
  f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
71
71
  75,
72
72
  ) from None
@@ -74,12 +74,12 @@ def _acquire_posix_lock(handle, timeout_seconds):
74
74
 
75
75
 
76
76
  @contextmanager
77
- def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
77
+ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS, label="run registry"):
78
78
  os.makedirs(os.path.dirname(path), exist_ok=True)
79
79
  with open(path + ".lock", "a") as handle:
80
80
  if sys.platform == "win32":
81
81
  import msvcrt
82
- _acquire_windows_lock(handle, timeout_seconds)
82
+ _acquire_windows_lock(handle, timeout_seconds, label)
83
83
  try:
84
84
  yield
85
85
  finally:
@@ -87,7 +87,7 @@ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
87
87
  msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
88
88
  else:
89
89
  import fcntl
90
- _acquire_posix_lock(handle, timeout_seconds)
90
+ _acquire_posix_lock(handle, timeout_seconds, label)
91
91
  try:
92
92
  yield
93
93
  finally:
package/src/run_usage.py CHANGED
@@ -40,6 +40,9 @@ caller can invent a fourth arithmetic.
40
40
  """
41
41
 
42
42
  import json
43
+ import math
44
+
45
+ from .errors import CdxError
43
46
 
44
47
  #: Order matters only for readability; every consumer addresses these by name.
45
48
  USAGE_KEYS = (
@@ -89,8 +92,8 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
89
92
 
90
93
  #: What a cached token costs relative to one uncached input token.
91
94
  #:
92
- #: Verified against both vendors on 2026-08-14 and identical on each: a cache
93
- #: read is a 90% discount on the input rate, and a cache write is 1.25x it.
95
+ #: Defaults for rows without an explicit cache-read price. Newer models can
96
+ #: discount reads further; both stats and currency use the table's rate.
94
97
  #: Anthropic's one-hour cache TTL costs 2x instead; nothing in a transcript
95
98
  #: says which TTL was used, so long-TTL writes are under-weighted. Cache writes
96
99
  #: are a small share of a cache-heavy session, so that error is bounded --
@@ -109,7 +112,19 @@ def output_multiplier(model, prices=None):
109
112
  entry = (prices if prices is not None else token_prices()[0]).get(model or "")
110
113
  if not entry:
111
114
  return DEFAULT_OUTPUT_MULTIPLIER
112
- return entry["output"] / entry["input"]
115
+ return entry["output"] / entry["input"] if entry["input"] else None
116
+
117
+
118
+ def cache_read_multiplier(model, prices=None):
119
+ """This model's cache-read/input ratio, with the legacy fallback."""
120
+ entry = (prices if prices is not None else token_prices()[0]).get(model or "")
121
+ if not entry:
122
+ return CACHE_READ_MULTIPLIER
123
+ if not entry["input"]:
124
+ return None
125
+ if "cache_read" not in entry:
126
+ return CACHE_READ_MULTIPLIER
127
+ return entry["cache_read"] / entry["input"] if entry["input"] else None
113
128
 
114
129
 
115
130
  def weighted_usage(usage, model=None, prices=None):
@@ -120,9 +135,8 @@ def weighted_usage(usage, model=None, prices=None):
120
135
  tokens -- so a raw total is very nearly a measure of cache reads alone.
121
136
  That is not the ranking anyone reading `cdx stats` is looking for.
122
137
 
123
- The output ratio comes from the model that served the run, because it is
124
- the one multiplier that differs between vendors. The cache multipliers do
125
- not, so they are constants.
138
+ Output and cache-read ratios come from the model that served the run.
139
+ Cache writes retain the five-minute TTL assumption.
126
140
 
127
141
  `reasoning_tokens` is deliberately excluded: Codex reports it as a subset
128
142
  of its output, and counting it would bill those tokens twice.
@@ -132,14 +146,16 @@ def weighted_usage(usage, model=None, prices=None):
132
146
  weights = {
133
147
  "input_tokens": 1.0,
134
148
  "cache_creation_tokens": CACHE_WRITE_MULTIPLIER,
135
- "cache_read_tokens": CACHE_READ_MULTIPLIER,
149
+ "cache_read_tokens": cache_read_multiplier(model, prices),
136
150
  "output_tokens": output_multiplier(model, prices),
137
151
  }
138
- present = [
139
- weight * usage[key]
140
- for key, weight in weights.items()
141
- if usage.get(key) is not None
142
- ]
152
+ present = []
153
+ for key, weight in weights.items():
154
+ value = usage.get(key)
155
+ if value is not None:
156
+ if weight is None and value:
157
+ return None
158
+ present.append((weight or 0) * value)
143
159
  if not present:
144
160
  return None
145
161
  return int(round(sum(present)))
@@ -160,12 +176,16 @@ def weighted_usage(usage, model=None, prices=None):
160
176
  #: Anthropic and OpenAI figures come from the vendors' own references, checked
161
177
  #: on the review date below.
162
178
  #:
163
- #: Not modelled: OpenAI's long-context tier, which roughly doubles both rates
179
+ #: Not modelled: OpenAI's long-context tier, which increases the rates
164
180
  #: above a 272K-token request. cdx records tokens per run, not per request, so
165
181
  #: it cannot tell which requests crossed that line -- long-context Codex work
166
182
  #: is therefore under-costed here, and knowingly so.
167
183
  DEFAULT_TOKEN_PRICES = {
168
184
  # Anthropic
185
+ "claude-fable-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25},
186
+ "claude-mythos-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25},
187
+ "claude-opus-5-5": {"input": 4.0, "output": 20.0, "cache_read": 0.2},
188
+ "claude-sonnet-5-5": {"input": 2.0, "output": 10.0},
169
189
  "claude-fable-5": {"input": 10.0, "output": 50.0},
170
190
  "claude-mythos-5": {"input": 10.0, "output": 50.0},
171
191
  "claude-opus-5": {"input": 5.0, "output": 25.0},
@@ -180,6 +200,9 @@ DEFAULT_TOKEN_PRICES = {
180
200
  "claude-haiku-4-5": {"input": 1.0, "output": 5.0},
181
201
  "claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0},
182
202
  # OpenAI
203
+ "gpt-6.1-sol": {"input": 2.0, "output": 10.0, "cache_read": 0.1},
204
+ "gpt-6-sol": {"input": 2.0, "output": 10.0},
205
+ "gpt-6-luna": {"input": 0.1, "output": 0.5},
183
206
  "gpt-6-astra": {"input": 10.0, "output": 50.0},
184
207
  "gpt-5.6": {"input": 4.0, "output": 20.0},
185
208
  "gpt-5.6-sol": {"input": 4.0, "output": 20.0},
@@ -195,7 +218,7 @@ DEFAULT_TOKEN_PRICES = {
195
218
  "gpt-5.4-nano-2026-03-17": {"input": 0.2, "output": 1.25},
196
219
  "gpt-5.3-codex": {"input": 1.75, "output": 14.0},
197
220
  }
198
- TOKEN_PRICES_REVIEWED = "2026-09-05"
221
+ TOKEN_PRICES_REVIEWED = "2026-10-04"
199
222
  #: How long a review stays fresh before the staleness test asks for another.
200
223
  TOKEN_PRICES_MAX_AGE_DAYS = 90
201
224
  TOKEN_PRICES_ENV = "CDX_TOKEN_PRICES"
@@ -210,15 +233,26 @@ def token_prices(env=None):
210
233
  if raw:
211
234
  try:
212
235
  overrides = json.loads(raw)
213
- except ValueError:
214
- return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED} ({TOKEN_PRICES_ENV} ignored: not JSON)"
215
- if isinstance(overrides, dict):
216
- merged = {**DEFAULT_TOKEN_PRICES}
217
- for name, entry in overrides.items():
218
- if isinstance(entry, dict) and "input" in entry and "output" in entry:
219
- merged[str(name)] = {
220
- "input": float(entry["input"]), "output": float(entry["output"])}
221
- return merged, TOKEN_PRICES_ENV
236
+ except ValueError as error:
237
+ raise CdxError(f"{TOKEN_PRICES_ENV} must be a JSON object of model rates.") from error
238
+ if not isinstance(overrides, dict):
239
+ raise CdxError(f"{TOKEN_PRICES_ENV} must be a JSON object of model rates.")
240
+ merged = {**DEFAULT_TOKEN_PRICES}
241
+ for name, entry in overrides.items():
242
+ if not isinstance(entry, dict) or not {"input", "output"} <= entry.keys():
243
+ raise CdxError(f"{TOKEN_PRICES_ENV}: {name} requires input and output rates.")
244
+ rates = {}
245
+ for key in ("input", "output", "cache_read"):
246
+ if key not in entry:
247
+ continue
248
+ value = entry[key]
249
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
250
+ value = float("nan")
251
+ if not math.isfinite(value) or value < 0:
252
+ raise CdxError(f"{TOKEN_PRICES_ENV}: {name}.{key} must be a finite nonnegative rate.")
253
+ rates[key] = value
254
+ merged[str(name)] = rates
255
+ return merged, TOKEN_PRICES_ENV
222
256
  return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED}"
223
257
 
224
258
 
@@ -229,10 +263,8 @@ def estimate_cost(usage, model, prices=None):
229
263
  figure. That shortcut was only correct while every model shared one
230
264
  output-to-input ratio, and they do not.
231
265
 
232
- ponytail: a run is priced at one model -- the one serving its most recent
233
- record -- rather than split per model. A session that switches models
234
- mid-run is priced at the newer one. Upgrade path if that matters: have the
235
- readers return a per-model breakdown and difference each model separately.
266
+ A run with mixed observed models and no measured per-model breakdown has
267
+ no usage_model, so its usage remains visible but unpriced.
236
268
 
237
269
  An unknown or absent model yields None. Pricing it at a default tier would
238
270
  turn "cdx does not know" into a number someone might believe.
@@ -244,7 +276,7 @@ def estimate_cost(usage, model, prices=None):
244
276
  return None
245
277
  per_million = {
246
278
  "input_tokens": rate["input"],
247
- "cache_read_tokens": rate["input"] * CACHE_READ_MULTIPLIER,
279
+ "cache_read_tokens": rate.get("cache_read", rate["input"] * CACHE_READ_MULTIPLIER),
248
280
  "cache_creation_tokens": rate["input"] * CACHE_WRITE_MULTIPLIER,
249
281
  "output_tokens": rate["output"],
250
282
  }
@@ -341,26 +373,41 @@ def _sum_present(*values):
341
373
 
342
374
 
343
375
  def extract_run_usage(provider, stdout_path):
376
+ return extract_run_observation(provider, stdout_path)[0]
377
+
378
+
379
+ def extract_run_observation(provider, stdout_path):
344
380
  if not stdout_path or not provider:
345
- return empty_usage()
381
+ return empty_usage(), None
346
382
  if provider not in SUPPORTED_PROVIDERS:
347
- return empty_usage()
383
+ return empty_usage(), None
348
384
  try:
349
385
  with open(stdout_path, encoding="utf-8", errors="replace") as handle:
350
386
  text = handle.read()
351
387
  except OSError:
352
- return empty_usage()
388
+ return empty_usage(), None
353
389
  if not text.strip():
354
- return empty_usage()
390
+ return empty_usage(), None
355
391
 
356
392
  records = _parse_json_records(text)
357
393
  if not records:
358
- return empty_usage()
394
+ return empty_usage(), None
359
395
 
360
396
  usage = _extract_usage_from_records(records)
361
397
  if not _has_usage(usage):
362
- return empty_usage()
363
- return usage
398
+ return empty_usage(), None
399
+ models = set()
400
+ for record in records:
401
+ if not isinstance(record, dict):
402
+ continue
403
+ message = record.get("message")
404
+ model = record.get("model") or (message.get("model") if isinstance(message, dict) else None)
405
+ if isinstance(model, str) and model and _has_usage(_find_usage(record)):
406
+ models.add(model)
407
+ model_usage = record.get("modelUsage")
408
+ if isinstance(model_usage, dict):
409
+ models.update(model_usage)
410
+ return usage, next(iter(models)) if len(models) == 1 else None
364
411
 
365
412
 
366
413
  def _parse_json_records(text):
@@ -306,12 +306,20 @@ def import_bundle(base_dir, store, file_path,
306
306
  old_record = None
307
307
  old_state = None
308
308
  preserved_profile_paths = []
309
+ # Read the live credential before moving its profile: a denied keychain
310
+ # read must leave the registered session untouched.
311
+ local_credential = None
309
312
  if is_existing and (force or merge):
310
313
  old_record = store["get_session"](name)
311
314
  old_state = store["read_session_state"](name)
312
315
  if merge:
313
316
  provider = old_record["provider"]
314
317
  auth_home = old_record["authHome"]
318
+ bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
319
+ item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
320
+ )
321
+ if bundle_carries_credential:
322
+ local_credential = read_keychain_credentials(old_record["authHome"])
315
323
  recovery_dir = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
316
324
  backup_root = os.path.join(recovery_dir, "profile")
317
325
  try:
@@ -335,13 +343,6 @@ def import_bundle(base_dir, store, file_path,
335
343
  # bundle credential exists at the credential-backend level and has to be
336
344
  # resolved there. Read it strictly: refusing an unreadable keychain is
337
345
  # better than importing over a credential we could not preserve.
338
- bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
339
- item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
340
- )
341
- local_credential = None
342
- if bundle_carries_credential and old_record is not None:
343
- local_credential = read_keychain_credentials(auth_home)
344
-
345
346
  existing_state_before = None
346
347
  try:
347
348
  _ensure_private_dir(session_root)