cdx-manager 0.7.0 → 0.7.1

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
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.7.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.7.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -134,7 +134,7 @@ For a specific version:
134
134
 
135
135
  ```bash
136
136
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
137
- CDX_VERSION=v0.7.0 sh install.sh
137
+ CDX_VERSION=v0.7.1 sh install.sh
138
138
  ```
139
139
 
140
140
  From source:
@@ -437,7 +437,7 @@ cdx run \
437
437
  --json
438
438
  ```
439
439
 
440
- The result includes `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Token counts are `null` when the provider does not expose them.
440
+ 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.
441
441
 
442
442
  `cdx select` exposes the same session selection logic directly:
443
443
 
@@ -0,0 +1,41 @@
1
+ # Changelog (`0.7.0 -> 0.7.1`)
2
+
3
+ Release date: 2026-05-29
4
+
5
+ ## Headless Automation
6
+
7
+ - Switched `cdx run --json` Codex execution to the provider-native `codex exec --json` path so headless runs no longer depend on an interactive terminal launch mode.
8
+ - Switched Claude headless execution to `claude --print --output-format json` while preserving isolated auth homes, model selection, permission mode, and effort mapping.
9
+ - Preserved Codex unattended `auto` permission behavior in headless mode with an explicit `approval_policy="never"` config override.
10
+ - Kept provider stdout and stderr captured in artifact files while cdx stdout remains reserved for the final JSON payload.
11
+
12
+ ## JSON Contract
13
+
14
+ - Added top-level `launcher: "cdx"` to `cdx run --json` result payloads so subprocess callers can identify cdx-manager responses directly.
15
+ - Ensured `launcher: "cdx"` is also present when provider auto-selection cannot find a suitable session.
16
+ - Added conservative token usage extraction for supported Codex and Claude JSON or JSONL stdout artifact shapes.
17
+ - Preserved all-null token usage when artifacts are missing, malformed, unsupported, or do not expose trusted usage data.
18
+
19
+ ## Status Display
20
+
21
+ - Clamped Claude rate-limit percentage calculations to the `0..100` range so over-limit provider utilization cannot display negative remaining availability.
22
+ - Clamped cached status percentages defensively so already-persisted invalid values render as valid availability values.
23
+ - Colored fully depleted or invalid low availability as red and high availability above 80% as bright cyan.
24
+
25
+ ## Planning Docs
26
+
27
+ - Added and completed Logics request, backlog items, and tasks for Orchestia headless token usage and provider-native launch modes.
28
+
29
+ ## Release Metadata
30
+
31
+ - Updated package metadata, CLI version output, README badge, pinned installer example, and release changelog to `v0.7.1`.
32
+
33
+ ## Validation and Regression Evidence
34
+
35
+ - `python -m unittest discover -s test -p 'test_*_py.py'`
36
+ - `npm run lint`
37
+ - `npm test`
38
+ - `npm pack --dry-run`
39
+ - `python -m build`
40
+ - `python -m twine check dist/*`
41
+ - `python3 -m logics_manager lint --require-status`
@@ -28,6 +28,10 @@
28
28
  "v0.6.5": {
29
29
  "github_tarball_sha256": "f2280917ea75b5ae1e99a99b011f09704d89b1a7ae42497f5093e6fff9f814d9",
30
30
  "github_zip_sha256": "0b8491037310ed82cf44419d0de887bc0f768c6690c69153992fc4d5cda69676"
31
+ },
32
+ "v0.7.0": {
33
+ "github_tarball_sha256": "c80231884bf20c9ae74144a1ec16e0685c2fdac67d7d93a3c6219c5ac6fc14dd",
34
+ "github_zip_sha256": "0e6af29e59d3a93a07e07656d6c0c93803a674e65830ef33fa2322649b7139c2"
31
35
  }
32
36
  }
33
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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.7.0"
7
+ version = "0.7.1"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -87,6 +87,15 @@ def _format_reset_date(unix_seconds):
87
87
  return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
