cdx-manager 0.19.1 → 0.19.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.19.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.19.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
 
@@ -112,7 +112,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
112
112
  ### Maintenance
113
113
 
114
114
  - **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
115
- - **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period.
115
+ - **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period. Interactive Codex and Claude launches also record provider-native token totals when their local session transcript is available.
116
116
 
117
117
  - **Disk usage and cleanup.** `cdx disk` reports `CDX_HOME` usage, `cdx disk profiles --candidates` identifies reclaimable profile caches/logs with evidence, and `cdx clean profiles ...` applies explicit cleanup actions.
118
118
  - **Health and repair.** `cdx doctor` inspects dependencies, permissions, and orphan profiles; `cdx repair` plans or applies safe fixes.
@@ -708,7 +708,7 @@ cdx run \
708
708
 
709
709
  The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
710
710
 
711
- Known headless token usage is persisted in launch history and can be aggregated later:
711
+ Known headless and completed interactive token usage is persisted in launch history and can be aggregated later. Interactive records are best-effort local data: Codex reads its latest cumulative rollout snapshot and Claude reads its assistant usage records. Cached input is retained separately from the provider's total, so it is never counted twice.
712
712
 
713
713
  ```bash
714
714
  cdx stats --since 7d --json
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.19.1
1
+ 0.19.3
@@ -0,0 +1,11 @@
1
+ # CDX Manager 0.19.2
2
+
3
+ ## Features
4
+
5
+ - Record Codex and Claude interactive CLI token usage, including cached input tokens, in existing run history and status statistics.
6
+
7
+ ## Fixes
8
+
9
+ - Make detached-run registry persistence durable and lock acquisition bounded on POSIX and Windows.
10
+ - Bound interactive transcript discovery and parsing while preserving fail-open behavior.
11
+ - Verify tray tests, macOS CI coverage, and installable npm/wheel package artifacts before release.
@@ -0,0 +1,6 @@
1
+ # CDX Manager 0.19.3
2
+
3
+ ## Fixes
4
+
5
+ - Make release-validation tests portable across Windows, macOS, and Linux.
6
+ - Includes all reliability, interactive token-usage, CI, and package-verification changes prepared for 0.19.2.
@@ -283,6 +283,21 @@
283
283
  "v0.19.0": {
284
284
  "github_tarball_sha256": "d9816c0e4a2313bc10d8d9b527795b3027798607af49c5ef820f491d167176b4",
285
285
  "github_zip_sha256": "656090f68694695fd947b0e73fc8c1ef66785054fd04054f8659b03997daedfb"
286
+ },
287
+ "v0.19.1": {
288
+ "github_tarball_sha256": "351b8840a1dc073a91d198c2d565d9666f9ea7a8d545bac6a01e31ae41aec3ca",
289
+ "github_zip_sha256": "7e74a60fdf20b66faec25d11a8b8780fe4c1fad4a62b24f2395ea7d3667549ae",
290
+ "tray_assets": {
291
+ "aarch64-apple-darwin": "fd07386f7d7d8528927980929a260f3816b050a8d985186a18dae0ce8563af56",
292
+ "aarch64-unknown-linux-musl": "ca963298f96bb125892472cbcb125513f11941cb29258ad2b1ea3b9fb441ff74",
293
+ "x86_64-apple-darwin": "b22601c1c203a17f143ce83e19c5a4c54c546ea52f927f2ad8042ce5aa57a16c",
294
+ "x86_64-pc-windows-msvc": "7d5b01ab05a83c5e5a18f515a7010b799c19b3b6b6374ed39d8fa386ea66892b",
295
+ "x86_64-unknown-linux-musl": "2b318774e1ddccda5e070475042c395c6c90dda3ec89a6cf6498ba11dbdc04e8"
296
+ }
297
+ },
298
+ "v0.19.2": {
299
+ "github_tarball_sha256": "d644666c727b2a29fa022700da1c23483173a2110606b994d200f72feb959c3b",
300
+ "github_zip_sha256": "bca42637ddab4502e2dd6d9d63239eca322d953f8d910d8ada209fcfdd2f98d4"
286
301
  }
287
302
  }
288
303
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.19.1",
3
+ "version": "0.19.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.19.1"
7
+ version = "0.19.3"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -25,6 +25,7 @@ from ..cli_render import _dim, _info, _success, _warn
25
25
  from ..config import PROVIDER_CODEX
26
26
  from ..context_store import install_context_for_session, write_context
27
27
  from ..errors import CdxError
28
+ from ..interactive_usage import extract_interactive_usage
28
29
  from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
29
30
 
30
31
 
@@ -264,6 +265,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
264
265
  )
265
266
  except CdxError as error:
266
267
  run_info = getattr(error, "run_info", {}) or {}
268
+ _attach_interactive_usage(session, run_info)
267
269
  if runtime_run_id:
268
270
  ctx["service"]["finish_session_runtime"](
269
271
  session["name"],
@@ -284,7 +286,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
284
286
  "action": "resume" if resume else "launch",
285
287
  "cwd": cwd,
286
288
  "exit_code": 0,
287
- **run_info,
289
+ **_attach_interactive_usage(session, run_info),
288
290
  })
289
291
  if json_flag:
290
292
  extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
@@ -293,6 +295,19 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
293
295
  _write_json(ctx, _json_success("resume" if resume else "launch", message, warnings=warnings, **extra))
294
296
  return 0
295
297
 
298
+
299
+ def _attach_interactive_usage(session, run_info):
300
+ """Attach best-effort local provider usage without affecting launch outcome."""
301
+ run_info = dict(run_info or {})
302
+ usage, provider_transcript = extract_interactive_usage(
303
+ session.get("provider"), _get_auth_home(session), run_info.get("started_at")
304
+ )
305
+ if usage:
306
+ run_info["usage"] = usage
307
+ if provider_transcript:
308
+ run_info["provider_transcript_path"] = provider_transcript
309
+ return run_info
310
+
296
311
  def handle_handoff(rest, ctx):
297
312
  json_flag, args = _parse_json_flag(rest)
298
313
  if len(args) not in (1, 2):
@@ -253,6 +253,7 @@ def _summarize_stats(entries):
253
253
  "duration_ms": 0,
254
254
  "usage_runs": 0,
255
255
  "input_tokens": 0,
256
+ "cached_input_tokens": 0,
256
257
  "output_tokens": 0,
257
258
  "reasoning_tokens": 0,
258
259
  "total_tokens": 0,
@@ -270,7 +271,7 @@ def _summarize_stats(entries):
270
271
  usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
271
272
  parsed_usage = {
272
273
  key: _token_value(usage, key)
273
- for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
274
+ for key in ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
274
275
  }
275
276
  if any(value is not None for value in parsed_usage.values()):
276
277
  row["usage_runs"] += 1
@@ -295,6 +296,7 @@ def _stats_totals(rows):
295
296
  "duration_ms": sum(row["duration_ms"] for row in rows),
296
297
  "usage_runs": sum(row["usage_runs"] for row in rows),
297
298
  "input_tokens": sum(row["input_tokens"] for row in rows),
299
+ "cached_input_tokens": sum(row["cached_input_tokens"] for row in rows),
298
300
  "output_tokens": sum(row["output_tokens"] for row in rows),
299
301
  "reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
300
302
  "total_tokens": sum(row["total_tokens"] for row in rows),
@@ -389,7 +391,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
389
391
  if not rows:
390
392
  return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
391
393
  table = [[_style(value, "1", use_color) for value in [
392
- "SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"
394
+ "SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "TIME", "LAST"
393
395
  ]]]
394
396
  for row in rows:
395
397
  session_name = row["session_name"]
@@ -400,6 +402,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
400
402
  _style(str(row["launches"]), "1", use_color),
401
403
  _style(str(row["usage_runs"]), "32" if row["usage_runs"] else "2", use_color),
402
404
  _style(_format_token_count(row["input_tokens"]), "96" if row["input_tokens"] else "2", use_color),
405
+ _style(_format_token_count(row["cached_input_tokens"]), "96" if row["cached_input_tokens"] else "2", use_color),
403
406
  _style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
404
407
  _style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
405
408
  _style(_format_token_count(row["total_tokens"]), "1;96" if row["total_tokens"] else "2", use_color),
package/src/fs_utils.py CHANGED
@@ -28,6 +28,15 @@ def atomic_write(path, data, mode=None):
28
28
  if mode is not None and os.name != "nt":
29
29
  os.chmod(temp_path, mode)
30
30
  os.replace(temp_path, path)
31
+ if os.name != "nt":
32
+ try:
33
+ directory_fd = os.open(directory, os.O_RDONLY)
34
+ try:
35
+ os.fsync(directory_fd)
36
+ finally:
37
+ os.close(directory_fd)
38
+ except OSError:
39
+ pass
31
40
  except Exception:
32
41
  try:
33
42
  os.unlink(temp_path)
@@ -0,0 +1,124 @@
1
+ """Best-effort usage readers for interactive provider sessions."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+
7
+ MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024
8
+ MAX_TRANSCRIPT_CANDIDATES = 1000
9
+ MAX_TRANSCRIPT_SCAN_SECONDS = 1
10
+
11
+ def extract_interactive_usage(provider, auth_home, started_at=None):
12
+ """Return the newest provider-native usage after an interactive session.
13
+
14
+ Provider transcripts are not stable public APIs. A missing or changed
15
+ record is therefore ordinary absence, never a launch failure.
16
+ """
17
+ path = _latest_transcript(provider, auth_home, started_at)
18
+ if not path:
19
+ return None, None
20
+ try:
21
+ if os.path.getsize(path) > MAX_TRANSCRIPT_BYTES:
22
+ return None, path
23
+ with open(path, encoding="utf-8", errors="replace") as handle:
24
+ return (_codex_usage(handle) if provider == "codex" else _claude_usage(handle)), path
25
+ except OSError:
26
+ return None, path
27
+
28
+
29
+ def _latest_transcript(provider, auth_home, started_at):
30
+ if not auth_home:
31
+ return None
32
+ root = os.path.join(auth_home, "sessions") if provider == "codex" else os.path.join(auth_home, ".claude", "projects")
33
+ if not os.path.isdir(root):
34
+ return None
35
+ cutoff = _timestamp(started_at)
36
+ candidates = []
37
+ deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
38
+ for directory, subdirs, names in os.walk(root):
39
+ if time.monotonic() >= deadline:
40
+ return max(candidates, default=(None, None))[1]
41
+ if provider == "claude":
42
+ subdirs[:] = [name for name in subdirs if name != "subagents"]
43
+ for name in names:
44
+ if not name.endswith(".jsonl"):
45
+ continue
46
+ path = os.path.join(directory, name)
47
+ try:
48
+ modified = os.path.getmtime(path)
49
+ except OSError:
50
+ continue
51
+ if cutoff is None or modified >= cutoff:
52
+ candidates.append((modified, path))
53
+ if len(candidates) > MAX_TRANSCRIPT_CANDIDATES:
54
+ return max(candidates)[1]
55
+ return max(candidates, default=(None, None))[1]
56
+
57
+
58
+ def _timestamp(value):
59
+ if not value:
60
+ return None
61
+ try:
62
+ from datetime import datetime
63
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
64
+ except ValueError:
65
+ return None
66
+
67
+
68
+ def _codex_usage(handle):
69
+ latest = None
70
+ for line in handle:
71
+ try:
72
+ record = json.loads(line)
73
+ payload = record.get("payload") or {}
74
+ usage = ((payload.get("info") or {}).get("total_token_usage") or {}) if payload.get("type") == "token_count" else {}
75
+ candidate = _normalize(usage, cached_key="cached_input_tokens", reasoning_key="reasoning_output_tokens")
76
+ if candidate:
77
+ latest = candidate
78
+ except (TypeError, ValueError):
79
+ continue
80
+ return latest
81
+
82
+
83
+ def _claude_usage(handle):
84
+ totals = {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0}
85
+ seen = set()
86
+ for line in handle:
87
+ try:
88
+ record = json.loads(line)
89
+ message = record.get("message") or {}
90
+ usage = message.get("usage") or {}
91
+ identifier = record.get("uuid")
92
+ if record.get("type") != "assistant" or not usage or (identifier and identifier in seen):
93
+ continue
94
+ if identifier:
95
+ seen.add(identifier)
96
+ totals["input_tokens"] += _number(usage.get("input_tokens"))
97
+ totals["cached_input_tokens"] += _number(usage.get("cache_creation_input_tokens")) + _number(usage.get("cache_read_input_tokens"))
98
+ totals["output_tokens"] += _number(usage.get("output_tokens"))
99
+ except (TypeError, ValueError):
100
+ continue
101
+ if not seen and not any(totals.values()):
102
+ return None
103
+ totals["reasoning_tokens"] = None
104
+ totals["total_tokens"] = totals["input_tokens"] + totals["output_tokens"]
105
+ return totals
106
+
107
+
108
+ def _normalize(usage, cached_key, reasoning_key):
109
+ if not isinstance(usage, dict) or not usage:
110
+ return None
111
+ return {
112
+ "input_tokens": _number(usage.get("input_tokens")),
113
+ "cached_input_tokens": _number(usage.get(cached_key)),
114
+ "output_tokens": _number(usage.get("output_tokens")),
115
+ "reasoning_tokens": _number(usage.get(reasoning_key)) or None,
116
+ "total_tokens": _number(usage.get("total_tokens")) or None,
117
+ }
118
+
119
+
120
+ def _number(value):
121
+ try:
122
+ return max(0, int(value or 0))
123
+ except (TypeError, ValueError):
124
+ return 0
@@ -1,12 +1,12 @@
1
1
  import json
2
2
  import os
3
3
  import sys
4
- import tempfile
5
4
  import time
6
5
  from contextlib import contextmanager
7
6
  from datetime import datetime, timezone
8
7
 
9
8
  from .errors import CdxError
9
+ from .fs_utils import atomic_write
10
10
 
11
11
 
12
12
  def utc_now_iso():
@@ -53,6 +53,25 @@ def _acquire_windows_lock(handle, timeout_seconds):
53
53
  time.sleep(_LOCK_RETRY_SECONDS)
54
54
 
55
55
 
56
+ def _acquire_posix_lock(handle, timeout_seconds):
57
+ """Acquire a POSIX advisory lock within the same bounded wait as Windows."""
58
+ import fcntl
59
+
60
+ deadline = time.monotonic() + timeout_seconds
61
+ while True:
62
+ try:
63
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
64
+ return
65
+ except OSError:
66
+ if time.monotonic() >= deadline:
67
+ raise CdxError(
68
+ "Timed out waiting for the run registry lock after "
69
+ f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
70
+ 75,
71
+ ) from None
72
+ time.sleep(_LOCK_RETRY_SECONDS)
73
+
74
+
56
75
  @contextmanager
57
76
  def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
58
77
  os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -67,7 +86,7 @@ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
67
86
  msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
68
87
  else:
69
88
  import fcntl
70
- fcntl.flock(handle, fcntl.LOCK_EX)
89
+ _acquire_posix_lock(handle, timeout_seconds)
71
90
  try:
72
91
  yield
73
92
  finally:
@@ -89,18 +108,7 @@ def _read_registry(path):
89
108
 
90
109
 
91
110
  def _write_registry(path, data):
92
- os.makedirs(os.path.dirname(path), exist_ok=True)
93
- fd, temp_path = tempfile.mkstemp(prefix=".runs-", suffix=".json", dir=os.path.dirname(path))
94
- try:
95
- with os.fdopen(fd, "w", encoding="utf-8") as handle:
96
- json.dump(data, handle, indent=2, sort_keys=True)
97
- handle.write("\n")
98
- os.replace(temp_path, path)
99
- finally:
100
- try:
101
- os.unlink(temp_path)
102
- except FileNotFoundError:
103
- pass
111
+ atomic_write(path, json.dumps(data, indent=2, sort_keys=True) + "\n", mode=0o600)
104
112
 
105
113
 
106
114
  def _is_pid_alive(pid):
package/src/run_usage.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import json
2
2
 
3
- USAGE_KEYS = ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
3
+ USAGE_KEYS = ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
4
4
  SUPPORTED_PROVIDERS = {"claude", "codex"}
5
5
 
6
6
 
@@ -89,6 +89,12 @@ def _usage_from_dict(value):
89
89
  usage.get("cache_creation_input_tokens"),
90
90
  usage.get("cache_read_input_tokens"),
91
91
  )
92
+ cached_input_tokens = _first_int(usage.get("cached_input_tokens"))
93
+ if cached_input_tokens is None:
94
+ cached_input_tokens = _int_value(
95
+ usage.get("cache_creation_input_tokens"),
96
+ usage.get("cache_read_input_tokens"),
97
+ )
92
98
  output_tokens = _int_value(usage.get("output_tokens"), usage.get("completion_tokens"))
93
99
  reasoning_tokens = _int_value(
94
100
  usage.get("reasoning_tokens"),
@@ -102,6 +108,7 @@ def _usage_from_dict(value):
102
108
 
103
109
  return {
104
110
  "input_tokens": input_tokens,
111
+ "cached_input_tokens": cached_input_tokens,
105
112
  "output_tokens": output_tokens,
106
113
  "reasoning_tokens": reasoning_tokens,
107
114
  "total_tokens": total_tokens,