cdx-manager 0.21.6 → 0.23.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/changelogs/CHANGELOGS_0_23_0.md +19 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +22 -11
- 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 +15 -5
- package/src/cli_args.py +69 -11
- package/src/cli_commands.py +3 -0
- package/src/cli_helpers.py +22 -9
- package/src/commands/launch.py +66 -7
- package/src/commands/maintenance.py +1 -0
- package/src/commands/rescue.py +207 -0
- package/src/commands/runs.py +26 -2
- package/src/commands/status.py +94 -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 +61 -3
- package/src/retro.py +131 -0
- package/src/rtk_savings.py +68 -0
- package/src/run_registry.py +78 -3
- 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 +105 -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
|
@@ -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
|
@@ -43,7 +43,7 @@ from ..provider_runtime import (
|
|
|
43
43
|
)
|
|
44
44
|
from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
|
|
45
45
|
from ..run_failover import MAX_FAILOVER_TRANSITIONS, failover_reason
|
|
46
|
-
from ..run_registry import RunRegistry, build_code_review_report
|
|
46
|
+
from ..run_registry import RunRegistry, build_code_review_report, final_message
|
|
47
47
|
from ..run_usage import extract_run_observation
|
|
48
48
|
from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
|
|
49
49
|
from ..status_view import _now_timestamp, _priority_reset_timestamp
|
|
@@ -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:
|
|
@@ -414,7 +415,7 @@ def handle_runs(rest, ctx):
|
|
|
414
415
|
raise CdxError(RUNS_USAGE)
|
|
415
416
|
since = parsed.get("since")
|
|
416
417
|
_reconcile_background_runs(ctx)
|
|
417
|
-
runs = _run_registry(ctx).list(limit=parsed["limit"], since=since)
|
|
418
|
+
runs = _run_registry(ctx).list(limit=parsed["limit"], since=since, running=parsed.get("running", False))
|
|
418
419
|
warnings = []
|
|
419
420
|
if since is not None and parsed.get("limit_given"):
|
|
420
421
|
warnings.append({
|
|
@@ -502,6 +503,7 @@ def handle_run_report(rest, ctx):
|
|
|
502
503
|
_write_json(ctx, _json_success("run-report", "Run report loaded.", report={
|
|
503
504
|
"run": run,
|
|
504
505
|
"final_payload": run.get("final_payload"),
|
|
506
|
+
"final_message": final_message(run),
|
|
505
507
|
"task_report": run.get("task_report"),
|
|
506
508
|
"artifacts": run.get("artifacts") or {},
|
|
507
509
|
"usage": run.get("usage"),
|
|
@@ -509,6 +511,17 @@ def handle_run_report(rest, ctx):
|
|
|
509
511
|
}))
|
|
510
512
|
return 0
|
|
511
513
|
|
|
514
|
+
def _env_max_runs(ctx):
|
|
515
|
+
"""CDX_MAX_RUNS as the default --max-parallel; unset, invalid or <= 0 means no limit."""
|
|
516
|
+
value = {**os.environ, **(ctx.get("env") or {})}.get("CDX_MAX_RUNS")
|
|
517
|
+
try:
|
|
518
|
+
limit = int(value) if value else 0
|
|
519
|
+
except ValueError:
|
|
520
|
+
return None
|
|
521
|
+
# 0 or negative reads as "no limit", like unset, rather than a cap of 1.
|
|
522
|
+
return limit if limit > 0 else None
|
|
523
|
+
|
|
524
|
+
|
|
512
525
|
def handle_run(rest, ctx):
|
|
513
526
|
registry = None
|
|
514
527
|
run_id = None
|
|
@@ -563,6 +576,15 @@ def handle_run(rest, ctx):
|
|
|
563
576
|
if parsed.get("reasoning_effort"):
|
|
564
577
|
launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
|
|
565
578
|
launch_updates["power"] = parsed["reasoning_effort"]
|
|
579
|
+
# A detached child runs in the slot its parent already took.
|
|
580
|
+
max_parallel = None if detached_run_id else (parsed.get("max_parallel") or _env_max_runs(ctx))
|
|
581
|
+
if max_parallel:
|
|
582
|
+
# Before anything changes the session: a refused run must not leave
|
|
583
|
+
# Claude on a fresh conversation id that resume and rescue then
|
|
584
|
+
# point at. Finished native background runs are closed out first,
|
|
585
|
+
# since nothing else frees their slot.
|
|
586
|
+
_reconcile_background_runs(ctx)
|
|
587
|
+
_run_registry(ctx).check_capacity(max_parallel)
|
|
566
588
|
# Claude needs its conversation id before the spec is built; Codex
|
|
567
589
|
# records its own afterwards, from the rollout. Without this a headless
|
|
568
590
|
# Claude run left the session with no identity and `cdx resume` fell
|
|
@@ -607,6 +629,8 @@ def handle_run(rest, ctx):
|
|
|
607
629
|
"stdout_path": artifacts.get("stdout_path"),
|
|
608
630
|
"stderr_path": artifacts.get("stderr_path"),
|
|
609
631
|
},
|
|
632
|
+
label=parsed.get("label"),
|
|
633
|
+
max_parallel=max_parallel,
|
|
610
634
|
)
|
|
611
635
|
if parsed.get("detach"):
|
|
612
636
|
# 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
|
|
@@ -11,6 +12,7 @@ from datetime import datetime
|
|
|
11
12
|
from ..claude_refresh import _refresh_claude_sessions
|
|
12
13
|
from ..cli_args import (
|
|
13
14
|
LAST_USAGE,
|
|
15
|
+
SESSIONS_USAGE,
|
|
14
16
|
STATUS_USAGE,
|
|
15
17
|
_filter_history_period,
|
|
16
18
|
_has_history_period,
|
|
@@ -49,6 +51,7 @@ from ..provider_runtime import (
|
|
|
49
51
|
_has_local_claude_auth,
|
|
50
52
|
_probe_provider_auth_status,
|
|
51
53
|
)
|
|
54
|
+
from ..rtk_savings import rtk_saved
|
|
52
55
|
from ..run_usage import (
|
|
53
56
|
USAGE_KEYS,
|
|
54
57
|
estimate_cost,
|
|
@@ -57,6 +60,7 @@ from ..run_usage import (
|
|
|
57
60
|
token_prices,
|
|
58
61
|
weighted_usage,
|
|
59
62
|
)
|
|
63
|
+
from ..session_status import session_paths
|
|
60
64
|
from ..status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
|
|
61
65
|
|
|
62
66
|
|
|
@@ -268,7 +272,7 @@ def _summarize_stats(entries):
|
|
|
268
272
|
name = entry.get("session_name") or "-"
|
|
269
273
|
row = rows.setdefault(name, {
|
|
270
274
|
"session_name": name,
|
|
271
|
-
"provider":
|
|
275
|
+
"provider": set(),
|
|
272
276
|
"launches": 0,
|
|
273
277
|
"successes": 0,
|
|
274
278
|
"failures": 0,
|
|
@@ -284,6 +288,7 @@ def _summarize_stats(entries):
|
|
|
284
288
|
"input_tokens": 0,
|
|
285
289
|
"cached_input_tokens": 0,
|
|
286
290
|
"cache_creation_tokens": 0,
|
|
291
|
+
"cache_creation_1h_tokens": 0,
|
|
287
292
|
"cache_read_tokens": 0,
|
|
288
293
|
"output_tokens": 0,
|
|
289
294
|
"reasoning_tokens": 0,
|
|
@@ -291,6 +296,8 @@ def _summarize_stats(entries):
|
|
|
291
296
|
"last_started_at": None,
|
|
292
297
|
})
|
|
293
298
|
row["launches"] += 1
|
|
299
|
+
if entry.get("provider"):
|
|
300
|
+
row["provider"].add(entry["provider"])
|
|
294
301
|
if entry.get("status") == "success":
|
|
295
302
|
row["successes"] += 1
|
|
296
303
|
elif entry.get("status") == "failed":
|
|
@@ -338,8 +345,10 @@ def _summarize_stats(entries):
|
|
|
338
345
|
started = entry.get("started_at")
|
|
339
346
|
if started and (not row["last_started_at"] or started > row["last_started_at"]):
|
|
340
347
|
row["last_started_at"] = started
|
|
341
|
-
row["provider"] = entry.get("provider") or row["provider"]
|
|
342
348
|
for row in rows.values():
|
|
349
|
+
# Every provider seen, not the last one: a project or model row often
|
|
350
|
+
# spans several.
|
|
351
|
+
row["provider"] = "+".join(sorted(row["provider"])) or "-"
|
|
343
352
|
# Derive from the summed parts rather than summing each run's stored
|
|
344
353
|
# total. Records written before the definition was settled carry a
|
|
345
354
|
# total that excluded cache, and adding those to correct ones produced
|
|
@@ -472,14 +481,43 @@ def _format_token_count(value):
|
|
|
472
481
|
return f"{amount / 1000:.1f}K"
|
|
473
482
|
return str(amount)
|
|
474
483
|
|
|
475
|
-
def
|
|
484
|
+
def _format_rtk_saved(rtk):
|
|
485
|
+
saved = _format_token_count(rtk["saved_tokens"])
|
|
486
|
+
share = f" ({rtk['saved_tokens'] * 100 // rtk['input_tokens']}% of their output)" if rtk["input_tokens"] else ""
|
|
487
|
+
return f"rtk saved {saved} tokens on {rtk['commands']:,} commands{share}."
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _stats_group(entry, by):
|
|
491
|
+
"""The project (launch folder) or the model a run is counted under."""
|
|
492
|
+
if by == "project":
|
|
493
|
+
return str(entry.get("cwd") or "").rstrip("/\\") or "-"
|
|
494
|
+
return entry.get("usage_model") or "-"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _project_labels(paths):
|
|
498
|
+
"""The shortest trailing part of each folder that still tells it apart, so
|
|
499
|
+
/a/app and /b/app read as a/app and b/app rather than both as app."""
|
|
500
|
+
parts = {path: [p for p in re.split(r"[/\\]", path) if p] for path in paths}
|
|
501
|
+
labels = {}
|
|
502
|
+
for path, segments in parts.items():
|
|
503
|
+
for n in range(1, len(segments) + 1):
|
|
504
|
+
label = "/".join(segments[-n:])
|
|
505
|
+
if not any(other != path and "/".join(rest[-n:]) == label for other, rest in parts.items()):
|
|
506
|
+
break
|
|
507
|
+
else:
|
|
508
|
+
label = path
|
|
509
|
+
labels[path] = label
|
|
510
|
+
return labels
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _format_stats(rows, totals, period=None, use_color=False, active_sessions=None, group_label="SESSION"):
|
|
476
514
|
from ..cli_render import _format_relative_age
|
|
477
515
|
|
|
478
516
|
active_sessions = active_sessions or set()
|
|
479
517
|
if not rows:
|
|
480
518
|
return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
|
|
481
519
|
table = [[_style(value, "1", use_color) for value in [
|
|
482
|
-
|
|
520
|
+
group_label, "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "COST~", "USD~", "TIME", "LAST"
|
|
483
521
|
]]]
|
|
484
522
|
for row in rows:
|
|
485
523
|
session_name = row["session_name"]
|
|
@@ -596,8 +634,18 @@ def handle_stats(rest, ctx):
|
|
|
596
634
|
parsed = _parse_stats_args(rest, now=now)
|
|
597
635
|
entries = ctx["service"]["get_launch_history"](parsed["name"], limit=0)
|
|
598
636
|
entries = _filter_history_period(entries, parsed["period"])
|
|
637
|
+
if parsed["by"] != "session":
|
|
638
|
+
# Same accounting, another key: each run is regrouped, never re-priced.
|
|
639
|
+
entries = [{**entry, "session_name": _stats_group(entry, parsed["by"])} for entry in entries]
|
|
599
640
|
rows = _summarize_stats(entries)
|
|
641
|
+
if parsed["by"] == "project":
|
|
642
|
+
labels = _project_labels([row["session_name"] for row in rows])
|
|
643
|
+
for row in rows:
|
|
644
|
+
row["project"] = row["session_name"]
|
|
645
|
+
row["session_name"] = labels[row["project"]]
|
|
600
646
|
totals = _stats_totals(rows)
|
|
647
|
+
# rtk cannot tell which session a command ran in, so only the unfiltered view shows it.
|
|
648
|
+
rtk = None if parsed["name"] else rtk_saved(ctx["service"]["base_dir"], parsed["period"], env=ctx.get("env"))
|
|
601
649
|
message = (
|
|
602
650
|
f"Calculated stats for {parsed['name']}"
|
|
603
651
|
if parsed["name"]
|
|
@@ -610,6 +658,8 @@ def handle_stats(rest, ctx):
|
|
|
610
658
|
period=_public_history_period(parsed["period"]),
|
|
611
659
|
stats=rows,
|
|
612
660
|
totals=totals,
|
|
661
|
+
group_by=parsed["by"],
|
|
662
|
+
rtk=rtk,
|
|
613
663
|
accounting={
|
|
614
664
|
"launches_unit": "history_attempts",
|
|
615
665
|
"period_policy": "whole_run_overlap",
|
|
@@ -619,8 +669,10 @@ def handle_stats(rest, ctx):
|
|
|
619
669
|
},
|
|
620
670
|
))
|
|
621
671
|
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")
|
|
672
|
+
active_sessions = _active_session_names(ctx) if parsed["by"] == "session" else set()
|
|
673
|
+
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")
|
|
674
|
+
if rows and rtk and rtk["commands"]:
|
|
675
|
+
ctx["out"](f"{_dim(_format_rtk_saved(rtk), ctx['use_color'])}\n")
|
|
624
676
|
return 0
|
|
625
677
|
|
|
626
678
|
def _resolve_last_launch_session(ctx):
|
|
@@ -876,3 +928,39 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out", rows=None):
|
|
|
876
928
|
rows_by_name = _rows_by_session(rows)
|
|
877
929
|
for item in refresh_errors:
|
|
878
930
|
write(f"{_warn(_format_refresh_warning(item, rows_by_name), ctx['use_color'])}\n")
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def handle_sessions(rest, ctx):
|
|
934
|
+
"""Interactive sessions cdx launched that are still running, or every account.
|
|
935
|
+
|
|
936
|
+
Read from the runtime cdx records at launch, so a provider started outside
|
|
937
|
+
cdx is not listed. A runtime whose process has died is reported stale and
|
|
938
|
+
dropped by the same check `cdx status` uses for `active`. `--all` adds the
|
|
939
|
+
idle enabled accounts, so a caller can resolve any account's directories.
|
|
940
|
+
"""
|
|
941
|
+
json_flag, args = _parse_json_flag(rest)
|
|
942
|
+
include_all = "--all" in args
|
|
943
|
+
args = [arg for arg in args if arg != "--all"]
|
|
944
|
+
if not json_flag or args:
|
|
945
|
+
raise CdxError(SESSIONS_USAGE)
|
|
946
|
+
service = ctx["service"]
|
|
947
|
+
rows = []
|
|
948
|
+
for session in service["list_sessions"]():
|
|
949
|
+
if session.get("enabled", True) is False:
|
|
950
|
+
continue
|
|
951
|
+
runtime = service["active_session_runtime"](session["name"]) or {}
|
|
952
|
+
if not runtime and not include_all:
|
|
953
|
+
continue
|
|
954
|
+
rows.append({
|
|
955
|
+
"session": session["name"],
|
|
956
|
+
"provider": session["provider"],
|
|
957
|
+
"active": bool(runtime),
|
|
958
|
+
"pid": runtime.get("pid"),
|
|
959
|
+
"cwd": runtime.get("cwd"),
|
|
960
|
+
"started_at": runtime.get("startedAt"),
|
|
961
|
+
"runtime_id": runtime.get("runId"),
|
|
962
|
+
"conversation_id": (session.get("conversation") or {}).get("id"),
|
|
963
|
+
**session_paths(session),
|
|
964
|
+
})
|
|
965
|
+
_write_json(ctx, _json_success("sessions", "Sessions loaded.", sessions=rows))
|
|
966
|
+
return 0
|
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)
|
package/src/interactive_usage.py
CHANGED
|
@@ -268,7 +268,9 @@ def _codex_usage(handle):
|
|
|
268
268
|
output_tokens=_number(usage.get("output_tokens")),
|
|
269
269
|
reasoning_tokens=_number(usage.get("reasoning_output_tokens")) or None,
|
|
270
270
|
)
|
|
271
|
-
except (TypeError, ValueError):
|
|
271
|
+
except (AttributeError, TypeError, ValueError):
|
|
272
|
+
# AttributeError: a line that is valid JSON but not an object
|
|
273
|
+
# ([], "x", "message": "x") is skipped like any malformed line.
|
|
272
274
|
continue
|
|
273
275
|
return latest, model
|
|
274
276
|
|
|
@@ -277,12 +279,14 @@ def _claude_usage(handle):
|
|
|
277
279
|
"""Sum Claude's per-message usage across the transcript.
|
|
278
280
|
|
|
279
281
|
Cache creation and cache read are kept apart rather than summed on the way
|
|
280
|
-
in: they cost 1.25x and 0.1x of uncached input, so
|
|
281
|
-
destroy the only information a cost-aware figure
|
|
282
|
+
in: they cost 1.25x (2x for one-hour writes) and 0.1x of uncached input, so
|
|
283
|
+
fusing them here would destroy the only information a cost-aware figure
|
|
284
|
+
needs. Claude reports no
|
|
282
285
|
separate reasoning count -- thinking is billed as output and is already
|
|
283
286
|
inside `output_tokens` -- so that field stays absent by design.
|
|
284
287
|
"""
|
|
285
288
|
totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
|
|
289
|
+
long_ttl = None
|
|
286
290
|
seen = set()
|
|
287
291
|
model = None
|
|
288
292
|
for line in handle:
|
|
@@ -301,13 +305,18 @@ def _claude_usage(handle):
|
|
|
301
305
|
totals["cache_creation_tokens"] += _number(usage.get("cache_creation_input_tokens"))
|
|
302
306
|
totals["cache_read_tokens"] += _number(usage.get("cache_read_input_tokens"))
|
|
303
307
|
totals["output_tokens"] += _number(usage.get("output_tokens"))
|
|
308
|
+
split = usage.get("cache_creation")
|
|
309
|
+
if isinstance(split, dict) and "ephemeral_1h_input_tokens" in split:
|
|
310
|
+
long_ttl = (long_ttl or 0) + _number(split["ephemeral_1h_input_tokens"])
|
|
304
311
|
if isinstance(message.get("model"), str) and not message["model"].startswith("<"):
|
|
305
312
|
model = message["model"]
|
|
306
|
-
except (TypeError, ValueError):
|
|
313
|
+
except (AttributeError, TypeError, ValueError):
|
|
314
|
+
# AttributeError: a line that is valid JSON but not an object
|
|
315
|
+
# ([], "x", "message": "x") is skipped like any malformed line.
|
|
307
316
|
continue
|
|
308
317
|
if not seen and not any(totals.values()):
|
|
309
318
|
return None, model
|
|
310
|
-
return normalize_usage(**totals), model
|
|
319
|
+
return normalize_usage(**totals, cache_creation_1h_tokens=long_ttl), model
|
|
311
320
|
|
|
312
321
|
|
|
313
322
|
def _number(value):
|
|
@@ -322,6 +331,7 @@ def _number(value):
|
|
|
322
331
|
MEASURED_KEYS = (
|
|
323
332
|
"input_tokens",
|
|
324
333
|
"cache_creation_tokens",
|
|
334
|
+
"cache_creation_1h_tokens",
|
|
325
335
|
"cache_read_tokens",
|
|
326
336
|
"output_tokens",
|
|
327
337
|
"reasoning_tokens",
|
|
@@ -353,6 +363,11 @@ def usage_delta(cumulative, previous):
|
|
|
353
363
|
if now is None:
|
|
354
364
|
parts[key] = None
|
|
355
365
|
continue
|
|
366
|
+
if before is None and key == "cache_creation_1h_tokens" and previous.get("cache_creation_tokens"):
|
|
367
|
+
# The previous figure predates the split: its writes are unknown,
|
|
368
|
+
# not zero, so this run's share cannot be told apart.
|
|
369
|
+
parts[key] = None
|
|
370
|
+
continue
|
|
356
371
|
difference = now - (before or 0)
|
|
357
372
|
if difference < 0:
|
|
358
373
|
return None
|