cdx-manager 0.7.1 → 0.7.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
@@ -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.1-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.3-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.1 sh install.sh
137
+ CDX_VERSION=v0.7.3 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`
@@ -439,6 +443,13 @@ cdx run \
439
443
 
440
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.
441
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
+ ```
452
+
442
453
  `cdx select` exposes the same session selection logic directly:
443
454
 
444
455
  ```bash
@@ -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/*`
@@ -0,0 +1,37 @@
1
+ # Changelog (`0.7.2 -> 0.7.3`)
2
+
3
+ Release date: 2026-05-31
4
+
5
+ ## Status Display
6
+
7
+ - Rounded Codex credit balances in the `cdx status` `CR` column to two decimal places.
8
+ - Applied the same credit formatting to the single-session status detail view.
9
+ - Parallelized status resolution across sessions with a bounded worker pool while preserving cache behavior and final row ordering.
10
+ - Added visible per-session progress for text status checks, including `Checked <session> (x/y)` messages for refreshed sessions.
11
+
12
+ ## Stats and History Output
13
+
14
+ - Improved `cdx stats` text output with colorized headings, sessions, token totals, durations, and metadata when color is enabled.
15
+ - Reformatted long duration values into readable day/hour/minute forms such as `2h 05m`.
16
+ - Marked active sessions with `*` in `cdx stats`, matching `cdx status`.
17
+ - Improved `cdx history` and `cdx history --summary` with colorized headings, success/failure states, durations, and metadata.
18
+ - Marked active sessions with `*` in `cdx history` text output without changing JSON payloads.
19
+
20
+ ## Coverage and Tests
21
+
22
+ - Added regression coverage for status credit rounding, parallel status refresh, status progress messages, colorized stats/history output, readable duration formatting, and active-session markers.
23
+ - Increased the Python test suite to 262 tests.
24
+
25
+ ## Release Metadata
26
+
27
+ - Updated package metadata, CLI version output, README badge, pinned installer example, and release changelog to `v0.7.3`.
28
+
29
+ ## Validation and Regression Evidence
30
+
31
+ - `npm run lint`
32
+ - `npm test`
33
+ - `npm pack --dry-run`
34
+ - `node bin/cdx.js -v`
35
+ - `python3 -m unittest discover -s test`
36
+ - `python3 -m build`
37
+ - `python3 -m twine check dist/cdx_manager-0.7.3*`
@@ -32,6 +32,14 @@
32
32
  "v0.7.0": {
33
33
  "github_tarball_sha256": "c80231884bf20c9ae74144a1ec16e0685c2fdac67d7d93a3c6219c5ac6fc14dd",
34
34
  "github_zip_sha256": "0e6af29e59d3a93a07e07656d6c0c93803a674e65830ef33fa2322649b7139c2"
35
+ },
36
+ "v0.7.1": {
37
+ "github_tarball_sha256": "276cf2f094405bef674290ff8d1f2401f99afc10981c97efe4fe8293801715c8",
38
+ "github_zip_sha256": "5d5cdefec3ebcd61d4149b71895f4d5b79e604cf874b2d567910e578a8164670"
39
+ },
40
+ "v0.7.2": {
41
+ "github_tarball_sha256": "9e993b15386c7b47895cfaca9c7e92165967ef34ecf8337c64823a5b0f9eb9e0",
42
+ "github_zip_sha256": "1e5fd926a16811f84137636830e0b27776b9f8f8d2eb8953b620023c8bfe6fdd"
35
43
  }
36
44
  }
37
45
  }
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.1",
3
+ "version": "0.7.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.7.1"
7
+ version = "0.7.3"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
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.1"
61
+ VERSION = "0.7.3"
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
 
@@ -8,7 +8,7 @@ import time
8
8
  from datetime import datetime, timedelta
9
9
 
10
10
  from .claude_refresh import _refresh_claude_sessions
11
- from .cli_render import _dim, _info, _success, _warn
11
+ from .cli_render import _dim, _info, _style, _success, _warn
12
12
  from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA, PROVIDERS
