cdx-manager 0.6.5 → 0.7.1

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.
@@ -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"],
@@ -27,6 +29,12 @@ LAUNCH_PERMISSION_ARGS = {
27
29
  "full": ["-s", "danger-full-access", "-a", "never"],
28
30
  },
29
31
  }
32
+ HEADLESS_CODEX_PERMISSION_ARGS = {
33
+ "review": ["-s", "read-only"],
34
+ "default": ["-s", "workspace-write"],
35
+ "auto": ["-s", "workspace-write", "-c", 'approval_policy="never"'],
36
+ "full": ["--dangerously-bypass-approvals-and-sandbox"],
37
+ }
30
38
 
31
39
 
32
40
  def _home_env_overrides(auth_home):
@@ -158,7 +166,7 @@ def _build_launch_transcript_path(session):
158
166
 
159
167
  def _launch_power(session):
160
168
  launch = session.get("launch") or {}
161
- power = launch.get("power")
169
+ power = launch.get("reasoning_effort") or launch.get("reasoningEffort") or launch.get("power")
162
170
  if power:
163
171
  return power
164
172
  if launch.get("fast") is True:
@@ -166,6 +174,28 @@ def _launch_power(session):
166
174
  return None
167
175
 
168
176
 
177
+ def _normalize_reasoning_effort(reasoning_effort=None, power=None, usage="Unsupported reasoning effort."):
178
+ effort = str(reasoning_effort).strip().lower() if reasoning_effort is not None else None
179
+ alias = str(power).strip().lower() if power is not None else None
180
+ if effort == "":
181
+ raise CdxError(usage)
182
+ if alias == "":
183
+ raise CdxError(usage)
184
+ if effort and effort not in REASONING_EFFORT_VALUES:
185
+ raise CdxError(f"Unsupported reasoning effort: {reasoning_effort}")
186
+ if alias and alias not in REASONING_EFFORT_VALUES:
187
+ raise CdxError(f"Unsupported power: {power}")
188
+ if effort and alias and effort != alias:
189
+ raise CdxError("--reasoning-effort and --power must match when both are provided.")
190
+ resolved = effort or alias
191
+ if not resolved:
192
+ return {}
193
+ return {
194
+ "reasoning_effort": resolved,
195
+ "power": resolved,
196
+ }
197
+
198
+
169
199
  def _launch_config_args(session):
170
200
  launch = session.get("launch") or {}
171
201
  args = []
@@ -254,12 +284,8 @@ def _default_script_args(transcript_path, spec):
254
284
  return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
255
285
 
256
286
 
257
- def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
258
- if initial_prompt is not None:
259
- if not isinstance(initial_prompt, str):
260
- raise CdxError("initial_prompt must be a string.")
261
- if len(initial_prompt) > 32768:
262
- raise CdxError("initial_prompt exceeds maximum allowed length.")
287
+ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None, capture_transcript=True):
288
+ _validate_initial_prompt(initial_prompt)
263
289
  cwd = cwd or os.getcwd()
264
290
  env_override = env_override or {}
265
291
  env = {**os.environ, **env_override}
@@ -280,7 +306,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
280
306
  "env": claude_env,
281
307
  },
282
308
  "label": "claude",
283
- }, env=env)
309
+ }, capture_transcript=capture_transcript, env=env)
284
310
  if session["provider"] == PROVIDER_ANTIGRAVITY:
285
311
  args = _launch_config_args(session)
286
312
  if initial_prompt:
@@ -293,7 +319,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
293
319
  "env": {**env, **_antigravity_env_overrides(_get_auth_home(session))},
294
320
  },
295
321
  "label": "antigravity",
296
- }, env=env)
322
+ }, capture_transcript=capture_transcript, env=env)
297
323
  if session["provider"] == PROVIDER_OLLAMA:
298
324
  launch = session.get("launch") or {}
299
325
  model = launch.get("model") or session["name"]
@@ -309,7 +335,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
309
335
  "env": ollama_env,
310
336
  },
311
337
  "label": "ollama",
312
- }, env=env)
338
+ }, capture_transcript=capture_transcript, env=env)
313
339
  args = ["--no-alt-screen", "--cd", cwd] + _launch_config_args(session)
314
340
  if initial_prompt:
315
341
  args.append(initial_prompt)
@@ -320,7 +346,193 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
320
346
  "env": {**env, "CODEX_HOME": _get_auth_home(session)},
321
347
  },
322
348
  "label": "codex",