88
88
 
89
89
 
90
+ def _remaining_from_utilization(value):
91
+ if value is None:
92
+ return None
93
+ try:
94
+ return max(0, min(100, round((1 - float(value)) * 100)))
95
+ except (TypeError, ValueError):
96
+ return None
97
+
98
+
90
99
  def _read_http_error_message(error):
91
100
  try:
92
101
  body = error.read().decode("utf-8", errors="replace")
@@ -159,8 +168,8 @@ def fetch_claude_rate_limit_headers(access_token):
159
168
  reset_at = reset_week_at or reset_5h_at
160
169
 
161
170
  return {
162
- "remaining_5h_pct": round((1 - utilization_5h) * 100) if utilization_5h is not None else None,
163
- "remaining_week_pct": round((1 - utilization_7d) * 100) if utilization_7d is not None else None,
171
+ "remaining_5h_pct": _remaining_from_utilization(utilization_5h),
172
+ "remaining_week_pct": _remaining_from_utilization(utilization_7d),
164
173
  "reset_5h_at": reset_5h_at,
165
174
  "reset_week_at": reset_week_at,
166
175
  "reset_at": reset_at,
package/src/cli.py CHANGED
@@ -57,7 +57,7 @@ from .status_view import (
57
57
  )
58
58
  from .update_check import check_for_update
59
59
 
60
- VERSION = "0.7.0"
60
+ VERSION = "0.7.1"
61
61
 
62
62
 
63
63
  # ---------------------------------------------------------------------------
@@ -40,6 +40,7 @@ from .provider_runtime import (
40
40
  )
41
41
  from .repair import format_repair_report, repair_health
42
42
  from .backup_bundle import read_bundle_meta
43
+ from .run_usage import empty_usage, extract_run_usage
43
44
  from .status_view import _format_status_detail, _format_status_rows
44
45
  from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
45
46
  from .update_manager import build_update_plan, format_update_failure, run_update_plan
@@ -1201,15 +1202,6 @@ def _read_run_prompt(parsed):
1201
1202
  raise CdxError(f"Unable to read prompt file: {parsed['prompt_file']}") from error
1202
1203
 
1203
1204
 
1204
- def _run_usage_payload():
1205
- return {
1206
- "input_tokens": None,
1207
- "output_tokens": None,
1208
- "reasoning_tokens": None,
1209
- "total_tokens": None,
1210
- }
1211
-
1212
-
1213
1205
  def _run_cdx_error_code(error):
1214
1206
  message = str(error)
1215
1207
  if message.startswith("Usage:"):
@@ -1233,10 +1225,16 @@ def _run_cdx_error_code(error):
1233
1225
 
1234
1226
  def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_source=None, error_code=None):
1235
1227
  run_info = run_info or {}
1228
+ usage = (
1229
+ extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
1230
+ if ok and session else
1231
+ empty_usage()
1232
+ )
1236
1233
  return {
1237
1234
  "schema_version": API_SCHEMA_VERSION,
1238
1235
  "ok": bool(ok),
1239
1236
  "action": "run",
1237
+ "launcher": "cdx",
1240
1238
  "session": session.get("name") if session else None,
1241
1239
  "provider": session.get("provider") if session else parsed.get("provider"),
1242
1240
  "model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
@@ -1249,7 +1247,7 @@ def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_so
1249
1247
  "transcript_path": run_info.get("transcript_path"),
1250
1248
  "stdout_path": run_info.get("stdout_path"),
1251
1249
  "stderr_path": run_info.get("stderr_path"),
1252
- "usage": _run_usage_payload(),
1250
+ "usage": usage,
1253
1251
  "warnings": [],
1254
1252
  "error": None if ok else {
1255
1253
  "source": error_source or "cdx",
@@ -1283,6 +1281,7 @@ def handle_run(rest, ctx):
1283
1281
  "run",
1284
1282
  "no_suitable_session",
1285
1283
  "No suitable session found.",
1284
+ launcher="cdx",
1286
1285
  provider=parsed["provider"],
1287
1286
  ))
