cdx-manager 0.6.4 → 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.
@@ -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"],
@@ -96,6 +98,23 @@ def _has_local_claude_auth(auth_home):
96
98
  return bool(isinstance(oauth, dict) and _clean_oauth_token(oauth.get("accessToken")))
97
99
 
98
100
 
101
+ def _has_local_codex_auth(auth_home):
102
+ try:
103
+ with open(os.path.join(auth_home, "auth.json"), "r", encoding="utf-8") as handle:
104
+ auth = json.load(handle)
105
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
106
+ return False
107
+ if not isinstance(auth, dict):
108
+ return False
109
+ tokens = auth.get("tokens")
110
+ if not isinstance(tokens, dict):
111
+ return False
112
+ return any(
113
+ _clean_oauth_token(tokens.get(name))
114
+ for name in ("id_token", "access_token", "refresh_token")
115
+ )
116
+
117
+
99
118
  def _read_claude_account_email(auth_home):
100
119
  config_path = os.path.join(auth_home, ".claude.json")
101
120
  try:
@@ -141,7 +160,7 @@ def _build_launch_transcript_path(session):
141
160
 
142
161
  def _launch_power(session):
143
162
  launch = session.get("launch") or {}
144
- power = launch.get("power")
163
+ power = launch.get("reasoning_effort") or launch.get("reasoningEffort") or launch.get("power")
145
164
  if power:
146
165
  return power
147
166
  if launch.get("fast") is True:
@@ -149,6 +168,28 @@ def _launch_power(session):
149
168
  return None
150
169
 
151
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
+
152
193
  def _launch_config_args(session):
153
194
  launch = session.get("launch") or {}
154
195
  args = []
@@ -237,7 +278,7 @@ def _default_script_args(transcript_path, spec):
237
278
  return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
238
279
 
239
280
 
240
- 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):
241
282
  if initial_prompt is not None:
242
283
  if not isinstance(initial_prompt, str):
243
284
  raise CdxError("initial_prompt must be a string.")
@@ -263,7 +304,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
263
304
  "env": claude_env,
264
305
  },
265
306
  "label": "claude",
266
- }, env=env)
307
+ }, capture_transcript=capture_transcript, env=env)
267
308
  if session["provider"] == PROVIDER_ANTIGRAVITY:
268
309
  args = _launch_config_args(session)
269
310
  if initial_prompt:
@@ -276,7 +317,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
276
317
  "env": {**env, **_antigravity_env_overrides(_get_auth_home(session))},
277
318
  },
278
319
  "label": "antigravity",
279
- }, env=env)
320
+ }, capture_transcript=capture_transcript, env=env)
280
321
  if session["provider"] == PROVIDER_OLLAMA:
281
322
  launch = session.get("launch") or {}
282
323
  model = launch.get("model") or session["name"]
@@ -292,7 +333,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
292
333
  "env": ollama_env,
293
334
  },
294
335
  "label": "ollama",
295
- }, env=env)
336
+ }, capture_transcript=capture_transcript, env=env)
296
337
  args = ["--no-alt-screen", "--cd", cwd] + _launch_config_args(session)
297
338
  if initial_prompt:
298
339
  args.append(initial_prompt)
@@ -303,7 +344,132 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
303
344
  "env": {**env, "CODEX_HOME": _get_auth_home(session)},
304
345
  },
305
346
  "label": "codex",
306
- }, 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
+ }
307
473
 
308
474
 
309
475
  def _build_login_status_spec(session, env_override=None):
@@ -397,10 +563,8 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None):
397
563
  spec = _build_login_status_spec(session, env_override)
398
564
  if session.get("provider") == PROVIDER_CLAUDE and _has_local_claude_auth(_get_auth_home(session)):
399
565
  return True
400
- if session.get("provider") == PROVIDER_CODEX:
401
- auth_path = os.path.join(_get_auth_home(session), "auth.json")
402
- if os.path.isfile(auth_path):
403
- return True
566
+ if session.get("provider") == PROVIDER_CODEX and _has_local_codex_auth(_get_auth_home(session)):
567
+ return True
404
568
  try:
405
569
  if spawn_sync is subprocess.run:
406
570
  command = _resolve_command(spec["command"], spec["env"])
@@ -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)}")