cdx-manager 0.8.0 → 0.9.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.
@@ -0,0 +1,14 @@
1
+ # CDX Manager 0.9.0
2
+
3
+ ## Highlights
4
+
5
+ - Adds an observable assistant run registry for CDX sessions.
6
+ - Generates the Logics corpus for assistant-run observability so follow-up work can be tracked from requests through closeout.
7
+ - Closes the observable run registry workflow chain after validation.
8
+
9
+ ## Validation
10
+
11
+ - `npm run prepublishOnly`
12
+ - `npm pack --dry-run`
13
+ - `logics-manager lint --require-status`
14
+ - `logics-manager audit --legacy-cutoff-version 1.1.0 --group-by-doc`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.8.0",
3
+ "version": "0.9.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.8.0"
7
+ version = "0.9.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
@@ -31,6 +31,9 @@ from .cli_commands import (
31
31
  handle_repair,
32
32
  handle_rename,
33
33
  handle_run,
34
+ handle_run_report,
35
+ handle_run_status,
36
+ handle_runs,
34
37
  handle_select,
35
38
  handle_stats,
36
39
  handle_status,
@@ -61,7 +64,7 @@ from .status_view import (
61
64
  )
62
65
  from .update_check import check_for_update, check_logics_manager_for_update
63
66
 
64
- VERSION = "0.8.0"
67
+ VERSION = "0.9.0"
65
68
 
66
69
 
67
70
  # ---------------------------------------------------------------------------
@@ -81,6 +84,9 @@ def _print_help(use_color=False):
81
84
  f" {_style('cdx next [--json] [--refresh]', '36', use_color)}",
82
85
  f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort low|medium|high] [--min-power low|medium|high] [--require-ready] [--refresh] --json', '36', use_color)}",
83
86
  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)}",
87
+ f" {_style('cdx runs [--limit N] --json', '36', use_color)}",
88
+ f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
89
+ f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
84
90
  f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
85
91
  f" {_style('cdx config <name> [--json]', '36', use_color)}",
86
92
  f" {_style('cdx configs [--json]', '36', use_color)}",
@@ -371,6 +377,15 @@ def main(argv, options=None):
371
377
  if command == "run":
372
378
  return handle_run(rest, ctx)
373
379
 
380
+ if command == "runs":
381
+ return handle_runs(rest, ctx)
382
+
383
+ if command == "run-status":
384
+ return handle_run_status(rest, ctx)
385
+
386
+ if command == "run-report":
387
+ return handle_run_report(rest, ctx)
388
+
374
389
  if command == "login":
375
390
  return handle_login(rest, ctx)
376
391
 
@@ -33,6 +33,7 @@ from .notify import (
33
33
  )
34
34
  from .provider_runtime import (
35
35
  _ensure_session_authentication,
36
+ _headless_artifact_paths,
36
37
  _list_launch_transcript_paths,
37
38
  _normalize_reasoning_effort,
38
39
  _probe_provider_auth,
@@ -41,6 +42,7 @@ from .provider_runtime import (
41
42
  )
42
43
  from .repair import format_repair_report, repair_health
43
44
  from .backup_bundle import read_bundle_meta
45
+ from .run_registry import RunRegistry, build_code_review_report
44
46
  from .run_usage import extract_run_usage
45
47
  from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
46
48
  from .status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
@@ -66,7 +68,10 @@ STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--
66
68
  LAST_USAGE = "Usage: cdx last [--json]"
67
69
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort low|medium|high] [--min-power low|medium|high] [--require-ready] [--refresh] --json"
68
70
  NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
69
- 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"
71
+ RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--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"
72
+ RUNS_USAGE = "Usage: cdx runs [--limit N] --json"
73
+ RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
74
+ RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
70
75
  API_SCHEMA_VERSION = 1
71
76
  HANDOFF_TRANSCRIPT_CHARS = 120000
72
77
  HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
@@ -695,6 +700,7 @@ def _parse_run_args(args):
695
700
  "--prompt": {"key": "prompt", "type": "str", "default": None},
696
701
  "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, RUN_USAGE)},
697
702
  "--model": {"key": "model", "type": "str", "default": None},
