cdx-manager 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.6.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.7.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -134,7 +134,7 @@ For a specific version:
134
134
 
135
135
  ```bash
136
136
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
137
- CDX_VERSION=v0.6.5 sh install.sh
137
+ CDX_VERSION=v0.7.0 sh install.sh
138
138
  ```
139
139
 
140
140
  From source:
@@ -263,6 +263,7 @@ cdx set work --power medium --permission full --fast off
263
263
  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
+ cdx set work --priority 80
266
267
  cdx power all low
267
268
  cdx perm provider:claude review
268
269
  cdx model provider:ollama llama3.2
@@ -275,12 +276,13 @@ Those values are stored on the session and reapplied every time you run `cdx wor
275
276
  cdx unset work --power
276
277
  cdx unset --sessions work,personal --fast
277
278
  cdx unset --provider claude --permission
279
+ cdx unset work --priority
278
280
  cdx unset work --all
279
281
  cdx power all default
280
282
  cdx model provider:ollama default
281
283
  ```
282
284
 
283
- `--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.
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.
284
286
 
285
287
  ### Launch History
286
288
 
@@ -317,8 +319,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
317
319
  | `cdx enable <name> [--json]` | Re-enable a disabled session |
318
320
  | `cdx config <name> [--json]` | Show persistent launch settings for a session |
319
321
  | `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
320
- | `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] [--json]` | Persist launch settings for one or more sessions |
321
- | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--model\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
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 |
322
324
  | `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 |
323
325
  | `cdx last [--json]` | Launch the most recent existing session from launch history |
324
326
  | `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
@@ -334,6 +336,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
334
336
  | `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
335
337
  | `cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait for a session reset time or schedule an OS wake-up notification when due |
336
338
  | `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
+ | `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
+ | `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 |
337
341
  | `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
338
342
  | `cdx status --small [--refresh]` / `cdx status -s [--refresh]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
339
343
  | `cdx status <name> [--json] [--refresh]` | Show detailed usage breakdown for one session |
@@ -371,6 +375,8 @@ Commands with machine-readable output:
371
375
  - `cdx update --json`
372
376
  - `cdx ready --json`
373
377
  - `cdx notify ... --json`
378
+ - `cdx select ... --json`
379
+ - `cdx run ... --json`
374
380
 
375
381
  Success payloads follow a shared envelope:
376
382
 
@@ -387,7 +393,7 @@ Success payloads follow a shared envelope:
387
393
  }
388
394
  ```
389
395
 
390
- Errors use a shared stderr JSON envelope whenever `--json` is present:
396
+ Most commands use a shared stderr JSON envelope for errors whenever `--json` is present:
391
397
 
392
398
  ```json
393
399
  {
@@ -401,10 +407,44 @@ Errors use a shared stderr JSON envelope whenever `--json` is present:
401
407
  }
402
408
  ```
403
409
 
404
- `status --json` and similar commands also use the same envelope and place non-fatal issues in `warnings` instead of mixing plain-text diagnostics into `stderr`.
410
+ `status --json` and similar commands also use the same envelope and place non-fatal issues in `warnings` instead of mixing plain-text diagnostics into `stderr`. `cdx run --json` is the exception: it always writes one final JSON payload to stdout, including cdx-side and provider-start errors, so supervisors can parse a single result stream while provider stdout and stderr are captured to files.
405
411
 
406
412
  This makes `cdx-manager` usable from editor plugins, scripts, and desktop apps without scraping human-readable terminal output.
407
413
 