1288
1287
  return 1
package/src/cli_render.py CHANGED
@@ -75,10 +75,12 @@ def _style_pct(value, use_color=False):
75
75
  text = _format_pct(value)
76
76
  if value is None:
77
77
  return _style(text, "2", use_color)
78
- if value == 0:
78
+ if value <= 0:
79
79
  return _style(text, "31", use_color)
80
80
  if value <= 10:
81
81
  return _style(text, "33", use_color)
82
+ if value > 80:
83
+ return _style(text, "96", use_color)
82
84
  return _style(text, "32", use_color)
83
85
 
84
86
 
@@ -29,6 +29,12 @@ LAUNCH_PERMISSION_ARGS = {
29
29
  "full": ["-s", "danger-full-access", "-a", "never"],
30
30
  },
31
31
  }
32
+ HEADLESS_CODEX_PERMISSION_ARGS = {
33
+ "review": ["-s", "read-only"],
34
+ "default": ["-s", "workspace-write"],
35
+ "auto": ["-s", "workspace-write", "-c", 'approval_policy="never"'],
36
+ "full": ["--dangerously-bypass-approvals-and-sandbox"],
37
+ }
32
38
 
33
39
 
34
40
  def _home_env_overrides(auth_home):
@@ -279,11 +285,7 @@ def _default_script_args(transcript_path, spec):
279
285
 
280
286
 
281
287
  def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None, capture_transcript=True):
282
- if initial_prompt is not None:
283
- if not isinstance(initial_prompt, str):
284
- raise CdxError("initial_prompt must be a string.")
285
- if len(initial_prompt) > 32768:
286
- raise CdxError("initial_prompt exceeds maximum allowed length.")
288
+ _validate_initial_prompt(initial_prompt)
287
289
  cwd = cwd or os.getcwd()
288
290
  env_override = env_override or {}
289
291
  env = {**os.environ, **env_override}
@@ -347,6 +349,68 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
347
349
  }, capture_transcript=capture_transcript, env=env)
348
350
 
349
351
 
352
+ def _validate_initial_prompt(initial_prompt):
353
+ if initial_prompt is not None:
354
+ if not isinstance(initial_prompt, str):
355
+ raise CdxError("initial_prompt must be a string.")
356
+ if len(initial_prompt) > 32768:
357
+ raise CdxError("initial_prompt exceeds maximum allowed length.")
358
+
359
+
360
+ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
361
+ _validate_initial_prompt(initial_prompt)
362
+ cwd = cwd or os.getcwd()
363
+ env = {**os.environ, **(env_override or {})}
364
+ launch = session.get("launch") or {}
365
+ power = _launch_power(session)
366
+ permission = launch.get("permission")
367
+ model = launch.get("model")
368
+
369
+ if session["provider"] == PROVIDER_CLAUDE:
370
+ args = ["--print", "--output-format", "json", "--name", session["name"]]
371
+ if model:
372
+ args += ["--model", model]
373
+ args += _launch_config_args(session)
374
+ if initial_prompt:
375
+ args.append(initial_prompt)
376
+ auth_home = _get_auth_home(session)
377
+ claude_env = _claude_env(env, auth_home)
378
+ oauth_token = _read_anthropic_oauth_token(auth_home)
379
+ if oauth_token:
380
+ claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
381
+ return {
382
+ "command": "claude",
383
+ "args": args,
384
+ "options": {"cwd": cwd, "env": claude_env},
385
+ "label": "claude",
386
+ }
387
+
388
+ if session["provider"] == PROVIDER_CODEX:
389
+ args = ["exec", "--json", "-C", cwd]
390
+ if model:
391
+ args += ["-m", model]
392
+ if power:
393
+ args += ["-c", f'model_reasoning_effort="{power}"']
394
+ if permission:
395
+ args += HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
396
+ if initial_prompt:
397
+ args.append(initial_prompt)
398
+ return {
399
+ "command": "codex",
400
+ "args": args,
401
+ "options": {"cwd": cwd, "env": {**env, "CODEX_HOME": _get_auth_home(session)}},
402
+ "label": "codex",
403
+ }
404
+
405
+ return _build_launch_spec(
406
+ session,
407
+ cwd=cwd,
408
+ env_override=env_override,
409
+ initial_prompt=initial_prompt,
410
+ capture_transcript=False,
411
+ )
412
+
413
+
350
414
  def _headless_artifact_paths(session, run_id=None):
