cdx-manager 0.20.0 → 0.20.2

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.20.0-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.2-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
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.20.0
1
+ 0.20.2
@@ -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.
@@ -0,0 +1,12 @@
1
+ # CDX Manager 0.20.2
2
+
3
+ ## Fixes
4
+
5
+ - Claude transcripts up to 128 MiB are now measured. Before each relaunch, CDX
6
+ reconciles the preceding Claude conversation when its exact transcript was
7
+ not captured; `cdx repair --force` also repairs safely attributable history.
8
+ - Token pricing now ignores synthetic transcript model placeholders when a real
9
+ provider model is present, so measured Claude sessions receive their price.
10
+ - `cdx history` and `cdx stats --since` now include sessions that overlap the
11
+ requested period, including sessions that started before the window and
12
+ completed inside it.
@@ -309,6 +309,28 @@
309
309
  "x86_64-pc-windows-msvc": "c5a29140bb9d634f2533084084ca10cb90ca64d34cac1b664998d305b934a7fb",
310
310
  "x86_64-unknown-linux-musl": "d1850f3acdede7c2cdcd31b5ca6d5e2372535a32bf75c0e6c8fd0100af7813f2"
311
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
+ }
323
+ },
324
+ "v0.20.1": {
325
+ "github_tarball_sha256": "00922589fbfa5edaee8bff2b9328bd185e91551eb63932fd7e0642a2fd101923",
326
+ "github_zip_sha256": "16aa1e4376368454769150b3adfec146dde5c88ddef8a157c343c00025ed58ce",
327
+ "tray_assets": {
328
+ "aarch64-apple-darwin": "4f73559113b77a67612e47be3cb3d81bfd9cee7b99fd8b5257f01ea6ec1b9d36",
329
+ "aarch64-unknown-linux-musl": "27f3bd4a6c03d164340a753387ac522d43ce0523f70f0251484b2d24a8e504df",
330
+ "x86_64-apple-darwin": "6a11eee823a76e00f02ab1c75c247b079f1e264351f467c41f4aeeb46bc9a3c3",
331
+ "x86_64-pc-windows-msvc": "3513680e96fd5f669c2f2868b3a195899e48ed4d5f3dce080289f90648e60199",
332
+ "x86_64-unknown-linux-musl": "ee86688537638671ceec1d805d23124d8511c11ab62836f14fa2545b418741d2"
333
+ }
312
334
  }
313
335
  }
314
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
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.20.0"
7
+ version = "0.20.2"
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"])
package/src/cli_args.py CHANGED
@@ -745,8 +745,8 @@ def _has_history_period(period):
745
745
  return period.get("from_ts") is not None or period.get("to_ts") is not None
746
746
 
747
747
 
748
- def _parse_entry_timestamp(entry):
749
- value = entry.get("started_at")
748
+ def _parse_entry_timestamp(entry, field="started_at"):
749
+ value = entry.get(field)
750
750
  if not value:
751
751
  return None
752
752
  text = str(value)
@@ -768,12 +768,16 @@ def _filter_history_period(entries, period):
768
768
  start = period.get("from_ts")
769
769
  end = period.get("to_ts")
770
770
  for entry in entries:
771
- timestamp = _parse_entry_timestamp(entry)
772
- if timestamp is None:
771
+ started = _parse_entry_timestamp(entry)
772
+ if started is None:
773
773
  continue
774
- if start is not None and timestamp < start:
774
+ # A long run belongs to a period it overlaps, not only the day it was
775
+ # launched. Usage is recorded per run, so include its whole measured
776
+ # total rather than inventing a time-proportional split.
777
+ ended = _parse_entry_timestamp(entry, "ended_at") or started
778
+ if start is not None and ended < start:
775
779
  continue
776
- if end is not None and timestamp > end:
780
+ if end is not None and started > end:
777
781
  continue
778
782
  filtered.append(entry)
779
783
  return filtered
@@ -790,7 +790,9 @@ def _rows_by_session(rows):
790
790
 
791
791
  def _is_invalid_claude_usage_auth(error):
792
792
  text = str(error or "").lower()
793
- 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
794
796
 
795
797
  def _has_valid_local_claude_auth(item, rows_by_name):
796
798
  session = item.get("session")
@@ -22,7 +22,10 @@ import time
22
22
  from .run_usage import claude_usage_dedup_key, normalize_usage
23
23
  from .status_source import _normalize_terminal_transcript
24
24
 