414
+ ### Headless Runs
415
+
416
+ `cdx run` is designed for supervisors such as Orchestia. In `--json` mode, stdout contains only the final JSON payload; provider stdout and stderr are captured to files.
417
+
418
+ ```bash
419
+ cdx run codex-work \
420
+ --cwd /path/to/workspace \
421
+ --prompt-file task_prompt.md \
422
+ --model gpt-5.3-codex \
423
+ --reasoning-effort low \
424
+ --permission workspace-write \
425
+ --timeout-seconds 1800 \
426
+ --json
427
+ ```
428
+
429
+ Use provider-based auto-selection when the caller wants cdx-manager to pick the account:
430
+
431
+ ```bash
432
+ cdx run \
433
+ --provider codex \
434
+ --cwd /path/to/workspace \
435
+ --prompt "Summarize the repo status." \
436
+ --reasoning-effort low \
437
+ --json
438
+ ```
439
+
440
+ The result includes `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Token counts are `null` when the provider does not expose them.
441
+
442
+ `cdx select` exposes the same session selection logic directly:
443
+
444
+ ```bash
445
+ cdx select --provider codex --min-reasoning-effort low --require-ready --json
446
+ ```
447
+
408
448
  ---
409
449
 
410
450
  ## Backup And Restore
@@ -0,0 +1,49 @@
1
+ # Changelog (`0.6.5 -> 0.7.0`)
2
+
3
+ Release date: 2026-05-29
4
+
5
+ ## Headless Automation
6
+
7
+ - Added `cdx run --json` as a headless execution contract for automation clients such as Orchestia.
8
+ - Added explicit session execution, provider-based auto-selection, inline prompt and prompt-file support, working-directory validation, timeout handling, and stable run metadata.
9
+ - Kept JSON mode stdout reserved for the final response so callers can parse command output without terminal UI noise.
10
+ - Returned structured cdx-side and provider-side error envelopes for missing provider CLIs, invalid launch inputs, provider start failures, disabled sessions, and reasoning validation errors.
11
+
12
+ ## Session Selection
13
+
14
+ - Added `cdx select --json` with deterministic ranking from provider match, readiness, cooldown, quota health, configured priority, reasoning capability, and session name.
15
+ - Reused the same selector from `cdx run --provider ...`.
16
+ - Added priority handling improvements, including configured-priority tie-breaking and clearing selection priority.
17
+ - Allowed ready selection for local providers where remote quota signals are not applicable.
18
+
19
+ ## Reasoning Effort
20
+
21
+ - Added provider-neutral `--reasoning-effort low|medium|high` handling.
22
+ - Kept `--power` as a compatibility alias and rejected conflicting values with cdx-sourced JSON errors.
23
+ - Mapped normalized effort into Codex launch options while leaving clear extension points for other providers.
24
+
25
+ ## Artifacts and Diagnostics
26
+
27
+ - Added transcript, stdout, and stderr artifact reporting for headless runs.
28
+ - Returned absolute artifact paths and normalized nullable token usage fields on success and failure responses.
29
+ - Hardened artifact and timeout tests to cover provider failures and unknown usage.
30
+
31
+ ## Documentation and Release Readiness
32
+
33
+ - Documented the Orchestia headless delivery, headless CLI options, selection reasons, and JSON error stream behavior.
34
+ - Completed Logics release-gate documentation with rollback coverage, project changelog, release notes, duplicate triage, and release gate artifacts.
35
+ - Carried forward the `v0.6.5` release checksum metadata so standalone installer integrity checks can resolve the previous release.
36
+
37
+ ## Release Metadata
38
+
39
+ - Updated package metadata, CLI version output, README badge, pinned installer example, and release changelog to `v0.7.0`.
40
+
41
+ ## Validation and Regression Evidence
42
+
43
+ - `python -m py_compile bin/cdx src/*.py test/test_*_py.py`
44
+ - `python -m unittest discover -s test -p 'test_*_py.py'`
45
+ - `npm run lint`
46
+ - `npm test`
47
+ - `npm pack --dry-run`
48
+ - `python -m build`
49
+ - `python -m twine check dist/*`
@@ -24,6 +24,10 @@
24
24
  "v0.6.4": {
25
25
  "github_tarball_sha256": "f7c83dd1ae7506cf1ae6420e718a285f4dc96a10fb3a19e24cc17e79d75a46b4",
26
26
  "github_zip_sha256": "d602800cf6b54f1e0adea751cc6b686ab2fc4a224715ade94f761df2ff7da2af"
27
+ },
28
+ "v0.6.5": {
29
+ "github_tarball_sha256": "f2280917ea75b5ae1e99a99b011f09704d89b1a7ae42497f5093e6fff9f814d9",
30
+ "github_zip_sha256": "0b8491037310ed82cf44419d0de887bc0f768c6690c69153992fc4d5cda69676"
27
31
  }
28
32
  }
29
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.6.5"
7
+ version = "0.7.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -28,6 +28,8 @@ from .cli_commands import (
28
28
  handle_remove,
29
29
  handle_repair,
30
30
  handle_rename,
31
+ handle_run,
32
+ handle_select,
31
33
  handle_status,
32
34
  handle_set,
33
35
  handle_unset,
@@ -55,7 +57,7 @@ from .status_view import (
55
57
  )
56
58
  from .update_check import check_for_update
57
59
 
58
- VERSION = "0.6.5"
60
+ VERSION = "0.7.0"
59
61
 
60
62
 
61
63
  # ---------------------------------------------------------------------------
@@ -72,11 +74,13 @@ def _print_help(use_color=False):
72
74
  f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
73
75
  f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
74
76
  f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
77
+ f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort low|medium|high] [--min-power low|medium|high] [--require-ready] [--refresh] --json', '36', use_color)}",
78
+ f" {_style('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', '36', use_color)}",
75
79
  f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
76
80
  f" {_style('cdx config <name> [--json]', '36', use_color)}",
77
81
  f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
78
- 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] [--json]', '36', use_color)}",
79
- f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--model|--all) [--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)}",
80
84
  f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
81
85
  f" {_style('cdx last [--json]', '36', use_color)}",
82
86
  f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
@@ -228,12 +232,13 @@ def main(argv, options=None):
228
232
  "service": service,
229
233
  "signal_emitter": signal_emitter,
230
234
  "spawn": spawn,
235
+ "spawn_headless": options.get("spawn_headless"),
231
236
  "spawn_sync": spawn_sync,
232
237
  "stdin_is_tty": stdin_is_tty,
233
238
  "version": VERSION,
234
239
  "cwd": options.get("cwd") or os.getcwd(),
235
240
  "update_notice": _get_update_notice(service, env, options) if command not in (
236
- "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", "help", "version"
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"
237
242
  ) else None,
238
243
  "use_color": use_color,
239
244
  }
@@ -309,6 +314,12 @@ def main(argv, options=None):
309
314
  if command == "status":
310
315
  return handle_status(rest, ctx)
311
316
 
317
+ if command == "select":
318
+ return handle_select(rest, ctx)
319
+
320
+ if command == "run":
321
+ return handle_run(rest, ctx)
322
+
312
323
  if command == "login":
313
324
  return handle_login(rest, ctx)
314
325
 
@@ -33,7 +33,9 @@ from .notify import (
33
33
  from .provider_runtime import (
34
34
  _ensure_session_authentication,
35
35
  _list_launch_transcript_paths,
36
+ _normalize_reasoning_effort,
36
37
  _probe_provider_auth,
38
+ _run_headless_provider_command,
37
39
  _run_interactive_provider_command,
38
40
  )
39
41
  from .repair import format_repair_report, repair_health
@@ -51,12 +53,14 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
51
53
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
52
54
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
53
55
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
54
- 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] [--json]"
55
- UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--model|--all) [--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]"
56
58
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
57
59
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
58
60
  HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
59
61
  LAST_USAGE = "Usage: cdx last [--json]"
62
+ SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort low|medium|high] [--min-power low|medium|high] [--require-ready] [--refresh] --json"
63
+ 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"
60
64
  API_SCHEMA_VERSION = 1
61
65
  HANDOFF_TRANSCRIPT_CHARS = 120000
62
66
  HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
@@ -78,6 +82,23 @@ def _json_success(action, message, warnings=None, **extra):
78
82
  return payload
79
83
 
80
84
 
85
+ def _json_failure(action, code, message, source="cdx", exit_code=1, warnings=None, **extra):
86
+ payload = {
87
+ "schema_version": API_SCHEMA_VERSION,
88
+ "ok": False,
89
+ "action": action,
90
+ "warnings": warnings or [],
91
+ "error": {
92
+ "source": source,
93
+ "code": code,
94
+ "message": message,
95
+ "exit_code": exit_code,
96
+ },
97
+ }
98
+ payload.update(extra)
99
+ return payload
100
+
101
+
81
102
  def _write_json(ctx, payload):
82
103
  ctx["out"](f"{json.dumps(payload, indent=2)}\n")
83
104
 
@@ -467,12 +488,23 @@ def _parse_fast_value(value):
467
488
  raise CdxError(SET_USAGE)
468
489
 
469
490
 
491
+ def _parse_priority_value(value):
492
+ try:
493
+ priority = int(value)
494
+ except (TypeError, ValueError) as error:
495
+ raise CdxError(SET_USAGE) from error
496
+ if priority < 0 or priority > 100:
497
+ raise CdxError(SET_USAGE)
498
+ return priority
499
+
500
+
470
501
  def _parse_set_args(args):
471
502
  parsed = _parse_flag_args(args, {
472
503
  "--power": {"key": "power", "type": "str", "default": None},
473
504
  "--permission": {"key": "permission", "type": "str", "default": None},
474
505
  "--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
475
506
  "--model": {"key": "model", "type": "str", "default": None},
507
+ "--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
476
508
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
477
509
  "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SET_USAGE)},
478
510
  "--json": {"key": "json", "type": "bool", "default": False},
@@ -487,7 +519,7 @@ def _parse_set_args(args):
487
519
  raise CdxError(SET_USAGE)
488
520
  settings = {
489
521
  key: parsed[key]
490
- for key in ("power", "permission", "fast", "model")
522
+ for key in ("power", "permission", "fast", "model", "priority")
491
523
  if parsed[key] is not None
492
524
  }
493
525
  if not settings:
@@ -507,6 +539,7 @@ def _parse_unset_args(args):
507
539
  "--permission": {"key": "permission", "type": "bool", "default": False},
508
540
  "--fast": {"key": "fast", "type": "bool", "default": False},
509
541
  "--model": {"key": "model", "type": "bool", "default": False},
542
+ "--priority": {"key": "priority", "type": "bool", "default": False},
510
543
  "--all": {"key": "all", "type": "bool", "default": False},
511
544
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
512
545
  "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, UNSET_USAGE)},
@@ -520,8 +553,8 @@ def _parse_unset_args(args):
520
553
  raise CdxError(UNSET_USAGE)
521
554
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
522
555
  raise CdxError(UNSET_USAGE)
523
- keys = ["power", "permission", "fast", "model"] if parsed["all"] else [
524
- key for key in ("power", "permission", "fast", "model") if parsed[key]
556
+ keys = ["power", "permission", "fast", "model", "priority"] if parsed["all"] else [
557
+ key for key in ("power", "permission", "fast", "model", "priority") if parsed[key]
525
558
  ]
526
559
  if not keys:
527
560
  raise CdxError(UNSET_USAGE)
@@ -543,6 +576,99 @@ def _parse_config_args(args):
543
576
  return {"name": parsed["names"][0], "json": parsed["json"]}
544
577
 
545
578
 
579
+ def _parse_select_args(args):
580
+ parsed = _parse_flag_args(args, {
581
+ "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SELECT_USAGE)},
582
+ "--min-reasoning-effort": {"key": "min_reasoning_effort", "type": "str", "default": None},
583
+ "--min-power": {"key": "min_power", "type": "str", "default": None},
584
+ "--require-ready": {"key": "require_ready", "type": "bool", "default": False},
585
+ "--refresh": {"key": "refresh", "type": "bool", "default": False},
586
+ "--json": {"key": "json", "type": "bool", "default": False},
587
+ }, SELECT_USAGE)
588
+ if not parsed["provider"] or not parsed["json"]:
589
+ raise CdxError(SELECT_USAGE)
590
+ effort = _normalize_reasoning_effort(
591
+ reasoning_effort=parsed["min_reasoning_effort"],
592
+ power=parsed["min_power"],
593
+ usage=SELECT_USAGE,
594
+ ).get("reasoning_effort")
595
+ return {
596
+ "provider": parsed["provider"],
597
+ "min_reasoning_effort": effort,
598
+ "require_ready": parsed["require_ready"],
599
+ "refresh": parsed["refresh"],
600
+ "json": parsed["json"],
601
+ }
602
+
603
+
604
+ def _parse_timeout_seconds(value):
605
+ try:
606
+ parsed = float(value)
607
+ except (TypeError, ValueError) as error:
608
+ raise CdxError(RUN_USAGE) from error
609
+ if parsed <= 0:
610
+ raise CdxError(RUN_USAGE)
611
+ return parsed
612
+
613
+
614
+ def _normalize_run_permission(value):
615
+ if value is None:
616
+ return None
617
+ text = str(value).strip().lower()
618
+ aliases = {
619
+ "workspace-write": "default",
620
+ "read-only": "review",
621
+ "danger-full-access": "full",
622
+ }
623
+ text = aliases.get(text, text)
624
+ if text not in ("review", "default", "auto", "full"):
625
+ raise CdxError(RUN_USAGE)
626
+ return text
627
+
628
+
629
+ def _parse_run_args(args):
630
+ parsed = _parse_flag_args(args, {
631
+ "--cwd": {"key": "cwd", "type": "str", "default": None},
632
+ "--prompt-file": {"key": "prompt_file", "type": "str", "default": None},
633
+ "--prompt": {"key": "prompt", "type": "str", "default": None},
634
+ "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, RUN_USAGE)},
635
+ "--model": {"key": "model", "type": "str", "default": None},
636
+ "--reasoning-effort": {"key": "reasoning_effort", "type": "str", "default": None},
637
+ "--power": {"key": "power", "type": "str", "default": None},
638
+ "--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
639
+ "--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_timeout_seconds},
640
+ "--json": {"key": "json", "type": "bool", "default": False},
641
+ }, RUN_USAGE, positionals_key="names", max_positionals=1)
642
+ if not parsed["json"]:
643
+ raise CdxError(RUN_USAGE)
644
+ name = parsed["names"][0] if parsed["names"] else None
645
+ if name and parsed["provider"]:
646
+ raise CdxError(RUN_USAGE)
647
+ if not name and not parsed["provider"]:
648
+ raise CdxError(RUN_USAGE)
649
+ if not parsed["cwd"]:
650
+ raise CdxError(RUN_USAGE)
651
+ if bool(parsed["prompt_file"]) == bool(parsed["prompt"]):
652
+ raise CdxError(RUN_USAGE)
653
+ effort = _normalize_reasoning_effort(
654
+ reasoning_effort=parsed["reasoning_effort"],
655
+ power=parsed["power"],
656
+ usage=RUN_USAGE,
657
+ )
658
+ return {
659
+ "name": name,
660
+ "provider": parsed["provider"],
661
+ "cwd": parsed["cwd"],
662
+ "prompt_file": parsed["prompt_file"],
663
+ "prompt": parsed["prompt"],
664
+ "model": parsed["model"],
665
+ "permission": parsed["permission"],
666
+ "timeout_seconds": parsed["timeout_seconds"],
667
+ "reasoning_effort": effort.get("reasoning_effort"),
668
+ "power": effort.get("power"),
669
+ }
670
+
671
+
546
672
  def _parse_positive_int(value):
547
673
  try:
548
674
  parsed = int(value)
@@ -1025,6 +1151,223 @@ def handle_enable(rest, ctx):
1025
1151
  return 0
1026
1152
 
1027
1153
 
1154
+ def handle_select(rest, ctx):
1155
+ parsed = _parse_select_args(rest)
1156
+ selected = _select_headless_session(
1157
+ ctx,
1158
+ parsed["provider"],
1159
+ min_reasoning_effort=parsed["min_reasoning_effort"],
1160
+ require_ready=parsed["require_ready"],
1161
+ force_refresh=parsed["refresh"],
1162
+ )
1163
+ policy = "ready_then_cooldown_then_health_then_priority_then_name"
1164
+ if not selected:
1165
+ message = "No suitable session found."
1166
+ _write_json(ctx, _json_failure(
1167
+ "select",
1168
+ "no_suitable_session",
1169
+ message,
1170
+ provider=parsed["provider"],
1171
+ min_reasoning_effort=parsed["min_reasoning_effort"],
1172
+ require_ready=parsed["require_ready"],
1173
+ selection_policy=policy,
1174
+ ))
1175
+ return 1
1176
+ session = selected["session"]
1177
+ row = selected.get("row") or {}
1178
+ reason = "highest availability suitable session"
1179
+ _write_json(ctx, _json_success(
1180
+ "select",
1181
+ f"Selected session {session['name']}",
1182
+ session=session["name"],
1183
+ provider=session["provider"],
1184
+ reason=reason,
1185
+ selection_policy=policy,
1186
+ min_reasoning_effort=parsed["min_reasoning_effort"],
1187
+ reasoning_effort=_session_reasoning_effort(session),
1188
+ available_pct=row.get("available_pct"),
1189
+ auth_status=row.get("auth_status"),
1190
+ ))
1191
+ return 0
1192
+
1193
+
1194
+ def _read_run_prompt(parsed):
1195
+ if parsed.get("prompt") is not None:
1196
+ return parsed["prompt"]
1197
+ try:
1198
+ with open(parsed["prompt_file"], "r", encoding="utf-8") as handle:
1199
+ return handle.read()
1200
+ except OSError as error:
1201
+ raise CdxError(f"Unable to read prompt file: {parsed['prompt_file']}") from error
1202
+
1203
+
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
+ def _run_cdx_error_code(error):
1214
+ message = str(error)
1215
+ if message.startswith("Usage:"):
1216
+ return "invalid_request"
1217
+ if message.startswith("Invalid cwd:"):
1218
+ return "invalid_cwd"
1219
+ if message.startswith("Session is disabled:"):
1220
+ return "session_disabled"
1221
+ if "CLI not found on PATH" in message:
1222
+ return "provider_cli_not_found"
1223
+ if message.startswith("Failed to start "):
1224
+ return "provider_start_failed"
1225
+ if (
1226
+ message.startswith("Unsupported reasoning effort:")
1227
+ or message.startswith("Unsupported power:")
1228
+ or "--reasoning-effort and --power must match" in message
1229
+ ):
1230
+ return "invalid_reasoning_effort"
1231
+ return "cdx_error"
1232
+
1233
+
1234
+ def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_source=None, error_code=None):
1235
+ run_info = run_info or {}
1236
+ return {
1237
+ "schema_version": API_SCHEMA_VERSION,
1238
+ "ok": bool(ok),
1239
+ "action": "run",
1240
+ "session": session.get("name") if session else None,
1241
+ "provider": session.get("provider") if session else parsed.get("provider"),
1242
+ "model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
1243
+ "reasoning_effort": parsed.get("reasoning_effort") or ((session.get("launch") or {}).get("reasoning_effort") if session else None),
1244
+ "power": parsed.get("power") or ((session.get("launch") or {}).get("power") if session else None),
1245
+ "cwd": os.path.abspath(parsed.get("cwd") or os.getcwd()),
1246
+ "run_id": run_info.get("run_id"),
1247
+ "exit_code": run_info.get("returncode"),
1248
+ "duration_seconds": (run_info.get("duration_ms") / 1000.0) if run_info.get("duration_ms") is not None else None,
1249
+ "transcript_path": run_info.get("transcript_path"),
1250
+ "stdout_path": run_info.get("stdout_path"),
1251
+ "stderr_path": run_info.get("stderr_path"),
1252
+ "usage": _run_usage_payload(),
1253
+ "warnings": [],
1254
+ "error": None if ok else {
1255
+ "source": error_source or "cdx",
1256
+ "code": error_code or "cdx_error",
1257
+ "message": str(error) if error else "Run failed.",
1258
+ "provider_code": run_info.get("returncode") if error_source == "provider" else None,
1259
+ },
1260
+ }
1261
+
1262
+
1263
+ def handle_run(rest, ctx):
1264
+ try:
1265
+ parsed = _parse_run_args(rest)
1266
+ session = None
1267
+ if parsed["name"]:
1268
+ session = ctx["service"]["get_session"](parsed["name"])
1269
+ if not session:
1270
+ raise CdxError(f"Unknown session: {parsed['name']}")
1271
+ if session.get("enabled", True) is False:
1272
+ raise CdxError(f"Session is disabled: {parsed['name']}")
1273
+ else:
1274
+ selected = _select_headless_session(
1275
+ ctx,
1276
+ parsed["provider"],
1277
+ min_reasoning_effort=parsed["reasoning_effort"],
1278
+ require_ready=True,
1279
+ force_refresh=False,
1280
+ )
1281
+ if not selected:
1282
+ _write_json(ctx, _json_failure(
1283
+ "run",
1284
+ "no_suitable_session",
1285
+ "No suitable session found.",
1286
+ provider=parsed["provider"],
1287
+ ))
1288
+ return 1
1289
+ session = selected["session"]
1290
+
1291
+ cwd = os.path.abspath(parsed["cwd"])
1292
+ if not os.path.isdir(cwd):
1293
+ raise CdxError(f"Invalid cwd: {parsed['cwd']}")
1294
+ prompt = _read_run_prompt(parsed)
1295
+ launch_updates = {}
1296
+ if parsed.get("model"):
1297
+ launch_updates["model"] = parsed["model"]
1298
+ if parsed.get("permission"):
1299
+ launch_updates["permission"] = parsed["permission"]
1300
+ if parsed.get("reasoning_effort"):
1301
+ launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
1302
+ launch_updates["power"] = parsed["reasoning_effort"]
1303
+ run_session = {
1304
+ **session,
1305
+ "launch": {
1306
+ **(session.get("launch") or {}),
1307
+ **launch_updates,
1308
+ },
1309
+ }
1310
+ _ensure_session_authentication(
1311
+ run_session,
1312
+ ctx["service"],
1313
+ spawn=ctx.get("spawn"),
1314
+ spawn_sync=ctx.get("spawn_sync"),
1315
+ env_override=ctx.get("env"),
1316
+ stdin_is_tty=False,
1317
+ behavior="launch",
1318
+ signal_emitter=ctx.get("signal_emitter"),
1319
+ )
1320
+ run_info = _run_headless_provider_command(
1321
+ run_session,
1322
+ cwd=cwd,
1323
+ env_override=ctx.get("env"),
1324
+ initial_prompt=prompt,
1325
+ timeout_seconds=parsed.get("timeout_seconds"),
1326
+ spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
1327
+ )
1328
+ ok = run_info.get("returncode") == 0
1329
+ if ok:
1330
+ ctx["service"]["record_launch_history"](session["name"], {
1331
+ "status": "success",
1332
+ "cwd": cwd,
1333
+ "exit_code": 0,
1334
+ **run_info,
1335
+ })
1336
+ _write_json(ctx, _run_result_payload(True, parsed, run_session, run_info=run_info))
1337
+ return 0
1338
+ message = "Provider process timed out." if run_info.get("timed_out") else "Provider process exited with a non-zero status."
1339
+ error = CdxError(message, run_info.get("returncode") or 1)
1340
+ ctx["service"]["record_launch_history"](session["name"], {
1341
+ "status": "failed",
1342
+ "cwd": cwd,
1343
+ "error": str(error),
1344
+ "exit_code": error.exit_code,
1345
+ **run_info,
1346
+ })
1347
+ _write_json(ctx, _run_result_payload(
1348
+ False,
1349
+ parsed,
1350
+ run_session,
1351
+ run_info=run_info,
1352
+ error=error,
1353
+ error_source="provider",
1354
+ error_code="provider_timeout" if run_info.get("timed_out") else "provider_failed",
1355
+ ))
1356
+ return error.exit_code or 1
1357
+ except CdxError as error:
1358
+ run_info = getattr(error, "run_info", None)
1359
+ _write_json(ctx, _run_result_payload(
1360
+ False,
1361
+ locals().get("parsed", {}) or {},
1362
+ locals().get("session"),
1363
+ run_info=run_info,
1364
+ error=error,
1365
+ error_source="cdx",
1366
+ error_code=_run_cdx_error_code(error),
1367
+ ))
1368
+ return error.exit_code
1369
+
1370
+
1028
1371
  def _format_launch_config(session):
1029
1372
  launch = session.get("launch") or {}
1030
1373
  return "\n".join([
@@ -1033,6 +1376,7 @@ def _format_launch_config(session):
1033
1376
  f"permission: {launch.get('permission') or 'default'}",
1034
1377
  f"fast: {'on' if launch.get('fast') is True else 'off' if launch.get('fast') is False else 'default'}",
1035
1378
  f"model: {launch.get('model') or 'default'}",
1379
+ f"priority: {launch.get('priority') if launch.get('priority') is not None else 'default'}",
1036
1380
  ])
1037
1381
 
1038
1382
 
@@ -1062,6 +1406,87 @@ def _resolve_bulk_launch_targets(parsed, service):
1062
1406
  return targets
1063
1407
 
1064
1408
 
1409
+ def _reasoning_rank(value):
1410
+ order = {"low": 0, "medium": 1, "high": 2, "xhigh": 2, "max": 2}
1411
+ return order.get(str(value or "low").lower(), 0)
1412
+
1413
+
1414
+ def _session_reasoning_effort(session):
1415
+ launch = session.get("launch") or {}
1416
+ return (
1417
+ launch.get("reasoning_effort")
1418
+ or launch.get("reasoningEffort")
1419
+ or launch.get("power")
1420
+ or ("low" if launch.get("fast") is True else None)
1421
+ or "low"
1422
+ )
1423
+
1424
+
1425
+ def _session_selection_priority(session):
1426
+ launch = session.get("launch") or {}
1427
+ try:
1428
+ return int(launch.get("priority") or 0)
1429
+ except (TypeError, ValueError):
1430
+ return 0
1431
+
1432
+
1433
+ def _row_blocks_ready(row):
1434
+ if row.get("provider") in (PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA):
1435
+ auth = "n/a"
1436
+ else:
1437
+ auth = row.get("auth_status") or "unknown"
1438
+ if auth not in ("authenticated", "n/a"):
1439
+ return True
1440
+ available = row.get("available_pct")
1441
+ if available is not None:
1442
+ try:
1443
+ if float(available) <= 0:
1444
+ return True
1445
+ except (TypeError, ValueError):
1446
+ return True
1447
+ return False
1448
+
1449
+
1450
+ def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False):
1451
+ sessions = [
1452
+ session for session in ctx["service"]["list_sessions"]()
1453
+ if session.get("provider") == provider and session.get("enabled", True) is not False
1454
+ ]
1455
+ minimum = _reasoning_rank(min_reasoning_effort)
1456
+ sessions = [
1457
+ session for session in sessions
1458
+ if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
1459
+ ]
1460
+ rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
1461
+ row_by_name = {row.get("session_name"): row for row in rows}
1462
+ candidates = []
1463
+ for session in sessions:
1464
+ row = row_by_name.get(session["name"], {})
1465
+ if require_ready and _row_blocks_ready(row):
1466
+ continue
1467
+ available = row.get("available_pct")
1468
+ try:
1469
+ available_sort = float(available) if available is not None else -1.0
1470
+ except (TypeError, ValueError):
1471
+ available_sort = -1.0
1472
+ cooldown_sort = 1 if available_sort > 0 else 0
1473
+ candidates.append({
1474
+ "session": session,
1475
+ "row": row,
1476
+ "sort_key": (
1477
+ -cooldown_sort,
1478
+ -available_sort,
1479
+ -_session_selection_priority(session),
1480
+ _reasoning_rank(_session_reasoning_effort(session)),
1481
+ session["name"],
1482
+ ),
1483
+ })
1484
+ if not candidates:
1485
+ return None
1486
+ candidates.sort(key=lambda item: item["sort_key"])
1487
+ return candidates[0]
1488
+
1489
+
1065
1490
  def _format_bulk_launch_summary(sessions):
1066
1491
  names = [session["name"] for session in sessions]
1067
1492
  if len(names) <= 8:
@@ -6,6 +6,7 @@ import shlex
6
6
  import shutil
7
7
  import subprocess
8
8
  import sys
9
+ import uuid
9
10
  from datetime import datetime, timezone
10
11
 
11
12
  from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
@@ -13,6 +14,7 @@ from .errors import CdxError
13
14
 
14
15
 
15
16
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
17
+ REASONING_EFFORT_VALUES = {"low", "medium", "high"}
16
18
  LAUNCH_PERMISSION_ARGS = {
17
19
  PROVIDER_CLAUDE: {
18
20
  "review": ["--permission-mode", "plan"],
@@ -158,7 +160,7 @@ def _build_launch_transcript_path(session):
158
160
 
159
161
  def _launch_power(session):
160
162
  launch = session.get("launch") or {}
161
- power = launch.get("power")
163
+ power = launch.get("reasoning_effort") or launch.get("reasoningEffort") or launch.get("power")
162
164
  if power:
163
165
  return power
164
166
  if launch.get("fast") is True:
@@ -166,6 +168,28 @@ def _launch_power(session):
166
168
  return None
167
169
 
168
170
 
171
+ def _normalize_reasoning_effort(reasoning_effort=None, power=None, usage="Unsupported reasoning effort."):
172
+ effort = str(reasoning_effort).strip().lower() if reasoning_effort is not None else None
173
+ alias = str(power).strip().lower() if power is not None else None
174
+ if effort == "":
175
+ raise CdxError(usage)
176
+ if alias == "":
177
+ raise CdxError(usage)
178
+ if effort and effort not in REASONING_EFFORT_VALUES:
179
+ raise CdxError(f"Unsupported reasoning effort: {reasoning_effort}")
180
+ if alias and alias not in REASONING_EFFORT_VALUES:
181
+ raise CdxError(f"Unsupported power: {power}")
182
+ if effort and alias and effort != alias:
183
+ raise CdxError("--reasoning-effort and --power must match when both are provided.")
184
+ resolved = effort or alias
185
+ if not resolved:
186
+ return {}
187
+ return {
188
+ "reasoning_effort": resolved,
189
+ "power": resolved,
190
+ }
191
+
192
+
169
193
  def _launch_config_args(session):
170
194
  launch = session.get("launch") or {}
171
195
  args = []
@@ -254,7 +278,7 @@ def _default_script_args(transcript_path, spec):
254
278
  return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
255
279
 
256
280
 
257
- def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
281
+ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None, capture_transcript=True):
258
282
  if initial_prompt is not None:
259
283
  if not isinstance(initial_prompt, str):
260
284
  raise CdxError("initial_prompt must be a string.")
@@ -280,7 +304,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
280
304
  "env": claude_env,
281
305
  },
282
306
  "label": "claude",
283
- }, env=env)
307
+ }, capture_transcript=capture_transcript, env=env)
284
308
  if session["provider"] == PROVIDER_ANTIGRAVITY:
285
309
  args = _launch_config_args(session)
286
310
  if initial_prompt:
@@ -293,7 +317,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
293
317
  "env": {**env, **_antigravity_env_overrides(_get_auth_home(session))},
294
318
  },
295
319
  "label": "antigravity",
296
- }, env=env)
320
+ }, capture_transcript=capture_transcript, env=env)
297
321
  if session["provider"] == PROVIDER_OLLAMA:
298
322
  launch = session.get("launch") or {}
299
323
  model = launch.get("model") or session["name"]
@@ -309,7 +333,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
309
333
  "env": ollama_env,
310
334
  },
311
335
  "label": "ollama",
312
- }, env=env)
336
+ }, capture_transcript=capture_transcript, env=env)
313
337
  args = ["--no-alt-screen", "--cd", cwd] + _launch_config_args(session)
314
338
  if initial_prompt:
315
339
  args.append(initial_prompt)
@@ -320,7 +344,132 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
320
344
  "env": {**env, "CODEX_HOME": _get_auth_home(session)},
321
345
  },
322
346
  "label": "codex",
323
- }, env=env)
347
+ }, capture_transcript=capture_transcript, env=env)
348
+
349
+
350
+ def _headless_artifact_paths(session, run_id=None):
351
+ run_id = run_id or uuid.uuid4().hex
352
+ log_dir = _get_launch_transcript_dir(session)
353
+ os.makedirs(log_dir, exist_ok=True)
354
+ prefix = os.path.join(log_dir, f"cdx-run-{run_id}")
355
+ return {
356
+ "run_id": run_id,
357
+ "transcript_path": os.path.abspath(f"{prefix}.log"),
358
+ "stdout_path": os.path.abspath(f"{prefix}.stdout.log"),
359
+ "stderr_path": os.path.abspath(f"{prefix}.stderr.log"),
360
+ }
361
+
362
+
363
+ def _combine_headless_transcript(paths):
364
+ transcript_path = paths["transcript_path"]
365
+ with open(transcript_path, "w", encoding="utf-8", errors="replace") as transcript:
366
+ for label, path_key in (("stdout", "stdout_path"), ("stderr", "stderr_path")):
367
+ path = paths.get(path_key)
368
+ if not path:
369
+ continue
370
+ transcript.write(f"--- {label} ---\n")
371
+ try:
372
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
373
+ content = handle.read()
374
+ except OSError:
375
+ content = ""
376
+ transcript.write(content)
377
+ if content and not content.endswith("\n"):
378
+ transcript.write("\n")
379
+
380
+
381
+ def _headless_run_info(paths, spec, start_time, returncode):
382
+ end_time = datetime.now(timezone.utc)
383
+ return {
384
+ **paths,
385
+ "started_at": start_time.isoformat().replace("+00:00", "Z"),
386
+ "ended_at": end_time.isoformat().replace("+00:00", "Z"),
387
+ "duration_ms": int((end_time - start_time).total_seconds() * 1000),
388
+ "command": spec.get("command"),
389
+ "args": list(spec.get("args") or []),
390
+ "label": spec.get("label"),
391
+ "pid": None,
392
+ "returncode": returncode,
393
+ "timed_out": False,
394
+ }
395
+
396
+
397
+ def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
398
+ timeout_seconds=None, spawn=None, run_id=None):
399
+ spawn = spawn or subprocess.Popen
400
+ spec = _build_launch_spec(
401
+ session,
402
+ cwd=cwd,
403
+ env_override=env_override,
404
+ initial_prompt=initial_prompt,
405
+ capture_transcript=False,
406
+ )
407
+ paths = _headless_artifact_paths(session, run_id=run_id)
408
+ start_time = datetime.now(timezone.utc)
409
+ command = spec["command"]
410
+ if spawn is subprocess.Popen:
411
+ command = _resolve_command(command, spec.get("options", {}).get("env"))
412
+
413
+ child = None
414
+ timed_out = False
415
+ with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
416
+ open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
417
+ try:
418
+ child = spawn(
419
+ [command] + spec["args"],
420
+ stdout=stdout_file,
421
+ stderr=stderr_file,
422
+ **{k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")},
423
+ )
424
+ except FileNotFoundError as error:
425
+ _combine_headless_transcript(paths)
426
+ cdx_error = CdxError(f"{spec['label']} CLI not found on PATH: {spec['command']}", 127)
427
+ cdx_error.run_info = _headless_run_info(paths, spec, start_time, 127)
428
+ raise cdx_error from error
429
+ except OSError as error:
430
+ _combine_headless_transcript(paths)
431
+ cdx_error = CdxError(f"Failed to start {spec['label']}: {error}", 126)
432
+ cdx_error.run_info = _headless_run_info(paths, spec, start_time, 126)
433
+ raise cdx_error from error
434
+ try:
435
+ if timeout_seconds is None:
436
+ child.wait()
437
+ else:
438
+ child.wait(timeout=timeout_seconds)
439
+ except TypeError:
440
+ child.wait()
441
+ except subprocess.TimeoutExpired:
442
+ timed_out = True
443
+ try:
444
+ child.terminate()
445
+ child.wait(timeout=5)
446
+ except Exception:
447
+ try:
448
+ child.kill()
449
+ except Exception:
450
+ pass
451
+ try:
452
+ child.wait()
453
+ except Exception:
454
+ pass
455
+
456
+ _combine_headless_transcript(paths)
457
+ end_time = datetime.now(timezone.utc)
458
+ returncode = getattr(child, "returncode", None) if child is not None else None
459
+ if timed_out and (returncode is None or returncode == 0):
460
+ returncode = 124
461
+ return {
462
+ **paths,
463
+ "started_at": start_time.isoformat().replace("+00:00", "Z"),
464
+ "ended_at": end_time.isoformat().replace("+00:00", "Z"),
465
+ "duration_ms": int((end_time - start_time).total_seconds() * 1000),
466
+ "command": spec.get("command"),
467
+ "args": list(spec.get("args") or []),
468
+ "label": spec.get("label"),
469
+ "pid": getattr(child, "pid", None),
470
+ "returncode": returncode,
471
+ "timed_out": timed_out,
472
+ }
324
473
 
325
474
 
326
475
  def _build_login_status_spec(session, env_override=None):
@@ -40,6 +40,8 @@ RESERVED_SESSION_NAMES = {
40
40
  "ren",
41
41
  "rename",
42
42
  "rmv",
43
+ "run",
44
+ "select",
43
45
  "config",
44
46
  "set",
45
47
  "status",
@@ -54,8 +56,11 @@ RESERVED_SESSION_NAMES = {
54
56
  STATUS_CACHE_TTL_SECONDS = 60
55
57
  CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
56
58
  LAUNCH_POWER_VALUES = {"low", "medium", "high", "xhigh", "max"}
59
+ LAUNCH_REASONING_EFFORT_VALUES = {"low", "medium", "high"}
57
60
  LAUNCH_PERMISSION_VALUES = {"review", "default", "auto", "full"}
58
61
  MAX_LAUNCH_MODEL_LENGTH = 128
62
+ MIN_LAUNCH_PRIORITY = 0
63
+ MAX_LAUNCH_PRIORITY = 100
59
64
 
60
65
 
61
66
  def _encode(name):
@@ -96,6 +101,11 @@ def _normalize_launch_settings(settings):
96
101
  if power not in LAUNCH_POWER_VALUES:
97
102
  raise CdxError(f"Unsupported power: {settings['power']}")
98
103
  normalized["power"] = power
104
+ if "reasoning_effort" in settings and settings["reasoning_effort"] is not None:
105
+ effort = str(settings["reasoning_effort"]).strip().lower()
106
+ if effort not in LAUNCH_REASONING_EFFORT_VALUES:
107
+ raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}")
108
+ normalized["reasoning_effort"] = effort
99
109
  if "permission" in settings and settings["permission"] is not None:
100
110
  permission = str(settings["permission"]).strip().lower()
101
111
  if permission not in LAUNCH_PERMISSION_VALUES:
@@ -120,6 +130,14 @@ def _normalize_launch_settings(settings):
120
130
  if len(model) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in model):
121
131
  raise CdxError("Model contains unsupported characters.")
122
132
  normalized["model"] = model
133
+ if "priority" in settings and settings["priority"] is not None:
134
+ try:
135
+ priority = int(settings["priority"])
136
+ except (TypeError, ValueError) as error:
137
+ raise CdxError(f"Unsupported priority: {settings['priority']}") from error
138
+ if priority < MIN_LAUNCH_PRIORITY or priority > MAX_LAUNCH_PRIORITY:
139
+ raise CdxError(f"Unsupported priority: {settings['priority']}")
140
+ normalized["priority"] = priority
123
141
  return normalized
124
142
 
125
143
 
@@ -773,7 +791,7 @@ def create_session_service(options=None):
773
791
  raise CdxError(f"Unknown session: {name}")
774
792
  if not keys:
775
793
  raise CdxError("At least one launch setting is required.")
776
- allowed = {"power", "permission", "fast", "model"}
794
+ allowed = {"power", "permission", "fast", "model", "priority"}
777
795
  unknown = [key for key in keys if key not in allowed]
778
796
  if unknown:
779
797
  raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")