cdx-manager 0.20.1 → 0.20.3

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.1-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.3-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.1
1
+ 0.20.3
@@ -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.
@@ -0,0 +1,23 @@
1
+ # CDX Manager 0.20.3
2
+
3
+ ## Fixes
4
+
5
+ - Playwright, npm, pip, and uv now share one cache under `<CDX_HOME>/shared-cache`
6
+ instead of each isolated profile re-downloading its own copy, since profile
7
+ isolation redirects `HOME` per profile and those tools resolve their cache
8
+ dir from it.
9
+ - A session now prints a short line when it ends, cleanly or by Ctrl-C/a
10
+ forwarded signal: the session name, how long it ran, and this run's own
11
+ cost (or token count when the model isn't priced). A genuine provider
12
+ crash still gets no line.
13
+
14
+ ## Performance
15
+
16
+ - `cdx status --refresh` is faster: ~2.5s → ~2.2s on a 15-profile fleet,
17
+ verified to return identical data before and after. The Claude auth probe
18
+ now runs concurrently across sessions instead of one at a time, and no
19
+ longer spawns `claude auth status` twice per session (the probe and the
20
+ usage refresh were each independently running the exact same command).
21
+ - `cdx disk profiles --candidates` is faster too: ~5.1s → ~4.6s, same
22
+ before/after verification. Per-profile `du` calls now run concurrently
23
+ instead of one profile at a time.
@@ -320,6 +320,28 @@
320
320
  "x86_64-pc-windows-msvc": "e88d296e706ae90624a1f82bf14d36d67787d5f73bbb04986c1ca82293178c68",
321
321
  "x86_64-unknown-linux-musl": "cf8ec3f5a56bf50ba42d2dfa44f05ecc4471c5a5a819ca8b5180c6d9eff75ff1"
322
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
+ }
334
+ },
335
+ "v0.20.2": {
336
+ "github_tarball_sha256": "aeadbd180438862b7b1c0cdb31aece5c3ddbfbf2f20631733ffc48e1fd0a9fb7",
337
+ "github_zip_sha256": "79043ee3366f4e3006a51038d011416d510acd29e1cb35038519a3c4ff91571f",
338
+ "tray_assets": {
339
+ "aarch64-apple-darwin": "3da1c5b786fffe757e2d763cf36a5bf6dd9baf6bec06c3f4ba1d521a9919fbe9",
340
+ "aarch64-unknown-linux-musl": "8f9b0181b95d010ea3a13154d0a41fe150e32982847e43a3bba6c5674a8dea6e",
341
+ "x86_64-apple-darwin": "e35682db7adc4c6b4e2a9c112124411726f522423180c3a753bfb8aa7aa1008a",
342
+ "x86_64-pc-windows-msvc": "5ceff82ecd907fda949dfc27835112ad9e3597da78191a0ede4f7d9db82607a8",
343
+ "x86_64-unknown-linux-musl": "db70bb77f08fd954a7378561954d00ad3084b4ada99a4930aa8f021978439e65"
344
+ }
323
345
  }
324
346
  }
325
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
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.1"
7
+ version = "0.20.3"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -30,8 +30,9 @@ def _is_stale(session, now=None, ttl_seconds=CLAUDE_REFRESH_TTL_SECONDS):
30
30
  return (now - updated_at.astimezone(now.tzinfo)).total_seconds() >= ttl_seconds
31
31
 
32
32
 
33
- def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=False, ttl_seconds=CLAUDE_REFRESH_TTL_SECONDS):
33
+ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=False, ttl_seconds=CLAUDE_REFRESH_TTL_SECONDS, already_auth_refreshed=None):
34
34
  refresh_fn = refresh_fn or refresh_claude_session_status
35
+ already_auth_refreshed = already_auth_refreshed or set()
35
36
  target_names = set(target_names or [])
36
37
  sessions = service["list_sessions"]()