323
- }, env=env)
349
+ }, capture_transcript=capture_transcript, env=env)
350
+
351
+
352
+ def _validate_initial_prompt(initial_prompt):
353
+ if initial_prompt is not None:
354
+ if not isinstance(initial_prompt, str):
355
+ raise CdxError("initial_prompt must be a string.")
356
+ if len(initial_prompt) > 32768:
357
+ raise CdxError("initial_prompt exceeds maximum allowed length.")
358
+
359
+
360
+ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
361
+ _validate_initial_prompt(initial_prompt)
362
+ cwd = cwd or os.getcwd()
363
+ env = {**os.environ, **(env_override or {})}
364
+ launch = session.get("launch") or {}
365
+ power = _launch_power(session)
366
+ permission = launch.get("permission")
367
+ model = launch.get("model")
368
+
369
+ if session["provider"] == PROVIDER_CLAUDE:
370
+ args = ["--print", "--output-format", "json", "--name", session["name"]]
371
+ if model:
372
+ args += ["--model", model]
373
+ args += _launch_config_args(session)
374
+ if initial_prompt:
375
+ args.append(initial_prompt)
376
+ auth_home = _get_auth_home(session)
377
+ claude_env = _claude_env(env, auth_home)
378
+ oauth_token = _read_anthropic_oauth_token(auth_home)
379
+ if oauth_token:
380
+ claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
381
+ return {
382
+ "command": "claude",
383
+ "args": args,
384
+ "options": {"cwd": cwd, "env": claude_env},
385
+ "label": "claude",
386
+ }
387
+
388
+ if session["provider"] == PROVIDER_CODEX:
389
+ args = ["exec", "--json", "-C", cwd]
390
+ if model:
391
+ args += ["-m", model]
392
+ if power:
393
+ args += ["-c", f'model_reasoning_effort="{power}"']
394
+ if permission:
395
+ args += HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
396
+ if initial_prompt:
397
+ args.append(initial_prompt)
398
+ return {
399
+ "command": "codex",
400
+ "args": args,
401
+ "options": {"cwd": cwd, "env": {**env, "CODEX_HOME": _get_auth_home(session)}},
402
+ "label": "codex",
403
+ }
404
+
405
+ return _build_launch_spec(
406
+ session,
407
+ cwd=cwd,
408
+ env_override=env_override,
409
+ initial_prompt=initial_prompt,
410
+ capture_transcript=False,
411
+ )
412
+
413
+
414
+ def _headless_artifact_paths(session, run_id=None):
415
+ run_id = run_id or uuid.uuid4().hex
416
+ log_dir = _get_launch_transcript_dir(session)
417
+ os.makedirs(log_dir, exist_ok=True)
418
+ prefix = os.path.join(log_dir, f"cdx-run-{run_id}")
419
+ return {
420
+ "run_id": run_id,
421
+ "transcript_path": os.path.abspath(f"{prefix}.log"),
422
+ "stdout_path": os.path.abspath(f"{prefix}.stdout.log"),
423
+ "stderr_path": os.path.abspath(f"{prefix}.stderr.log"),
424
+ }
425
+
426
+
427
+ def _combine_headless_transcript(paths):
428
+ transcript_path = paths["transcript_path"]
429
+ with open(transcript_path, "w", encoding="utf-8", errors="replace") as transcript:
430
+ for label, path_key in (("stdout", "stdout_path"), ("stderr", "stderr_path")):
431
+ path = paths.get(path_key)
432
+ if not path:
433
+ continue
434
+ transcript.write(f"--- {label} ---\n")
435
+ try:
436
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
437
+ content = handle.read()
438
+ except OSError:
439
+ content = ""
440
+ transcript.write(content)
441
+ if content and not content.endswith("\n"):
442
+ transcript.write("\n")
443
+
444
+
445
+ def _headless_run_info(paths, spec, start_time, returncode):
446
+ end_time = datetime.now(timezone.utc)
447
+ return {
448
+ **paths,
449
+ "started_at": start_time.isoformat().replace("+00:00", "Z"),
450
+ "ended_at": end_time.isoformat().replace("+00:00", "Z"),
451
+ "duration_ms": int((end_time - start_time).total_seconds() * 1000),
452
+ "command": spec.get("command"),
453
+ "args": list(spec.get("args") or []),
454
+ "label": spec.get("label"),
455
+ "pid": None,
456
+ "returncode": returncode,
457
+ "timed_out": False,
458
+ }
459
+
460
+
461
+ def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
462
+ timeout_seconds=None, spawn=None, run_id=None):
463
+ spawn = spawn or subprocess.Popen
464
+ spec = _build_headless_launch_spec(
465
+ session,
466
+ cwd=cwd,
467
+ env_override=env_override,
468
+ initial_prompt=initial_prompt,
469
+ )
470
+ paths = _headless_artifact_paths(session, run_id=run_id)
471
+ start_time = datetime.now(timezone.utc)
472
+ command = spec["command"]
473
+ if spawn is subprocess.Popen:
474
+ command = _resolve_command(command, spec.get("options", {}).get("env"))
475
+
476
+ child = None
477
+ timed_out = False
478
+ with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
479
+ open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
480
+ try:
481
+ child = spawn(
482
+ [command] + spec["args"],
483
+ stdout=stdout_file,
484
+ stderr=stderr_file,
485
+ **{k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")},
486
+ )
487
+ except FileNotFoundError as error:
488
+ _combine_headless_transcript(paths)
489
+ cdx_error = CdxError(f"{spec['label']} CLI not found on PATH: {spec['command']}", 127)
490
+ cdx_error.run_info = _headless_run_info(paths, spec, start_time, 127)
491
+ raise cdx_error from error
492
+ except OSError as error:
493
+ _combine_headless_transcript(paths)
494
+ cdx_error = CdxError(f"Failed to start {spec['label']}: {error}", 126)
495
+ cdx_error.run_info = _headless_run_info(paths, spec, start_time, 126)
496
+ raise cdx_error from error
497
+ try:
498
+ if timeout_seconds is None:
499
+ child.wait()
500
+ else:
501
+ child.wait(timeout=timeout_seconds)
502
+ except TypeError:
503
+ child.wait()
504
+ except subprocess.TimeoutExpired:
505
+ timed_out = True
506
+ try:
507
+ child.terminate()
508
+ child.wait(timeout=5)
509
+ except Exception:
510
+ try:
511
+ child.kill()
512
+ except Exception:
513
+ pass
514
+ try:
515
+ child.wait()
516
+ except Exception:
517
+ pass
518
+
519
+ _combine_headless_transcript(paths)
520
+ end_time = datetime.now(timezone.utc)
521
+ returncode = getattr(child, "returncode", None) if child is not None else None
522
+ if timed_out and (returncode is None or returncode == 0):
523
+ returncode = 124
524
+ return {
525
+ **paths,
526
+ "started_at": start_time.isoformat().replace("+00:00", "Z"),
527
+ "ended_at": end_time.isoformat().replace("+00:00", "Z"),
528
+ "duration_ms": int((end_time - start_time).total_seconds() * 1000),
529
+ "command": spec.get("command"),
530
+ "args": list(spec.get("args") or []),
531
+ "label": spec.get("label"),
532
+ "pid": getattr(child, "pid", None),
533
+ "returncode": returncode,
534
+ "timed_out": timed_out,
535
+ }
324
536
 
325
537
 
326
538
  def _build_login_status_spec(session, env_override=None):
@@ -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)
@@ -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
 
