cdx-manager 0.7.0 → 0.7.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
@@ -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.2-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.2 sh install.sh
138
138
  ```
139
139
 
140
140
  From source:
@@ -264,6 +264,7 @@ cdx set personal --power low --permission review
264
264
  cdx set --sessions all --permission auto
265
265
  cdx set --provider ollama --model llama3.2
266
266
  cdx set work --priority 80
267
+ cdx set work --rtk on
267
268
  cdx power all low
268
269
  cdx perm provider:claude review
269
270
  cdx model provider:ollama llama3.2
@@ -277,12 +278,13 @@ cdx unset work --power
277
278
  cdx unset --sessions work,personal --fast
278
279
  cdx unset --provider claude --permission
279
280
  cdx unset work --priority
281
+ cdx unset work --rtk
280
282
  cdx unset work --all
281
283
  cdx power all default
282
284
  cdx model provider:ollama default
283
285
  ```
284
286
 
285
- `--power` maps to Codex `model_reasoning_effort` and Claude `--effort`. `--permission` maps to provider-native permission flags. `--fast on` uses low effort when no explicit power is set. `--priority` is a 0..100 selector preference used as a tie-breaker after readiness and availability.
287
+ `--power` maps to Codex `model_reasoning_effort` and Claude `--effort`. `--permission` maps to provider-native permission flags. `--fast on` uses low effort when no explicit power is set. `--priority` is a 0..100 selector preference used as a tie-breaker after readiness and availability. `--rtk on` injects a launch instruction that encourages assistants to use RTK (`rtk <command>`) for noisy terminal commands when RTK is available, while keeping raw commands for exact output.
286
288
 
287
289
  ### Launch History
288
290
 
@@ -319,8 +321,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
319
321
  | `cdx enable <name> [--json]` | Re-enable a disabled session |
320
322
  | `cdx config <name> [--json]` | Show persistent launch settings for a session |
321
323
  | `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
322
- | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power low\|medium\|high\|xhigh\|max] [--permission review\|default\|auto\|full] [--fast on\|off] [--model MODEL] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
323
- | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--model\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
324
+ | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power low\|medium\|high\|xhigh\|max] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--model MODEL] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
325
+ | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--rtk\|--model\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
324
326
  | `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
325
327
  | `cdx last [--json]` | Launch the most recent existing session from launch history |
326
328
  | `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
@@ -338,6 +340,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
338
340
  | `cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait until the recommended session is usable, or schedule the next known reset notification |
339
341
  | `cdx select --provider PROVIDER [--min-reasoning-effort low\|medium\|high] [--min-power low\|medium\|high] [--require-ready] [--refresh] --json` | Select a suitable session for headless automation |
340
342
  | `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--reasoning-effort low\|medium\|high] [--power low\|medium\|high] [--permission MODE] [--timeout-seconds N] --json` | Run one headless task and return a stable JSON result |
343
+ | `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known headless token usage by session |
341
344
  | `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
342
345
  | `cdx status --small [--refresh]` / `cdx status -s [--refresh]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
343
346
  | `cdx status <name> [--json] [--refresh]` | Show detailed usage breakdown for one session |
@@ -369,6 +372,7 @@ Commands with machine-readable output:
369
372
  - `cdx context ... --json`
370
373
  - `cdx handoff ... --json`
371
374
  - `cdx history ... --json`
375
+ - `cdx stats ... --json`
372
376
  - `cdx last --json`
373
377
  - `cdx doctor --json`
374
378
  - `cdx repair --json`
@@ -437,7 +441,14 @@ cdx run \
437
441
  --json
438
442
  ```
439
443
 
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.
444
+ 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.
445
+
446
+ Known headless token usage is persisted in launch history and can be aggregated later:
447
+
448
+ ```bash
449
+ cdx stats --since 7d --json
450
+ cdx stats work
451
+ ```
441
452
 
442
453
  `cdx select` exposes the same session selection logic directly:
443
454
 