13
13
  from .context_store import (
14
14
  clear_context,
@@ -43,7 +43,7 @@ from .backup_bundle import read_bundle_meta
43
43
  from .run_usage import empty_usage, extract_run_usage
44
44
  from .status_view import _format_status_detail, _format_status_rows
45
45
  from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
46
- 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
47
47
 
48
48
 
49
49
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
@@ -54,11 +54,12 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
54
54
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
55
55
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
56
56
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--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] [--model MODEL] [--priority 0..100] [--json]"
58
- 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]"
59
59
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
60
60
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
61
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]"
62
63
  LAST_USAGE = "Usage: cdx last [--json]"
63
64
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort low|medium|high] [--min-power low|medium|high] [--require-ready] [--refresh] --json"
64
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"
@@ -179,15 +180,24 @@ def _make_export_progress(ctx):
179
180
 
180
181
 
181
182
  def _make_status_progress(ctx):
183
+ progress_state = {"checked": 0, "total": 0}
184
+
182
185
  def progress(event):
183
186
  kind = event.get("event")
184
187
  if kind == "status_started":
188
+ progress_state["checked"] = 0
189
+ progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
185
190
  message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
186
191
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
187
192
  elif kind == "session_started":
188
193
  provider = event.get("provider") or "session"
189
194
  message = f"Checking {event.get('session_name')} ({provider})..."
190
195
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
196
+ elif kind == "session_finished" and not event.get("cache_hit"):
197
+ progress_state["checked"] += 1
198
+ total = progress_state["total"] or progress_state["checked"]
199
+ message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
200
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
191
201
  elif kind == "status_finished":
192
202
  message = f"Resolved {event.get('row_count', 0)} status row(s)."
193
203
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
@@ -195,18 +205,27 @@ def _make_status_progress(ctx):
195
205
 
196
206
 
197
207
  def _make_notify_progress(ctx):
208
+ progress_state = {"checked": 0, "total": 0}
209
+
198
210
  def progress(event):
199
211
  kind = event.get("event")
200
212
  if kind == "notify_check_started":
201
213
  target = event.get("session_name") or "next ready session"
202
214
  ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
203
215
  elif kind == "status_started":
216
+ progress_state["checked"] = 0
217
+ progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
204
218
  message = f"Loading status for {event.get('session_count', 0)} session(s)..."
205
219
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
206
220
  elif kind == "session_started":
207
221
  provider = event.get("provider") or "session"
208
222
  message = f"Checking {event.get('session_name')} ({provider})..."
209
223
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
224
+ elif kind == "session_finished" and not event.get("cache_hit"):
225
+ progress_state["checked"] += 1
226
+ total = progress_state["total"] or progress_state["checked"]
227
+ message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
228
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
210
229
  elif kind == "notify_waiting":
211
230
  message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
212
231
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
@@ -504,6 +523,7 @@ def _parse_set_args(args):
504
523
  "--power": {"key": "power", "type": "str", "default": None},
505
524
  "--permission": {"key": "permission", "type": "str", "default": None},
506
525
  "--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
526
+ "--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
507
527
  "--model": {"key": "model", "type": "str", "default": None},
508
528
  "--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
509
529
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
@@ -520,7 +540,7 @@ def _parse_set_args(args):
520
540
  raise CdxError(SET_USAGE)
521
541
  settings = {
522
542
  key: parsed[key]
523
- for key in ("power", "permission", "fast", "model", "priority")
543
+ for key in ("power", "permission", "fast", "rtk", "model", "priority")
524
544
  if parsed[key] is not None
525
545
  }
526
546
  if not settings:
@@ -539,6 +559,7 @@ def _parse_unset_args(args):
539
559
  "--power": {"key": "power", "type": "bool", "default": False},
540
560
  "--permission": {"key": "permission", "type": "bool", "default": False},
541
561
  "--fast": {"key": "fast", "type": "bool", "default": False},
562
+ "--rtk": {"key": "rtk", "type": "bool", "default": False},
542
563
  "--model": {"key": "model", "type": "bool", "default": False},
543
564
  "--priority": {"key": "priority", "type": "bool", "default": False},
544
565
  "--all": {"key": "all", "type": "bool", "default": False},
@@ -554,8 +575,8 @@ def _parse_unset_args(args):
554
575
  raise CdxError(UNSET_USAGE)
555
576
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
556
577
  raise CdxError(UNSET_USAGE)