37
38
  claude_sessions = [
@@ -52,7 +53,17 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
52
53
 
53
54
  def fetch(s):
54
55
  try:
55
- usage = refresh_fn(s)
56
+ # The auth-probe phase right before this one already ran
57
+ # `claude auth status` for this session (see
58
+ # commands/status.py:_refresh_claude_auth_states), which is the
59
+ # exact side effect this refresh needs before reading
60
+ # credentials from disk. Skip re-running it - but only when
61
+ # refresh_fn is the real default, since a caller-injected
62
+ # refresh_fn (tests, mocks) may not accept auth_refresher.
63
+ if refresh_fn is refresh_claude_session_status and s["name"] in already_auth_refreshed:
64
+ usage = refresh_fn(s, auth_refresher=lambda _auth_home: None)
65
+ else:
66
+ usage = refresh_fn(s)
56
67
  if inspect.isawaitable(usage):
57
68
  raise CdxError("Claude refresh function returned an awaitable from a sync callable.")
58
69
  if usage:
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
@@ -26,7 +26,14 @@ from ..config import PROVIDER_CODEX
26
26
  from ..context_store import install_context_for_session, write_context
27
27
  from ..errors import CdxError
28
28
  from ..interactive_usage import extract_interactive_usage, transcript_predates_run, usage_delta
29
- from ..provider_runtime import _conversation_id, _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
29
+ from ..provider_runtime import (
30
+ INTERRUPT_EXIT_CODES,
31
+ _conversation_id,
32
+ _ensure_session_authentication,
33
+ _get_auth_home,
34
+ _run_interactive_provider_command,
35
+ )
36
+ from ..run_usage import estimate_cost
30
37
 
31
38
 
32
39
  def _parse_launch_args(args):
@@ -268,6 +275,8 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
268
275
  run_info = _attach_interactive_usage(
269
276
  session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
270
277
  )
278
+ if not json_flag and error.exit_code in INTERRUPT_EXIT_CODES:
279
+ ctx["out"](f"{_goodbye_line(session, run_info, ctx['use_color'])}\n")
271
280
  if runtime_run_id:
272
281
  ctx["service"]["finish_session_runtime"](
273
282
  session["name"],
@@ -283,15 +292,18 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
283
292
  **run_info,
284
293
  })
285
294
  raise
295
+ success_run_info = _attach_interactive_usage(
296
+ session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
297
+ )
286
298
  ctx["service"]["record_launch_history"](session["name"], {
287
299
  "status": "success",
288
300
  "action": "resume" if resume else "launch",
289
301
  "cwd": cwd,
290
302
  "exit_code": 0,
291
- **_attach_interactive_usage(
292
- session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
293
- ),
303
+ **success_run_info,
294
304
  })
305
+ if not json_flag:
306
+ ctx["out"](f"{_goodbye_line(session, success_run_info, ctx['use_color'])}\n")
295
307
  if json_flag:
296
308
  extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
297
309
  if capability:
@@ -300,6 +312,29 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
300
312
  return 0
301
313
 
302
314
 
315
+ def _goodbye_line(session, run_info, use_color):
316
+ """One line printed when a session ends: cleanly, or by SIGINT/SIGTERM/SIGHUP.
317
+
318
+ Not printed for a genuine provider crash (a nonzero exit that isn't one
319
+ of those signals) - that path already gets its own error message.
320
+
321
+ Local import: `commands.status` imports `handle_launch` from this module,
322
+ so importing it back at module scope would be circular.
323
+ """
324
+ from .status import _format_duration_ms, _format_token_count, _format_usd
325
+
326
+ parts = [f"Session {session['name']} ended", _format_duration_ms(run_info.get("duration_ms"))]
327
+ usage = run_info.get("usage")
328
+ cost = estimate_cost(usage, run_info.get("usage_model"))
329
+ if cost is not None:
330
+ parts.append(_format_usd(cost))
331
+ elif isinstance(usage, dict):
332
+ total_tokens = sum(v for v in usage.values() if isinstance(v, (int, float)))
333
+ if total_tokens:
334
+ parts.append(f"{_format_token_count(total_tokens)} tokens")
335
+ return _dim(" · ".join(parts), use_color)
336
+
337
+
303
338
  def _attach_interactive_usage(session, run_info, history=None):
