cdx-manager 0.21.5 → 0.22.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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/changelogs/CHANGELOGS_0_21_6.md +33 -0
  4. package/changelogs/CHANGELOGS_0_22_0.md +25 -0
  5. package/checksums/release-archives.json +22 -0
  6. package/docs/cli.md +17 -7
  7. package/docs/operations.md +80 -0
  8. package/docs/sessions-and-notifications.md +1 -1
  9. package/docs/technical-overview.md +1 -1
  10. package/install.ps1 +7 -4
  11. package/package.json +1 -1
  12. package/pyproject.toml +1 -1
  13. package/src/agent_notify.py +23 -7
  14. package/src/backup_bundle.py +60 -16
  15. package/src/claude_usage.py +1 -1
  16. package/src/cli.py +15 -4
  17. package/src/cli_args.py +76 -19
  18. package/src/cli_helpers.py +23 -9
  19. package/src/codex_usage.py +1 -1
  20. package/src/commands/launch.py +41 -3
  21. package/src/commands/maintenance.py +1 -0
  22. package/src/commands/rescue.py +207 -0
  23. package/src/commands/runs.py +26 -3
  24. package/src/commands/status.py +56 -6
  25. package/src/config.py +17 -1
  26. package/src/context_store.py +11 -6
  27. package/src/errors.py +5 -1
  28. package/src/handoff.py +22 -11
  29. package/src/health.py +2 -2
  30. package/src/interactive_usage.py +20 -5
  31. package/src/notify.py +23 -18
  32. package/src/provider_background.py +13 -8
  33. package/src/provider_runtime.py +101 -8
  34. package/src/retro.py +131 -0
  35. package/src/rtk_savings.py +68 -0
  36. package/src/run_registry.py +27 -1
  37. package/src/run_usage.py +36 -9
  38. package/src/session_backup.py +16 -5
  39. package/src/session_helpers.py +4 -0
  40. package/src/session_ranking.py +10 -0
  41. package/src/session_service.py +39 -34
  42. package/src/session_status.py +86 -2
  43. package/src/shared_memory.py +102 -0
  44. package/src/status_source.py +2 -0
  45. package/src/status_view.py +23 -23
  46. package/src/tray_autostart.py +21 -2
  47. package/src/tray_events.py +23 -4
  48. package/src/tray_instance.py +11 -2
  49. package/src/tray_logics.py +1 -1
  50. package/src/update_all.py +1 -1
  51. package/src/update_check.py +1 -1
  52. package/src/update_manager.py +17 -7
package/src/notify.py CHANGED
@@ -215,8 +215,13 @@ def windows_aumid(env=None):
215
215
  return _POWERSHELL_AUMID
216
216
 
217
217
 
218
+ # PowerShell also ends a single-quoted string on the typographic quotes
219
+ # U+2018..U+201B, so each must be doubled like ASCII ' (French "l’agent").
220
+ _POWERSHELL_QUOTES = "'\u2018\u2019\u201a\u201b"
221
+
222
+
218
223
  def _escape_powershell(value):
219
- return str(value).replace("'", "''")
224
+ return "".join(ch * 2 if ch in _POWERSHELL_QUOTES else ch for ch in str(value))
220
225
 
221
226
 
222
227
  def _escape_applescript(value):
@@ -370,23 +375,23 @@ def _schedule_linux(parsed, event, argv, spawn_sync, env):
370
375
  def _schedule_windows_task(parsed, event, argv, spawn_sync, env):
371
376
  name = _schedule_id("cdx-manager-notify", parsed, event)
372
377
  target = datetime.fromtimestamp(event["target_timestamp"]).astimezone()
