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 +16 -5
- package/changelogs/CHANGELOGS_0_7_2.md +41 -0
- package/changelogs/CHANGELOGS_0_7_3.md +37 -0
- package/checksums/release-archives.json +8 -0
- package/install.ps1 +5 -5
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +9 -4
- package/src/cli_commands.py +263 -32
- package/src/health.py +8 -0
- package/src/provider_runtime.py +18 -0
- package/src/session_service.py +60 -23
- package/src/status_view.py +14 -2
- package/src/update_manager.py +80 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
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
|
|
15
|
+
function Has-Command {
|
|
16
16
|
param([string]$Name)
|
|
17
|
-
|
|
18
|
-
throw "cdx install: missing required command: $Name"
|
|
19
|
-
}
|
|
17
|
+
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
|
|
20
18
|
}
|
|
21
19
|
|
|
22
|
-
|
|
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
package/pyproject.toml
CHANGED
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.
|
|
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
|
|
package/src/cli_commands.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1599
|
-
entry.get("provider") or "-",
|
|
1600
|
-
|
|
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
|
-
|
|
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(
|
package/src/provider_runtime.py
CHANGED
|
@@ -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 {})}
|
package/src/session_service.py
CHANGED
|
@@ -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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
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 ""
|
package/src/status_view.py
CHANGED
|
@@ -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 =
|
|
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
|
|
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)}",
|
package/src/update_manager.py
CHANGED
|
@@ -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
|
-
|
|
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."
|