@@ -171,14 +189,23 @@ def _to_local_iso(value):
171
189
  return parsed.astimezone().isoformat()
172
190
 
173
191
 
192
+ def _normalize_pct_value(value):
193
+ if value is None:
194
+ return None
195
+ try:
196
+ return max(0, min(100, round(float(value))))
197
+ except (TypeError, ValueError):
198
+ return None
199
+
200
+
174
201
  def _normalize_status_payload(payload=None):
175
202
  if payload is None:
176
203
  payload = {}
177
204
  now = _local_now_iso()
178
205
  return {
179
- "usage_pct": payload.get("usage_pct"),
180
- "remaining_5h_pct": payload.get("remaining_5h_pct"),
181
- "remaining_week_pct": payload.get("remaining_week_pct"),
206
+ "usage_pct": _normalize_pct_value(payload.get("usage_pct")),
207
+ "remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
208
+ "remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
182
209
  "credits": payload.get("credits"),
183
210
  "reset_5h_at": payload.get("reset_5h_at"),
184
211
  "reset_week_at": payload.get("reset_week_at"),
@@ -279,8 +306,8 @@ def _compute_available_pct(status):
279
306
  if not status:
280
307
  return None
281
308
  values = [
282
- status.get("remaining_5h_pct"),
283
- status.get("remaining_week_pct"),
309
+ _normalize_pct_value(status.get("remaining_5h_pct")),
310
+ _normalize_pct_value(status.get("remaining_week_pct")),
284
311
  ]
285
312
  values = [value for value in values if value is not None]
286
313
  if not values:
@@ -773,7 +800,7 @@ def create_session_service(options=None):
773
800
  raise CdxError(f"Unknown session: {name}")
774
801
  if not keys:
775
802
  raise CdxError("At least one launch setting is required.")
776
- allowed = {"power", "permission", "fast", "model"}
803
+ allowed = {"power", "permission", "fast", "model", "priority"}
777
804
  unknown = [key for key in keys if key not in allowed]
778
805
  if unknown:
779
806
  raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
@@ -963,8 +990,8 @@ def create_session_service(options=None):
963
990
  "status": "enabled" if enabled else "disabled",
964
991
  "auth_status": (s.get("auth") or {}).get("status") or "unknown",
965
992
  "auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
966
- "remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
967
- "remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
993
+ "remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
994
+ "remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
968
995
  "credits": row_status.get("credits") if row_status else None,
969
996
  "available_pct": _compute_available_pct(row_status),
970
997
  "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,