557
- keys = ["power", "permission", "fast", "model", "priority"] if parsed["all"] else [
558
- key for key in ("power", "permission", "fast", "model", "priority") if parsed[key]
578
+ keys = ["power", "permission", "fast", "rtk", "model", "priority"] if parsed["all"] else [
579
+ key for key in ("power", "permission", "fast", "rtk", "model", "priority") if parsed[key]
559
580
  ]
560
581
  if not keys:
561
582
  raise CdxError(UNSET_USAGE)
@@ -818,6 +839,22 @@ def _parse_history_args(args, now=None):
818
839
  }
819
840
 
820
841
 
842
+ def _parse_stats_args(args, now=None):
843
+ now = now or datetime.now().astimezone()
844
+ parsed = _parse_flag_args(args, {
845
+ "--since": {"key": "since", "type": "str", "default": None},
846
+ "--from": {"key": "from", "type": "str", "default": None},
847
+ "--to": {"key": "to", "type": "str", "default": None},
848
+ "--json": {"key": "json", "type": "bool", "default": False},
849
+ }, STATS_USAGE, positionals_key="names", max_positionals=1)
850
+ period = _parse_history_period(parsed, now)
851
+ return {
852
+ "name": parsed["names"][0] if parsed["names"] else None,
853
+ "period": period,
854
+ "json": parsed["json"],
855
+ }
856
+
857
+
821
858
  def _read_option_value(args, index, usage):
822
859
  if index + 1 >= len(args):
823
860
  raise CdxError(usage)
@@ -1225,9 +1262,9 @@ def _run_cdx_error_code(error):
1225
1262
 
1226
1263
  def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_source=None, error_code=None):
1227
1264
  run_info = run_info or {}
