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