304
339
  """Attach this run's own token usage without affecting launch outcome.
305
340
 
@@ -9,6 +9,7 @@ import shlex
9
9
  import shutil
10
10
  import subprocess
11
11
  import sys
12
+ import threading
12
13
  import time
13
14
  from datetime import datetime
14
15
 
@@ -83,20 +84,35 @@ def _directory_size_bytes(path, runner=None):
83
84
  return total
84
85
 
85
86
  def _directory_child_sizes(path, runner=None, progress=None):
86
- rows = []
87
87
  try:
88
88
  names = os.listdir(path)
89
89
  except OSError:
90
- return rows
90
+ return []
91
91
  children = [(name, os.path.join(path, name)) for name in names if os.path.isdir(os.path.join(path, name))]
92
- for index, (name, child_path) in enumerate(children, start=1):
92
+ # `du` per profile is I/O-bound (a subprocess spawn + directory walk), so
93
+ # one profile's measurement doesn't block the next - same reasoning as
94
+ # the auth-probe fix in commands/status.py. Progress is announced from
95
+ # the main thread, in listing order, before dispatch: du itself only
96
+ # runs concurrently, so the printed order still matches `len(children)`.
97
+ sizes = [None] * len(children)
98
+ threads = []
99
+
100
+ def measure(index, child_path):
101
+ sizes[index] = _directory_size_bytes(child_path, runner=runner)
102
+
103
+ for index, (name, child_path) in enumerate(children):
93
104
  if progress:
94
- progress("profile", name, index, len(children))
95
- rows.append({
96
- "name": name,
97
- "path": child_path,
98
- "bytes": _directory_size_bytes(child_path, runner=runner),
99
- })
105
+ progress("profile", name, index + 1, len(children))
106
+ t = threading.Thread(target=measure, args=(index, child_path), daemon=True)
107
+ threads.append(t)
108
+ t.start()
109
+ for t in threads:
110
+ t.join()
111
+
112
+ rows = [
113
+ {"name": name, "path": child_path, "bytes": sizes[index]}
114
+ for index, (name, child_path) in enumerate(children)
115
+ ]
100
116
  rows.sort(key=lambda row: row["bytes"], reverse=True)
101
117
  for row in rows:
102
118
  row["size"] = _format_bytes(row["bytes"])
@@ -170,11 +186,15 @@ def _collect_old_logs(profile_name, profile_path, days, now=None):
170
186
  )
171
187
 
172
188
  def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=None, now=None, progress=None):
173
- candidates = []
174
189
  profiles = list(_iter_profile_dirs(base_dir))
175
- for index, (profile_name, profile_path) in enumerate(profiles, start=1):
176
- if progress:
177
- progress("candidates", profile_name, index, len(profiles))
190
+ # Same reasoning as _directory_child_sizes: each profile's du calls and
191
+ # log walk are independent I/O, so profiles scan concurrently. Each
192
+ # thread only appends to its own slot in `per_profile`, never a shared list.
193
+ per_profile = [None] * len(profiles)
194
+ threads = []
195
+
196
+ def scan(index, profile_name, profile_path):
197
+ local = []
178
198
  tmp_dir = os.path.join(profile_path, ".tmp")
179
199
  tmp_targets = [
180
200
  ("tmp-marketplaces", os.path.join(tmp_dir, "marketplaces"), "temporary marketplace cache/staging"),
@@ -198,7 +218,7 @@ def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=Non
198
218
  mtime = datetime.fromtimestamp(os.path.getmtime(path)).isoformat()
199
219
  except OSError:
200
220
  mtime = None
201
- candidates.append(_candidate(
221
+ local.append(_candidate(
202
222
  profile_name,
203
223
  kind,
204
224
  path,
@@ -208,7 +228,19 @@ def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=Non
208
228
  ))
209
229
  old_logs = _collect_old_logs(profile_name, profile_path, old_log_days, now=now)
210
230
  if old_logs and old_logs["bytes"] > 0:
211
- candidates.append(old_logs)
231
+ local.append(old_logs)
232
+ per_profile[index] = local
233
+
234
+ for index, (profile_name, profile_path) in enumerate(profiles):
235
+ if progress:
236
+ progress("candidates", profile_name, index + 1, len(profiles))
237
+ t = threading.Thread(target=scan, args=(index, profile_name, profile_path), daemon=True)
238
+ threads.append(t)
239
+ t.start()
240
+ for t in threads:
241
+ t.join()
242
+
243
+ candidates = [item for local in per_profile for item in (local or [])]
212
244
  candidates.sort(key=lambda item: item["bytes"], reverse=True)
213
245
  return candidates
214
246
 
@@ -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 threading
7
8
  import time
8
9
  from datetime import datetime
9
10
 
@@ -40,7 +41,14 @@ from ..cli_render import _dim, _info, _pad_table, _style, _warn
40
41
  from ..commands.launch import handle_launch
41
42
  from ..config import PROVIDER_CLAUDE
42
43
  from ..errors import CdxError
43
- from ..provider_runtime import AUTH_PROBE_AUTHENTICATED, AUTH_PROBE_DEGRADED, _probe_provider_auth_status
44
+ from ..provider_runtime import (
45
+ AUTH_PROBE_AUTHENTICATED,
46
+ AUTH_PROBE_DEGRADED,
47
+ AUTH_PROBE_TIMEOUT_SECONDS,
48
+ _get_auth_home,
49
+ _has_local_claude_auth,
50
+ _probe_provider_auth_status,
51
+ )
44
52
  from ..run_usage import (
45
53
  USAGE_KEYS,
46
54
  estimate_cost,
@@ -692,6 +700,7 @@ def handle_status(rest, ctx):
692
700
  ctx.get("refresh_fn"),
693
701
  target_names=args if len(args) == 1 else None,
694
702
  force=parsed["refresh"],
703
+ already_auth_refreshed=auth_refresh.get("probed"),
695
704
  )
696
705
  )
697
706
  refresh_errors = [
@@ -746,40 +755,84 @@ def handle_status(rest, ctx):
746
755
 
747
756
  def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
748
757
  target_names = set(target_names or [])
758
+ candidates = [
759
+ session for session in service["list_sessions"]()
760
+ if session["provider"] == PROVIDER_CLAUDE
761
+ and session.get("enabled", True) is not False
762
+ and (not target_names or session["name"] in target_names)
763
+ and (session.get("auth") or {}).get("status") in ("authenticated", "logged_out")
764
+ ]
765
+ if not candidates:
766
+ return {"updated": [], "errors": [], "probed": set()}
767
+
749
768
  errors = []
750
- updated = []
751
- for session in service["list_sessions"]():
752
- if session["provider"] != PROVIDER_CLAUDE:
753
- continue
754
- if session.get("enabled", True) is False:
755
- continue
756
- if target_names and session["name"] not in target_names:
757
- continue
758
- if (session.get("auth") or {}).get("status") not in ("authenticated", "logged_out"):
759
- continue
769
+ results = {}
770
+ threads = []
771
+ # Sessions without a local credentials file are the ones whose probe
772
+ # below actually spawns `claude auth status` (see _probe_provider_auth_status);
773
+ # the Claude usage refresh right after this call spawns that exact same
774
+ # command again to refresh its own on-disk copy before reading it. Track
775
+ # them so the caller can skip that second, redundant spawn.
776
+ probed = {
777
+ session["name"] for session in candidates
778
+ if not _has_local_claude_auth(_get_auth_home(session))
779
+ }
780
+
781
+ # The probe itself is the slow part (a subprocess spawn per session on a
782
+ # cache miss); it doesn't touch shared state, so it's safe to run
783
+ # concurrently, same as the sibling Claude usage refresh right after this
784
+ # call. Side effects (service["update_auth_state"]) still run serially,
785
+ # after every thread has joined.
786
+ def probe(session):
760
787
  try:
761
- auth_status = _probe_provider_auth_status(
788
+ results[session["name"]] = _probe_provider_auth_status(
762
789
  session,
763
790
  spawn_sync=spawn_sync,
764
791
  env_override=env_override,
765
792
  )
766
- if auth_status == AUTH_PROBE_DEGRADED:
767
- errors.append({
768
- "session": session["name"],
769
- "error": "Auth probe timed out; authentication status is degraded.",
770
- })
771
- continue
772
- now = _local_now_iso()
773
- service["update_auth_state"](session["name"], lambda auth, auth_status=auth_status, now=now: {
774
- **auth,
775
- "status": "authenticated" if auth_status == AUTH_PROBE_AUTHENTICATED else "logged_out",
776
- "lastCheckedAt": now,
777
- **({"lastAuthenticatedAt": now} if auth_status == AUTH_PROBE_AUTHENTICATED else {}),
778
- })
779
- updated.append(session["name"])
780
793
  except Exception as error:
781
794
  errors.append({"session": session["name"], "error": str(error)})
782
- return {"updated": updated, "errors": errors}
795
+
796
+ for session in candidates:
797
+ t = threading.Thread(target=probe, args=(session,), daemon=True)
798
+ threads.append(t)
799
+ t.start()
800
+ for t in threads:
801
+ t.join(timeout=AUTH_PROBE_TIMEOUT_SECONDS + 1)
802
+
803
+ results = dict(results)
804
+ errors = list(errors)
805
+
806
+ updated = []
807
+ now = _local_now_iso()
808
+ for session in candidates:
809
+ auth_status = results.get(session["name"])
810
+ if auth_status is None:
811
+ continue
812
+ if auth_status == AUTH_PROBE_DEGRADED:
813
+ errors.append({
814
+ "session": session["name"],
815
+ "error": "Auth probe timed out; authentication status is degraded.",
816
+ })
817
+ # A timed-out probe never finished running `claude auth status`,
818
+ # so its on-disk credentials aren't necessarily fresh - don't
819
+ # tell the caller this session's auth refresh can be skipped.
820
+ probed.discard(session["name"])
821
+ continue
822
+ service["update_auth_state"](session["name"], lambda auth, auth_status=auth_status, now=now: {
823
+ **auth,
824
+ "status": "authenticated" if auth_status == AUTH_PROBE_AUTHENTICATED else "logged_out",
825
+ "lastCheckedAt": now,
826
+ **({"lastAuthenticatedAt": now} if auth_status == AUTH_PROBE_AUTHENTICATED else {}),
827
+ })
828
+ updated.append(session["name"])
829
+ for session in candidates:
830
+ if session["name"] not in results:
831
+ # Probe raised/never completed for this session (exception or a
832
+ # join() that outran an unresponsive thread); same reasoning as
833
+ # the degraded case above.
834
+ probed.discard(session["name"])
835
+ return {"updated": updated, "errors": errors, "probed": probed}
783
836
 
784
837
  def _rows_by_session(rows):
785
838
  return {
@@ -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
@@ -20,6 +20,7 @@ from .config import (
20
20
  PROVIDER_CODEX,
21
21
  PROVIDER_OLLAMA,
22
22
  REASONING_EFFORT_VALUES,
23
+ get_cdx_home,
23
24
  split_extra_args,
24
25
  )
25
26
  from .errors import CdxError
@@ -158,6 +159,25 @@ def _home_env_overrides(auth_home):
158
159
  return overrides
159
160
 
160
161
 
162
+ def _shared_tool_cache_overrides(env):
163
+ """Pin tool download/build caches to one shared dir, outside any profile home.
164
+
165
+ Profile isolation redirects HOME to a per-profile auth_home (see
166
+ `_home_env_overrides`), and several dev tools resolve their cache dir from
167
+ HOME. Left alone, that means every profile re-downloads its own copy of
168
+ Playwright's browsers, npm/pip/uv packages, etc. `env` here is the ambient
169
+ env, before HOME gets swapped, so `get_cdx_home(env)` resolves the same
170
+ real per-user location regardless of which profile is launching.
171
+ """
172
+ shared_cache = os.path.join(get_cdx_home(env), "shared-cache")
173
+ return {
174
+ "PLAYWRIGHT_BROWSERS_PATH": os.path.join(shared_cache, "playwright"),
175
+ "NPM_CONFIG_CACHE": os.path.join(shared_cache, "npm"),
176
+ "PIP_CACHE_DIR": os.path.join(shared_cache, "pip"),
177
+ "UV_CACHE_DIR": os.path.join(shared_cache, "uv"),
178
+ }
179
+
180
+
161
181
  def _anthropic_profile_name():
162
182
  return "default"
163
183
 
@@ -562,6 +582,8 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
562
582
  cwd = cwd or os.getcwd()
563
583
  env_override = env_override or {}
564
584
  env = {**os.environ, **env_override}
585
+ for key, value in _shared_tool_cache_overrides(env).items():
586
+ env.setdefault(key, value)
565
587
  env.update(launch_notify_env(session, notifications_enabled(session)))
566
588
  initial_prompt = _with_launch_preferences(session, initial_prompt, env=env)
567
589
  _validate_initial_prompt(initial_prompt)
@@ -1190,11 +1212,17 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_loca
1190
1212
  ) == AUTH_PROBE_AUTHENTICATED
1191
1213
 
1192
1214
 
1215
+ _SIGNAL_EXIT_CODES = {signal.SIGINT: 130, signal.SIGTERM: 143}
1216
+ if hasattr(signal, "SIGHUP"):
1217
+ _SIGNAL_EXIT_CODES[signal.SIGHUP] = 129
1218
+
1219
+ # The exit codes above, on their own: what a caller checks to tell "the user
1220
+ # killed this session" apart from "the provider process crashed on its own".
1221
+ INTERRUPT_EXIT_CODES = frozenset(_SIGNAL_EXIT_CODES.values())
1222
+
1223
+
1193
1224
  def _signal_exit_code(sig):
1194
- mapping = {signal.SIGINT: 130, signal.SIGTERM: 143}
1195
- if hasattr(signal, "SIGHUP"):
1196
- mapping[signal.SIGHUP] = 129
1197
- return mapping.get(sig, 1)
1225
+ return _SIGNAL_EXIT_CODES.get(sig, 1)
1198
1226
 
1199
1227
 
1200
1228
  def _signal_name(sig):
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
  }