373
- command = subprocess.list2cmdline([str(part) for part in argv])
374
- result = _run_scheduler_command([
375
- "schtasks",
376
- "/Create",
377
- "/SC",
378
- "ONCE",
379
- "/TN",
380
- name,
381
- "/TR",
382
- command,
383
- "/ST",
384
- target.strftime("%H:%M"),
385
- "/SD",
386
- target.strftime("%m/%d/%Y"),
387
- "/F",
388
- "/Z",
389
- ], spawn_sync, env)
378
+ # Not `schtasks /SD`: it reads the date in the user's locale format, so
379
+ # mm/dd/yyyy is refused on a dd/MM/yyyy (French) Windows. An invariant
380
+ # ISO date through Register-ScheduledTask works on every locale.
381
+ start = target.strftime("%Y-%m-%dT%H:%M")
382
+ end = (target + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M")
383
+ arguments = subprocess.list2cmdline([str(part) for part in argv[1:]])
384
+ script = (
385
+ "$c = [Globalization.CultureInfo]::InvariantCulture; "
386
+ f"$t = New-ScheduledTaskTrigger -Once -At ([datetime]::ParseExact('{start}', 'yyyy-MM-ddTHH:mm', $c)); "
387
+ f"$t.EndBoundary = ([datetime]::ParseExact('{end}', 'yyyy-MM-ddTHH:mm', $c)).ToString('s'); "
388
+ f"$a = New-ScheduledTaskAction -Execute '{_escape_powershell(argv[0])}' -Argument '{_escape_powershell(arguments)}'; "
389
+ "$s = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Seconds 1); "
390
+ f"Register-ScheduledTask -TaskName '{_escape_powershell(name)}' -Action $a -Trigger $t -Settings $s -Force | Out-Null"
391
+ )
392
+ result = _run_scheduler_command(
393
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], spawn_sync, env,
394
+ )
390
395
  if not result["ok"]:
391
396
  raise CdxError(f"Failed to schedule notification with Task Scheduler: {result['error']}")
392
397
  return {"scheduled": True, "existing": False, "backend": "schtasks", "id": name}
@@ -72,12 +72,13 @@ def supports_native_background(provider, env=None, spawn_sync=None):
72
72
  1. A delegated run cannot honour the `cdx run --json` contract. The
73
73
  in-house path runs `claude --print --output-format json` and gets a
74
74
  structured result, token usage, and a final payload. `--bg` runs the
75
- interactive TUI; `claude logs <id>` returns an ANSI screen dump. So
76
- usage accounting, the final payload, and the rate-limit classification
77
- that `--failover` depends on all have no source.
78
- 2. Nothing yet drives a delegated run to a terminal status. `agent_terminal_status`
79
- exists but no caller consults the provider, so the record relies on pid
80
- liveness and a blocked agent reads as running indefinitely.
75
+ interactive TUI; `claude logs <id>` returns an ANSI screen dump. Usage
76
+ and the final text are read back from the session transcript, but the
77
+ rate-limit classification that `--failover` depends on has no source.
78
+ 2. A delegated run only reaches a terminal status when a caller reads it:
79
+ `_reconcile_background_runs` in commands/runs.py consults the
80
+ transcript, then `agent_terminal_status`, on `cdx runs`, `run-status`
81
+ and `run-report`. Until one of those runs, the record stays `running`.
81
82
  3. cdx's own launch spec makes the agent block. A hand-run
82
83
  `claude --bg --permission-mode bypassPermissions "..."` reaches
83
84
  `state: done`; the same task launched through cdx sits at