703
+ "--kind": {"key": "kind", "type": "str", "default": "assistant"},
698
704
  "--reasoning-effort": {"key": "reasoning_effort", "type": "str", "default": None},
699
705
  "--power": {"key": "power", "type": "str", "default": None},
700
706
  "--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
@@ -712,6 +718,8 @@ def _parse_run_args(args):
712
718
  raise CdxError(RUN_USAGE)
713
719
  if bool(parsed["prompt_file"]) == bool(parsed["prompt"]):
714
720
  raise CdxError(RUN_USAGE)
721
+ if parsed["kind"] not in ("assistant", "code-review"):
722
+ raise CdxError(RUN_USAGE)
715
723
  effort = _normalize_reasoning_effort(
716
724
  reasoning_effort=parsed["reasoning_effort"],
717
725
  power=parsed["power"],
@@ -723,6 +731,7 @@ def _parse_run_args(args):
723
731
  "cwd": parsed["cwd"],
724
732
  "prompt_file": parsed["prompt_file"],
725
733
  "prompt": parsed["prompt"],
734
+ "kind": parsed["kind"],
726
735
  "model": parsed["model"],
727
736
  "permission": parsed["permission"],
728
737
  "timeout_seconds": parsed["timeout_seconds"],
@@ -1338,7 +1347,65 @@ def handle_next(rest, ctx):
1338
1347
  return 0
1339
1348
 
1340
1349
 
1350
+ def _parse_runs_args(rest):
1351
+ return _parse_flag_args(rest, {
1352
+ "--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
1353
+ "--json": {"key": "json", "type": "bool", "default": False},
1354
+ }, RUNS_USAGE, max_positionals=0)
1355
+
1356
+
1357
+ def _parse_run_id_json_args(rest, usage):
1358
+ parsed = _parse_flag_args(rest, {
1359
+ "--json": {"key": "json", "type": "bool", "default": False},
1360
+ }, usage, positionals_key="ids", max_positionals=1)
1361
+ if not parsed["json"] or len(parsed["ids"]) != 1:
1362
+ raise CdxError(usage)
1363
+ return {"run_id": parsed["ids"][0], "json": True}
1364
+
1365
+
1366
+ def _run_registry(ctx):
1367
+ return RunRegistry(ctx["service"]["base_dir"])
1368
+
1369
+
1370
+ def handle_runs(rest, ctx):
1371
+ parsed = _parse_runs_args(rest)
1372
+ if not parsed["json"]:
1373
+ raise CdxError(RUNS_USAGE)
1374
+ runs = _run_registry(ctx).list(limit=parsed["limit"])
1375
+ _write_json(ctx, _json_success("runs", "Runs loaded.", runs=runs))
1376
+ return 0
1377
+
1378
+
1379
+ def handle_run_status(rest, ctx):
1380
+ parsed = _parse_run_id_json_args(rest, RUN_STATUS_USAGE)
1381
+ run = _run_registry(ctx).get(parsed["run_id"])
1382
+ if not run:
1383
+ _write_json(ctx, _json_failure("run-status", "run_not_found", f"Unknown run: {parsed['run_id']}"))
1384
+ return 1
1385
+ _write_json(ctx, _json_success("run-status", "Run loaded.", run=run))
1386
+ return 0
1387
+
1388
+
1389
+ def handle_run_report(rest, ctx):
1390
+ parsed = _parse_run_id_json_args(rest, RUN_REPORT_USAGE)
1391
+ run = _run_registry(ctx).get(parsed["run_id"])
1392
+ if not run:
1393
+ _write_json(ctx, _json_failure("run-report", "run_not_found", f"Unknown run: {parsed['run_id']}"))
1394
+ return 1
1395
+ _write_json(ctx, _json_success("run-report", "Run report loaded.", report={
1396
+ "run": run,
1397
+ "final_payload": run.get("final_payload"),
1398
+ "task_report": run.get("task_report"),
1399
+ "artifacts": run.get("artifacts") or {},
1400
+ "usage": run.get("usage"),
1401
+ "error": run.get("error"),
1402
+ }))
1403
+ return 0
1404
+
1405
+
1341
1406
  def handle_run(rest, ctx):
1407
+ registry = None
1408
+ run_id = None
1342
1409
  try:
1343
1410
  parsed = _parse_run_args(rest)
1344
1411
  session = None
@@ -1397,6 +1464,22 @@ def handle_run(rest, ctx):
1397
1464
  signal_emitter=ctx.get("signal_emitter"),
1398
1465
  trust_local_credentials=False,
1399
1466
  )
