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/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
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -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:
|
|
@@ -674,12 +715,15 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
674
715
|
env = {**os.environ, **env_override}
|
|
675
716
|
for key, value in _shared_tool_cache_overrides(env).items():
|
|
676
717
|
env.setdefault(key, value)
|
|
718
|
+
_shared_rtk_history(env)
|
|
677
719
|
env.update(launch_notify_env(session, notifications_enabled(session)))
|
|
678
720
|
env.update(_scrub_env(session))
|
|
679
721
|
preference_args = _launch_preference_args(session, env=env)
|
|
680
722
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
681
723
|
launch = session.get("launch") or {}
|
|
682
|
-
|
|
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 = []
|
|
683
727
|
conversation_id = _conversation_id(session)
|
|
684
728
|
if conversation_id:
|
|
685
729
|
args += ["--session-id", conversation_id]
|
|
@@ -850,6 +894,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
850
894
|
cwd = cwd or os.getcwd()
|
|
851
895
|
env_override = env_override or {}
|
|
852
896
|
env = {**os.environ, **env_override}
|
|
897
|
+
_shared_rtk_history(env)
|
|
853
898
|
capability = get_resume_capability(session, cwd=cwd)
|
|
854
899
|
if not capability["resumable"]:
|
|
855
900
|
raise CdxError(f"Provider {session.get('provider')} does not support native resume through cdx.")
|
|
@@ -869,7 +914,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
869
914
|
if capability["strategy"] == "provider_conversation_id"
|
|
870
915
|
else ["--continue"]
|
|
871
916
|
)
|
|
872
|
-
|
|
917
|
+
# No --name: on a resume it would overwrite the user's /rename.
|
|
873
918
|
if launch.get("model"):
|
|
874
919
|
args += ["--model", _claude_cli_model(launch["model"])]
|
|
875
920
|
args += _launch_config_args(session)
|
|
@@ -926,6 +971,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
926
971
|
_validate_initial_prompt(initial_prompt)
|
|
927
972
|
cwd = cwd or os.getcwd()
|
|
928
973
|
env = {**os.environ, **(env_override or {})}
|
|
974
|
+
_shared_rtk_history(env)
|
|
929
975
|
# Headless runs read the same home, and so the same hooks, as an interactive
|
|
930
976
|
# launch. Their caller already learns of completion from the return value.
|
|
931
977
|
env.update(launch_notify_env(session, enabled=False))
|
|
@@ -937,7 +983,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
937
983
|
model = launch.get("model")
|
|
938
984
|
|
|
939
985
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
940
|
-
args = ["--print", "--output-format", "json"
|
|
986
|
+
args = ["--print", "--output-format", "json"]
|
|
941
987
|
conversation_id = _conversation_id(session)
|
|
942
988
|
if conversation_id:
|
|
943
989
|
args += ["--session-id", conversation_id]
|
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)
|
package/src/run_usage.py
CHANGED
|
@@ -14,6 +14,11 @@ The definitions below are therefore normative, not a suggestion:
|
|
|
14
14
|
natively; Codex does not, so its cache-inclusive count
|
|
15
15
|
is reduced here rather than left for a reader to guess.
|
|
16
16
|
cache_creation_tokens Tokens written to the provider's prompt cache.
|
|
17
|
+
cache_creation_1h_tokens
|
|
18
|
+
The part of cache_creation_tokens written with
|
|
19
|
+
Anthropic's one-hour TTL, which costs 2x instead of
|
|
20
|
+
1.25x. A subset, never added to a total; absent when
|
|
21
|
+
the provider does not report the split.
|
|
17
22
|
cache_read_tokens Tokens served from it.
|
|
18
23
|
cached_input_tokens cache_creation + cache_read. Kept because it is a
|
|
19
24
|
published field of `cdx run --json`, and because the
|
|
@@ -31,8 +36,8 @@ The definitions below are therefore normative, not a suggestion:
|
|
|
31
36
|
quantity.
|
|
32
37
|
|
|
33
38
|
Cache creation and cache read are separate fields because they cost very
|
|
34
|
-
differently -- 1.25x and 0.1x of uncached input -- and
|
|
35
|
-
recover the split once they are summed.
|
|
39
|
+
differently -- 1.25x (2x for a one-hour TTL) and 0.1x of uncached input -- and
|
|
40
|
+
a weighted figure cannot recover the split once they are summed.
|
|
36
41
|
|
|
37
42
|
Absence stays absent: `None` means "not reported", which is not zero. Only
|
|
38
43
|
`normalize_usage` may derive `cached_input_tokens` and `total_tokens`, so no
|
|
@@ -49,6 +54,7 @@ USAGE_KEYS = (
|
|
|
49
54
|
"input_tokens",
|
|
50
55
|
"cached_input_tokens",
|
|
51
56
|
"cache_creation_tokens",
|
|
57
|
+
"cache_creation_1h_tokens",
|
|
52
58
|
"cache_read_tokens",
|
|
53
59
|
"output_tokens",
|
|
54
60
|
"reasoning_tokens",
|
|
@@ -64,7 +70,7 @@ def empty_usage():
|
|
|
64
70
|
|
|
65
71
|
|
|
66
72
|
def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_tokens=None,
|
|
67
|
-
output_tokens=None, reasoning_tokens=None):
|
|
73
|
+
output_tokens=None, reasoning_tokens=None, cache_creation_1h_tokens=None):
|
|
68
74
|
"""Build a usage record from the four measured quantities.
|
|
69
75
|
|
|
70
76
|
The derived fields are computed here and only here. A record whose parts are
|
|
@@ -80,6 +86,11 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
|
|
|
80
86
|
}
|
|
81
87
|
if all(value is None for value in parts.values()):
|
|
82
88
|
return empty_usage()
|
|
89
|
+
long_ttl = _non_negative(cache_creation_1h_tokens)
|
|
90
|
+
if long_ttl is not None:
|
|
91
|
+
# A subset of the writes; more than the writes is not a subset.
|
|
92
|
+
long_ttl = min(long_ttl, parts["cache_creation_tokens"] or 0)
|
|
93
|
+
parts["cache_creation_1h_tokens"] = long_ttl
|
|
83
94
|
cached = _sum_present(parts["cache_creation_tokens"], parts["cache_read_tokens"])
|
|
84
95
|
total = _sum_present(
|
|
85
96
|
parts["input_tokens"],
|
|
@@ -94,12 +105,13 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
|
|
|
94
105
|
#:
|
|
95
106
|
#: Defaults for rows without an explicit cache-read price. Newer models can
|
|
96
107
|
#: discount reads further; both stats and currency use the table's rate.
|
|
97
|
-
#: Anthropic's one-hour cache TTL costs 2x instead
|
|
98
|
-
#:
|
|
99
|
-
#:
|
|
100
|
-
#:
|
|
108
|
+
#: Anthropic's one-hour cache TTL costs 2x instead. Claude transcripts report
|
|
109
|
+
#: that split as `cache_creation.ephemeral_1h_input_tokens`; Claude Code writes
|
|
110
|
+
#: most of its cache that way, so pricing every write at 1.25x understated
|
|
111
|
+
#: them by up to 1.6x. A record without the split keeps the 1.25x rate.
|
|
101
112
|
CACHE_READ_MULTIPLIER = 0.1
|
|
102
113
|
CACHE_WRITE_MULTIPLIER = 1.25
|
|
114
|
+
CACHE_WRITE_1H_MULTIPLIER = 2.0
|
|
103
115
|
|
|
104
116
|
#: What an output token costs relative to an uncached input one, when the model
|
|
105
117
|
#: is unknown. Current model ratios vary, so an unknown model is priced at the
|
|
@@ -144,7 +156,8 @@ def weighted_usage(usage, model=None, prices=None):
|
|
|
144
156
|
That is not the ranking anyone reading `cdx stats` is looking for.
|
|
145
157
|
|
|
146
158
|
Output and cache-read ratios come from the model that served the run.
|
|
147
|
-
Cache writes
|
|
159
|
+
Cache writes use the one-hour rate for the part the provider reported as
|
|
160
|
+
one-hour, the five-minute rate for the rest.
|
|
148
161
|
|
|
149
162
|
`reasoning_tokens` is deliberately excluded: Codex reports it as a subset
|
|
150
163
|
of its output, and counting it would bill those tokens twice.
|
|
@@ -154,12 +167,13 @@ def weighted_usage(usage, model=None, prices=None):
|
|
|
154
167
|
weights = {
|
|
155
168
|
"input_tokens": 1.0,
|
|
156
169
|
"cache_creation_tokens": CACHE_WRITE_MULTIPLIER,
|
|
170
|
+
"cache_creation_1h_tokens": CACHE_WRITE_1H_MULTIPLIER - CACHE_WRITE_MULTIPLIER,
|
|
157
171
|
"cache_read_tokens": cache_read_multiplier(model, prices),
|
|
158
172
|
"output_tokens": output_multiplier(model, prices),
|
|
159
173
|
}
|
|
160
174
|
present = []
|
|
161
175
|
for key, weight in weights.items():
|
|
162
|
-
value = usage.get(key)
|
|
176
|
+
value = _long_ttl_writes(usage) if key == "cache_creation_1h_tokens" else usage.get(key)
|
|
163
177
|
if value is not None:
|
|
164
178
|
if weight is None and value:
|
|
165
179
|
return None
|
|
@@ -297,9 +311,20 @@ def estimate_cost(usage, model, prices=None):
|
|
|
297
311
|
]
|
|
298
312
|
if not present:
|
|
299
313
|
return None
|
|
314
|
+
long_ttl = _long_ttl_writes(usage) or 0
|
|
315
|
+
present.append(rate["input"] * (CACHE_WRITE_1H_MULTIPLIER - CACHE_WRITE_MULTIPLIER) * long_ttl)
|
|
300
316
|
return sum(present) / 1_000_000
|
|
301
317
|
|
|
302
318
|
|
|
319
|
+
def _long_ttl_writes(usage):
|
|
320
|
+
"""One-hour cache writes, bounded by the writes they are a part of."""
|
|
321
|
+
long_ttl = usage.get("cache_creation_1h_tokens")
|
|
322
|
+
if not isinstance(long_ttl, (int, float)) or isinstance(long_ttl, bool) or long_ttl <= 0:
|
|
323
|
+
return None
|
|
324
|
+
writes = usage.get("cache_creation_tokens")
|
|
325
|
+
return min(long_ttl, writes) if isinstance(writes, (int, float)) and not isinstance(writes, bool) else None
|
|
326
|
+
|
|
327
|
+
|
|
303
328
|
def claude_usage_dedup_key(record):
|
|
304
329
|
"""What identifies one *billed* Claude API response in a transcript.
|
|
305
330
|
|
|
@@ -362,6 +387,7 @@ def coerce_usage(usage):
|
|
|
362
387
|
cache_read_tokens=usage.get("cache_read_tokens"),
|
|
363
388
|
output_tokens=usage.get("output_tokens"),
|
|
364
389
|
reasoning_tokens=usage.get("reasoning_tokens"),
|
|
390
|
+
cache_creation_1h_tokens=usage.get("cache_creation_1h_tokens"),
|
|
365
391
|
)
|
|
366
392
|
if record["cached_input_tokens"] is None:
|
|
367
393
|
# A legacy record carrying only the fused figure: keep it visible rather
|
|
@@ -493,6 +519,7 @@ def _usage_from_dict(value):
|
|
|
493
519
|
return normalize_usage(
|
|
494
520
|
input_tokens=input_tokens,
|
|
495
521
|
cache_creation_tokens=cache_creation,
|
|
522
|
+
cache_creation_1h_tokens=_nested_int(usage, "cache_creation", "ephemeral_1h_input_tokens"),
|
|
496
523
|
cache_read_tokens=cache_read,
|
|
497
524
|
output_tokens=_int_value(usage.get("output_tokens"), usage.get("completion_tokens")),
|
|
498
525
|
reasoning_tokens=_int_value(
|
package/src/session_backup.py
CHANGED
|
@@ -106,13 +106,16 @@ def _collect_auth_files(session_root, provider, session_name=None, progress_call
|
|
|
106
106
|
})
|
|
107
107
|
return {"files": files, "file_count": len(files), "bytes": total_bytes}
|
|
108
108
|
|
|
109
|
-
def _validate_import_profile_files(payload, imported_sessions):
|
|
109
|
+
def _validate_import_profile_files(payload, imported_sessions, include_auth=True):
|
|
110
110
|
profiles = payload.get("profiles") or {}
|
|
111
111
|
if not isinstance(profiles, dict):
|
|
112
112
|
raise CdxError("Bundle contains invalid profile data.")
|
|
113
|
+
if any(profiles.values()) and not include_auth:
|
|
114
|
+
raise CdxError("Bundle contains profile files but no auth payload.")
|
|
113
115
|
decoded_profiles = {}
|
|
114
116
|
for session_payload in imported_sessions:
|
|
115
117
|
name = session_payload["name"]
|
|
118
|
+
allowed_paths = _auth_bundle_paths(_normalize_provider(session_payload["provider"]))
|
|
116
119
|
files = []
|
|
117
120
|
profile_items = profiles.get(name, [])
|
|
118
121
|
if not isinstance(profile_items, list):
|
|
@@ -121,6 +124,8 @@ def _validate_import_profile_files(payload, imported_sessions):
|
|
|
121
124
|
if not isinstance(item, dict):
|
|
122
125
|
raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
|
|
123
126
|
rel_path = _safe_relpath(item.get("path"))
|
|
127
|
+
if rel_path not in allowed_paths:
|
|
128
|
+
raise CdxError(f"Bundle contains an unexpected file for session {name}: {rel_path}")
|
|
124
129
|
data_b64 = item.get("data_b64")
|
|
125
130
|
if not isinstance(data_b64, str):
|
|
126
131
|
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
|
|
@@ -288,9 +293,14 @@ def import_bundle(base_dir, store, file_path,
|
|
|
288
293
|
)
|
|
289
294
|
|
|
290
295
|
for session_payload in imported_sessions:
|
|
291
|
-
|
|
296
|
+
if session_payload["name"] not in existing:
|
|
297
|
+
_validate_new_session_name(session_payload["name"])
|
|
292
298
|
_normalize_provider(session_payload["provider"])
|
|
293
|
-
|
|
299
|
+
# A rollback removes the profile folder, so never adopt one we did not create.
|
|
300
|
+
if session_payload["name"] not in existing and os.path.lexists(_get_session_root(base_dir, session_payload["name"])):
|
|
301
|
+
raise CdxError(f"Session profile already exists: {session_payload['name']}")
|
|
302
|
+
decoded_profiles = _validate_import_profile_files(payload, imported_sessions,
|
|
303
|
+
include_auth=bool(decoded["meta"].get("include_auth")))
|
|
294
304
|
|
|
295
305
|
for session_payload in imported_sessions:
|
|
296
306
|
name = session_payload["name"]
|
|
@@ -318,7 +328,8 @@ def import_bundle(base_dir, store, file_path,
|
|
|
318
328
|
bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
|
|
319
329
|
item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
|
|
320
330
|
)
|
|
321
|
-
|
|
331
|
+
# Force replaces the login even when the bundle has none to offer.
|
|
332
|
+
if bundle_carries_credential or (force and provider == PROVIDER_CLAUDE):
|
|
322
333
|
local_credential = read_keychain_credentials(old_record["authHome"])
|
|
323
334
|
recovery_dir = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
|
|
324
335
|
backup_root = os.path.join(recovery_dir, "profile")
|
|
@@ -405,7 +416,7 @@ def import_bundle(base_dir, store, file_path,
|
|
|
405
416
|
# Decrypted credentials: 0o600 from creation, no umask window.
|
|
406
417
|
atomic_write(dest_path, item["content"], mode=0o600)
|
|
407
418
|
imported_paths.append(item["path"])
|
|
408
|
-
if provider == PROVIDER_CLAUDE and CLAUDE_CREDENTIALS_BUNDLE_PATH in imported_paths:
|
|
419
|
+
if provider == PROVIDER_CLAUDE and (force and old_record or CLAUDE_CREDENTIALS_BUNDLE_PATH in imported_paths):
|
|
409
420
|
delete_keychain_credentials(auth_home)
|
|
410
421
|
_restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
|
|
411
422
|
except Exception:
|