351
415
  run_id = run_id or uuid.uuid4().hex
352
416
  log_dir = _get_launch_transcript_dir(session)
@@ -397,12 +461,11 @@ def _headless_run_info(paths, spec, start_time, returncode):
397
461
  def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
398
462
  timeout_seconds=None, spawn=None, run_id=None):
399
463
  spawn = spawn or subprocess.Popen
400
- spec = _build_launch_spec(
464
+ spec = _build_headless_launch_spec(
401
465
  session,
402
466
  cwd=cwd,
403
467
  env_override=env_override,
404
468
  initial_prompt=initial_prompt,
405
- capture_transcript=False,
406
469
  )
407
470
  paths = _headless_artifact_paths(session, run_id=run_id)
408
471
  start_time = datetime.now(timezone.utc)
@@ -0,0 +1,133 @@
1
+ import json
2
+
3
+
4
+ USAGE_KEYS = ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
5
+ SUPPORTED_PROVIDERS = {"claude", "codex"}
6
+
7
+
8
+ def empty_usage():
9
+ return {key: None for key in USAGE_KEYS}
10
+
11
+
12
+ def extract_run_usage(provider, stdout_path):
13
+ if not stdout_path or not provider:
14
+ return empty_usage()
15
+ if provider not in SUPPORTED_PROVIDERS:
16
+ return empty_usage()
17
+ try:
18
+ with open(stdout_path, "r", encoding="utf-8", errors="replace") as handle:
19
+ text = handle.read()
20
+ except OSError:
21
+ return empty_usage()
22
+ if not text.strip():
23
+ return empty_usage()
24
+
25
+ records = _parse_json_records(text)
26
+ if not records:
27
+ return empty_usage()
28
+
29
+ usage = _extract_usage_from_records(records)
30
+ if not _has_usage(usage):
31
+ return empty_usage()
32
+ return usage
33
+
34
+
35
+ def _parse_json_records(text):
36
+ stripped = text.strip()
37
+ try:
38
+ return [json.loads(stripped)]
39
+ except json.JSONDecodeError:
40
+ pass
41
+
42
+ records = []
43
+ for line in stripped.splitlines():
44
+ line = line.strip()
45
+ if not line:
46
+ continue
47
+ try:
48
+ records.append(json.loads(line))
49
+ except json.JSONDecodeError:
50
+ return []
51
+ return records
52
+
53
+
54
+ def _extract_usage_from_records(records):
55
+ latest = None
56
+ for record in records:
57
+ candidate = _find_usage(record)
58
+ if _has_usage(candidate):
59
+ latest = candidate
60
+ return latest or empty_usage()
61
+
62
+
63
+ def _find_usage(value):
64
+ if isinstance(value, dict):
65
+ direct = _usage_from_dict(value)
66
+ if _has_usage(direct):
67
+ return direct
68
+ for child in value.values():
69
+ found = _find_usage(child)
70
+ if _has_usage(found):
71
+ return found
72
+ if isinstance(value, list):
73
+ for child in value:
74
+ found = _find_usage(child)
75
+ if _has_usage(found):
76
+ return found
77
+ return empty_usage()
78
+
79
+
80
+ def _usage_from_dict(value):
81
+ usage = value.get("usage") if isinstance(value.get("usage"), dict) else value
82
+ if not isinstance(usage, dict):
83
+ return empty_usage()
84
+
85
+ input_tokens = _int_value(
86
+ usage.get("input_tokens"),
87
+ usage.get("prompt_tokens"),
88
+ usage.get("cache_creation_input_tokens"),
89
+ usage.get("cache_read_input_tokens"),
90
+ )
91
+ output_tokens = _int_value(usage.get("output_tokens"), usage.get("completion_tokens"))
92
+ reasoning_tokens = _int_value(
93
+ usage.get("reasoning_tokens"),
94
+ _nested_int(usage, "output_tokens_details", "reasoning_tokens"),
95
+ _nested_int(usage, "completion_tokens_details", "reasoning_tokens"),
96
+ )
97
+ total_tokens = _first_int(usage.get("total_tokens"))
98
+ if total_tokens is None and input_tokens is not None and output_tokens is not None:
99
+ total_tokens = input_tokens + output_tokens
100
+
101
+ return {
102
+ "input_tokens": input_tokens,
103
+ "output_tokens": output_tokens,
104
+ "reasoning_tokens": reasoning_tokens,
105
+ "total_tokens": total_tokens,
106
+ }
107
+
108
+
109
+ def _nested_int(value, parent, child):
110
+ nested = value.get(parent)
111
+ if not isinstance(nested, dict):
112
+ return None
113
+ return nested.get(child)
114
+
115
+
116
+ def _first_int(value):
117
+ try:
118
+ parsed = int(value)
119
+ except (TypeError, ValueError):
120
+ return None
121
+ return parsed if parsed >= 0 else None
122
+
123
+
124
+ def _int_value(*values):
125
+ parsed = [_first_int(value) for value in values]
126
+ parsed = [value for value in parsed if value is not None]
127
+ if not parsed:
128
+ return None
129
+ return sum(parsed)
130
+
131
+
132
+ def _has_usage(usage):
133
+ return isinstance(usage, dict) and any(usage.get(key) is not None for key in USAGE_KEYS)
@@ -189,14 +189,23 @@ def _to_local_iso(value):
189
189
  return parsed.astimezone().isoformat()
190
190
 
191
191
 
192
+ def _normalize_pct_value(value):
193
+ if value is None:
194
+ return None
195
+ try:
196
+ return max(0, min(100, round(float(value))))
197
+ except (TypeError, ValueError):
198
+ return None
199
+
200
+
192
201
  def _normalize_status_payload(payload=None):
193
202
  if payload is None:
194
203
  payload = {}
195
204
  now = _local_now_iso()
196
205
  return {
197
- "usage_pct": payload.get("usage_pct"),
198
- "remaining_5h_pct": payload.get("remaining_5h_pct"),
199
- "remaining_week_pct": payload.get("remaining_week_pct"),
206
+ "usage_pct": _normalize_pct_value(payload.get("usage_pct")),
207
+ "remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
208
+ "remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
200
209
  "credits": payload.get("credits"),
201
210
  "reset_5h_at": payload.get("reset_5h_at"),
202
211
  "reset_week_at": payload.get("reset_week_at"),
@@ -297,8 +306,8 @@ def _compute_available_pct(status):
297
306
  if not status:
298
307
  return None
299
308
  values = [
300
- status.get("remaining_5h_pct"),
301
- status.get("remaining_week_pct"),
309
+ _normalize_pct_value(status.get("remaining_5h_pct")),
310
+ _normalize_pct_value(status.get("remaining_week_pct")),
302
311
  ]
303
312
  values = [value for value in values if value is not None]
304
313
  if not values:
@@ -981,8 +990,8 @@ def create_session_service(options=None):
981
990
  "status": "enabled" if enabled else "disabled",
982
991
  "auth_status": (s.get("auth") or {}).get("status") or "unknown",
983
992
  "auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
984
- "remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
985
- "remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
993
+ "remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
994
+ "remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
986
995
  "credits": row_status.get("credits") if row_status else None,
987
996
  "available_pct": _compute_available_pct(row_status),
988
997
  "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,