1467
+ registry = _run_registry(ctx)
1468
+ artifacts = _headless_artifact_paths(run_session)
1469
+ run_id = artifacts["run_id"]
1470
+ registry.start(
1471
+ run_id,
1472
+ kind=parsed.get("kind"),
1473
+ session=run_session.get("name"),
1474
+ provider=run_session.get("provider"),
1475
+ model=parsed.get("model") or ((run_session.get("launch") or {}).get("model")),
1476
+ cwd=cwd,
1477
+ artifacts={
1478
+ "transcript_path": artifacts.get("transcript_path"),
1479
+ "stdout_path": artifacts.get("stdout_path"),
1480
+ "stderr_path": artifacts.get("stderr_path"),
1481
+ },
1482
+ )
1400
1483
  run_info = _run_headless_provider_command(
1401
1484
  run_session,
1402
1485
  cwd=cwd,
@@ -1404,29 +1487,32 @@ def handle_run(rest, ctx):
1404
1487
  initial_prompt=prompt,
1405
1488
  timeout_seconds=parsed.get("timeout_seconds"),
1406
1489
  spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
1490
+ run_id=run_id,
1407
1491
  )
1408
1492
  usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
1409
1493
  run_info = {**run_info, "usage": usage}
1410
1494
  ok = run_info.get("returncode") == 0
1411
1495
  if ok:
1496
+ final_payload = run_result_payload(API_SCHEMA_VERSION, True, parsed, run_session, run_info=run_info)
1497
+ task_report = build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None
1498
+ registry.finish(
1499
+ run_id,
1500
+ status="succeeded",
1501
+ final_payload=final_payload,
1502
+ run_info=run_info,
1503
+ task_report=task_report,
1504
+ )
1412
1505
  ctx["service"]["record_launch_history"](session["name"], {
1413
1506
  "status": "success",
1414
1507
  "cwd": cwd,
1415
1508
  "exit_code": 0,
1416
1509
  **run_info,
1417
1510
  })
1418
- _write_json(ctx, run_result_payload(API_SCHEMA_VERSION, True, parsed, run_session, run_info=run_info))
1511
+ _write_json(ctx, final_payload)
1419
1512
  return 0
1420
1513
  message = "Provider process timed out." if run_info.get("timed_out") else "Provider process exited with a non-zero status."
1421
1514
  error = CdxError(message, run_info.get("returncode") or 1)
1422
- ctx["service"]["record_launch_history"](session["name"], {
1423
- "status": "failed",
1424
- "cwd": cwd,
1425
- "error": str(error),
1426
- "exit_code": error.exit_code,
1427
- **run_info,
1428
- })
1429
- _write_json(ctx, run_result_payload(
1515
+ final_payload = run_result_payload(
1430
1516
  API_SCHEMA_VERSION,
1431
1517
  False,
1432
1518
  parsed,
@@ -1435,11 +1521,27 @@ def handle_run(rest, ctx):
1435
1521
  error=error,
1436
1522
  error_source="provider",
1437
1523
  error_code="provider_timeout" if run_info.get("timed_out") else "provider_failed",
1438
- ))
1524
+ )
1525
+ registry.finish(
1526
+ run_id,
1527
+ status="timed_out" if run_info.get("timed_out") else "failed",
1528
+ final_payload=final_payload,
1529
+ run_info=run_info,
1530
+ error=final_payload.get("error"),
1531
+ task_report=build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None,
1532
+ )
1533
+ ctx["service"]["record_launch_history"](session["name"], {
1534
+ "status": "failed",
1535
+ "cwd": cwd,
1536
+ "error": str(error),
1537
+ "exit_code": error.exit_code,
1538
+ **run_info,
1539
+ })
1540
+ _write_json(ctx, final_payload)
1439
1541
  return error.exit_code or 1
1440
1542
  except CdxError as error:
1441
1543
  run_info = getattr(error, "run_info", None)