@@ -107,7 +108,7 @@ def _default_spawn_sync(argv, env=None):
107
108
  completed = subprocess.run(
108
109
  argv,
109
110
  capture_output=True,
110
- text=True,
111
+ text=True, encoding="utf-8", errors="replace",
111
112
  timeout=_CAPABILITY_PROBE_TIMEOUT_SECONDS,
112
113
  env=env,
113
114
  check=False,
@@ -223,6 +224,7 @@ def read_transcript_outcome(path):
223
224
  if not path:
224
225
  return outcome
225
226
  totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
227
+ long_ttl = None
226
228
  # This path had no de-duplication at all, so a transcript that records the
227
229
  # same billed response more than once inflated the total outright.
228
230
  counted = set()
@@ -268,10 +270,13 @@ def read_transcript_outcome(path):
268
270
  totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
269
271
  totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
270
272
  totals["output_tokens"] += usage.get("output_tokens") or 0
273
+ split = usage.get("cache_creation")
274
+ if isinstance(split, dict) and "ephemeral_1h_input_tokens" in split:
275
+ long_ttl = (long_ttl or 0) + (split["ephemeral_1h_input_tokens"] or 0)
271
276
  except OSError:
272
277
  return outcome
273
278
  if seen:
274
- outcome["usage"] = normalize_usage(**totals)
279
+ outcome["usage"] = normalize_usage(**totals, cache_creation_1h_tokens=long_ttl)
275
280
  if len(models) == 1:
276
281
  outcome["usage_model"] = next(iter(models))
277
282
  return outcome
@@ -150,6 +150,10 @@ CLAUDE_PARENT_SESSION_ENV = (
150
150
  "CLAUDE_CODE_MESSAGING_SOCKET",
151
151
  "CLAUDE_CODE_MESSAGING_TOKEN",
152
152
  "CLAUDE_PID",
153
+ # Credentials: a profile launched from inside another profile's session must
154
+ # not inherit that account's token. The profile's own token is set after.
155
+ "CLAUDE_CODE_OAUTH_TOKEN",
156
+ "ANTHROPIC_API_KEY",
153
157
  )
154
158
  CLAUDE_CLI_MODEL_ALIASES = {
155
159
  "claude-sonnet": "sonnet",
@@ -208,6 +212,40 @@ def _shared_tool_cache_overrides(env):
208
212
  }
209
213
 
210
214
 
215
+ def operator_rtk_history_path(env=None):
216
+ """The rtk history database of the operator's real home.
217
+
218
+ rtk files its savings under the data dir of HOME, and a profile launch
219
+ swaps HOME, so each account kept its own history.db and `rtk gain` in the
220
+ operator's shell saw none of what the agents saved. Read from the passwd
221
+ entry rather than HOME, which is already a profile home when cdx runs
222
+ inside a session.
223
+ """
224
+ env = os.environ if env is None else env
225
+ if os.name == "nt":
226
+ base = env.get("LOCALAPPDATA")
227
+ return os.path.join(base, "rtk", "history.db") if base else None
228
+ import pwd
229
+
230
+ try:
231
+ home = pwd.getpwuid(os.getuid()).pw_dir
232
+ except KeyError:
233
+ # A uid without a passwd entry (common in containers): no better
234
+ # answer than HOME.
235
+ home = env.get("HOME") or os.path.expanduser("~")
236
+ if sys.platform == "darwin":
237
+ return os.path.join(home, "Library", "Application Support", "rtk", "history.db")
238
+ data_home = env.get("XDG_DATA_HOME") or os.path.join(home, ".local", "share")
239
+ return os.path.join(data_home, "rtk", "history.db")
240
+
241
+
242
+ def _shared_rtk_history(env):
243
+ """Point every profile's rtk at one history, unless the operator chose one."""
244
+ path = None if env.get("RTK_DB_PATH") else operator_rtk_history_path(env)
245
+ if path:
246
+ env["RTK_DB_PATH"] = path
247
+
248
+
211
249
  def _anthropic_profile_name():
212
250
  return "default"
213
251
 
@@ -419,11 +457,11 @@ def _normalize_reasoning_effort(reasoning_effort=None, power=None, usage="Unsupp
419
457
  if alias == "":
420
458
  raise CdxError(usage)
421
459
  if effort and effort not in REASONING_EFFORT_VALUES:
422
- raise CdxError(f"Unsupported reasoning effort: {reasoning_effort}")
460
+ raise CdxError(f"Unsupported reasoning effort: {reasoning_effort}", code="invalid_reasoning_effort")
423
461
  if alias and alias not in REASONING_EFFORT_VALUES:
424
462
  raise CdxError(f"Unsupported power: {power}")
425
463
  if effort and alias and effort != alias:
426
- raise CdxError("--reasoning-effort and --power must match when both are provided.")
464
+ raise CdxError("--reasoning-effort and --power must match when both are provided.", code="invalid_reasoning_effort")
427
465
  resolved = effort or alias
428
466
  if not resolved:
429
467
  return {}
@@ -542,6 +580,9 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
542
580
  script_args = env.get("CDX_SCRIPT_ARGS")
543
581
  transcript_path = _build_launch_transcript_path(session)
544
582
  os.makedirs(os.path.dirname(transcript_path), exist_ok=True)
583
+ if os.name != "nt":
584
+ # script(1) keeps an existing file's mode, and a transcript can hold a token.
585
+ os.close(os.open(transcript_path, os.O_CREAT | os.O_WRONLY, 0o600))
545
586
  if script_args:
546
587
  args = shlex.split(script_args)
547
588
  if "{transcript}" in args:
@@ -573,6 +614,19 @@ def _rtk_enabled(session):
573
614
  return (session.get("launch") or {}).get("rtk") is True
574
615
 
575
616
 
617
+ # Claude Code passes credentials (including the setup-token cdx injects) to
618
+ # the Bash commands, hooks and MCP servers it starts unless this is set. Opt-in:
619
+ # it also strips other secrets an agent may need and, on Linux, isolates Bash
620
+ # from host processes (`ps`, `kill`).
621
+ CLAUDE_SCRUB_ENV = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"
622
+
623
+
624
+ def _scrub_env(session):
625
+ if session.get("provider") == PROVIDER_CLAUDE and (session.get("launch") or {}).get("scrub_env") is True:
626
+ return {CLAUDE_SCRUB_ENV: "1"}
627
+ return {}
628
+
629
+
576
630
  def _logics_manager_available(path=None):
577
631
  return shutil.which("logics-manager", path=path) is not None
578
632
 
@@ -661,11 +715,15 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
661
715
  env = {**os.environ, **env_override}
662
716
  for key, value in _shared_tool_cache_overrides(env).items():
663
717
  env.setdefault(key, value)
718
+ _shared_rtk_history(env)
664
719
  env.update(launch_notify_env(session, notifications_enabled(session)))
720
+ env.update(_scrub_env(session))
665
721
  preference_args = _launch_preference_args(session, env=env)
666
722
  if session["provider"] == PROVIDER_CLAUDE:
667
723
  launch = session.get("launch") or {}
668
- args = ["--name", session["name"]]
724
+ # No --name: it would title every conversation after the account in
725
+ # the /resume picker. The account already shows in cdx's terminal title.
726
+ args = []
669
727
  conversation_id = _conversation_id(session)
670
728
  if conversation_id:
671
729
  args += ["--session-id", conversation_id]
@@ -836,6 +894,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
836
894
  cwd = cwd or os.getcwd()
837
895
  env_override = env_override or {}
838
896
  env = {**os.environ, **env_override}
897
+ _shared_rtk_history(env)
839
898
  capability = get_resume_capability(session, cwd=cwd)
840
899
  if not capability["resumable"]:
841
900
  raise CdxError(f"Provider {session.get('provider')} does not support native resume through cdx.")
@@ -855,7 +914,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
855
914
  if capability["strategy"] == "provider_conversation_id"
856
915
  else ["--continue"]
857
916
  )
858
- args += ["--name", session["name"]]
917
+ # No --name: on a resume it would overwrite the user's /rename.
859
918
  if launch.get("model"):
860
919
  args += ["--model", _claude_cli_model(launch["model"])]
861
920
  args += _launch_config_args(session)
@@ -912,9 +971,11 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
912
971
  _validate_initial_prompt(initial_prompt)
913
972
  cwd = cwd or os.getcwd()
914
973
  env = {**os.environ, **(env_override or {})}
974
+ _shared_rtk_history(env)
915
975
  # Headless runs read the same home, and so the same hooks, as an interactive
916
976
  # launch. Their caller already learns of completion from the return value.
917
977
  env.update(launch_notify_env(session, enabled=False))
978
+ env.update(_scrub_env(session))
918
979
  preference_args = _launch_preference_args(session, env=env)
919
980
  launch = session.get("launch") or {}
920
981
  power = _launch_power(session)
@@ -922,7 +983,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
922
983
  model = launch.get("model")
923
984
 
924
985
  if session["provider"] == PROVIDER_CLAUDE:
925
- args = ["--print", "--output-format", "json", "--name", session["name"]]
986
+ args = ["--print", "--output-format", "json"]
926
987
  conversation_id = _conversation_id(session)
927
988
  if conversation_id:
928
989
  args += ["--session-id", conversation_id]
@@ -1056,6 +1117,32 @@ def _signal_child_group(child, sig):
1056
1117
  return False
1057
1118
 
1058
1119
 
1120
+ def _raise_keyboard_interrupt(_signum, _frame):
1121
+ raise KeyboardInterrupt
1122
+
1123
+
1124
+ def _route_termination_to_interrupt():
1125
+ """Make SIGTERM and SIGHUP raise KeyboardInterrupt; returns what to restore."""
1126
+ previous = {}
1127
+ for name in ("SIGTERM", "SIGHUP"):
1128
+ sig = getattr(signal, name, None)
1129
+ if sig is None:
1130
+ continue
1131
+ try:
1132
+ previous[sig] = signal.signal(sig, _raise_keyboard_interrupt)
1133
+ except (ValueError, OSError): # not the main thread, or not settable here
1134
+ pass
1135
+ return previous
1136
+
1137
+
1138
+ def _restore_signal_handlers(previous):
1139
+ for sig, handler in previous.items():
1140
+ try:
1141
+ signal.signal(sig, handler)
1142
+ except (ValueError, OSError):
1143
+ pass
1144
+
1145
+
1059
1146
  def _terminate_child_tree(child):
1060
1147
  if not _signal_child_group(child, signal.SIGTERM):
1061
1148
  try:
@@ -1111,14 +1198,18 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1111
1198
  )
