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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_21_6.md +33 -0
- package/changelogs/CHANGELOGS_0_22_0.md +25 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +17 -7
- package/docs/operations.md +80 -0
- package/docs/sessions-and-notifications.md +1 -1
- package/docs/technical-overview.md +1 -1
- package/install.ps1 +7 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +23 -7
- package/src/backup_bundle.py +60 -16
- package/src/claude_usage.py +1 -1
- package/src/cli.py +15 -4
- package/src/cli_args.py +76 -19
- package/src/cli_helpers.py +23 -9
- package/src/codex_usage.py +1 -1
- package/src/commands/launch.py +41 -3
- package/src/commands/maintenance.py +1 -0
- package/src/commands/rescue.py +207 -0
- package/src/commands/runs.py +26 -3
- package/src/commands/status.py +56 -6
- package/src/config.py +17 -1
- package/src/context_store.py +11 -6
- package/src/errors.py +5 -1
- package/src/handoff.py +22 -11
- package/src/health.py +2 -2
- package/src/interactive_usage.py +20 -5
- package/src/notify.py +23 -18
- package/src/provider_background.py +13 -8
- package/src/provider_runtime.py +101 -8
- package/src/retro.py +131 -0
- package/src/rtk_savings.py +68 -0
- package/src/run_registry.py +27 -1
- package/src/run_usage.py +36 -9
- package/src/session_backup.py +16 -5
- package/src/session_helpers.py +4 -0
- package/src/session_ranking.py +10 -0
- package/src/session_service.py +39 -34
- package/src/session_status.py +86 -2
- package/src/shared_memory.py +102 -0
- package/src/status_source.py +2 -0
- package/src/status_view.py +23 -23
- package/src/tray_autostart.py +21 -2
- package/src/tray_events.py +23 -4
- package/src/tray_instance.py +11 -2
- package/src/tray_logics.py +1 -1
- package/src/update_all.py +1 -1
- package/src/update_check.py +1 -1
- 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)
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
"
|
|
382
|
-
|
|
383
|
-
"
|
|
384
|
-
|
|
385
|
-
"
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
"
|
|
389
|
-
|
|
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.
|
|
76
|
-
|
|
77
|
-
that `--failover` depends on
|
|
78
|
-
2.
|
|
79
|
-
|
|
80
|
-
|
|
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
|
package/src/provider_runtime.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
package/src/run_registry.py
CHANGED
|
@@ -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
|
|
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)
|