1228
- usage = (
1265
+ usage = run_info.get("usage") if isinstance(run_info.get("usage"), dict) else (
1229
1266
  extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
1230
- if ok and session else
1267
+ if session else
1231
1268
  empty_usage()
1232
1269
  )
1233
1270
  return {
@@ -1324,6 +1361,8 @@ def handle_run(rest, ctx):
1324
1361
  timeout_seconds=parsed.get("timeout_seconds"),
1325
1362
  spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
1326
1363
  )
1364
+ usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
1365
+ run_info = {**run_info, "usage": usage}
1327
1366
  ok = run_info.get("returncode") == 0
1328
1367
  if ok:
1329
1368
  ctx["service"]["record_launch_history"](session["name"], {
@@ -1374,6 +1413,7 @@ def _format_launch_config(session):
1374
1413
  f"power: {launch.get('power') or 'default'}",
1375
1414
  f"permission: {launch.get('permission') or 'default'}",
1376
1415
  f"fast: {'on' if launch.get('fast') is True else 'off' if launch.get('fast') is False else 'default'}",
1416
+ f"rtk: {'on' if launch.get('rtk') is True else 'off' if launch.get('rtk') is False else 'default'}",
1377
1417
  f"model: {launch.get('model') or 'default'}",
1378
1418
  f"priority: {launch.get('priority') if launch.get('priority') is not None else 'default'}",
1379
1419
  ])
@@ -1507,7 +1547,15 @@ def _format_duration_ms(value):
1507
1547
  return f"{seconds:.1f}s"
1508
1548
  minutes = int(seconds // 60)
1509
1549
  remaining = int(seconds % 60)
1510
- return f"{minutes}m{remaining:02d}s"
1550
+ if minutes < 60:
1551
+ return f"{minutes}m {remaining:02d}s"
1552
+ hours = minutes // 60
1553
+ remaining_minutes = minutes % 60
1554
+ if hours < 24:
1555
+ return f"{hours}h {remaining_minutes:02d}m"
1556
+ days = hours // 24
1557
+ remaining_hours = hours % 24
1558
+ return f"{days}d {remaining_hours:02d}h"
1511
1559
 
1512
1560
 
1513
1561
  def _summarize_history(entries):
@@ -1543,6 +1591,79 @@ def _summarize_history(entries):
1543
1591
  )
1544
1592
 
1545
1593
 
1594
+ def _token_value(usage, key):
1595
+ if not isinstance(usage, dict):
1596
+ return None
1597
+ value = usage.get(key)
1598
+ try:
1599
+ parsed = int(value)
1600
+ except (TypeError, ValueError):
1601
+ return None
1602
+ return parsed if parsed >= 0 else None
1603
+
1604
+
1605
+ def _summarize_stats(entries):
1606
+ rows = {}
1607
+ for entry in entries:
1608
+ name = entry.get("session_name") or "-"
1609
+ row = rows.setdefault(name, {
1610
+ "session_name": name,
1611
+ "provider": entry.get("provider") or "-",
1612
+ "launches": 0,
1613
+ "successes": 0,
1614
+ "failures": 0,
1615
+ "duration_ms": 0,
1616
+ "usage_runs": 0,
1617
+ "input_tokens": 0,
1618
+ "output_tokens": 0,
1619
+ "reasoning_tokens": 0,
1620
+ "total_tokens": 0,
1621
+ "last_started_at": None,
1622
+ })
1623
+ row["launches"] += 1
1624
+ if entry.get("status") == "success":
1625
+ row["successes"] += 1
1626
+ elif entry.get("status") == "failed":
1627
+ row["failures"] += 1
1628
+ try:
1629
+ row["duration_ms"] += int(entry.get("duration_ms") or 0)
1630
+ except (TypeError, ValueError):
1631
+ pass
1632
+ usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
1633
+ parsed_usage = {
1634
+ key: _token_value(usage, key)
1635
+ for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
1636
+ }
1637
+ if any(value is not None for value in parsed_usage.values()):
1638
+ row["usage_runs"] += 1
1639
+ for key, value in parsed_usage.items():
1640
+ row[key] += value or 0
1641
+ started = entry.get("started_at")
1642
+ if started and (not row["last_started_at"] or started > row["last_started_at"]):
1643
+ row["last_started_at"] = started
1644
+ row["provider"] = entry.get("provider") or row["provider"]
1645
+ return sorted(
1646
+ rows.values(),
1647
+ key=lambda item: (item["total_tokens"], item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
1648
+ reverse=True,
1649
+ )
1650
+
1651
+
1652
+ def _stats_totals(rows):
1653
+ return {
1654
+ "sessions": len(rows),
1655
+ "launches": sum(row["launches"] for row in rows),
1656
+ "successes": sum(row["successes"] for row in rows),
1657
+ "failures": sum(row["failures"] for row in rows),
1658
+ "duration_ms": sum(row["duration_ms"] for row in rows),
1659
+ "usage_runs": sum(row["usage_runs"] for row in rows),
1660
+ "input_tokens": sum(row["input_tokens"] for row in rows),
1661
+ "output_tokens": sum(row["output_tokens"] for row in rows),
1662
+ "reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
1663
+ "total_tokens": sum(row["total_tokens"] for row in rows),
1664
+ }
1665
+
1666
+
1546
1667
  def _format_history_period(period):
1547
1668
  if not _has_history_period(period or {}):
1548
1669
  return None
@@ -1561,55 +1682,125 @@ def _format_period_display(value):
1561
1682
  return parsed.strftime("%Y-%m-%d %H:%M")
1562
1683
 
1563
1684
 
1564
- def _format_history_summary(entries, period=None, use_color=False):
1685
+ def _format_history_summary(entries, period=None, use_color=False, active_sessions=None):
1565
1686
  from .cli_render import _format_relative_age, _pad_table
1566
1687
 
1688
+ active_sessions = active_sessions or set()
1567
1689
  summary = _summarize_history(entries)
1568
1690
  if not summary:
1569
1691
  return "No launch history for this period." if _has_history_period(period or {}) else "No launch history."
1570
- rows = [["SESSION", "PROV.", "LAUNCHES", "OK", "FAIL", "TIME", "LAST"]]
1692
+ rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROV.", "LAUNCHES", "OK", "FAIL", "TIME", "LAST"]]]
1571
1693
  for row in summary:
1694
+ session_name = row["session_name"]
1695
+ display_name = f"{session_name}*" if session_name in active_sessions else session_name
1572
1696
  rows.append([
1573
- row["session_name"],
1574
- row["provider"],
1575
- str(row["launches"]),
1576
- str(row["successes"]),
1577
- str(row["failures"]),
1578
- _format_duration_ms(row["duration_ms"]),
1579
- _format_relative_age(row.get("last_started_at")),
1697
+ _style(display_name, "36", use_color),
1698
+ _dim(row["provider"], use_color),
1699
+ _style(str(row["launches"]), "1", use_color),
1700
+ _style(str(row["successes"]), "32" if row["successes"] else "2", use_color),
1701
+ _style(str(row["failures"]), "31" if row["failures"] else "2", use_color),
1702
+ _style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
1703
+ _dim(_format_relative_age(row.get("last_started_at")), use_color),
1580
1704
  ])
1581
- lines = ["Assistant time:"]
1705
+ lines = [_style("Assistant time:", "1", use_color)]
1582
1706
  period_line = _format_history_period(period or {})
1583
1707
  if period_line:
1584
- lines.extend([period_line, ""])
1708
+ lines.extend([_dim(period_line, use_color), ""])
1585
1709
  lines.append(_pad_table(rows))
1586
1710
  return "\n".join(lines)
1587
1711
 
1588
1712
 
1589
- def _format_history(entries, use_color=False):
1713
+ def _format_history(entries, use_color=False, active_sessions=None):
1590
1714
  from .cli_render import _format_relative_age, _pad_table
1591
1715
 
1716
+ active_sessions = active_sessions or set()
1592
1717
  if not entries:
1593
1718
  return "No launch history."
1594
- rows = [["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]
1719
+ rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]]
1595
1720
  for entry in entries:
1596
1721
  transcript_path = entry.get("transcript_path")
1722
+ session_name = entry.get("session_name") or "-"
1723
+ display_name = f"{session_name}*" if session_name in active_sessions else session_name
1724
+ status = entry.get("status") or "-"
1725
+ status_color = "32" if status == "success" else "31" if status == "failed" else "2"
1597
1726
  rows.append([
1598
- entry.get("session_name") or "-",
1599
- entry.get("provider") or "-",
1600
- entry.get("status") or "-",
1601
- _format_duration_ms(entry.get("duration_ms")),
1602
- _format_relative_age(entry.get("started_at")),
1603
- os.path.basename(transcript_path) if transcript_path else "-",
1727
+ _style(display_name, "36", use_color),
1728
+ _dim(entry.get("provider") or "-", use_color),
1729
+ _style(status, status_color, use_color),
1730
+ _style(_format_duration_ms(entry.get("duration_ms")), "33" if entry.get("duration_ms") else "2", use_color),
1731
+ _dim(_format_relative_age(entry.get("started_at")), use_color),
1732
+ _dim(os.path.basename(transcript_path) if transcript_path else "-", use_color),
1604
1733
  ])
1605
1734
  return "\n".join([
1606
- "Recent launches:",
1735
+ _style("Recent launches:", "1", use_color),
1607
1736
  _pad_table(rows),
1608
1737
  "",
1609
1738
  _dim("Full transcript paths and cwd are available with --json.", use_color),
1610
1739
  ])
1611
1740
 
1612
1741
 
1742
+ def _format_token_count(value):
1743
+ try:
1744
+ amount = int(value)
1745
+ except (TypeError, ValueError):
1746
+ return "-"
1747
+ if amount >= 1000000:
1748
+ return f"{amount / 1000000:.1f}M"
1749
+ if amount >= 1000:
1750
+ return f"{amount / 1000:.1f}K"
1751
+ return str(amount)
1752
+
1753
+
1754
+ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=None):
1755
+ from .cli_render import _format_relative_age, _pad_table
1756
+
1757
+ active_sessions = active_sessions or set()
1758
+ if not rows:
1759
+ return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
1760
+ table = [[_style(value, "1", use_color) for value in [
1761
+ "SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"
1762
+ ]]]
1763
+ for row in rows:
1764
+ session_name = row["session_name"]
1765
+ display_name = f"{session_name}*" if session_name in active_sessions else session_name
1766
+ table.append([
1767
+ _style(display_name, "36", use_color),
1768
+ _dim(row["provider"], use_color),
1769
+ _style(str(row["launches"]), "1", use_color),
1770
+ _style(str(row["usage_runs"]), "32" if row["usage_runs"] else "2", use_color),
1771
+ _style(_format_token_count(row["input_tokens"]), "96" if row["input_tokens"] else "2", use_color),
1772
+ _style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
1773
+ _style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
1774
+ _style(_format_token_count(row["total_tokens"]), "1;96" if row["total_tokens"] else "2", use_color),
1775
+ _style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
1776
+ _dim(_format_relative_age(row.get("last_started_at")), use_color),
1777
+ ])
1778
+ lines = [_style("Assistant stats:", "1", use_color)]
1779
+ period_line = _format_history_period(period or {})
1780
+ if period_line:
1781
+ lines.extend([_dim(period_line, use_color), ""])
1782
+ lines.append(_pad_table(table))
1783
+ lines.extend([
1784
+ "",
1785
+ _dim(
1786
+ "Totals: "
1787
+ f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
1788
+ f"{_format_token_count(totals['total_tokens'])} tokens, "
1789
+ f"{_format_duration_ms(totals['duration_ms'])}.",
1790
+ use_color,
1791
+ ),
1792
+ ])
1793
+ return "\n".join(lines)
1794
+
1795
+
1796
+ def _active_session_names(ctx):
1797
+ return {
1798
+ row["name"]
1799
+ for row in ctx["service"]["format_list_rows"]()
1800
+ if row.get("active")
1801
+ }
1802
+
1803
+
1613
1804
  def _apply_launch_settings(parsed, ctx, action="set"):
1614
1805
  targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
1615
1806
  sessions = [
@@ -1715,10 +1906,38 @@ def handle_history(rest, ctx):
1715
1906
  payload["summary"] = _summarize_history(entries)
1716
1907
  _write_json(ctx, _json_success("history", message, **payload))
1717
1908
  return 0
1909
+ active_sessions = _active_session_names(ctx)
1718
1910
  if parsed["summary"]:
1719
- ctx["out"](f"{_format_history_summary(entries, period=parsed['period'], use_color=ctx['use_color'])}\n")
1911
+ ctx["out"](f"{_format_history_summary(entries, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
1912
+ return 0
1913
+ ctx["out"](f"{_format_history(entries, use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
1914
+ return 0
1915
+
1916
+
1917
+ def handle_stats(rest, ctx):
1918
+ now_fn = ctx["options"].get("now") or time.time
1919
+ now = datetime.fromtimestamp(now_fn()).astimezone()
1920
+ parsed = _parse_stats_args(rest, now=now)
1921
+ entries = ctx["service"]["get_launch_history"](parsed["name"], limit=0)
1922
+ entries = _filter_history_period(entries, parsed["period"])
1923
+ rows = _summarize_stats(entries)
1924
+ totals = _stats_totals(rows)
1925
+ message = (
1926
+ f"Calculated stats for {parsed['name']}"
1927
+ if parsed["name"]
1928
+ else "Calculated stats"
1929
+ )
1930
+ if parsed["json"]:
1931
+ _write_json(ctx, _json_success(
1932
+ "stats",
1933
+ message,
1934
+ period=_public_history_period(parsed["period"]),
1935
+ stats=rows,
1936
+ totals=totals,
1937
+ ))
1720
1938
  return 0
1721
- ctx["out"](f"{_format_history(entries, use_color=ctx['use_color'])}\n")
1939
+ active_sessions = _active_session_names(ctx)
1940
+ ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
1722
1941
  return 0
1723
1942
 
1724
1943
 
@@ -2352,11 +2571,21 @@ def handle_update(rest, ctx):
2352
2571
  if failed:
2353
2572
  raise CdxError(format_update_failure(results))
2354
2573
 
2574
+ warnings = []
2575
+ version_warning = verify_updated_command(
2576
+ target_version,
2577
+ runner=ctx["options"].get("runVersionCheck"),
2578
+ env=ctx.get("env"),
2579
+ )
2580
+ if version_warning:
2581
+ warnings.append(version_warning)
2582
+
2355
2583
  message = f"Updated cdx-manager to {target_version}"
2356
2584
  if json_flag:
2357
2585
  _write_json(ctx, _json_success(
2358
2586
  "update",
2359
2587
  message,
2588
+ warnings=warnings,
2360
2589
  updated=True,
2361
2590
  current_version=current_version,
2362
2591
  target_version=target_version,
@@ -2365,6 +2594,8 @@ def handle_update(rest, ctx):
2365
2594
  ))
2366
2595
  return 0
2367
2596
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2597
+ for warning in warnings:
2598
+ ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
2368
2599
  return 0
2369
2600
 
2370
2601
 
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"],
@@ -284,7 +288,20 @@ def _default_script_args(transcript_path, spec):
284
288
  return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
285
289
 
286
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
+
287
303
  def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None, capture_transcript=True):
304
+ initial_prompt = _with_launch_preferences(session, initial_prompt)
288
305
  _validate_initial_prompt(initial_prompt)
289
306
  cwd = cwd or os.getcwd()
290
307
  env_override = env_override or {}
@@ -358,6 +375,7 @@ def _validate_initial_prompt(initial_prompt):
358
375
 
359
376
 
360
377
  def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
378
+ initial_prompt = _with_launch_preferences(session, initial_prompt)
361
379
  _validate_initial_prompt(initial_prompt)
362
380
  cwd = cwd or os.getcwd()
363
381
  env = {**os.environ, **(env_override or {})}
@@ -5,6 +5,7 @@ import base64
5
5
  import sys
6
6
  import tempfile
7
7
  import uuid
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
9
  from datetime import datetime, timezone
9
10
  from urllib.parse import quote
10
11
 
@@ -55,6 +56,7 @@ RESERVED_SESSION_NAMES = {
55
56
  }
56
57
  STATUS_CACHE_TTL_SECONDS = 60
57
58
  CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
59
+ MAX_STATUS_WORKERS = 8
58
60
  LAUNCH_POWER_VALUES = {"low", "medium", "high", "xhigh", "max"}
59
61
  LAUNCH_REASONING_EFFORT_VALUES = {"low", "medium", "high"}
60
62
  LAUNCH_PERMISSION_VALUES = {"review", "default", "auto", "full"}
@@ -123,6 +125,18 @@ def _normalize_launch_settings(settings):
123
125
  normalized["fast"] = False
124
126
  else:
125
127
  raise CdxError(f"Unsupported fast value: {settings['fast']}")
128
+ if "rtk" in settings and settings["rtk"] is not None:
129
+ value = settings["rtk"]
130
+ if isinstance(value, bool):
131
+ normalized["rtk"] = value
132
+ else:
133
+ text = str(value).strip().lower()
134
+ if text in ("on", "true", "1", "yes"):
135
+ normalized["rtk"] = True
136
+ elif text in ("off", "false", "0", "no"):
137
+ normalized["rtk"] = False
138
+ else:
139
+ raise CdxError(f"Unsupported rtk value: {settings['rtk']}")
126
140
  if "model" in settings and settings["model"] is not None:
127
141
  model = str(settings["model"]).strip()
128
142
  if not model:
@@ -800,7 +814,7 @@ def create_session_service(options=None):
800
814
  raise CdxError(f"Unknown session: {name}")
801
815
  if not keys:
802
816
  raise CdxError("At least one launch setting is required.")
803
- allowed = {"power", "permission", "fast", "model", "priority"}
817
+ allowed = {"power", "permission", "fast", "rtk", "model", "priority"}
804
818
  unknown = [key for key in keys if key not in allowed]
805
819
  if unknown:
806
820
  raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
@@ -927,14 +941,9 @@ def create_session_service(options=None):
927
941
 
928
942
  def get_status_rows(progress_callback=None, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
929
943
  sessions = list_sessions()
930
- if progress_callback:
931
- progress_callback({
932
- "event": "status_started",
933
- "session_count": len(sessions),
934
- })
935
- resolved = []
936
- for s in sessions:
937
- cache_hit = (
944
+
945
+ def _status_cache_hit(s):
946
+ return (
938
947
  s.get("enabled", True) is False
939
948
  or (
940
949
  s.get("lastStatus")
@@ -942,28 +951,56 @@ def create_session_service(options=None):
942
951
  and _is_status_cache_fresh(s, ttl_seconds=cache_ttl_seconds)
943
952
  )
944
953
  )
945
- if progress_callback and not cache_hit:
946
- progress_callback({
947
- "event": "session_started",
948
- "session_name": s["name"],
949
- "provider": s["provider"],
950
- })
954
+
955
+ cache_hits = {
956
+ s["name"]: _status_cache_hit(s)
957
+ for s in sessions
958
+ }
959
+ if progress_callback:
960
+ progress_callback({
961
+ "event": "status_started",
962
+ "session_count": len(sessions),
963
+ "check_count": sum(1 for cache_hit in cache_hits.values() if not cache_hit),
964
+ })
965
+
966
+ def _resolve_row_session(s):
951
967
  status = _resolve_session_status(
952
968
  s,
953
969
  force_refresh=force_refresh,
954
970
  cache_ttl_seconds=cache_ttl_seconds,
955
971
  )
956
- if progress_callback:
957
- progress_callback({
958
- "event": "session_finished",
959
- "session_name": s["name"],
960
- "has_status": bool(status),
961
- })
962
- resolved.append({
972
+ return {
963
973
  **s,
964
974
  "lastStatus": status,
965
975
  "lastStatusAt": (status and status.get("updated_at")) or s.get("lastStatusAt"),
966
- })
976
+ }
977
+
978
+ resolved_by_name = {}
979
+ if sessions:
980
+ max_workers = min(MAX_STATUS_WORKERS, len(sessions))
981
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
982
+ futures = {}
983
+ for s in sessions:
984
+ cache_hit = cache_hits[s["name"]]
985
+ if progress_callback and not cache_hit:
986
+ progress_callback({
987
+ "event": "session_started",
988
+ "session_name": s["name"],
989
+ "provider": s["provider"],
990
+ })
991
+ futures[executor.submit(_resolve_row_session, s)] = s
992
+ for future in as_completed(futures):
993
+ s = futures[future]
994
+ resolved = future.result()
995
+ resolved_by_name[s["name"]] = resolved
996
+ if progress_callback:
997
+ progress_callback({
998
+ "event": "session_finished",
999
+ "session_name": s["name"],
1000
+ "has_status": bool(resolved.get("lastStatus")),
1001
+ "cache_hit": cache_hits[s["name"]],
1002
+ })
1003
+ resolved = [resolved_by_name[s["name"]] for s in sessions]
967
1004
 
968
1005
  def sort_key(s):
969
1006
  at = s.get("lastStatusAt") or ""
@@ -1,4 +1,5 @@
1
1
  from datetime import datetime
2
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
2
3
 
3
4
  from .cli_render import (
4
5
  _dim,
@@ -61,6 +62,17 @@ def _style_reset_time(value, use_color=False):
61
62
  return text
62
63
 
63
64
 
65
+ def _format_credits(value, empty="n/a"):
66
+ if value is None:
67
+ return empty
68
+ try:
69
+ normalized = str(value).strip().replace(",", "")
70
+ rounded = Decimal(normalized).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
71
+ return f"{rounded:.2f}"
72
+ except (InvalidOperation, ValueError):
73
+ return str(value)
74
+
75
+
64
76
  def _format_status_rows(rows, use_color=False, small=False):
65
77
  has_provider = len({r["provider"] for r in rows}) > 1 and not small
66
78
  if small:
@@ -114,7 +126,7 @@ def _format_status_rows(rows, use_color=False, small=False):
114
126
  base += usage_columns
115
127
  else:
116
128
  block = "-" if r.get("enabled", True) is False else _format_blocking_quota(r)
117
- credits = str(r["credits"]) if r.get("credits") is not None else "-"
129
+ credits = _format_credits(r.get("credits"), empty="-")
118
130
  base += usage_columns[:3] + [
119
131
  _style(block, "33" if block not in ("?", "-") else "2", use_color),
120
132
  _style(credits, "33" if r.get("credits") is not None else "2", use_color),
@@ -332,7 +344,7 @@ def _format_status_detail(row, use_color=False):
332
344
  f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
333
345
  f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
334
346
  f"{_style('Block:', '1', use_color)} {_style(_format_blocking_quota(row), '33', use_color)}",
335
- f"{_style('Credits:', '1', use_color)} {_style(row['credits'] if row.get('credits') is not None else 'n/a', '33' if row.get('credits') is not None else '2', use_color)}",
347
+ f"{_style('Credits:', '1', use_color)} {_style(_format_credits(row.get('credits')), '33' if row.get('credits') is not None else '2', use_color)}",
336
348
  f"{_style('5h reset:', '1', use_color)} {_style_reset_time(row.get('reset_5h_at'), use_color)}",
337
349
  f"{_style('Week reset:', '1', use_color)} {_style_reset_time(row.get('reset_week_at'), use_color)}",
338
350
  f"{_style('Updated:', '1', use_color)} {_dim(_format_relative_age(row.get('updated_at')), use_color)}",
@@ -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."