1442
- _write_json(ctx, run_result_payload(
1544
+ final_payload = run_result_payload(
1443
1545
  API_SCHEMA_VERSION,
1444
1546
  False,
1445
1547
  locals().get("parsed", {}) or {},
@@ -1448,7 +1550,16 @@ def handle_run(rest, ctx):
1448
1550
  error=error,
1449
1551
  error_source="cdx",
1450
1552
  error_code=run_cdx_error_code(error),
1451
- ))
1553
+ )
1554
+ if registry and run_id:
1555
+ registry.finish(
1556
+ run_id,
1557
+ status="failed",
1558
+ final_payload=final_payload,
1559
+ run_info=run_info,
1560
+ error=final_payload.get("error"),
1561
+ )
1562
+ _write_json(ctx, final_payload)
1452
1563
  return error.exit_code
1453
1564
 
1454
1565
 
package/src/cli_view.py CHANGED
@@ -95,6 +95,9 @@ def handle_view(rest, ctx):
95
95
  result = run_logics_viewer(executable, cwd, env=env, runner=ctx.get("spawn_sync"))
96
96
  except FileNotFoundError as error:
97
97
  raise CdxError(f"logics-manager is required for cdx view. {LOGICS_MANAGER_INSTALL_HINT}") from error
98
+ except KeyboardInterrupt:
99
+ ctx["out"]("\n")
100
+ return 130
98
101
  returncode = getattr(result, "returncode", 0)
99
102
  if returncode not in (0, None):
100
103
  raise CdxError("logics-manager view failed.", exit_code=returncode)