1112
1199
  except FileNotFoundError as error:
1113
1200
  _combine_headless_transcript(paths)
1114
- cdx_error = CdxError(f"{spec['label']} CLI not found on PATH: {spec['command']}", 127)
1201
+ cdx_error = CdxError(f"{spec['label']} CLI not found on PATH: {spec['command']}", 127, code="provider_cli_not_found")
1115
1202
  cdx_error.run_info = _headless_run_info(paths, spec, start_time, 127)
1116
1203
  raise cdx_error from error
1117
1204
  except OSError as error:
1118
1205
  _combine_headless_transcript(paths)
1119
- cdx_error = CdxError(f"Failed to start {spec['label']}: {error}", 126)
1206
+ cdx_error = CdxError(f"Failed to start {spec['label']}: {error}", 126, code="provider_start_failed")
1120
1207
  cdx_error.run_info = _headless_run_info(paths, spec, start_time, 126)
1121
1208
  raise cdx_error from error
1209
+ # SIGTERM/SIGHUP (kill, systemctl stop, a closed SSH session) unwind
1210
+ # like Ctrl+C: the provider's own process group would otherwise outlive
1211
+ # this supervisor and keep its run "running" forever.
1212
+ previous_handlers = _route_termination_to_interrupt()
1122
1213
  try:
