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
|
@@ -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]
|
|
@@ -776,6 +820,18 @@ def _conversation_is_resumable(session, provider, conversation_id):
|
|
|
776
820
|
return False
|
|
777
821
|
|
|
778
822
|
|
|
823
|
+
def with_requested_conversation(session, conversation_id):
|
|
824
|
+
"""The session as if `conversation_id` were the one it last opened.
|
|
825
|
+
|
|
826
|
+
Returns None when that conversation does not exist in this session's
|
|
827
|
+
profile: an explicitly named resume must fail, not quietly fall back to
|
|
828
|
+
--last/--continue and open a different conversation.
|
|
829
|
+
"""
|
|
830
|
+
if not _conversation_is_resumable(session, session.get("provider"), conversation_id):
|
|
831
|
+
return None
|
|
832
|
+
return {**session, "conversation": {"id": conversation_id, "provenance": "requested"}}
|
|
833
|
+
|
|
834
|
+
|
|
779
835
|
def _redacted_resume_command_preview(session, cwd=None):
|
|
780
836
|
capability = get_resume_capability(session, cwd=cwd)
|
|
781
837
|
return capability.get("command_preview")
|
|
@@ -850,6 +906,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
850
906
|
cwd = cwd or os.getcwd()
|
|
851
907
|
env_override = env_override or {}
|
|
852
908
|
env = {**os.environ, **env_override}
|
|
909
|
+
_shared_rtk_history(env)
|
|
853
910
|
capability = get_resume_capability(session, cwd=cwd)
|
|
854
911
|
if not capability["resumable"]:
|
|
855
912
|
raise CdxError(f"Provider {session.get('provider')} does not support native resume through cdx.")
|
|
@@ -869,7 +926,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
869
926
|
if capability["strategy"] == "provider_conversation_id"
|
|
870
927
|
else ["--continue"]
|
|
871
928
|
)
|
|
872
|
-
|
|
929
|
+
# No --name: on a resume it would overwrite the user's /rename.
|
|
873
930
|
if launch.get("model"):
|
|
874
931
|
args += ["--model", _claude_cli_model(launch["model"])]
|
|
875
932
|
args += _launch_config_args(session)
|
|
@@ -926,6 +983,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
926
983
|
_validate_initial_prompt(initial_prompt)
|
|
927
984
|
cwd = cwd or os.getcwd()
|
|
928
985
|
env = {**os.environ, **(env_override or {})}
|
|
986
|
+
_shared_rtk_history(env)
|
|
929
987
|
# Headless runs read the same home, and so the same hooks, as an interactive
|
|
930
988
|
# launch. Their caller already learns of completion from the return value.
|
|
931
989
|
env.update(launch_notify_env(session, enabled=False))
|
|
@@ -937,7 +995,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
937
995
|
model = launch.get("model")
|
|
938
996
|
|
|
939
997
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
940
|
-
args = ["--print", "--output-format", "json"
|
|
998
|
+
args = ["--print", "--output-format", "json"]
|
|
941
999
|
conversation_id = _conversation_id(session)
|
|
942
1000
|
if conversation_id:
|
|
943
1001
|
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
|
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
|
|
7
7
|
|
|
8
8
|
from .errors import CdxError
|
|
9
9
|
from .fs_utils import atomic_write
|
|
10
|
-
from .run_usage import coerce_usage
|
|
10
|
+
from .run_usage import _parse_json_records, coerce_usage
|
|
11
11
|
from .session_helpers import _process_is_running
|
|
12
12
|
|
|
13
13
|
|
|
@@ -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)
|
|
@@ -341,7 +367,7 @@ class RunRegistry:
|
|
|
341
367
|
return run
|
|
342
368
|
return None
|
|
343
369
|
|
|
344
|
-
def list(self, limit=20, since=None):
|
|
370
|
+
def list(self, limit=20, since=None, running=False):
|
|
345
371
|
"""Recent runs, newest first.
|
|
346
372
|
|
|
347
373
|
`since` selects every run that *completed* strictly after that moment
|
|
@@ -358,6 +384,10 @@ class RunRegistry:
|
|
|
358
384
|
if changed:
|
|
359
385
|
_write_registry(self.path, data)
|
|
360
386
|
runs = data["runs"]
|
|
387
|
+
if running:
|
|
388
|
+
# Every in-flight run, uncapped: a caller counting live runs against
|
|
389
|
+
# a cap must not be handed a truncated count.
|
|
390
|
+
return [run for run in runs if run.get("status") == "running"]
|
|
361
391
|
if since is not None:
|
|
362
392
|
return [run for run in runs if _completed_after(run, since)]
|
|
363
393
|
return runs[: max(0, int(limit or 20))]
|
|
@@ -384,6 +414,51 @@ def read_text_file(path, limit=120000):
|
|
|
384
414
|
return ""
|
|
385
415
|
|
|
386
416
|
|
|
417
|
+
def _record_final_message(record):
|
|
418
|
+
"""The final assistant text one provider record carries, or None."""
|
|
419
|
+
if not isinstance(record, dict):
|
|
420
|
+
return None
|
|
421
|
+
# Claude: `--output-format json` ends with a `result` envelope.
|
|
422
|
+
if record.get("type") == "result" and isinstance(record.get("result"), str):
|
|
423
|
+
return record["result"]
|
|
424
|
+
# Codex `exec --json`: an `item.completed` event wrapping an agent_message.
|
|
425
|
+
item = record.get("item")
|
|
426
|
+
if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str):
|
|
427
|
+
return item["text"]
|
|
428
|
+
# Older Codex builds wrapped it as {"msg": {"type": "agent_message", "message": ...}}.
|
|
429
|
+
msg = record.get("msg")
|
|
430
|
+
if isinstance(msg, dict) and msg.get("type") == "agent_message" and isinstance(msg.get("message"), str):
|
|
431
|
+
return msg["message"]
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def final_message(run):
|
|
436
|
+
"""The run's last assistant message, read the same way for every provider.
|
|
437
|
+
|
|
438
|
+
A `--bg` run records it from the transcript as `final_payload.result`; any
|
|
439
|
+
other run left it in its stdout, in the provider's own event format. None
|
|
440
|
+
when neither holds one (a plain-text provider, a run that died early).
|
|
441
|
+
"""
|
|
442
|
+
result = (run.get("final_payload") or {}).get("result")
|
|
443
|
+
if isinstance(result, str) and result.strip():
|
|
444
|
+
return result
|
|
445
|
+
stdout_path = (run.get("artifacts") or {}).get("stdout_path")
|
|
446
|
+
if not stdout_path:
|
|
447
|
+
return None
|
|
448
|
+
try:
|
|
449
|
+
with open(stdout_path, encoding="utf-8", errors="replace") as handle:
|
|
450
|
+
text = handle.read()
|
|
451
|
+
except OSError:
|
|
452
|
+
return None
|
|
453
|
+
if not text.strip():
|
|
454
|
+
return None
|
|
455
|
+
for record in reversed(_parse_json_records(text)):
|
|
456
|
+
message = _record_final_message(record)
|
|
457
|
+
if message is not None:
|
|
458
|
+
return message
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
387
462
|
def build_code_review_report(run_id, final_payload):
|
|
388
463
|
stdout = read_text_file(final_payload.get("stdout_path") if final_payload else None)
|
|
389
464
|
parsed = None
|
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(
|