cdx-manager 0.14.0 → 0.15.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 +84 -18
- package/changelogs/CHANGELOGS_0_15_0.md +139 -0
- package/checksums/release-archives.json +12 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli_args.py +51 -7
- package/src/cli_helpers.py +13 -0
- package/src/cli_render.py +17 -0
- package/src/commands/runs.py +306 -19
- package/src/commands/status.py +41 -9
- package/src/config.py +29 -0
- package/src/provider_background.py +263 -0
- package/src/provider_runtime.py +96 -4
- package/src/run_failover.py +221 -0
- package/src/run_registry.py +74 -0
- package/src/session_service.py +126 -2
- package/src/session_status.py +17 -1
- package/src/status_source.py +45 -0
package/src/commands/runs.py
CHANGED
|
@@ -21,8 +21,27 @@ from ..cli_args import (
|
|
|
21
21
|
)
|
|
22
22
|
from ..cli_helpers import API_SCHEMA_VERSION, _json_failure, _json_success, _write_json
|
|
23
23
|
from ..errors import CdxError
|
|
24
|
-
from ..
|
|
24
|
+
from ..provider_background import (
|
|
25
|
+
BACKGROUND_PATH_CDX,
|
|
26
|
+
BACKGROUND_PATH_PROVIDER,
|
|
27
|
+
agent_terminal_status,
|
|
28
|
+
find_agent,
|
|
29
|
+
find_session_transcript,
|
|
30
|
+
list_background_agents,
|
|
31
|
+
parse_backgrounded_id,
|
|
32
|
+
read_transcript_outcome,
|
|
33
|
+
stop_background_agent,
|
|
34
|
+
supports_native_background,
|
|
35
|
+
turn_is_complete,
|
|
36
|
+
)
|
|
37
|
+
from ..provider_runtime import (
|
|
38
|
+
_build_launch_spec,
|
|
39
|
+
_ensure_session_authentication,
|
|
40
|
+
_headless_artifact_paths,
|
|
41
|
+
_run_headless_provider_command,
|
|
42
|
+
)
|
|
25
43
|
from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
|
|
44
|
+
from ..run_failover import MAX_FAILOVER_TRANSITIONS, failover_reason
|
|
26
45
|
from ..run_registry import RunRegistry, build_code_review_report
|
|
27
46
|
from ..run_usage import extract_run_usage
|
|
28
47
|
from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
|
|
@@ -233,18 +252,144 @@ def _spawn_detached_run(parsed, prompt, artifacts, ctx, session_name, cwd):
|
|
|
233
252
|
# registered run failed, instead of letting a raw OSError escape as an
|
|
234
253
|
# unhandled traceback on a --json call.
|
|
235
254
|
raise CdxError(f"Failed to start detached cdx run: {error}", 126) from error
|
|
236
|
-
return {
|
|
255
|
+
return {
|
|
256
|
+
"pid": getattr(child, "pid", None),
|
|
257
|
+
"launch_log_path": launch_log_path,
|
|
258
|
+
"background_path": BACKGROUND_PATH_CDX,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _spawn_provider_background_run(run_session, prompt, artifacts, ctx, cwd):
|
|
263
|
+
"""Start the run as the provider's own background agent.
|
|
264
|
+
|
|
265
|
+
Returns the same shape as the in-house launcher so the caller does not
|
|
266
|
+
branch on which path ran, plus the provider handle needed to observe it
|
|
267
|
+
later. Returns None when the provider declines to start one, which sends
|
|
268
|
+
the caller back to the in-house path rather than failing the run.
|
|
269
|
+
"""
|
|
270
|
+
# Derived from the interactive spec, not the headless one. `--bg` starts a
|
|
271
|
+
# session; it does not pass `--print`, and the headless spec is built around
|
|
272
|
+
# flags the provider accepts only alongside `--print` - `--output-format`,
|
|
273
|
+
# `--max-budget-usd`, `--fallback-model`. Reusing it and stripping `--print`
|
|
274
|
+
# would hand the CLI print-only flags without `--print` and have the launch
|
|
275
|
+
# rejected.
|
|
276
|
+
spec = _build_launch_spec(
|
|
277
|
+
run_session, cwd=cwd, env_override=ctx.get("env"),
|
|
278
|
+
initial_prompt=prompt, capture_transcript=False,
|
|
279
|
+
)
|
|
280
|
+
argv = [spec["command"], "--bg"] + list(spec["args"])
|
|
281
|
+
env = (spec.get("options") or {}).get("env")
|
|
282
|
+
spawn = ctx.get("spawn_detached") or subprocess.Popen
|
|
283
|
+
prefix = os.path.splitext(artifacts["transcript_path"])[0]
|
|
284
|
+
launch_log_path = f"{prefix}.launch.log"
|
|
285
|
+
try:
|
|
286
|
+
with open(launch_log_path, "w", encoding="utf-8", errors="replace") as log_file:
|
|
287
|
+
child = spawn(
|
|
288
|
+
argv,
|
|
289
|
+
stdout=log_file,
|
|
290
|
+
stderr=subprocess.STDOUT,
|
|
291
|
+
stdin=subprocess.DEVNULL,
|
|
292
|
+
cwd=cwd,
|
|
293
|
+
env=env,
|
|
294
|
+
**_detached_spawn_options(),
|
|
295
|
+
)
|
|
296
|
+
# --bg returns immediately, so this waits for the launcher itself, not
|
|
297
|
+
# for the task: the id line is printed before it exits.
|
|
298
|
+
if hasattr(child, "wait"):
|
|
299
|
+
child.wait()
|
|
300
|
+
except OSError:
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
with open(launch_log_path, encoding="utf-8", errors="replace") as handle:
|
|
304
|
+
identifier = parse_backgrounded_id(handle.read())
|
|
305
|
+
if not identifier:
|
|
306
|
+
# The agent may nonetheless be running. Falling back to the in-house
|
|
307
|
+
# launcher here would run the same task twice, once untracked, on the
|
|
308
|
+
# user's account - so this fails loudly instead.
|
|
309
|
+
raise CdxError(
|
|
310
|
+
"Started a provider background agent but it did not report an id; "
|
|
311
|
+
"refusing to launch a second run for the same task.",
|
|
312
|
+
126,
|
|
313
|
+
)
|
|
314
|
+
agent = find_agent(
|
|
315
|
+
list_background_agents(env=env, cwd=cwd, spawn_sync=ctx.get("spawn_text")),
|
|
316
|
+
identifier,
|
|
317
|
+
) or {}
|
|
318
|
+
return {
|
|
319
|
+
"pid": agent.get("pid"),
|
|
320
|
+
"launch_log_path": launch_log_path,
|
|
321
|
+
"background_path": BACKGROUND_PATH_PROVIDER,
|
|
322
|
+
"provider_session_id": agent.get("sessionId") or identifier,
|
|
323
|
+
"provider_agent_id": identifier,
|
|
324
|
+
}
|
|
237
325
|
|
|
238
326
|
def handle_schema(rest, ctx):
|
|
239
327
|
_parse_schema_args(rest)
|
|
240
328
|
_write_json(ctx, _json_success("schema", "Schema loaded.", **cdx_schema()))
|
|
241
329
|
return 0
|
|
242
330
|
|
|
331
|
+
def _reconcile_background_runs(ctx):
|
|
332
|
+
"""Close out delegated runs the provider has finished.
|
|
333
|
+
|
|
334
|
+
A delegated run has no cdx child to report its own completion, so the
|
|
335
|
+
registry would leave it `running` forever. This asks the provider once, on
|
|
336
|
+
the read paths a caller actually uses, and closes what has finished -
|
|
337
|
+
including the usage and final result read back from the session transcript,
|
|
338
|
+
which is the only place a `--bg` run records them.
|
|
339
|
+
|
|
340
|
+
Never raises: a provider that is slow, missing, or unparseable must not
|
|
341
|
+
take down `cdx runs`.
|
|
342
|
+
"""
|
|
343
|
+
registry = _run_registry(ctx)
|
|
344
|
+
try:
|
|
345
|
+
pending = [run for run in registry.list(limit=50)
|
|
346
|
+
if run.get("status") == "running"
|
|
347
|
+
and run.get("background_path") == BACKGROUND_PATH_PROVIDER]
|
|
348
|
+
except Exception:
|
|
349
|
+
return
|
|
350
|
+
if not pending:
|
|
351
|
+
return
|
|
352
|
+
for run in pending:
|
|
353
|
+
session = ctx["service"]["get_session"](run.get("session") or "")
|
|
354
|
+
if not session:
|
|
355
|
+
continue
|
|
356
|
+
auth_home = session.get("authHome")
|
|
357
|
+
env = {**os.environ, "HOME": auth_home} if auth_home else None
|
|
358
|
+
try:
|
|
359
|
+
outcome = read_transcript_outcome(
|
|
360
|
+
find_session_transcript(auth_home, run.get("provider_session_id"))
|
|
361
|
+
)
|
|
362
|
+
# The transcript decides, not the agent state: a --bg session parks
|
|
363
|
+
# after answering and never reports a terminal state on its own.
|
|
364
|
+
if turn_is_complete(outcome):
|
|
365
|
+
status = "succeeded"
|
|
366
|
+
else:
|
|
367
|
+
agents = list_background_agents(env=env, cwd=run.get("cwd"), spawn_sync=ctx.get("spawn_text"))
|
|
368
|
+
status = agent_terminal_status(find_agent(agents, run.get("provider_session_id")))
|
|
369
|
+
if not status:
|
|
370
|
+
continue
|
|
371
|
+
# `claude stop` accepts the short agent id only; the session id
|
|
372
|
+
# names the transcript file and is rejected here.
|
|
373
|
+
stop_background_agent(run.get("provider_agent_id"), env=env, spawn_sync=ctx.get("spawn_text"))
|
|
374
|
+
registry.finish(
|
|
375
|
+
run["run_id"],
|
|
376
|
+
status=status,
|
|
377
|
+
final_payload={
|
|
378
|
+
"usage": outcome["usage"],
|
|
379
|
+
"result": outcome["result"],
|
|
380
|
+
"background_path": BACKGROUND_PATH_PROVIDER,
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
except Exception:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
|
|
243
387
|
def handle_runs(rest, ctx):
|
|
244
388
|
parsed = _parse_runs_args(rest)
|
|
245
389
|
if not parsed["json"]:
|
|
246
390
|
raise CdxError(RUNS_USAGE)
|
|
247
391
|
since = parsed.get("since")
|
|
392
|
+
_reconcile_background_runs(ctx)
|
|
248
393
|
runs = _run_registry(ctx).list(limit=parsed["limit"], since=since)
|
|
249
394
|
warnings = []
|
|
250
395
|
if since is not None and parsed.get("limit_given"):
|
|
@@ -315,6 +460,7 @@ def handle_run_tail(rest, ctx):
|
|
|
315
460
|
|
|
316
461
|
def handle_run_status(rest, ctx):
|
|
317
462
|
parsed = _parse_run_id_json_args(rest, RUN_STATUS_USAGE)
|
|
463
|
+
_reconcile_background_runs(ctx)
|
|
318
464
|
run = _run_registry(ctx).get(parsed["run_id"])
|
|
319
465
|
if not run:
|
|
320
466
|
_write_json(ctx, _json_failure("run-status", "run_not_found", f"Unknown run: {parsed['run_id']}"))
|
|
@@ -324,6 +470,7 @@ def handle_run_status(rest, ctx):
|
|
|
324
470
|
|
|
325
471
|
def handle_run_report(rest, ctx):
|
|
326
472
|
parsed = _parse_run_id_json_args(rest, RUN_REPORT_USAGE)
|
|
473
|
+
_reconcile_background_runs(ctx)
|
|
327
474
|
run = _run_registry(ctx).get(parsed["run_id"])
|
|
328
475
|
if not run:
|
|
329
476
|
_write_json(ctx, _json_failure("run-report", "run_not_found", f"Unknown run: {parsed['run_id']}"))
|
|
@@ -392,6 +539,11 @@ def handle_run(rest, ctx):
|
|
|
392
539
|
if parsed.get("reasoning_effort"):
|
|
393
540
|
launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
|
|
394
541
|
launch_updates["power"] = parsed["reasoning_effort"]
|
|
542
|
+
# Claude needs its conversation id before the spec is built; Codex
|
|
543
|
+
# records its own afterwards, from the rollout. Without this a headless
|
|
544
|
+
# Claude run left the session with no identity and `cdx resume` fell
|
|
545
|
+
# back to recency for Claude while working by id for Codex.
|
|
546
|
+
session = ctx["service"]["begin_conversation"](session["name"])
|
|
395
547
|
run_session = {
|
|
396
548
|
**session,
|
|
397
549
|
"launch": {
|
|
@@ -433,7 +585,24 @@ def handle_run(rest, ctx):
|
|
|
433
585
|
},
|
|
434
586
|
)
|
|
435
587
|
if parsed.get("detach"):
|
|
436
|
-
|
|
588
|
+
# Delegate where the installed CLI can both start a background
|
|
589
|
+
# agent and report on it; fall back to the in-house launcher
|
|
590
|
+
# otherwise, and whenever the provider declines to start one.
|
|
591
|
+
launch = None
|
|
592
|
+
if supports_native_background(
|
|
593
|
+
run_session.get("provider"),
|
|
594
|
+
env=ctx.get("env"),
|
|
595
|
+
spawn_sync=ctx.get("spawn_text"),
|
|
596
|
+
):
|
|
597
|
+
launch = _spawn_provider_background_run(run_session, prompt, artifacts, ctx, cwd)
|
|
598
|
+
if launch is None:
|
|
599
|
+
launch = _spawn_detached_run(parsed, prompt, artifacts, ctx, run_session["name"], cwd)
|
|
600
|
+
registry.record_background(
|
|
601
|
+
run_id,
|
|
602
|
+
path=launch.get("background_path"),
|
|
603
|
+
provider_session_id=launch.get("provider_session_id"),
|
|
604
|
+
provider_agent_id=launch.get("provider_agent_id"),
|
|
605
|
+
)
|
|
437
606
|
# Hand the record the child's pid: this process is about to exit,
|
|
438
607
|
# and a run pointing at a dead pid gets swept up as stale.
|
|
439
608
|
if launch.get("pid"):
|
|
@@ -450,17 +619,73 @@ def handle_run(rest, ctx):
|
|
|
450
619
|
))
|
|
451
620
|
return 0
|
|
452
621
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
622
|
+
attempted = [run_session["name"]]
|
|
623
|
+
failover_error_code = None
|
|
624
|
+
while True:
|
|
625
|
+
run_info = _run_headless_provider_command(
|
|
626
|
+
run_session,
|
|
627
|
+
cwd=cwd,
|
|
628
|
+
env_override=ctx.get("env"),
|
|
629
|
+
initial_prompt=prompt,
|
|
630
|
+
timeout_seconds=parsed.get("timeout_seconds"),
|
|
631
|
+
spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
|
|
632
|
+
run_id=run_id,
|
|
633
|
+
)
|
|
634
|
+
usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
|
|
635
|
+
run_info = {**run_info, "usage": usage}
|
|
636
|
+
if run_info.get("returncode") == 0 or not parsed.get("failover"):
|
|
637
|
+
break
|
|
638
|
+
reason = failover_reason(
|
|
639
|
+
run_session.get("provider"),
|
|
640
|
+
run_info,
|
|
641
|
+
_status_row_for(ctx, run_session["name"]),
|
|
642
|
+
)
|
|
643
|
+
if not reason:
|
|
644
|
+
break
|
|
645
|
+
if len(attempted) > MAX_FAILOVER_TRANSITIONS:
|
|
646
|
+
# A systematic misclassification must not be able to walk one
|
|
647
|
+
# task through every account the user owns.
|
|
648
|
+
failover_error_code = "failover_limit_reached"
|
|
649
|
+
break
|
|
650
|
+
successor = _select_headless_session(
|
|
651
|
+
ctx,
|
|
652
|
+
parsed["provider"] or run_session.get("provider"),
|
|
653
|
+
min_reasoning_effort=parsed["reasoning_effort"],
|
|
654
|
+
require_ready=True,
|
|
655
|
+
force_refresh=False,
|
|
656
|
+
exclude=attempted,
|
|
657
|
+
)
|
|
658
|
+
if not successor:
|
|
659
|
+
failover_error_code = "failover_exhausted"
|
|
660
|
+
break
|
|
661
|
+
selection_row = successor.get("row") or {}
|
|
662
|
+
run_session = {
|
|
663
|
+
**successor["session"],
|
|
664
|
+
"launch": {**(successor["session"].get("launch") or {}), **launch_updates},
|
|
665
|
+
}
|
|
666
|
+
attempted.append(run_session["name"])
|
|
667
|
+
# A different account means different credentials; an unauthenticated
|
|
668
|
+
# successor would fail for a reason unrelated to the task.
|
|
669
|
+
_ensure_session_authentication(
|
|
670
|
+
run_session,
|
|
671
|
+
ctx["service"],
|
|
672
|
+
spawn=ctx.get("spawn"),
|
|
673
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
674
|
+
env_override=ctx.get("env"),
|
|
675
|
+
stdin_is_tty=False,
|
|
676
|
+
behavior="launch",
|
|
677
|
+
signal_emitter=ctx.get("signal_emitter"),
|
|
678
|
+
trust_local_credentials=False,
|
|
679
|
+
)
|
|
680
|
+
registry.migrate(
|
|
681
|
+
run_id,
|
|
682
|
+
session=run_session["name"],
|
|
683
|
+
provider=run_session.get("provider"),
|
|
684
|
+
reason=reason,
|
|
685
|
+
)
|
|
686
|
+
# The successor is a different account with its own auth, so the
|
|
687
|
+
# context the task built so far travels with it as the prompt.
|
|
688
|
+
prompt = _failover_prompt(prompt, attempted)
|
|
464
689
|
ok = run_info.get("returncode") == 0
|
|
465
690
|
if ok:
|
|
466
691
|
final_payload = run_result_payload(
|
|
@@ -483,7 +708,14 @@ def handle_run(rest, ctx):
|
|
|
483
708
|
})
|
|
484
709
|
_write_json(ctx, final_payload)
|
|
485
710
|
return 0
|
|
486
|
-
|
|
711
|
+
if failover_error_code == "failover_exhausted":
|
|
712
|
+
message = f"Every eligible session was rate limited: {', '.join(attempted)}."
|
|
713
|
+
elif failover_error_code == "failover_limit_reached":
|
|
714
|
+
message = f"Failover stopped after {MAX_FAILOVER_TRANSITIONS} transitions: {', '.join(attempted)}."
|
|
715
|
+
elif run_info.get("timed_out"):
|
|
716
|
+
message = "Provider process timed out."
|
|
717
|
+
else:
|
|
718
|
+
message = "Provider process exited with a non-zero status."
|
|
487
719
|
error = CdxError(message, run_info.get("returncode") or 1)
|
|
488
720
|
final_payload = run_result_payload(
|
|
489
721
|
API_SCHEMA_VERSION,
|
|
@@ -493,7 +725,11 @@ def handle_run(rest, ctx):
|
|
|
493
725
|
run_info=run_info,
|
|
494
726
|
error=error,
|
|
495
727
|
error_source="provider",
|
|
496
|
-
|
|
728
|
+
# Running out of accounts is a different outcome from the task
|
|
729
|
+
# failing, and a caller has to be able to branch on which happened.
|
|
730
|
+
error_code=failover_error_code or (
|
|
731
|
+
"provider_timeout" if run_info.get("timed_out") else "provider_failed"
|
|
732
|
+
),
|
|
497
733
|
selection_row=selection_row,
|
|
498
734
|
)
|
|
499
735
|
registry.finish(
|
|
@@ -537,12 +773,17 @@ def handle_run(rest, ctx):
|
|
|
537
773
|
_write_json(ctx, final_payload)
|
|
538
774
|
return error.exit_code
|
|
539
775
|
|
|
540
|
-
def _select_headless_session(
|
|
776
|
+
def _select_headless_session(
|
|
777
|
+
ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False, exclude=None,
|
|
778
|
+
):
|
|
541
779
|
"""The best session for a headless run, using the one shared ranking.
|
|
542
780
|
|
|
543
781
|
Returns the winning session with its status row and why it won, or None if
|
|
544
|
-
no candidate survived the filters.
|
|
782
|
+
no candidate survived the filters. `exclude` drops sessions a failover has
|
|
783
|
+
already tried, which is the only selection input failover adds - the
|
|
784
|
+
ranking itself is untouched.
|
|
545
785
|
"""
|
|
786
|
+
excluded = set(exclude or ())
|
|
546
787
|
sessions_by_name = {
|
|
547
788
|
session["name"]: session for session in ctx["service"]["list_sessions"]()
|
|
548
789
|
}
|
|
@@ -556,7 +797,53 @@ def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_r
|
|
|
556
797
|
min_reasoning_effort=min_reasoning_effort,
|
|
557
798
|
)
|
|
558
799
|
for row in ordered:
|
|
559
|
-
|
|
800
|
+
name = row.get("session_name")
|
|
801
|
+
if name in excluded:
|
|
802
|
+
continue
|
|
803
|
+
session = sessions_by_name.get(name)
|
|
560
804
|
if session:
|
|
561
805
|
return {"session": session, "row": row, "decision": decision}
|
|
562
806
|
return None
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _failover_prompt(prompt, attempted):
|
|
810
|
+
"""The prompt the successor account receives.
|
|
811
|
+
|
|
812
|
+
What travels is the task, plus the fact that earlier accounts ran out
|
|
813
|
+
mid-way. What does NOT travel is the provider conversation: the successor
|
|
814
|
+
is a different account with its own auth and its own history, so it starts
|
|
815
|
+
fresh. Any work the previous attempt left on disk is still on disk, which
|
|
816
|
+
is why the note tells the assistant to check before redoing it.
|
|
817
|
+
"""
|
|
818
|
+
previous = ", ".join(attempted[:-1])
|
|
819
|
+
return (
|
|
820
|
+
"This task was already started on another account "
|
|
821
|
+
f"({previous}) and stopped when that account hit its rate limit. "
|
|
822
|
+
"Work already written to the working tree is still there; check the "
|
|
823
|
+
"current state before redoing anything.\n\n"
|
|
824
|
+
f"{prompt}"
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _status_row_for(ctx, name):
|
|
829
|
+
"""The status to corroborate a rate-limited termination against.
|
|
830
|
+
|
|
831
|
+
Forced refresh on purpose: this runs once per rate-limited termination. The
|
|
832
|
+
Codex probe spawns app-server and can rotate the OAuth token, which is why
|
|
833
|
+
the cache exists, so this must stay rare and must never be called in a loop.
|
|
834
|
+
|
|
835
|
+
A refresh that comes back with no readable window falls back to the stored
|
|
836
|
+
status rather than being taken as "not exhausted": a failed probe is an
|
|
837
|
+
absence of evidence, and the account may well have a recorded 0% from the
|
|
838
|
+
moment it stopped answering.
|
|
839
|
+
"""
|
|
840
|
+
def _read(force_refresh):
|
|
841
|
+
try:
|
|
842
|
+
return ctx["service"]["get_status_row"](name, force_refresh=force_refresh)
|
|
843
|
+
except CdxError:
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
row = _read(True)
|
|
847
|
+
if row and any(row.get(key) is not None for key in ("remaining_5h_pct", "remaining_week_pct")):
|
|
848
|
+
return row
|
|
849
|
+
return _read(False) or row
|
package/src/commands/status.py
CHANGED
|
@@ -117,12 +117,25 @@ def _format_launch_configs(sessions, use_color=False):
|
|
|
117
117
|
"",
|
|
118
118
|
_dim(_format_launch_settings_hint(), use_color),
|
|
119
119
|
])
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
# The table is already nine columns wide, so the two headless-only settings
|
|
121
|
+
# follow the LABEL convention from the session list: shown once somebody
|
|
122
|
+
# uses them, absent otherwise.
|
|
123
|
+
has_headless = any(
|
|
124
|
+
(session.get("launch") or {}).get(key) is not None
|
|
125
|
+
for session in sessions
|
|
126
|
+
for key in ("fallback_model", "budget")
|
|
127
|
+
)
|
|
128
|
+
has_extra = any((session.get("launch") or {}).get("extra_args") for session in sessions)
|
|
129
|
+
headers = ["SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "MODEL"]
|
|
130
|
+
if has_headless:
|
|
131
|
+
headers += ["FALLBACK", "BUDGET"]
|
|
132
|
+
if has_extra:
|
|
133
|
+
headers.append("EXTRA ARGS")
|
|
134
|
+
headers.append("PRIORITY")
|
|
135
|
+
rows = [[_style(value, "1", use_color) for value in headers]]
|
|
123
136
|
for session in sessions:
|
|
124
137
|
launch = session.get("launch") or {}
|
|
125
|
-
|
|
138
|
+
row = [
|
|
126
139
|
_style(session["name"], "36", use_color),
|
|
127
140
|
_dim(session.get("provider") or "-", use_color),
|
|
128
141
|
_format_launch_setting_value(launch, "power", use_color),
|
|
@@ -131,14 +144,33 @@ def _format_launch_configs(sessions, use_color=False):
|
|
|
131
144
|
_format_launch_setting_value(launch, "rtk", use_color),
|
|
132
145
|
_format_launch_setting_value(launch, "logics", use_color),
|
|
133
146
|
_format_launch_setting_value(launch, "model", use_color),
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
]
|
|
148
|
+
if has_headless:
|
|
149
|
+
row.append(_format_launch_setting_value(launch, "fallback_model", use_color))
|
|
150
|
+
row.append(_format_launch_setting_value(launch, "budget", use_color))
|
|
151
|
+
if has_extra:
|
|
152
|
+
row.append(_format_launch_setting_value(launch, "extra_args", use_color))
|
|
153
|
+
row.append(_format_launch_setting_value(launch, "priority", use_color))
|
|
154
|
+
rows.append(row)
|
|
155
|
+
lines = [
|
|
137
156
|
_style("Launch settings:", "1", use_color),
|
|
138
157
|
_pad_table(rows),
|
|
139
158
|
"",
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
]
|
|
160
|
+
if has_headless:
|
|
161
|
+
lines.append(_dim(
|
|
162
|
+
"FALLBACK and BUDGET apply to headless Claude runs only "
|
|
163
|
+
"(cdx run); the provider accepts them with --print alone.",
|
|
164
|
+
use_color,
|
|
165
|
+
))
|
|
166
|
+
if has_extra:
|
|
167
|
+
lines.append(_dim(
|
|
168
|
+
"EXTRA ARGS are passed to the provider unchanged and unvalidated. "
|
|
169
|
+
"cdx does not check them, and cdx schema does not describe them.",
|
|
170
|
+
use_color,
|
|
171
|
+
))
|
|
172
|
+
lines.append(_dim(_format_launch_settings_hint(), use_color))
|
|
173
|
+
return "\n".join(lines)
|
|
142
174
|
|
|
143
175
|
def _format_duration_ms(value):
|
|
144
176
|
if value is None:
|
package/src/config.py
CHANGED
|
@@ -34,6 +34,35 @@ PERMISSION_ALIASES = {
|
|
|
34
34
|
# Everything accepted as input for a permission, canonical values first.
|
|
35
35
|
PERMISSION_INPUT_VALUES = PERMISSION_VALUES + tuple(PERMISSION_ALIASES)
|
|
36
36
|
|
|
37
|
+
# A budget is a ceiling, so the only bound worth enforcing is the one a caller
|
|
38
|
+
# could cross by accident. Zero and negatives express nothing; the upper bound
|
|
39
|
+
# catches a misplaced decimal rather than stating a policy. Defined here, not in
|
|
40
|
+
# each parser, because `cdx set` and `cdx run` validating the same setting from
|
|
41
|
+
# separate literals is how they came to disagree before.
|
|
42
|
+
MAX_LAUNCH_BUDGET_USD = 10000
|
|
43
|
+
|
|
44
|
+
MAX_LAUNCH_EXTRA_ARGS_LENGTH = 512
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def split_extra_args(value):
|
|
48
|
+
"""Passthrough arguments as an argv list, or None if the string is malformed.
|
|
49
|
+
|
|
50
|
+
POSIX splitting on every platform, deliberately. The alternative - following
|
|
51
|
+
the host's own quoting rules - would make the same `cdx set` mean different
|
|
52
|
+
things on Windows and on macOS, and these strings travel between machines
|
|
53
|
+
through `cdx export`. One rule everywhere is worth more than matching the
|
|
54
|
+
local shell, especially since cdx never invokes a shell: the result is
|
|
55
|
+
passed as argv, so nothing here is ever re-interpreted.
|
|
56
|
+
"""
|
|
57
|
+
import shlex
|
|
58
|
+
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
return shlex.split(str(value), posix=True)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
37
66
|
|
|
38
67
|
def normalize_permission(value):
|
|
39
68
|
"""Canonical form of a permission input, or None if it is not one.
|