25
- MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024
25
+ # Readers stream JSONL line-by-line. A 16 MiB cap excluded ordinary long
26
+ # Claude sessions (for example a five-hour session at 26 MiB) without saving
27
+ # meaningful memory; keep a bounded ceiling for genuinely pathological files.
28
+ MAX_TRANSCRIPT_BYTES = 128 * 1024 * 1024
26
29
  MAX_TRANSCRIPT_CANDIDATES = 1000
27
30
  MAX_TRANSCRIPT_SCAN_SECONDS = 1
28
31
 
@@ -298,7 +301,7 @@ def _claude_usage(handle):
298
301
  totals["cache_creation_tokens"] += _number(usage.get("cache_creation_input_tokens"))
299
302
  totals["cache_read_tokens"] += _number(usage.get("cache_read_input_tokens"))
300
303
  totals["output_tokens"] += _number(usage.get("output_tokens"))
301
- if message.get("model"):
304
+ if isinstance(message.get("model"), str) and not message["model"].startswith("<"):
302
305
  model = message["model"]
303
306
  except (TypeError, ValueError):
304
307
  continue
package/src/repair.py CHANGED
@@ -51,6 +51,24 @@ def repair_health(service, base_dir, env=None, dry_run=True, force=False):
51
51
  None,
52
52
  None if dry_run else "applied",
53
53
  ))