1123
1214
  if timeout_seconds is None:
1124
1215
  child.wait()
@@ -1136,6 +1227,8 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1136
1227
  # the run still gets a terminal record.
1137
1228
  cancelled = True
1138
1229
  _terminate_child_tree(child)
1230
+ finally:
1231
+ _restore_signal_handlers(previous_handlers)
1139
1232
 
1140
1233
  if cancelled:
1141
1234
  _combine_headless_transcript(paths)
@@ -1283,7 +1376,7 @@ def _probe_provider_auth_status(session, spawn_sync=None, env_override=None, tru
1283
1376
  result = subprocess.run(
1284
1377
  [command] + spec["args"],
1285
1378
  env=spec["env"],
1286
- capture_output=True, text=True,
1379
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1287
1380
  timeout=AUTH_PROBE_TIMEOUT_SECONDS,
1288
1381
  )
1289
1382
  output = (result.stdout or "") + (result.stderr or "")
package/src/retro.py ADDED
@@ -0,0 +1,131 @@
1
+ """Session retrospectives: what the user had to correct, so the next session knows it.
2
+
3
+ With `cdx set <name> --retro on`, the end of an interactive session starts a
4
+ detached, read-only `cdx run --kind retro` on a ready account. It reads the
5
+ session's transcript and reports the corrections the user made and the lessons
6
+ worth keeping, each with where it belongs (memory, runbook, skill, CLAUDE.md).
7
+ It changes nothing: `cdx retro` shows the reports, and the user decides.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+
15
+ #: A session with fewer prompts than this rarely holds a correction worth a model call.
16
+ MIN_USER_PROMPTS = 3
17
+
18
+ PROMPT = """You are writing a retrospective of one finished coding-agent session. Do not modify any file.
19
+
20
+ Transcript (JSONL, possibly large; read it in bounded portions, never all at once): {transcript}
21
+ Project folder: {cwd}
22
+
23
+ Find the moments where the user corrected, redirected or overruled the agent, and the
24
+ non-obvious facts the session had to discover. Ignore routine back-and-forth.
25
+
26
+ Answer in Markdown with exactly these sections:
27
+ ## Corrections
28
+ One bullet per correction: what the agent did, what the user wanted, in one line each.
29
+ ## Lessons
30
+ One bullet per lesson worth keeping for future sessions, each starting with where it belongs:
31
+ `memory:`, `runbook:`, `skill:` or `CLAUDE.md:`, followed by the exact text to add.
32
+ ## Verdict
33
+ One line: "nothing to learn" if both sections are empty, else the single most useful lesson.
34
+ """
35
+
36
+
37
+ def enabled(session):
38
+ return (session.get("launch") or {}).get("retro") is True
39
+
40
+
41
+ def start(session, cwd, transcript, env=None, popen=subprocess.Popen):
42
+ """Start the retro run in the background; returns the argv, or None when skipped."""
43
+ env = {**os.environ, **(env or {})}
44
+ if not enabled(session) or not transcript or not os.path.isfile(transcript):
45
+ return None
46
+ if env.get("CDX_RETRO") == "1" or _user_prompts(transcript) < MIN_USER_PROMPTS:
47
+ return None
48
+ executable = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
49
+ argv = [
50
+ executable, "run", "--provider", session["provider"], "--cwd", cwd, "--kind", "retro",
51
+ "--permission", "read-only", "--power", "low", "--detach", "--json",
52
+ "--prompt", PROMPT.format(transcript=transcript, cwd=cwd),
53
+ ]
54
+ env["CDX_RETRO"] = "1" # a retro never asks for a retro of itself
55
+ try:
56
+ popen(argv, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
57
+ stderr=subprocess.DEVNULL, start_new_session=True)
58
+ except OSError:
59
+ return None
60
+ return argv
61
+
62
+
63
+ def _user_prompts(transcript):
64
+ """Prompts the user typed: Claude `user` records that are not tool results, Codex `user_message` events.
65
+
66
+ Size is no proxy: one long handoff prompt makes a large transcript of a session
67
+ that never started.
68
+ """
69
+ count = 0
70
+ try:
71
+ with open(transcript, encoding="utf-8", errors="replace") as handle:
72
+ for line in handle:
73
+ if ('"type":"user"' in line and '"tool_result"' not in line) or '"type":"user_message"' in line:
74
+ count += 1
75
+ if count >= MIN_USER_PROMPTS:
76
+ break
77
+ except OSError:
78
+ return 0
79
+ return count
80
+
81
+
82
+ def reports(registry, limit=5):
83
+ """The newest retro runs, newest first, with their text when finished."""
84
+ found = []
85
+ for run in registry.list(limit=200):
86
+ if run.get("kind") != "retro":
87
+ continue
88
+ payload = run.get("final_payload") if isinstance(run.get("final_payload"), dict) else {}
89
+ found.append({"run_id": run.get("run_id"), "status": run.get("status"), "cwd": run.get("cwd"),
90
+ "started_at": run.get("started_at"), "session": run.get("session"),
91
+ "report": _result_text(payload.get("stdout_path"))})
92
+ if len(found) >= limit:
93
+ break
94
+ return found
95
+
96
+
97
+ def _result_text(stdout_path):
98
+ """The answer of a finished run: Claude's `result`, else where to read it."""
99
+ if not stdout_path or not os.path.isfile(stdout_path):
100
+ return None
101
+ try:
102
+ with open(stdout_path, encoding="utf-8") as handle:
103
+ document = json.load(handle)
104
+ except (OSError, ValueError):
105
+ return f"(see {stdout_path})"
106
+ result = document.get("result") if isinstance(document, dict) else None
107
+ return result if isinstance(result, str) else f"(see {stdout_path})"
108
+
109
+
110
+ def handle_retro(rest, ctx):
111
+ from .cli_helpers import _json_success, _write_json
112
+ from .errors import CdxError
113
+ from .run_registry import RunRegistry
114
+
115
+ json_flag = "--json" in rest
116
+ args = [arg for arg in rest if arg != "--json"]
117
+ limit = 5
118
+ if args[:1] == ["--limit"] and len(args) == 2 and args[1].isdigit():
119
+ limit = int(args[1])
120
+ elif args:
121
+ raise CdxError("Usage: cdx retro [--limit N] [--json]")
122
+ found = reports(RunRegistry(ctx["service"]["base_dir"]), limit=limit)
123
+ if json_flag:
124
+ _write_json(ctx, _json_success("retro", f"{len(found)} retro(s)", retros=found))
125
+ return 0
126
+ if not found:
127
+ ctx["out"]("No session retro yet. Turn them on with `cdx set <name> --retro on`.\n")
128
+ return 0
129
+ for item in found:
130
+ ctx["out"](f"## {item['started_at']} · {item['cwd']} · {item['status']}\n{item['report'] or '(still running)'}\n\n")
131
+ return 0
@@ -0,0 +1,68 @@
1
+ """What rtk saved, for the period `cdx stats` reports on.
2
+
3
+ Launches share the operator's rtk history (RTK_DB_PATH), but profiles used before
4
+ that kept their own `history.db` under their home; both are read so older
5
+ savings are not lost. rtk itself does the reading (`rtk gain --daily --format
6
+ json`), so this never depends on its database schema.
7
+ """
8
+
9
+ import glob
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ from datetime import datetime
15
+
16
+ from .provider_runtime import operator_rtk_history_path
17
+
18
+ _PROFILE_DBS = (
19
+ ("*", "Library", "Application Support", "rtk", "history.db"),
20
+ ("*", "*-home", "Library", "Application Support", "rtk", "history.db"),
21
+ ("*", ".local", "share", "rtk", "history.db"),
22
+ ("*", "*-home", ".local", "share", "rtk", "history.db"),
23
+ )
24
+
25
+
26
+ def history_databases(base_dir, env=None):
27
+ """Every rtk history this operator's sessions wrote to, the shared one first."""
28
+ env = os.environ if env is None else env
29
+ found = []
30
+ for path in [env.get("RTK_DB_PATH") or operator_rtk_history_path(env)] + [
31
+ match for pattern in _PROFILE_DBS
32
+ for match in sorted(glob.glob(os.path.join(glob.escape(os.path.join(base_dir, "profiles")), *pattern)))
33
+ ]:
34
+ if path and os.path.isfile(path) and os.path.realpath(path) not in {os.path.realpath(p) for p in found}:
35
+ found.append(path)
36
+ return found
37
+
38
+
39
+ def _in_period(day, period):
40
+ for key, keep in (("from", lambda d, edge: d >= edge), ("to", lambda d, edge: d <= edge)):
41
+ edge = (period or {}).get(key)
42
+ if edge and not keep(day, datetime.fromisoformat(edge).date().isoformat()):
43
+ return False
44
+ return True
45
+
46
+
47
+ def rtk_saved(base_dir, period=None, env=None, run=subprocess.run):
48
+ """{commands, input_tokens, saved_tokens, databases} over the period, or None without rtk."""
49
+ env = {**os.environ, **(env or {})}
50
+ rtk = shutil.which("rtk", path=env.get("PATH"))
51
+ databases = history_databases(base_dir, env)
52
+ if not rtk or not databases:
53
+ return None
54
+ totals = {"commands": 0, "input_tokens": 0, "saved_tokens": 0, "databases": 0}
55
+ for database in databases:
56
+ try:
57
+ result = run([rtk, "gain", "--daily", "--format", "json"], env={**env, "RTK_DB_PATH": database},
58
+ capture_output=True, text=True, timeout=15, check=False)
59
+ days = json.loads(result.stdout).get("daily") or []
60
+ except (OSError, ValueError, AttributeError, subprocess.TimeoutExpired):
61
+ continue
62
+ totals["databases"] += 1
63
+ for day in days:
64
+ if isinstance(day, dict) and _in_period(str(day.get("date")), period):
65
+ totals["commands"] += int(day.get("commands") or 0)
66
+ totals["input_tokens"] += int(day.get("input_tokens") or 0)
67
+ totals["saved_tokens"] += int(day.get("saved_tokens") or 0)
68
+ return totals if totals["databases"] else None
@@ -219,15 +219,41 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
219
219
  }