@@ -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`
@@ -0,0 +1,41 @@
1
+ # Changelog (`0.7.1 -> 0.7.2`)
2
+
3
+ Release date: 2026-05-29
4
+
5
+ ## Update Reliability
6
+
7
+ - Fixed Unix standalone update detection for installs under `~/.local/share/cdx-manager/<version>` so `cdx update` uses the standalone installer instead of incorrectly upgrading npm.
8
+ - Added a post-update PATH verification check that runs the resolved `cdx -v` and warns when another older executable shadows the updated install.
9
+ - Reduced the environment passed to the post-update version check so sensitive variables are not forwarded to the verification subprocess.
10
+ - Aligned the PowerShell standalone installer with the Windows launcher by accepting `py`, `python`, or `python3`.
11
+
12
+ ## Headless Usage and Stats
13
+
14
+ - Persisted normalized token usage from `cdx run --json` into launch history for future aggregation.
15
+ - Added `cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]` to aggregate launches, successes, failures, duration, known usage runs, and token totals by session.
16
+ - Continued extracting provider stdout usage even when a headless provider exits with an error, preserving useful usage data from structured failure payloads.
17
+
18
+ ## RTK Preference
19
+
20
+ - Added `cdx set <session> --rtk on|off` and `cdx unset <session> --rtk` to store a per-session preference that encourages assistants to use RTK for noisy terminal commands.
21
+ - Injected the RTK preference into interactive and headless launch prompts without rewriting commands automatically.
22
+ - Added `cdx doctor` detection for the `rtk` CLI.
23
+
24
+ ## Coverage and Tests
25
+
26
+ - Added focused coverage for update checking, context storage, RTK launch preferences, post-update shadow warnings, and per-session stats aggregation.
27
+ - Fixed Windows CI portability in tests that exercise npm shim paths, version mismatch warnings, and context write byte counts.
28
+ - Increased the Python test suite to 260 tests.
29
+
30
+ ## Release Metadata
31
+
32
+ - Updated package metadata, CLI version output, README badge, pinned installer example, and release changelog to `v0.7.2`.
33
+
34
+ ## Validation and Regression Evidence
35
+
36
+ - `npm run lint`
37
+ - `npm test`
38
+ - `npm pack --dry-run`
39
+ - `node bin/cdx.js -v`
40
+ - `python3 -m build`
41
+ - `python3 -m twine check dist/*`
@@ -28,6 +28,14 @@
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"
35
+ },
36
+ "v0.7.1": {
37
+ "github_tarball_sha256": "276cf2f094405bef674290ff8d1f2401f99afc10981c97efe4fe8293801715c8",
38
+ "github_zip_sha256": "5d5cdefec3ebcd61d4149b71895f4d5b79e604cf874b2d567910e578a8164670"
31
39
  }
32
40
  }
33
41
  }
package/install.ps1 CHANGED
@@ -12,14 +12,14 @@ if (-not $ChecksumsUrl) {
12
12
  $ChecksumsUrl = "https://raw.githubusercontent.com/$repo/main/checksums/release-archives.json"
13
13
  }
14
14
 
15
- function Require-Command {
15
+ function Has-Command {
16
16
  param([string]$Name)
17
- if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
18
- throw "cdx install: missing required command: $Name"
19
- }
17
+ return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
20
18
  }
21
19
 
22
- Require-Command python
20
+ if (-not (Has-Command py) -and -not (Has-Command python) -and -not (Has-Command python3)) {
21
+ throw "cdx install: missing required command: py, python, or python3"
22
+ }
23
23
 
24
24
  if (-not $Prefix) {
25
25
  $Prefix = Join-Path $env:LOCALAPPDATA "cdx-manager"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.7.0",
3
+ "version": "0.7.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.7.0"
7
+ version = "0.7.2"
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
@@ -30,6 +30,7 @@ from .cli_commands import (
30
30
  handle_rename,
31
31
  handle_run,
32
32
  handle_select,
33
+ handle_stats,
33
34
  handle_status,
34
35
  handle_set,
35
36
  handle_unset,
@@ -57,7 +58,7 @@ from .status_view import (
57
58
  )
58
59
  from .update_check import check_for_update
59
60
 
60
- VERSION = "0.7.0"
61
+ VERSION = "0.7.2"
61
62
 
62
63
 
63
64
  # ---------------------------------------------------------------------------
@@ -79,9 +80,10 @@ def _print_help(use_color=False):
79
80
  f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
80
81
  f" {_style('cdx config <name> [--json]', '36', use_color)}",
81
82
  f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
82
- f" {_style('cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--model MODEL] [--priority 0..100] [--json]', '36', use_color)}",
83
- f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--model|--priority|--all) [--json]', '36', use_color)}",
83
+ f" {_style('cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--model MODEL] [--priority 0..100] [--json]', '36', use_color)}",
84
+ f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--model|--priority|--all) [--json]', '36', use_color)}",
84
85
  f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
86
+ f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
85
87
  f" {_style('cdx last [--json]', '36', use_color)}",
86
88
  f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
87
89
  f" {_style('cdx handoff <source> <target> [--json]', '36', use_color)}",
@@ -238,7 +240,7 @@ def main(argv, options=None):
238
240
  "version": VERSION,
239
241
  "cwd": options.get("cwd") or os.getcwd(),
240
242
  "update_notice": _get_update_notice(service, env, options) if command not in (
241
- "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "ready", "notify", "context", "config", "set", "unset", "power", "perm", "fast", "model", "history", "handoff", "login", "logout", "disable", "enable", "export", "import", "select", "run", "help", "version"
243
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "ready", "notify", "context", "config", "set", "unset", "power", "perm", "fast", "model", "history", "stats", "handoff", "login", "logout", "disable", "enable", "export", "import", "select", "run", "help", "version"
242
244
  ) else None,
243
245
  "use_color": use_color,
244
246
  }
@@ -305,6 +307,9 @@ def main(argv, options=None):
305
307
  if command == "history":
306
308
  return handle_history(rest, ctx)
307
309
 
310
+ if command == "stats":
311
+ return handle_stats(rest, ctx)
312
+
308
313
  if command == "last":
309
314
  return handle_last(rest, ctx)
310
315
 
@@ -40,9 +40,10 @@ 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
- from .update_manager import build_update_plan, format_update_failure, run_update_plan
46
+ from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
46
47
 
47
48
 
48
49
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
@@ -53,11 +54,12 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
53
54
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
54
55
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
55
56
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
56
- SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--model MODEL] [--priority 0..100] [--json]"
57
- UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--model|--priority|--all) [--json]"
57
+ SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--model MODEL] [--priority 0..100] [--json]"
58
+ UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--model|--priority|--all) [--json]"
58
59
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
59
60
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
60
61
  HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
62
+ STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
61
63
  LAST_USAGE = "Usage: cdx last [--json]"
62
64
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort low|medium|high] [--min-power low|medium|high] [--require-ready] [--refresh] --json"
63
65
  RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--reasoning-effort low|medium|high] [--power low|medium|high] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] --json"
@@ -503,6 +505,7 @@ def _parse_set_args(args):
503
505
  "--power": {"key": "power", "type": "str", "default": None},
504
506
  "--permission": {"key": "permission", "type": "str", "default": None},
505
507
  "--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
508
+ "--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
506
509
  "--model": {"key": "model", "type": "str", "default": None},
507
510
  "--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
508
511
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
@@ -519,7 +522,7 @@ def _parse_set_args(args):
519
522
  raise CdxError(SET_USAGE)
520
523
  settings = {
521
524
  key: parsed[key]
522
- for key in ("power", "permission", "fast", "model", "priority")
525
+ for key in ("power", "permission", "fast", "rtk", "model", "priority")
523
526
  if parsed[key] is not None
524
527
  }
525
528
  if not settings:
@@ -538,6 +541,7 @@ def _parse_unset_args(args):
538
541
  "--power": {"key": "power", "type": "bool", "default": False},
539
542
  "--permission": {"key": "permission", "type": "bool", "default": False},
540
543
  "--fast": {"key": "fast", "type": "bool", "default": False},
544
+ "--rtk": {"key": "rtk", "type": "bool", "default": False},
541
545
  "--model": {"key": "model", "type": "bool", "default": False},
542
546
  "--priority": {"key": "priority", "type": "bool", "default": False},
543
547
  "--all": {"key": "all", "type": "bool", "default": False},
@@ -553,8 +557,8 @@ def _parse_unset_args(args):
553
557
  raise CdxError(UNSET_USAGE)
554
558
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
555
559
  raise CdxError(UNSET_USAGE)
556
- keys = ["power", "permission", "fast", "model", "priority"] if parsed["all"] else [
557
- key for key in ("power", "permission", "fast", "model", "priority") if parsed[key]
560
+ keys = ["power", "permission", "fast", "rtk", "model", "priority"] if parsed["all"] else [
561
+ key for key in ("power", "permission", "fast", "rtk", "model", "priority") if parsed[key]
558
562
  ]
559
563
  if not keys:
560
564
  raise CdxError(UNSET_USAGE)
@@ -817,6 +821,22 @@ def _parse_history_args(args, now=None):
817
821
  }
818
822
 
819
823
 
824
+ def _parse_stats_args(args, now=None):
825
+ now = now or datetime.now().astimezone()
826
+ parsed = _parse_flag_args(args, {
827
+ "--since": {"key": "since", "type": "str", "default": None},
828
+ "--from": {"key": "from", "type": "str", "default": None},
829
+ "--to": {"key": "to", "type": "str", "default": None},
830
+ "--json": {"key": "json", "type": "bool", "default": False},
831
+ }, STATS_USAGE, positionals_key="names", max_positionals=1)
832
+ period = _parse_history_period(parsed, now)
833
+ return {
834
+ "name": parsed["names"][0] if parsed["names"] else None,
835
+ "period": period,
836
+ "json": parsed["json"],
837
+ }
838
+
839
+
820
840
  def _read_option_value(args, index, usage):
821
841
  if index + 1 >= len(args):
822
842
  raise CdxError(usage)
@@ -1201,15 +1221,6 @@ def _read_run_prompt(parsed):
1201
1221
  raise CdxError(f"Unable to read prompt file: {parsed['prompt_file']}") from error
1202
1222
 
1203
1223
 
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
1224
  def _run_cdx_error_code(error):
1214
1225
  message = str(error)
1215
1226
  if message.startswith("Usage:"):
@@ -1233,10 +1244,16 @@ def _run_cdx_error_code(error):
1233
1244
 
1234
1245
  def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_source=None, error_code=None):
1235
1246
  run_info = run_info or {}
1247
+ usage = run_info.get("usage") if isinstance(run_info.get("usage"), dict) else (
1248
+ extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
1249
+ if session else
1250
+ empty_usage()
1251
+ )
1236
1252
  return {
1237
1253
  "schema_version": API_SCHEMA_VERSION,
1238
1254
  "ok": bool(ok),
1239
1255
  "action": "run",
1256
+ "launcher": "cdx",
1240
1257
  "session": session.get("name") if session else None,
1241
1258
  "provider": session.get("provider") if session else parsed.get("provider"),
1242
1259
  "model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
@@ -1249,7 +1266,7 @@ def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_so
1249
1266
  "transcript_path": run_info.get("transcript_path"),
1250
1267
  "stdout_path": run_info.get("stdout_path"),
1251
1268
  "stderr_path": run_info.get("stderr_path"),
1252
- "usage": _run_usage_payload(),
1269
+ "usage": usage,
1253
1270
  "warnings": [],
1254
1271
  "error": None if ok else {
1255
1272
  "source": error_source or "cdx",
@@ -1283,6 +1300,7 @@ def handle_run(rest, ctx):
1283
1300
  "run",
1284
1301
  "no_suitable_session",
1285
1302
  "No suitable session found.",
1303
+ launcher="cdx",
1286
1304
  provider=parsed["provider"],
1287
1305
  ))
1288
1306
  return 1
@@ -1325,6 +1343,8 @@ def handle_run(rest, ctx):
1325
1343
  timeout_seconds=parsed.get("timeout_seconds"),
1326
1344
  spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
1327
1345
  )
1346
+ usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
1347
+ run_info = {**run_info, "usage": usage}
1328
1348
  ok = run_info.get("returncode") == 0
1329
1349
  if ok:
1330
1350
  ctx["service"]["record_launch_history"](session["name"], {
@@ -1375,6 +1395,7 @@ def _format_launch_config(session):
1375
1395
  f"power: {launch.get('power') or 'default'}",
1376
1396
  f"permission: {launch.get('permission') or 'default'}",
1377
1397
  f"fast: {'on' if launch.get('fast') is True else 'off' if launch.get('fast') is False else 'default'}",
1398
+ f"rtk: {'on' if launch.get('rtk') is True else 'off' if launch.get('rtk') is False else 'default'}",
1378
1399
  f"model: {launch.get('model') or 'default'}",
1379
1400
  f"priority: {launch.get('priority') if launch.get('priority') is not None else 'default'}",
1380
1401
  ])
@@ -1544,6 +1565,79 @@ def _summarize_history(entries):
1544
1565
  )
1545
1566
 
1546
1567
 
1568
+ def _token_value(usage, key):
1569
+ if not isinstance(usage, dict):
1570
+ return None
1571
+ value = usage.get(key)
1572
+ try:
1573
+ parsed = int(value)
1574
+ except (TypeError, ValueError):
1575
+ return None
1576
+ return parsed if parsed >= 0 else None
1577
+
1578
+
1579
+ def _summarize_stats(entries):
1580
+ rows = {}
1581
+ for entry in entries:
1582
+ name = entry.get("session_name") or "-"
1583
+ row = rows.setdefault(name, {
1584
+ "session_name": name,
1585
+ "provider": entry.get("provider") or "-",
1586
+ "launches": 0,
1587
+ "successes": 0,
1588
+ "failures": 0,
1589
+ "duration_ms": 0,
1590
+ "usage_runs": 0,
1591
+ "input_tokens": 0,
1592
+ "output_tokens": 0,
1593
+ "reasoning_tokens": 0,
1594
+ "total_tokens": 0,
1595
+ "last_started_at": None,
1596
+ })
1597
+ row["launches"] += 1
1598
+ if entry.get("status") == "success":
1599
+ row["successes"] += 1
1600
+ elif entry.get("status") == "failed":
1601
+ row["failures"] += 1
1602
+ try:
1603
+ row["duration_ms"] += int(entry.get("duration_ms") or 0)
1604
+ except (TypeError, ValueError):
1605
+ pass
1606
+ usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
1607
+ parsed_usage = {
1608
+ key: _token_value(usage, key)
1609
+ for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
1610
+ }
1611
+ if any(value is not None for value in parsed_usage.values()):
1612
+ row["usage_runs"] += 1
1613
+ for key, value in parsed_usage.items():
1614
+ row[key] += value or 0
1615
+ started = entry.get("started_at")
1616
+ if started and (not row["last_started_at"] or started > row["last_started_at"]):
1617
+ row["last_started_at"] = started
1618
+ row["provider"] = entry.get("provider") or row["provider"]
1619
+ return sorted(
1620
+ rows.values(),
1621
+ key=lambda item: (item["total_tokens"], item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
1622
+ reverse=True,
1623
+ )
1624
+
1625
+
1626
+ def _stats_totals(rows):
1627
+ return {
1628
+ "sessions": len(rows),
1629
+ "launches": sum(row["launches"] for row in rows),
1630
+ "successes": sum(row["successes"] for row in rows),
1631
+ "failures": sum(row["failures"] for row in rows),
1632
+ "duration_ms": sum(row["duration_ms"] for row in rows),
1633
+ "usage_runs": sum(row["usage_runs"] for row in rows),
1634
+ "input_tokens": sum(row["input_tokens"] for row in rows),
1635
+ "output_tokens": sum(row["output_tokens"] for row in rows),
1636
+ "reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
1637
+ "total_tokens": sum(row["total_tokens"] for row in rows),
1638
+ }
1639
+
1640
+
1547
1641
  def _format_history_period(period):
1548
1642
  if not _has_history_period(period or {}):
1549
1643
  return None
@@ -1611,6 +1705,55 @@ def _format_history(entries, use_color=False):
1611
1705
  ])
1612
1706
 
1613
1707
 
1708
+ def _format_token_count(value):
1709
+ try:
1710
+ amount = int(value)
1711
+ except (TypeError, ValueError):
1712
+ return "-"
1713
+ if amount >= 1000000:
1714
+ return f"{amount / 1000000:.1f}M"
1715
+ if amount >= 1000:
1716
+ return f"{amount / 1000:.1f}K"
1717
+ return str(amount)
1718
+
1719
+
1720
+ def _format_stats(rows, totals, period=None, use_color=False):
1721
+ from .cli_render import _format_relative_age, _pad_table
1722
+
1723
+ if not rows:
1724
+ return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
1725
+ table = [["SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"]]
1726
+ for row in rows:
1727
+ table.append([
1728
+ row["session_name"],
1729
+ row["provider"],
1730
+ str(row["launches"]),
1731
+ str(row["usage_runs"]),
1732
+ _format_token_count(row["input_tokens"]),
1733
+ _format_token_count(row["output_tokens"]),
1734
+ _format_token_count(row["reasoning_tokens"]),
1735
+ _format_token_count(row["total_tokens"]),
1736
+ _format_duration_ms(row["duration_ms"]),
1737
+ _format_relative_age(row.get("last_started_at")),
1738
+ ])
1739
+ lines = ["Assistant stats:"]
1740
+ period_line = _format_history_period(period or {})
1741
+ if period_line:
1742
+ lines.extend([period_line, ""])
1743
+ lines.append(_pad_table(table))
1744
+ lines.extend([
1745
+ "",
1746
+ _dim(
1747
+ "Totals: "
1748
+ f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
1749
+ f"{_format_token_count(totals['total_tokens'])} tokens, "
1750
+ f"{_format_duration_ms(totals['duration_ms'])}.",
1751
+ use_color,
1752
+ ),
1753
+ ])
1754
+ return "\n".join(lines)
1755
+
1756
+
1614
1757
  def _apply_launch_settings(parsed, ctx, action="set"):
1615
1758
  targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
1616
1759
  sessions = [
@@ -1723,6 +1866,32 @@ def handle_history(rest, ctx):
1723
1866
  return 0
1724
1867
 
1725
1868
 
1869
+ def handle_stats(rest, ctx):
1870
+ now_fn = ctx["options"].get("now") or time.time
1871
+ now = datetime.fromtimestamp(now_fn()).astimezone()
1872
+ parsed = _parse_stats_args(rest, now=now)
1873
+ entries = ctx["service"]["get_launch_history"](parsed["name"], limit=0)
1874
+ entries = _filter_history_period(entries, parsed["period"])
1875
+ rows = _summarize_stats(entries)
1876
+ totals = _stats_totals(rows)
1877
+ message = (
1878
+ f"Calculated stats for {parsed['name']}"
1879
+ if parsed["name"]
1880
+ else "Calculated stats"
1881
+ )
1882
+ if parsed["json"]:
1883
+ _write_json(ctx, _json_success(
1884
+ "stats",
1885
+ message,
1886
+ period=_public_history_period(parsed["period"]),
1887
+ stats=rows,
1888
+ totals=totals,
1889
+ ))
1890
+ return 0
1891
+ ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'])}\n")
1892
+ return 0
1893
+
1894
+
1726
1895
  def _resolve_last_launch_session(ctx):
1727
1896
  for entry in ctx["service"]["get_launch_history"](limit=0):
1728
1897
  name = entry.get("session_name")
@@ -2353,11 +2522,21 @@ def handle_update(rest, ctx):
2353
2522
  if failed:
2354
2523
  raise CdxError(format_update_failure(results))
2355
2524
 
2525
+ warnings = []
2526
+ version_warning = verify_updated_command(
2527
+ target_version,
2528
+ runner=ctx["options"].get("runVersionCheck"),
2529
+ env=ctx.get("env"),
2530
+ )
2531
+ if version_warning:
2532
+ warnings.append(version_warning)
2533
+
2356
2534
  message = f"Updated cdx-manager to {target_version}"
2357
2535
  if json_flag:
2358
2536
  _write_json(ctx, _json_success(
2359
2537
  "update",
2360
2538
  message,
2539
+ warnings=warnings,
2361
2540
  updated=True,
2362
2541
  current_version=current_version,
2363
2542
  target_version=target_version,
@@ -2366,6 +2545,8 @@ def handle_update(rest, ctx):
2366
2545
  ))
2367
2546
  return 0
2368
2547
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2548
+ for warning in warnings:
2549
+ ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
2369
2550
  return 0
2370
2551
 
2371
2552
 
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
 
package/src/health.py CHANGED
@@ -39,6 +39,14 @@ def collect_health_report(service, base_dir, env=None):
39
39
  status = "OK" if path else "WARN"
40
40
  issues.append(_issue(status, f"{command}_cli", f"{command} CLI {'found' if path else 'not found'}", path))
41
41
 
42
+ rtk_path = shutil.which("rtk", path=env.get("PATH"))
43
+ issues.append(_issue(
44
+ "OK" if rtk_path else "WARN",
45
+ "rtk_cli",
46
+ "RTK CLI found" if rtk_path else "RTK CLI not found; assistants can still run normally but may use more context on noisy commands",
47
+ rtk_path,
48
+ ))
49
+
42
50
  script_bin = env.get("CDX_SCRIPT_BIN", "script")
43
51
  script_path = shutil.which(script_bin, path=env.get("PATH"))
44
52
  issues.append(_issue(
@@ -15,6 +15,10 @@ from .errors import CdxError
15
15
 
16
16
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
17
17
  REASONING_EFFORT_VALUES = {"low", "medium", "high"}
18
+ RTK_PROMPT = (
19
+ "When running noisy shell commands, prefer RTK wrappers (`rtk <command>`) if `rtk` is available. "
20
+ "Use raw commands when exact, unfiltered output is required."
21
+ )
18
22
  LAUNCH_PERMISSION_ARGS = {
19
23
  PROVIDER_CLAUDE: {
20
24
  "review": ["--permission-mode", "plan"],
@@ -29,6 +33,12 @@ LAUNCH_PERMISSION_ARGS = {
29
33
  "full": ["-s", "danger-full-access", "-a", "never"],
30
34
  },
31
35
  }
36
+ HEADLESS_CODEX_PERMISSION_ARGS = {
37
+ "review": ["-s", "read-only"],
38
+ "default": ["-s", "workspace-write"],
39
+ "auto": ["-s", "workspace-write", "-c", 'approval_policy="never"'],
40
+ "full": ["--dangerously-bypass-approvals-and-sandbox"],
41
+ }
32
42
 
33
43
 
34
44
  def _home_env_overrides(auth_home):
@@ -278,12 +288,21 @@ def _default_script_args(transcript_path, spec):
278
288
  return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
279
289
 
280
290
 
291
+ def _rtk_enabled(session):
292
+ return (session.get("launch") or {}).get("rtk") is True
293
+
294
+
295
+ def _with_launch_preferences(session, initial_prompt=None):
296
+ if not _rtk_enabled(session):
297
+ return initial_prompt
298
+ if initial_prompt:
299
+ return f"{RTK_PROMPT}\n\n{initial_prompt}"
300
+ return RTK_PROMPT
301
+
302
+
281
303
  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.")
304
+ initial_prompt = _with_launch_preferences(session, initial_prompt)
305
+ _validate_initial_prompt(initial_prompt)
287
306
  cwd = cwd or os.getcwd()
288
307
  env_override = env_override or {}
289
308
  env = {**os.environ, **env_override}
@@ -347,6 +366,69 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
347
366
  }, capture_transcript=capture_transcript, env=env)
348
367
 
349
368
 
369
+ def _validate_initial_prompt(initial_prompt):
370
+ if initial_prompt is not None:
371
+ if not isinstance(initial_prompt, str):
372
+ raise CdxError("initial_prompt must be a string.")
373
+ if len(initial_prompt) > 32768:
374
+ raise CdxError("initial_prompt exceeds maximum allowed length.")
375
+
376
+
377
+ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
378
+ initial_prompt = _with_launch_preferences(session, initial_prompt)
379
+ _validate_initial_prompt(initial_prompt)
380
+ cwd = cwd or os.getcwd()
381
+ env = {**os.environ, **(env_override or {})}
382
+ launch = session.get("launch") or {}
383
+ power = _launch_power(session)
384
+ permission = launch.get("permission")
385
+ model = launch.get("model")
386
+
387
+ if session["provider"] == PROVIDER_CLAUDE:
388
+ args = ["--print", "--output-format", "json", "--name", session["name"]]
389
+ if model:
390
+ args += ["--model", model]
391
+ args += _launch_config_args(session)
392
+ if initial_prompt:
393
+ args.append(initial_prompt)
394
+ auth_home = _get_auth_home(session)
395
+ claude_env = _claude_env(env, auth_home)
396
+ oauth_token = _read_anthropic_oauth_token(auth_home)
397
+ if oauth_token:
398
+ claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
399
+ return {
400
+ "command": "claude",
401
+ "args": args,
402
+ "options": {"cwd": cwd, "env": claude_env},
403
+ "label": "claude",
404
+ }
405
+
406
+ if session["provider"] == PROVIDER_CODEX:
407
+ args = ["exec", "--json", "-C", cwd]
408
+ if model:
409
+ args += ["-m", model]
410
+ if power:
411
+ args += ["-c", f'model_reasoning_effort="{power}"']
412
+ if permission:
413
+ args += HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
414
+ if initial_prompt:
415
+ args.append(initial_prompt)
416
+ return {
417
+ "command": "codex",
418
+ "args": args,
419
+ "options": {"cwd": cwd, "env": {**env, "CODEX_HOME": _get_auth_home(session)}},
420
+ "label": "codex",
421
+ }
422
+
423
+ return _build_launch_spec(
424
+ session,
425
+ cwd=cwd,
426
+ env_override=env_override,
427
+ initial_prompt=initial_prompt,
428
+ capture_transcript=False,
429
+ )
430
+
431
+
350
432
  def _headless_artifact_paths(session, run_id=None):
351
433
  run_id = run_id or uuid.uuid4().hex
352
434
  log_dir = _get_launch_transcript_dir(session)
@@ -397,12 +479,11 @@ def _headless_run_info(paths, spec, start_time, returncode):
397
479
  def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
398
480
  timeout_seconds=None, spawn=None, run_id=None):
399
481
  spawn = spawn or subprocess.Popen
400
- spec = _build_launch_spec(
482
+ spec = _build_headless_launch_spec(
401
483
  session,
402
484
  cwd=cwd,
403
485
  env_override=env_override,
404
486
  initial_prompt=initial_prompt,
405
- capture_transcript=False,
406
487
  )
407
488
  paths = _headless_artifact_paths(session, run_id=run_id)
408
489
  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)
@@ -123,6 +123,18 @@ def _normalize_launch_settings(settings):
123
123
  normalized["fast"] = False
124
124
  else:
125
125
  raise CdxError(f"Unsupported fast value: {settings['fast']}")
126
+ if "rtk" in settings and settings["rtk"] is not None:
127
+ value = settings["rtk"]
128
+ if isinstance(value, bool):
129
+ normalized["rtk"] = value
130
+ else:
131
+ text = str(value).strip().lower()
132
+ if text in ("on", "true", "1", "yes"):
133
+ normalized["rtk"] = True
134
+ elif text in ("off", "false", "0", "no"):
135
+ normalized["rtk"] = False
136
+ else:
137
+ raise CdxError(f"Unsupported rtk value: {settings['rtk']}")
126
138
  if "model" in settings and settings["model"] is not None:
127
139
  model = str(settings["model"]).strip()
128
140
  if not model:
@@ -189,14 +201,23 @@ def _to_local_iso(value):
189
201
  return parsed.astimezone().isoformat()
190
202
 
191
203
 
204
+ def _normalize_pct_value(value):
205
+ if value is None:
206
+ return None
207
+ try:
208
+ return max(0, min(100, round(float(value))))
209
+ except (TypeError, ValueError):
210
+ return None
211
+
212
+
192
213
  def _normalize_status_payload(payload=None):
193
214
  if payload is None:
194
215
  payload = {}
195
216
  now = _local_now_iso()
196
217
  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"),
218
+ "usage_pct": _normalize_pct_value(payload.get("usage_pct")),
219
+ "remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
220
+ "remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
200
221
  "credits": payload.get("credits"),
201
222
  "reset_5h_at": payload.get("reset_5h_at"),
202
223
  "reset_week_at": payload.get("reset_week_at"),
@@ -297,8 +318,8 @@ def _compute_available_pct(status):
297
318
  if not status:
298
319
  return None
299
320
  values = [
300
- status.get("remaining_5h_pct"),
301
- status.get("remaining_week_pct"),
321
+ _normalize_pct_value(status.get("remaining_5h_pct")),
322
+ _normalize_pct_value(status.get("remaining_week_pct")),
302
323
  ]
303
324
  values = [value for value in values if value is not None]
304
325
  if not values:
@@ -791,7 +812,7 @@ def create_session_service(options=None):
791
812
  raise CdxError(f"Unknown session: {name}")
792
813
  if not keys:
793
814
  raise CdxError("At least one launch setting is required.")
794
- allowed = {"power", "permission", "fast", "model", "priority"}
815
+ allowed = {"power", "permission", "fast", "rtk", "model", "priority"}
795
816
  unknown = [key for key in keys if key not in allowed]
796
817
  if unknown:
797
818
  raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
@@ -981,8 +1002,8 @@ def create_session_service(options=None):
981
1002
  "status": "enabled" if enabled else "disabled",
982
1003
  "auth_status": (s.get("auth") or {}).get("status") or "unknown",
983
1004
  "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,
1005
+ "remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
1006
+ "remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
986
1007
  "credits": row_status.get("credits") if row_status else None,
987
1008
  "available_pct": _compute_available_pct(row_status),
988
1009
  "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
@@ -1,4 +1,6 @@
1
1
  import os
2
+ import re
3
+ import shutil
2
4
  import subprocess
3
5
  import sys
4
6
  from pathlib import Path
@@ -22,7 +24,14 @@ def _normalize_version(value):
22
24
 
23
25
 
24
26
  def _is_standalone_install(package_root):
25
- return package_root.parent.name == "versions"
27
+ if package_root.parent.name == "versions":
28
+ return True
29
+ return (
30
+ package_root.parent.name == "cdx-manager"
31
+ and re.match(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$", package_root.name) is not None
32
+ and (package_root / "install.sh").exists()
33
+ and (package_root / "bin" / "cdx").exists()
34
+ )
26
35
 
27
36
 
28
37
  def _is_source_checkout(package_root):
@@ -200,6 +209,76 @@ def run_update_plan(plan, runner=None, env=None):
200
209
  return results
201
210
 
202
211
 
212
+ def _version_check_env(env):
213
+ source = env or os.environ
214
+ allowed = {
215
+ key: source[key]
216
+ for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR")
217
+ if source.get(key)
218
+ }
219
+ return allowed
220
+
221
+
222
+ def verify_updated_command(target_version, runner=None, env=None):
223
+ target = _normalize_version(target_version)
224
+ if not target:
225
+ return None
226
+ env = env or os.environ
227
+ executable = shutil.which("cdx", path=env.get("PATH"))
228
+ if not executable:
229
+ return {
230
+ "code": "update_version_check_failed",
231
+ "message": "Updated cdx-manager, but no cdx executable was found on PATH.",
232
+ "target_version": target,
233
+ "resolved_version": None,
234
+ "path": None,
235
+ }
236
+ runner = runner or subprocess.run
237
+ try:
238
+ result = runner(
239
+ [executable, "-v"],
240
+ cwd=None,
241
+ env=_version_check_env(env),
242
+ check=False,
243
+ capture_output=True,
244
+ text=True,
245
+ )
246
+ except OSError as error:
247
+ return {
248
+ "code": "update_version_check_failed",
249
+ "message": f"Updated cdx-manager, but failed to verify {executable}: {error}",
250
+ "target_version": target,
251
+ "resolved_version": None,
252
+ "path": executable,
253
+ }
254
+
255
+ version = str(_result_text(result, "stdout") or "").strip().splitlines()
256
+ version = _normalize_version(version[0]) if version else None
257
+ code = _result_code(result)
258
+ if code not in (0, None) or not version:
259
+ message = str(_result_text(result, "stderr") or _result_text(result, "stdout") or "").strip()
260
+ suffix = f": {message}" if message else "."
261
+ return {
262
+ "code": "update_version_check_failed",
263
+ "message": f"Updated cdx-manager, but failed to verify {executable}{suffix}",
264
+ "target_version": target,
265
+ "resolved_version": version,
266
+ "path": executable,
267
+ }
268
+ if version != target:
269
+ return {
270
+ "code": "update_version_mismatch",
271
+ "message": (
272
+ f"Updated cdx-manager to {target}, but PATH resolves {executable} "
273
+ f"which reports {version}."
274
+ ),
275
+ "target_version": target,
276
+ "resolved_version": version,
277
+ "path": executable,
278
+ }
279
+ return None
280
+
281
+
203
282
  def format_update_failure(results):
204
283
  if not results:
205
284
  return "Update failed."