54
+ backfill = service["backfill_interactive_usage"](dry_run=dry_run)
55
+ if backfill["backfilled"]:
56
+ actions.append(_action(
57
+ "backfill_interactive_usage",
58
+ f"backfill token usage for {backfill['backfilled']} exactly matched Claude launch record"
59
+ f"{'s' if backfill['backfilled'] != 1 else ''} (of {backfill['scanned']} scanned)",
60
+ None,
61
+ None if dry_run else "applied",
62
+ ))
63
+ models = service["refresh_interactive_usage_models"](dry_run=dry_run)
64
+ if models["backfilled"]:
65
+ actions.append(_action(
66
+ "refresh_interactive_usage_models",
67
+ f"replace placeholder models on {models['backfilled']} launch record"
68
+ f"{'s' if models['backfilled'] != 1 else ''}",
69
+ None,
70
+ None if dry_run else "applied",
71
+ ))
54
72
  return {
55
73
  "dry_run": dry_run,
56
74
  "force": force,
@@ -727,6 +727,72 @@ def get_launch_history(store, name=None, limit=20):
727
727
  return store["list_launch_history"](session_name=name, limit=limit)
728
728
 
729
729
 
730
+ def backfill_interactive_usage(store, name=None, dry_run=True):
731
+ """Recover one safely attributable missing Claude launch per profile."""
732
+ from datetime import datetime
733
+
734
+ from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
735
+
736
+ updates = {}
737
+ sessions = [store["get_session"](name)] if name else store["list_sessions"]()
738
+ for session in sessions:
739
+ if not session:
740
+ continue
741
+ if session.get("provider") != PROVIDER_CLAUDE:
742
+ continue
743
+ conversation = session.get("conversation") or {}
744
+ identifier = conversation.get("id")
745
+ recorded = conversation.get("recordedAt")
746
+ if not identifier or not recorded:
747
+ continue
748
+ usage, path, match, model = extract_interactive_usage(
749
+ PROVIDER_CLAUDE, session.get("authHome"), conversation_id=identifier
750
+ )
751
+ if not usage or match != MATCH_CONVERSATION_ID:
752
+ continue
753
+ history = store["list_launch_history"](session_name=session["name"], limit=0)
754
+ target = next((entry for entry in history if not entry.get("usage") and entry.get("provider_transcript_path") in (None, path)), None)
755
+ if not target or not target.get("started_at"):
756
+ continue
757
+ try:
758
+ delta = abs((datetime.fromisoformat(recorded.replace("Z", "+00:00")) - datetime.fromisoformat(target["started_at"].replace("Z", "+00:00"))).total_seconds())
759
+ except ValueError:
760
+ continue
761
+ if delta > 300:
762
+ continue
763
+ updates[(session["name"], target["started_at"])] = {
764
+ "usage": usage,
765
+ "usage_cumulative": usage,
766
+ "usage_model": model,
767
+ "provider_transcript_path": path,
768
+ "provider_transcript_match": match,
769
+ "usage_backfilled_reason": "exact_current_conversation",
770
+ }
771
+ return store["backfill_launch_usage"](updates, dry_run=dry_run)
772
+
773
+
774
+ def refresh_interactive_usage_models(store, dry_run=True):
775
+ """Replace a placeholder model with the exact transcript's real model."""
776
+ from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
777
+
778
+ updates = {}
779
+ for session in store["list_sessions"]():
780
+ if session.get("provider") != PROVIDER_CLAUDE:
781
+ continue
782
+ identifier = ((session.get("conversation") or {}).get("id"))
783
+ if not identifier:
784
+ continue
785
+ _usage, path, match, model = extract_interactive_usage(
786
+ PROVIDER_CLAUDE, session.get("authHome"), conversation_id=identifier
787
+ )
788
+ if not path or match != MATCH_CONVERSATION_ID or not model or model.startswith("<"):
789
+ continue
790
+ for entry in store["list_launch_history"](session_name=session["name"], limit=0):
791
+ if entry.get("provider_transcript_path") == path and str(entry.get("usage_model") or "").startswith("<"):
792
+ updates[(session["name"], entry.get("started_at"))] = {"usage_model": model}
793
+ return store["backfill_launch_usage"](updates, dry_run=dry_run)
794
+
795
+
730
796
  def start_session_runtime(store, name, payload=None):
731
797
  session = store["get_session"](name)
732
798
  if not session:
@@ -803,6 +869,9 @@ def launch_session(store, name, resume=False):
803
869
  raise CdxError(f"Unknown session: {name}")
804
870
  if session.get("enabled", True) is False:
805
871
  raise CdxError(f"Session is disabled: {name}")
872
+ # Reconcile the prior Claude conversation before a fresh launch replaces
873
+ # its id. This is a single exact transcript lookup, not a global stats scan.
874
+ backfill_interactive_usage(store, name=name, dry_run=False)
806
875
  now = _local_now_iso()
807
876
 
808
877
  def state_updater(state):
@@ -921,6 +990,8 @@ def create_session_service(options=None):
921
990
  "record_launch_history": partial(record_launch_history, store),
922
991
  "get_launch_history": partial(get_launch_history, store),
923
992
  "drop_unvouched_usage": store["drop_unvouched_usage"],
993
+ "backfill_interactive_usage": partial(backfill_interactive_usage, store),
994
+ "refresh_interactive_usage_models": partial(refresh_interactive_usage_models, store),
924
995
  "update_auth_state": partial(update_auth_state, store),
925
996
  "get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
926
997
  "get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
@@ -333,6 +333,37 @@ def create_session_store(base_dir):
333
333
  _fsync_directory(state_dir)
334
334
  return {"scanned": scanned, "dropped": dropped, "dry_run": dry_run}
335
335
 
336
+ def backfill_launch_usage(updates, dry_run=True):
337
+ """Apply pre-validated usage updates to otherwise empty history rows."""
338
+ with _file_lock(lock_file):
339
+ try:
340
+ with open(launch_history_file, encoding="utf-8") as handle:
341
+ lines = handle.readlines()
342
+ except FileNotFoundError:
343
+ return {"scanned": 0, "backfilled": 0, "dry_run": dry_run}
344
+ scanned = backfilled = 0
345
+ rewritten = []
346
+ for line in lines:
347
+ stripped = line.strip()
348
+ if not stripped:
349
+ continue
350
+ try:
351
+ entry = json.loads(stripped)
352
+ except ValueError:
353
+ rewritten.append(line)
354
+ continue
355
+ scanned += 1
356
+ update = updates.get((entry.get("session_name"), entry.get("started_at")))
357
+ if update and (not entry.get("usage") or str(entry.get("usage_model") or "").startswith("<")):
358
+ entry.update(update)
359
+ line = json.dumps(entry, separators=(",", ":")) + "\n"
360
+ backfilled += 1
361
+ rewritten.append(line)
362
+ if not dry_run and backfilled:
363
+ atomic_write(launch_history_file, "".join(rewritten))
364
+ _fsync_directory(state_dir)
365
+ return {"scanned": scanned, "backfilled": backfilled, "dry_run": dry_run}
366
+
336
367
  def list_launch_history(session_name=None, limit=20):
337
368
  with _file_lock(lock_file):
338
369
  try:
@@ -369,5 +400,6 @@ def create_session_store(base_dir):
369
400
  "update_session_state": update_session_state,
370
401
  "append_launch_history": append_launch_history,
371
402
  "drop_unvouched_usage": drop_unvouched_usage,
403
+ "backfill_launch_usage": backfill_launch_usage,
372
404
  "list_launch_history": list_launch_history,
373
405
  }