cdx-manager 0.21.6 → 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_22_0.md +25 -0
- package/checksums/release-archives.json +11 -0
- package/docs/cli.md +17 -7
- package/docs/operations.md +79 -0
- package/docs/sessions-and-notifications.md +1 -1
- package/docs/technical-overview.md +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +22 -6
- package/src/cli.py +9 -2
- package/src/cli_args.py +37 -8
- package/src/cli_helpers.py +22 -9
- 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 +23 -0
- package/src/commands/status.py +56 -6
- package/src/handoff.py +22 -11
- package/src/interactive_usage.py +20 -5
- package/src/provider_background.py +12 -7
- package/src/provider_runtime.py +49 -3
- 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 +22 -2
- 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_instance.py +11 -2
- package/src/update_manager.py +10 -4
package/src/cli_helpers.py
CHANGED
|
@@ -249,13 +249,23 @@ def _bootstrap_claude_setup_token(session, ctx):
|
|
|
249
249
|
ctx["out"](
|
|
250
250
|
"Minting a long-lived Claude setup-token for this session.\n"
|
|
251
251
|
)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
252
|
+
try:
|
|
253
|
+
run_info = _run_interactive_provider_command(
|
|
254
|
+
session,
|
|
255
|
+
"setup-token",
|
|
256
|
+
spawn=ctx.get("spawn"),
|
|
257
|
+
env_override=ctx.get("env"),
|
|
258
|
+
signal_emitter=ctx.get("signal_emitter"),
|
|
259
|
+
)
|
|
260
|
+
except Exception as error:
|
|
261
|
+
# A failed or interrupted run can still have printed the token.
|
|
262
|
+
failed_path = (getattr(error, "run_info", None) or {}).get("transcript_path")
|
|
263
|
+
if failed_path:
|
|
264
|
+
try:
|
|
265
|
+
os.remove(failed_path)
|
|
266
|
+
except OSError:
|
|
267
|
+
pass
|
|
268
|
+
raise
|
|
259
269
|
transcript_path = run_info.get("transcript_path")
|
|
260
270
|
if not transcript_path or not os.path.isfile(transcript_path):
|
|
261
271
|
raise CdxError(
|
|
@@ -304,11 +314,14 @@ def _format_launch_config(session, use_color=False):
|
|
|
304
314
|
("notify", "Agent alerts"),
|
|
305
315
|
("notify_preview", "Response preview"),
|
|
306
316
|
("scrub_env", "Scrub agent env"),
|
|
317
|
+
("shared_memory", "Shared memory"),
|
|
318
|
+
("retro", "Session retro"),
|
|
307
319
|
("model", "Model"),
|
|
308
320
|
("fallback_model", "Fallback"),
|
|
309
321
|
("budget", "Budget"),
|
|
310
322
|
("extra_args", "Extra args"),
|
|
311
323
|
("priority", "Priority"),
|
|
324
|
+
("policy", "Policy"),
|
|
312
325
|
]:
|
|
313
326
|
rows.append([
|
|
314
327
|
_dim(label, use_color),
|
|
@@ -330,14 +343,14 @@ def _format_launch_settings_hint(name="<name>"):
|
|
|
330
343
|
)
|
|
331
344
|
|
|
332
345
|
def _format_launch_setting_value(launch, key, use_color=False):
|
|
333
|
-
if key in ("fast", "rtk", "logics", "notify", "notify_preview", "scrub_env"):
|
|
346
|
+
if key in ("fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "shared_memory", "retro"):
|
|
334
347
|
if launch.get(key) is True:
|
|
335
348
|
return _style("on", "32", use_color)
|
|
336
349
|
if launch.get(key) is False:
|
|
337
350
|
return _style("off", "2", use_color)
|
|
338
351
|
if key == "logics":
|
|
339
352
|
return _dim("auto", use_color)
|
|
340
|
-
if key in ("notify", "notify_preview", "scrub_env"):
|
|
353
|
+
if key in ("notify", "notify_preview", "scrub_env", "shared_memory", "retro"):
|
|
341
354
|
return _style("off", "2", use_color)
|
|
342
355
|
return _dim("default", use_color)
|
|
343
356
|
value = launch.get(key)
|
package/src/commands/launch.py
CHANGED
|
@@ -6,6 +6,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
|
6
6
|
import os
|
|
7
7
|
import shlex
|
|
8
8
|
|
|
9
|
+
from .. import retro, shared_memory
|
|
9
10
|
from ..agent_notify import notifications_enabled, provision, supports_agent_alerts
|
|
10
11
|
from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag
|
|
11
12
|
from ..cli_helpers import (
|
|
@@ -19,7 +20,7 @@ from ..cli_helpers import (
|
|
|
19
20
|
_write_update_notice,
|
|
20
21
|
)
|
|
21
22
|
from ..cli_render import _dim, _info, _success, _warn
|
|
22
|
-
from ..config import PROVIDER_CODEX
|
|
23
|
+
from ..config import PROVIDER_CLAUDE, PROVIDER_CODEX
|
|
23
24
|
from ..errors import CdxError
|
|
24
25
|
from ..handoff import HandoffSourceError, launch_prompt, load_entry, prepare_entry, resolve_source
|
|
25
26
|
from ..interactive_usage import extract_interactive_usage, transcript_predates_run, usage_delta
|
|
@@ -33,12 +34,24 @@ from ..provider_runtime import (
|
|
|
33
34
|
from ..run_command import PROMPT_STDIN, read_run_prompt
|
|
34
35
|
from ..run_usage import estimate_cost
|
|
35
36
|
|
|
36
|
-
LAUNCH_USAGE = "Usage: cdx <name> [--dir PATH] [--prompt TEXT|--prompt-file PATH] [--resume] [--json]"
|
|
37
|
+
LAUNCH_USAGE = "Usage: cdx <name> [--dir PATH] [--prompt TEXT|--prompt-file PATH|--task TEXT [--end-state TEXT]] [--resume] [--json]"
|
|
38
|
+
|
|
39
|
+
#: The first message of a session started with --task. The end state is what the
|
|
40
|
+
#: user's go-ahead covers: a bare "show me first" made sessions stop after a commit
|
|
41
|
+
#: and wait for a push they could have made.
|
|
42
|
+
TASK_PROMPT = """Task: {task}
|
|
43
|
+
|
|
44
|
+
{end_state}
|
|
45
|
+
|
|
46
|
+
Before starting, read this repository's instructions (AGENTS.md, CLAUDE.md and what they reference) and use its skills where one fits; they override general habits."""
|
|
47
|
+
END_STATE = """End state: {end_state}
|
|
48
|
+
Reaching it is approved: take every step it needs, including the commits, pushes or pull requests it names, without asking again. Stop and ask only for a decision it does not cover."""
|
|
49
|
+
NO_END_STATE = """No end state was given: stop and show the result before any commit, push or pull request."""
|
|
37
50
|
|
|
38
51
|
|
|
39
52
|
def _parse_launch_args(args):
|
|
40
53
|
"""Return the per-launch directory, initial prompt source and ordinary launch flags."""
|
|
41
|
-
values = {"--dir": None, "--prompt": None, "--prompt-file": None}
|
|
54
|
+
values = {"--dir": None, "--prompt": None, "--prompt-file": None, "--task": None, "--end-state": None}
|
|
42
55
|
cleaned = []
|
|
43
56
|
index = 0
|
|
44
57
|
while index < len(args):
|
|
@@ -59,6 +72,13 @@ def _parse_launch_args(args):
|
|
|
59
72
|
values[flag] = value
|
|
60
73
|
if values["--prompt"] is not None and values["--prompt-file"] is not None:
|
|
61
74
|
raise CdxError("cdx <name>: --prompt and --prompt-file are mutually exclusive.")
|
|
75
|
+
if values["--end-state"] is not None and values["--task"] is None:
|
|
76
|
+
raise CdxError("cdx <name>: --end-state goes with --task.")
|
|
77
|
+
if values["--task"] is not None:
|
|
78
|
+
if values["--prompt"] is not None or values["--prompt-file"] is not None:
|
|
79
|
+
raise CdxError("cdx <name>: --task replaces --prompt and --prompt-file.")
|
|
80
|
+
end = END_STATE.format(end_state=values["--end-state"]) if values["--end-state"] else NO_END_STATE
|
|
81
|
+
values["--prompt"] = TASK_PROMPT.format(task=values["--task"], end_state=end)
|
|
62
82
|
if values["--prompt-file"] == PROMPT_STDIN:
|
|
63
83
|
raise CdxError("cdx <name>: --prompt-file - is not supported; the interactive session needs standard input.")
|
|
64
84
|
prompt = {"prompt": values["--prompt"], "prompt_file": values["--prompt-file"]}
|
|
@@ -202,6 +222,21 @@ def _notification_provisioning_enabled(session, ctx, json_flag):
|
|
|
202
222
|
return True
|
|
203
223
|
|
|
204
224
|
|
|
225
|
+
def _link_shared_memory(session, ctx, cwd, json_flag):
|
|
226
|
+
if session["provider"] != PROVIDER_CLAUDE or (session.get("launch") or {}).get("shared_memory") is not True:
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
status, detail = shared_memory.link(_get_auth_home(session), ctx["service"]["base_dir"], cwd)
|
|
230
|
+
except OSError as error:
|
|
231
|
+
status, detail = "unsupported", str(error)
|
|
232
|
+
if json_flag or status == "linked":
|
|
233
|
+
return
|
|
234
|
+
if status == "conflict":
|
|
235
|
+
detail = f"merge these files by hand first: {', '.join(detail)}"
|
|
236
|
+
message = f"Shared memory not linked for {session['name']}"
|
|
237
|
+
ctx["out"](f"{_warn(message, ctx['use_color'])} {detail}\n")
|
|
238
|
+
|
|
239
|
+
|
|
205
240
|
def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None):
|
|
206
241
|
json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
|
|
207
242
|
if force_json is not None:
|
|
@@ -262,6 +297,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
262
297
|
# a real turn — so it gets no instruction it does not need.
|
|
263
298
|
notice += " — approve them once in Codex to enable"
|
|
264
299
|
ctx["out"](f"{_info(notice, ctx['use_color'])}\n")
|
|
300
|
+
_link_shared_memory(session, ctx, cwd, json_flag)
|
|
265
301
|
runtime_run_id = None
|
|
266
302
|
|
|
267
303
|
def runtime_lifecycle(event, info):
|
|
@@ -335,6 +371,8 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
335
371
|
})
|
|
336
372
|
if not json_flag:
|
|
337
373
|
ctx["out"](f"{_goodbye_line(session, success_run_info, ctx['use_color'])}\n")
|
|
374
|
+
if retro.start(session, cwd, success_run_info.get("provider_transcript_path"), env=ctx.get("env")) and not json_flag:
|
|
375
|
+
ctx["out"](f"{_dim('Session retro started; read it later with `cdx retro`', ctx['use_color'])}\n")
|
|
338
376
|
if json_flag:
|
|
339
377
|
extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
|
|
340
378
|
if capability:
|
|
@@ -775,6 +775,7 @@ def handle_update(rest, ctx):
|
|
|
775
775
|
package_root=ctx["options"].get("packageRoot"),
|
|
776
776
|
prefix=ctx["options"].get("prefix"),
|
|
777
777
|
base_prefix=ctx["options"].get("basePrefix"),
|
|
778
|
+
env=ctx.get("env"),
|
|
778
779
|
)
|
|
779
780
|
results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
|
|
780
781
|
failed = any((result.get("returncode") != 0) for result in results)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""cdx rescue: move a conversation stopped by a usage limit to an account that can go on.
|
|
2
|
+
|
|
3
|
+
Everything it does already exists -- `status` knows which accounts are spent,
|
|
4
|
+
the shared ranking knows which one is best, `handoff` carries a conversation
|
|
5
|
+
over. This command only puts them in one step, and asks before it does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
|
|
12
|
+
from ..cli_helpers import _json_failure, _json_success, _write_json
|
|
13
|
+
from ..cli_render import _info, _warn
|
|
14
|
+
from ..errors import CdxError
|
|
15
|
+
from ..session_ranking import rank_sessions
|
|
16
|
+
from ..status_view import _now_timestamp, _priority_reset_timestamp
|
|
17
|
+
from .launch import handle_handoff
|
|
18
|
+
|
|
19
|
+
RESCUE_USAGE = "Usage: cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]"
|
|
20
|
+
#: A conversation stopped longer ago than this is history, not something to rescue.
|
|
21
|
+
RECENT = timedelta(hours=24)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse(rest):
|
|
25
|
+
parsed = {"source": None, "target": None, "yes": False, "dry_run": False, "json": False}
|
|
26
|
+
index = 0
|
|
27
|
+
while index < len(rest):
|
|
28
|
+
arg = rest[index]
|
|
29
|
+
if arg == "--to" and index + 1 < len(rest):
|
|
30
|
+
parsed["target"] = rest[index + 1]
|
|
31
|
+
index += 1
|
|
32
|
+
elif arg in ("--yes", "-y"):
|
|
33
|
+
parsed["yes"] = True
|
|
34
|
+
elif arg == "--dry-run":
|
|
35
|
+
parsed["dry_run"] = True
|
|
36
|
+
elif arg == "--json":
|
|
37
|
+
parsed["json"] = True
|
|
38
|
+
elif not arg.startswith("-") and parsed["source"] is None:
|
|
39
|
+
parsed["source"] = arg
|
|
40
|
+
else:
|
|
41
|
+
raise CdxError(RESCUE_USAGE)
|
|
42
|
+
index += 1
|
|
43
|
+
return parsed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _recent(iso, now):
|
|
47
|
+
try:
|
|
48
|
+
when = datetime.fromisoformat(str(iso))
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
return False
|
|
51
|
+
if when.tzinfo is None:
|
|
52
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
53
|
+
return now - when <= RECENT
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: How much of a transcript's end is read to find how it stopped.
|
|
57
|
+
TAIL_BYTES = 256 * 1024
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ended_on_limit(transcript):
|
|
61
|
+
"""Whether the conversation's last word is a usage-limit error.
|
|
62
|
+
|
|
63
|
+
Claude writes an API error record (`isApiErrorMessage`, `error: rate_limit`);
|
|
64
|
+
Codex an `error` event with `codex_error_info: usage_limit_exceeded`. A normal
|
|
65
|
+
reply after it means the conversation went on, so only the last of the two
|
|
66
|
+
counts. This catches a stop before `cdx status` shows the account at 0%.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
with open(transcript, "rb") as handle:
|
|
70
|
+
handle.seek(max(0, os.path.getsize(transcript) - TAIL_BYTES))
|
|
71
|
+
lines = handle.read().decode("utf-8", errors="replace").splitlines()
|
|
72
|
+
except (OSError, TypeError):
|
|
73
|
+
return False
|
|
74
|
+
stopped = False
|
|
75
|
+
for line in lines:
|
|
76
|
+
try:
|
|
77
|
+
record = json.loads(line)
|
|
78
|
+
except ValueError:
|
|
79
|
+
continue
|
|
80
|
+
if not isinstance(record, dict):
|
|
81
|
+
continue
|
|
82
|
+
payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
|
|
83
|
+
if record.get("isApiErrorMessage") and record.get("error") == "rate_limit":
|
|
84
|
+
stopped = True
|
|
85
|
+
elif payload.get("type") == "error" and payload.get("codex_error_info") == "usage_limit_exceeded":
|
|
86
|
+
stopped = True
|
|
87
|
+
elif record.get("type") == "assistant" and not record.get("isApiErrorMessage"):
|
|
88
|
+
stopped = False
|
|
89
|
+
elif payload.get("type") == "message" and payload.get("role") == "assistant":
|
|
90
|
+
stopped = False
|
|
91
|
+
return stopped
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def stranded(service, rows, now=None):
|
|
95
|
+
"""Sessions on a spent account whose last conversation can be handed off, newest first."""
|
|
96
|
+
now = now or datetime.now(timezone.utc)
|
|
97
|
+
found = []
|
|
98
|
+
for session in service["list_sessions"]():
|
|
99
|
+
row = rows.get(session["name"]) or {}
|
|
100
|
+
conversation = (session.get("conversation") or {}).get("id")
|
|
101
|
+
if not conversation or not _recent(row.get("last_launched_at"), now):
|
|
102
|
+
continue
|
|
103
|
+
if _rescued_since_launch(session, row):
|
|
104
|
+
continue
|
|
105
|
+
history = service["get_launch_history"](session["name"], limit=1)
|
|
106
|
+
last = history[0] if history else {}
|
|
107
|
+
cwd = last.get("cwd") or None
|
|
108
|
+
if not cwd or not os.path.isdir(cwd):
|
|
109
|
+
continue
|
|
110
|
+
spent = row.get("available_pct") == 0
|
|
111
|
+
if not spent and not ended_on_limit(last.get("provider_transcript_path")):
|
|
112
|
+
continue
|
|
113
|
+
found.append({
|
|
114
|
+
"session": session["name"], "provider": session["provider"], "conversation_id": conversation,
|
|
115
|
+
"cwd": cwd, "reset_at": row.get("reset_at"), "last_launched_at": row.get("last_launched_at"),
|
|
116
|
+
})
|
|
117
|
+
return sorted(found, key=lambda item: item["last_launched_at"] or "", reverse=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _rescued_since_launch(session, row):
|
|
121
|
+
"""Already handed off, and not launched again since: rescuing it twice forks it."""
|
|
122
|
+
try:
|
|
123
|
+
rescued = datetime.fromisoformat(str((session.get("conversation") or {}).get("rescuedAt")))
|
|
124
|
+
launched = datetime.fromisoformat(str(row.get("last_launched_at")))
|
|
125
|
+
return rescued >= launched
|
|
126
|
+
except (TypeError, ValueError):
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def pick_target(rows, source, provider):
|
|
131
|
+
"""(target row, needs confirmation) for the best ready account, or (None, False).
|
|
132
|
+
|
|
133
|
+
`use` accounts first; an `ask-first` one only when no `use` account is
|
|
134
|
+
ready, and always behind a confirmation. `never` is not considered.
|
|
135
|
+
"""
|
|
136
|
+
others = [row for row in rows.values() if row.get("session_name") != source]
|
|
137
|
+
ordered, _ = rank_sessions(others, _now_timestamp(), _priority_reset_timestamp,
|
|
138
|
+
provider=provider, require_ready=True)
|
|
139
|
+
if ordered:
|
|
140
|
+
return ordered[0], False
|
|
141
|
+
asked = [{**row, "policy": "use"} for row in others if row.get("policy") == "ask-first"]
|
|
142
|
+
ordered, _ = rank_sessions(asked, _now_timestamp(), _priority_reset_timestamp,
|
|
143
|
+
provider=provider, require_ready=True)
|
|
144
|
+
return (ordered[0], True) if ordered else (None, False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def handle_rescue(rest, ctx):
|
|
148
|
+
parsed = _parse(rest)
|
|
149
|
+
service = ctx["service"]
|
|
150
|
+
rows = {row["session_name"]: row for row in service["get_status_rows"](force_refresh=False)}
|
|
151
|
+
candidates = stranded(service, rows)
|
|
152
|
+
if parsed["source"]:
|
|
153
|
+
candidates = [c for c in candidates if c["session"] == parsed["source"]]
|
|
154
|
+
if not candidates:
|
|
155
|
+
message = "No conversation stopped by a usage limit in the last 24 hours."
|
|
156
|
+
if parsed["json"]:
|
|
157
|
+
_write_json(ctx, _json_success("rescue", message, plan=None, stranded=[]))
|
|
158
|
+
else:
|
|
159
|
+
ctx["out"](f"{message}\n")
|
|
160
|
+
return 0
|
|
161
|
+
source = candidates[0]
|
|
162
|
+
if parsed["target"]:
|
|
163
|
+
target = rows.get(parsed["target"])
|
|
164
|
+
if not target or target.get("policy") == "never":
|
|
165
|
+
raise CdxError(f"Cannot rescue onto {parsed['target']}: unknown account or policy never.")
|
|
166
|
+
confirm = target.get("policy") == "ask-first"
|
|
167
|
+
else:
|
|
168
|
+
target, confirm = pick_target(rows, source["session"], source["provider"])
|
|
169
|
+
if not target:
|
|
170
|
+
message = f"No ready {source['provider']} account to take over {source['session']}."
|
|
171
|
+
if parsed["json"]:
|
|
172
|
+
_write_json(ctx, _json_failure("rescue", "no_ready_account", message, stranded=candidates))
|
|
173
|
+
return 1
|
|
174
|
+
raise CdxError(message)
|
|
175
|
+
plan = {**source, "target": target["session_name"], "target_policy": target.get("policy", "use")}
|
|
176
|
+
if parsed["json"] or parsed["dry_run"]:
|
|
177
|
+
if parsed["json"]:
|
|
178
|
+
_write_json(ctx, _json_success("rescue", "Rescue plan", plan=plan, stranded=candidates))
|
|
179
|
+
else:
|
|
180
|
+
ctx["out"](_describe(plan, ctx) + "\n")
|
|
181
|
+
return 0
|
|
182
|
+
ctx["out"](_describe(plan, ctx) + "\n")
|
|
183
|
+
if confirm or not parsed["yes"]:
|
|
184
|
+
if not ctx["stdin_is_tty"]:
|
|
185
|
+
raise CdxError("cdx rescue needs a terminal to confirm; pass --yes (not enough for an ask-first account).")
|
|
186
|
+
ask = ctx.get("options", {}).get("input") or input
|
|
187
|
+
question = f"Hand {source['session']} over to {target['session_name']}"
|
|
188
|
+
question += " (policy ask-first)" if confirm else ""
|
|
189
|
+
if ask(f"{question}? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
190
|
+
raise CdxError("Rescue cancelled.", exit_code=130)
|
|
191
|
+
code = handle_handoff(
|
|
192
|
+
[source["session"], target["session_name"], "--source-conversation", source["conversation_id"]],
|
|
193
|
+
{**ctx, "cwd": source["cwd"]},
|
|
194
|
+
)
|
|
195
|
+
if code == 0:
|
|
196
|
+
service["mark_conversation_rescued"](source["session"], source["conversation_id"])
|
|
197
|
+
return code
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _describe(plan, ctx):
|
|
201
|
+
reset = f", back {plan['reset_at']}" if plan.get("reset_at") else ""
|
|
202
|
+
lines = [
|
|
203
|
+
_warn(f"{plan['session']} is out of usage{reset}", ctx["use_color"]),
|
|
204
|
+
f" conversation {plan['conversation_id']} in {plan['cwd']}",
|
|
205
|
+
_info(f"-> {plan['target']}" + (" (ask-first)" if plan["target_policy"] == "ask-first" else ""), ctx["use_color"]),
|
|
206
|
+
]
|
|
207
|
+
return "\n".join(lines)
|
package/src/commands/runs.py
CHANGED
|
@@ -193,6 +193,7 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
|
|
|
193
193
|
("--model", "model"),
|
|
194
194
|
("--kind", "kind"),
|
|
195
195
|
("--permission", "permission"),
|
|
196
|
+
("--label", "label"),
|
|
196
197
|
):
|
|
197
198
|
value = parsed.get(key)
|
|
198
199
|
if value:
|
|
@@ -509,6 +510,17 @@ def handle_run_report(rest, ctx):
|
|
|
509
510
|
}))
|
|
510
511
|
return 0
|
|
511
512
|
|
|
513
|
+
def _env_max_runs(ctx):
|
|
514
|
+
"""CDX_MAX_RUNS as the default --max-parallel; unset, invalid or <= 0 means no limit."""
|
|
515
|
+
value = {**os.environ, **(ctx.get("env") or {})}.get("CDX_MAX_RUNS")
|
|
516
|
+
try:
|
|
517
|
+
limit = int(value) if value else 0
|
|
518
|
+
except ValueError:
|
|
519
|
+
return None
|
|
520
|
+
# 0 or negative reads as "no limit", like unset, rather than a cap of 1.
|
|
521
|
+
return limit if limit > 0 else None
|
|
522
|
+
|
|
523
|
+
|
|
512
524
|
def handle_run(rest, ctx):
|
|
513
525
|
registry = None
|
|
514
526
|
run_id = None
|
|
@@ -563,6 +575,15 @@ def handle_run(rest, ctx):
|
|
|
563
575
|
if parsed.get("reasoning_effort"):
|
|
564
576
|
launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
|
|
565
577
|
launch_updates["power"] = parsed["reasoning_effort"]
|
|
578
|
+
# A detached child runs in the slot its parent already took.
|
|
579
|
+
max_parallel = None if detached_run_id else (parsed.get("max_parallel") or _env_max_runs(ctx))
|
|
580
|
+
if max_parallel:
|
|
581
|
+
# Before anything changes the session: a refused run must not leave
|
|
582
|
+
# Claude on a fresh conversation id that resume and rescue then
|
|
583
|
+
# point at. Finished native background runs are closed out first,
|
|
584
|
+
# since nothing else frees their slot.
|
|
585
|
+
_reconcile_background_runs(ctx)
|
|
586
|
+
_run_registry(ctx).check_capacity(max_parallel)
|
|
566
587
|
# Claude needs its conversation id before the spec is built; Codex
|
|
567
588
|
# records its own afterwards, from the rollout. Without this a headless
|
|
568
589
|
# Claude run left the session with no identity and `cdx resume` fell
|
|
@@ -607,6 +628,8 @@ def handle_run(rest, ctx):
|
|
|
607
628
|
"stdout_path": artifacts.get("stdout_path"),
|
|
608
629
|
"stderr_path": artifacts.get("stderr_path"),
|
|
609
630
|
},
|
|
631
|
+
label=parsed.get("label"),
|
|
632
|
+
max_parallel=max_parallel,
|
|
610
633
|
)
|
|
611
634
|
if parsed.get("detach"):
|
|
612
635
|
# Delegate where the installed CLI can both start a background
|
package/src/commands/status.py
CHANGED
|
@@ -4,6 +4,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
import threading
|
|
8
9
|
import time
|
|
9
10
|
from datetime import datetime
|
|
@@ -49,6 +50,7 @@ from ..provider_runtime import (
|
|
|
49
50
|
_has_local_claude_auth,
|
|
50
51
|
_probe_provider_auth_status,
|
|
51
52
|
)
|
|
53
|
+
from ..rtk_savings import rtk_saved
|
|
52
54
|
from ..run_usage import (
|
|
53
55
|
USAGE_KEYS,
|
|
54
56
|
estimate_cost,
|
|
@@ -268,7 +270,7 @@ def _summarize_stats(entries):
|
|
|
268
270
|
name = entry.get("session_name") or "-"
|
|
269
271
|
row = rows.setdefault(name, {
|
|
270
272
|
"session_name": name,
|
|
271
|
-
"provider":
|
|
273
|
+
"provider": set(),
|
|
272
274
|
"launches": 0,
|
|
273
275
|
"successes": 0,
|
|
274
276
|
"failures": 0,
|
|
@@ -284,6 +286,7 @@ def _summarize_stats(entries):
|
|
|
284
286
|
"input_tokens": 0,
|
|
285
287
|
"cached_input_tokens": 0,
|
|
286
288
|
"cache_creation_tokens": 0,
|
|
289
|
+
"cache_creation_1h_tokens": 0,
|
|
287
290
|
"cache_read_tokens": 0,
|
|
288
291
|
"output_tokens": 0,
|
|
289
292
|
"reasoning_tokens": 0,
|
|
@@ -291,6 +294,8 @@ def _summarize_stats(entries):
|
|
|
291
294
|
"last_started_at": None,
|
|
292
295
|
})
|
|
293
296
|
row["launches"] += 1
|
|
297
|
+
if entry.get("provider"):
|
|
298
|
+
row["provider"].add(entry["provider"])
|
|
294
299
|
if entry.get("status") == "success":
|
|
295
300
|
row["successes"] += 1
|
|
296
301
|
elif entry.get("status") == "failed":
|
|
@@ -338,8 +343,10 @@ def _summarize_stats(entries):
|
|
|
338
343
|
started = entry.get("started_at")
|
|
339
344
|
if started and (not row["last_started_at"] or started > row["last_started_at"]):
|
|
340
345
|
row["last_started_at"] = started
|
|
341
|
-
row["provider"] = entry.get("provider") or row["provider"]
|
|
342
346
|
for row in rows.values():
|
|
347
|
+
# Every provider seen, not the last one: a project or model row often
|
|
348
|
+
# spans several.
|
|
349
|
+
row["provider"] = "+".join(sorted(row["provider"])) or "-"
|
|
343
350
|
# Derive from the summed parts rather than summing each run's stored
|
|
344
351
|
# total. Records written before the definition was settled carry a
|
|
345
352
|
# total that excluded cache, and adding those to correct ones produced
|
|
@@ -472,14 +479,43 @@ def _format_token_count(value):
|
|
|
472
479
|
return f"{amount / 1000:.1f}K"
|
|
473
480
|
return str(amount)
|
|
474
481
|
|
|
475
|
-
def
|
|
482
|
+
def _format_rtk_saved(rtk):
|
|
483
|
+
saved = _format_token_count(rtk["saved_tokens"])
|
|
484
|
+
share = f" ({rtk['saved_tokens'] * 100 // rtk['input_tokens']}% of their output)" if rtk["input_tokens"] else ""
|
|
485
|
+
return f"rtk saved {saved} tokens on {rtk['commands']:,} commands{share}."
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _stats_group(entry, by):
|
|
489
|
+
"""The project (launch folder) or the model a run is counted under."""
|
|
490
|
+
if by == "project":
|
|
491
|
+
return str(entry.get("cwd") or "").rstrip("/\\") or "-"
|
|
492
|
+
return entry.get("usage_model") or "-"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _project_labels(paths):
|
|
496
|
+
"""The shortest trailing part of each folder that still tells it apart, so
|
|
497
|
+
/a/app and /b/app read as a/app and b/app rather than both as app."""
|
|
498
|
+
parts = {path: [p for p in re.split(r"[/\\]", path) if p] for path in paths}
|
|
499
|
+
labels = {}
|
|
500
|
+
for path, segments in parts.items():
|
|
501
|
+
for n in range(1, len(segments) + 1):
|
|
502
|
+
label = "/".join(segments[-n:])
|
|
503
|
+
if not any(other != path and "/".join(rest[-n:]) == label for other, rest in parts.items()):
|
|
504
|
+
break
|
|
505
|
+
else:
|
|
506
|
+
label = path
|
|
507
|
+
labels[path] = label
|
|
508
|
+
return labels
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _format_stats(rows, totals, period=None, use_color=False, active_sessions=None, group_label="SESSION"):
|
|
476
512
|
from ..cli_render import _format_relative_age
|
|
477
513
|
|
|
478
514
|
active_sessions = active_sessions or set()
|
|
479
515
|
if not rows:
|
|
480
516
|
return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
|
|
481
517
|
table = [[_style(value, "1", use_color) for value in [
|
|
482
|
-
|
|
518
|
+
group_label, "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "COST~", "USD~", "TIME", "LAST"
|
|
483
519
|
]]]
|
|
484
520
|
for row in rows:
|
|
485
521
|
session_name = row["session_name"]
|
|
@@ -596,8 +632,18 @@ def handle_stats(rest, ctx):
|
|
|
596
632
|
parsed = _parse_stats_args(rest, now=now)
|
|
597
633
|
entries = ctx["service"]["get_launch_history"](parsed["name"], limit=0)
|
|
598
634
|
entries = _filter_history_period(entries, parsed["period"])
|
|
635
|
+
if parsed["by"] != "session":
|
|
636
|
+
# Same accounting, another key: each run is regrouped, never re-priced.
|
|
637
|
+
entries = [{**entry, "session_name": _stats_group(entry, parsed["by"])} for entry in entries]
|
|
599
638
|
rows = _summarize_stats(entries)
|
|
639
|
+
if parsed["by"] == "project":
|
|
640
|
+
labels = _project_labels([row["session_name"] for row in rows])
|
|
641
|
+
for row in rows:
|
|
642
|
+
row["project"] = row["session_name"]
|
|
643
|
+
row["session_name"] = labels[row["project"]]
|
|
600
644
|
totals = _stats_totals(rows)
|
|
645
|
+
# rtk cannot tell which session a command ran in, so only the unfiltered view shows it.
|
|
646
|
+
rtk = None if parsed["name"] else rtk_saved(ctx["service"]["base_dir"], parsed["period"], env=ctx.get("env"))
|
|
601
647
|
message = (
|
|
602
648
|
f"Calculated stats for {parsed['name']}"
|
|
603
649
|
if parsed["name"]
|
|
@@ -610,6 +656,8 @@ def handle_stats(rest, ctx):
|
|
|
610
656
|
period=_public_history_period(parsed["period"]),
|
|
611
657
|
stats=rows,
|
|
612
658
|
totals=totals,
|
|
659
|
+
group_by=parsed["by"],
|
|
660
|
+
rtk=rtk,
|
|
613
661
|
accounting={
|
|
614
662
|
"launches_unit": "history_attempts",
|
|
615
663
|
"period_policy": "whole_run_overlap",
|
|
@@ -619,8 +667,10 @@ def handle_stats(rest, ctx):
|
|
|
619
667
|
},
|
|
620
668
|
))
|
|
621
669
|
return 0
|
|
622
|
-
active_sessions = _active_session_names(ctx)
|
|
623
|
-
ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
|
|
670
|
+
active_sessions = _active_session_names(ctx) if parsed["by"] == "session" else set()
|
|
671
|
+
ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions, group_label=parsed['by'].upper())}\n")
|
|
672
|
+
if rows and rtk and rtk["commands"]:
|
|
673
|
+
ctx["out"](f"{_dim(_format_rtk_saved(rtk), ctx['use_color'])}\n")
|
|
624
674
|
return 0
|
|
625
675
|
|
|
626
676
|
def _resolve_last_launch_session(ctx):
|
package/src/handoff.py
CHANGED
|
@@ -48,7 +48,7 @@ def inspect_transcript(path, provider, extent=None):
|
|
|
48
48
|
Never flatten messages: the descriptor points to the original tool evidence.
|
|
49
49
|
The digest binds that evidence to the preparation boundary, even after append.
|
|
50
50
|
"""
|
|
51
|
-
ids, workspaces = set(),
|
|
51
|
+
ids, workspaces = set(), []
|
|
52
52
|
last_event = None
|
|
53
53
|
digest = hashlib.sha256()
|
|
54
54
|
consumed = 0
|
|
@@ -88,7 +88,9 @@ def inspect_transcript(path, provider, extent=None):
|
|
|
88
88
|
if cwd:
|
|
89
89
|
if not isinstance(cwd, str) or not os.path.isabs(cwd):
|
|
90
90
|
raise ValueError('invalid workspace')
|
|
91
|
-
|
|
91
|
+
real = os.path.realpath(cwd)
|
|
92
|
+
if real not in workspaces:
|
|
93
|
+
workspaces.append(real)
|
|
92
94
|
stamp = record.get('timestamp')
|
|
93
95
|
if isinstance(stamp, str):
|
|
94
96
|
try:
|
|
@@ -102,24 +104,33 @@ def inspect_transcript(path, provider, extent=None):
|
|
|
102
104
|
consumed = handle.tell()
|
|
103
105
|
except (OSError, ValueError) as error:
|
|
104
106
|
raise CdxError(f'Cannot use transcript {path}: {error}') from error
|
|
105
|
-
if len(ids) != 1 or
|
|
107
|
+
if len(ids) != 1 or not workspaces:
|
|
106
108
|
raise CdxError(f'Transcript needs one conversation identity and workspace: {path}')
|
|
109
|
+
# A session that moved between folders (a worktree, a subfolder) keeps
|
|
110
|
+
# every one of them; it starts in the first, and may be handed off in any.
|
|
107
111
|
return {
|
|
108
112
|
'path': os.path.abspath(path), 'provider': provider, 'conversation_id': ids.pop(),
|
|
109
|
-
'workspace': workspaces
|
|
113
|
+
'workspace': workspaces[0], 'workspaces': workspaces, 'last_event_at': last_event,
|
|
110
114
|
'extent_bytes': consumed, 'sha256': digest.hexdigest(), 'format': 'jsonl',
|
|
111
115
|
'mode': 'native',
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
|
|
119
|
+
def _in_workspace(info, workspace):
|
|
120
|
+
"""The transcript as seen from `workspace`, or None if it never worked there."""
|
|
121
|
+
if workspace not in info['workspaces']:
|
|
122
|
+
return None
|
|
123
|
+
return {**info, 'workspace': workspace}
|
|
124
|
+
|
|
125
|
+
|
|
115
126
|
def source_candidates(session, workspace):
|
|
116
127
|
candidates = []
|
|
117
128
|
for path in _native_paths(session):
|
|
118
129
|
try:
|
|
119
|
-
info = inspect_transcript(path, session['provider'])
|
|
130
|
+
info = _in_workspace(inspect_transcript(path, session['provider']), workspace)
|
|
120
131
|
except CdxError:
|
|
121
132
|
continue
|
|
122
|
-
if info
|
|
133
|
+
if info:
|
|
123
134
|
candidates.append(info)
|
|
124
135
|
# Event time helps humans choose; it is never an automatic selection rule.
|
|
125
136
|
return sorted(candidates, key=lambda c: (c['last_event_at'] or '', c['path']), reverse=True)[:CANDIDATE_LIMIT]
|
|
@@ -163,11 +174,11 @@ def resolve_source(session, workspace, conversation_id=None, terminal_path=None,
|
|
|
163
174
|
if path not in allowed:
|
|
164
175
|
raise HandoffSourceError('Native transcript must be a top-level source file in the source profile.')
|
|
165
176
|
try:
|
|
166
|
-
info = inspect_transcript(path, session['provider'])
|
|
177
|
+
info = _in_workspace(inspect_transcript(path, session['provider']), workspace)
|
|
167
178
|
except CdxError as error:
|
|
168
179
|
raise HandoffSourceError(str(error)) from error
|
|
169
|
-
if info
|
|
170
|
-
raise HandoffSourceError('Native transcript
|
|
180
|
+
if not info:
|
|
181
|
+
raise HandoffSourceError('Native transcript never worked in the target workspace.')
|
|
171
182
|
info['provenance'] = 'explicit_native_path'
|
|
172
183
|
return info
|
|
173
184
|
if selected:
|
|
@@ -177,10 +188,10 @@ def resolve_source(session, workspace, conversation_id=None, terminal_path=None,
|
|
|
177
188
|
matches = []
|
|
178
189
|
for path in paths:
|
|
179
190
|
try:
|
|
180
|
-
info = inspect_transcript(path, session['provider'])
|
|
191
|
+
info = _in_workspace(inspect_transcript(path, session['provider']), workspace)
|
|
181
192
|
except CdxError:
|
|
182
193
|
continue
|
|
183
|
-
if info
|
|
194
|
+
if info and info['conversation_id'] == selected:
|
|
184
195
|
matches.append(info)
|
|
185
196
|
if len(matches) > 1:
|
|
186
197
|
raise HandoffSourceError(f'Conversation {selected} has multiple native transcripts; select --source-native-transcript PATH.', matches)
|