@@ -0,0 +1,195 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from datetime import datetime, timezone
5
+
6
+
7
+ def utc_now_iso():
8
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
9
+
10
+
11
+ def registry_path(base_dir):
12
+ return os.path.join(base_dir, "runs.json")
13
+
14
+
15
+ def _read_registry(path):
16
+ try:
17
+ with open(path, "r", encoding="utf-8") as handle:
18
+ data = json.load(handle)
19
+ except FileNotFoundError:
20
+ return {"schema_version": 1, "runs": []}
21
+ if not isinstance(data, dict):
22
+ return {"schema_version": 1, "runs": []}
23
+ runs = data.get("runs")
24
+ if not isinstance(runs, list):
25
+ runs = []
26
+ return {"schema_version": int(data.get("schema_version") or 1), "runs": [run for run in runs if isinstance(run, dict)]}
27
+
28
+
29
+ def _write_registry(path, data):
30
+ os.makedirs(os.path.dirname(path), exist_ok=True)
31
+ fd, temp_path = tempfile.mkstemp(prefix=".runs-", suffix=".json", dir=os.path.dirname(path))
32
+ try:
33
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
34
+ json.dump(data, handle, indent=2, sort_keys=True)
35
+ handle.write("\n")
36
+ os.replace(temp_path, path)
37
+ finally:
38
+ try:
39
+ os.unlink(temp_path)
40
+ except FileNotFoundError:
41
+ pass
42
+
43
+
44
+ def _is_pid_alive(pid):
45
+ if not pid:
46
+ return False
47
+ try:
48
+ os.kill(int(pid), 0)
49
+ return True
50
+ except (OSError, ValueError):
51
+ return False
52
+
53
+
54
+ def _refresh_stale_runs(data):
55
+ now = utc_now_iso()
56
+ changed = False
57
+ for run in data["runs"]:
58
+ if run.get("status") != "running":
59
+ continue
60
+ pid = run.get("pid")
61
+ if pid and _is_pid_alive(pid):
62
+ continue
63
+ run["status"] = "stale"
64
+ run["ended_at"] = now
65
+ run["error"] = {"code": "stale_process", "message": "Run was marked running but no live provider process was found."}
66
+ changed = True
67
+ return changed
68
+
69
+
70
+ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None):
71
+ return {
72
+ "run_id": run_id,
73
+ "kind": kind or "assistant",
74
+ "status": "running",
75
+ "session": session,
76
+ "provider": provider,
77
+ "model": model,
78
+ "cwd": os.path.abspath(cwd),
79
+ "pid": None,
80
+ "started_at": utc_now_iso(),
81
+ "ended_at": None,
82
+ "duration_seconds": None,
83
+ "exit_code": None,
84
+ "usage": None,
85
+ "artifacts": dict(artifacts or {}),
86
+ "error": None,
87
+ "task_report": None,
88
+ "final_payload": None,
89
+ }
90
+
91
+
92
+ class RunRegistry:
93
+ def __init__(self, base_dir):
94
+ self.path = registry_path(base_dir)
95
+
96
+ def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None):
97
+ data = _read_registry(self.path)
98
+ data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
99
+ record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
100
+ data["runs"].insert(0, record)
101
+ _write_registry(self.path, data)
102
+ return record
103
+
104
+ def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
105
+ data = _read_registry(self.path)
106
+ now = utc_now_iso()
107
+ for run in data["runs"]:
108
+ if run.get("run_id") != run_id:
109
+ continue
110
+ run["status"] = status
111
+ run["ended_at"] = now
112
+ if run.get("started_at"):
113
+ try:
114
+ start = datetime.fromisoformat(str(run["started_at"]).replace("Z", "+00:00"))
115
+ end = datetime.fromisoformat(now.replace("Z", "+00:00"))
116
+ run["duration_seconds"] = (end - start).total_seconds()
117
+ except ValueError:
118
+ run["duration_seconds"] = None
119
+ if run_info:
120
+ run["pid"] = run_info.get("pid")
121
+ run["exit_code"] = run_info.get("returncode")
122
+ run["artifacts"] = {
123
+ **(run.get("artifacts") or {}),
124
+ "transcript_path": run_info.get("transcript_path"),
125
+ "stdout_path": run_info.get("stdout_path"),
126
+ "stderr_path": run_info.get("stderr_path"),
127
+ }
128
+ if final_payload:
129
+ run["usage"] = final_payload.get("usage")
130
+ run["final_payload"] = final_payload
131
+ if error:
132
+ run["error"] = error
133
+ if task_report:
134
+ run["task_report"] = task_report
135
+ _write_registry(self.path, data)
136
+ return run
137
+ return None
138
+
139
+ def list(self, limit=20):
140
+ data = _read_registry(self.path)
141
+ changed = _refresh_stale_runs(data)
142
+ if changed:
143
+ _write_registry(self.path, data)
144
+ return data["runs"][: max(0, int(limit or 20))]
145
+
146
+ def get(self, run_id):
147
+ data = _read_registry(self.path)
148
+ changed = _refresh_stale_runs(data)
149
+ if changed:
150
+ _write_registry(self.path, data)
151
+ for run in data["runs"]:
152
+ if run.get("run_id") == run_id:
153
+ return run
154
+ return None
155
+
156
+
157
+ def read_text_file(path, limit=120000):
158
+ if not path:
159
+ return ""
160
+ try:
161
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
162
+ return handle.read(limit)
163
+ except OSError:
164
+ return ""
165
+
166
+
167
+ def build_code_review_report(run_id, final_payload):
168
+ stdout = read_text_file(final_payload.get("stdout_path") if final_payload else None)
169
+ parsed = None
170
+ try:
171
+ candidate = json.loads(stdout or "{}")
172
+ if isinstance(candidate, dict):
173
+ parsed = candidate
174
+ except json.JSONDecodeError:
175
+ parsed = None
176
+ findings = parsed.get("findings") if parsed else []
177
+ if not isinstance(findings, list):
178
+ findings = []
179
+ summary = parsed.get("summary") if parsed else None
180
+ next_steps = parsed.get("next_steps") if parsed else None
181
+ if not isinstance(next_steps, list):
182
+ next_steps = []
183
+ return {
184
+ "schema_version": 1,
185
+ "kind": "code-review",
186
+ "run_id": run_id,
187
+ "summary": summary or "Code review completed. Structured findings were not emitted by the provider.",
188
+ "findings": [finding for finding in findings if isinstance(finding, dict)],
189
+ "next_steps": [str(step) for step in next_steps],
190
+ "artifacts": {
191
+ "transcript_path": final_payload.get("transcript_path") if final_payload else None,
192
+ "stdout_path": final_payload.get("stdout_path") if final_payload else None,
193
+ "stderr_path": final_payload.get("stderr_path") if final_payload else None,
194
+ },
195
+ }