220
220
 
221
221
 
222
+ def _check_capacity(data, max_parallel):
223
+ _refresh_stale_runs(data)
224
+ running = sum(1 for run in data["runs"] if run.get("status") == "running")
225
+ if running >= max_parallel:
226
+ raise CdxError(
227
+ f"{running} run(s) already running, the limit is {max_parallel}. "
228
+ "Wait for one to finish (cdx runs --json) or raise --max-parallel.",
229
+ code="too_many_runs",
230
+ )
231
+
232
+
222
233
  class RunRegistry:
223
234
  def __init__(self, base_dir):
224
235
  self.path = registry_path(base_dir)
225
236
 
226
- def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None):
237
+ def check_capacity(self, max_parallel):
238
+ """Raise too_many_runs now, before a refused run changes any session state.
239
+
240
+ `start()` checks again under the same lock, so a run that slips in
241
+ between is still refused there.
242
+ """
243
+ with _registry_lock(self.path):
244
+ _check_capacity(_read_registry(self.path), max_parallel)
245
+
246
+ def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None, label=None, max_parallel=None):
227
247
  with _registry_lock(self.path):
228
248
  data = _read_registry(self.path)
229
249
  data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
250
+ if max_parallel:
251
+ # Counted under the lock and without this run's own record, which a
252
+ # detached child writes a second time under the same id.
253
+ _check_capacity(data, max_parallel)
230
254
  record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
255
+ if label:
256
+ record["label"] = label
231
257
  data["runs"].insert(0, record)
232
258
  _prune_finished_runs(data)
233
259
  _write_registry(self.path, data)