cdx-manager 0.20.14 → 0.21.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 +28 -1153
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_21_0.md +17 -0
- package/checksums/release-archives.json +11 -0
- package/docs/assets/icon.png +0 -0
- package/docs/assets/list.png +0 -0
- package/docs/assets/next.png +0 -0
- package/docs/assets/stats.png +0 -0
- package/docs/assets/status.png +0 -0
- package/docs/cli.md +411 -0
- package/docs/features.md +102 -0
- package/docs/getting-started.md +183 -0
- package/docs/operations.md +161 -0
- package/docs/sessions-and-notifications.md +138 -0
- package/docs/technical-overview.md +92 -0
- package/docs/tray-local-testing.md +154 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/cli_args.py +1 -1
- package/src/commands/launch.py +44 -9
- package/src/commands/runs.py +38 -16
- package/src/commands/status.py +22 -35
- package/src/context_store.py +2 -1
- package/src/handoff.py +36 -20
- package/src/provider_background.py +6 -1
- package/src/provider_runtime.py +1 -1
- package/src/run_registry.py +7 -7
- package/src/run_usage.py +61 -30
- package/src/session_backup.py +8 -7
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Testing tray changes locally
|
|
2
|
+
|
|
3
|
+
How to see a tray change working on a development machine, from the cheapest
|
|
4
|
+
loop to the one that puts a real icon in the menu bar. Every command here was
|
|
5
|
+
run on macOS (Apple Silicon); the notes say where another platform differs.
|
|
6
|
+
|
|
7
|
+
The ordering is the point: three of the four loops need no install, no signing,
|
|
8
|
+
and no interference with the companion already running. Reach for the last one
|
|
9
|
+
only when the change is something only a drawn menu can show.
|
|
10
|
+
|
|
11
|
+
## Prerequisite
|
|
12
|
+
|
|
13
|
+
A Rust toolchain, once:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
rustup default stable # nothing below builds without this
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`cargo` alone is not enough — `rustup` with no default toolchain fails with
|
|
20
|
+
"could not choose a version of cargo to run", which reads like a broken install
|
|
21
|
+
and is not one.
|
|
22
|
+
|
|
23
|
+
Python needs nothing: the repository runs from `./bin/cdx`.
|
|
24
|
+
|
|
25
|
+
## 1. CDX side only — no Rust at all
|
|
26
|
+
|
|
27
|
+
Anything that changes what CDX publishes (the snapshot, `cdx tray …` commands,
|
|
28
|
+
the alert payload) is testable without building the companion:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
./bin/cdx tray status --json | head -40 # the whole snapshot contract
|
|
32
|
+
./bin/cdx tray terminal # one preference, read back
|
|
33
|
+
python3 -m pytest test/test_tray_*.py test/test_notify_py.py -q
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`./bin/cdx` is the working tree, not the installed CDX. Use it deliberately:
|
|
37
|
+
`cdx` on the PATH is the released build and will not show your change.
|
|
38
|
+
|
|
39
|
+
## 2. The menu, rendered to stdout
|
|
40
|
+
|
|
41
|
+
The companion draws its whole menu to the terminal with `--print`, which is the
|
|
42
|
+
loop to live in. First build is around 25 seconds, every one after is instant.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
CDX_TRAY_CDX="$PWD/bin/cdx" cargo run -q --manifest-path tray/Cargo.toml -- --print
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`CDX_TRAY_CDX` is what points the companion at the working tree instead of the
|
|
49
|
+
installed CDX. Without it you are testing the released behaviour with a
|
|
50
|
+
development menu, which looks like it works and proves nothing.
|
|
51
|
+
|
|
52
|
+
Submenus are rendered indented, checks as `(•)`, disabled actions as `[ ]`, so
|
|
53
|
+
the output covers everything a drawn menu shows.
|
|
54
|
+
|
|
55
|
+
Safe to run while the real companion is running: `--print` reads the spool but
|
|
56
|
+
neither heartbeats nor acknowledges, so it cannot steal an alert from the tray
|
|
57
|
+
that is actually delivering them.
|
|
58
|
+
|
|
59
|
+
## 3. The test suites
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cargo test -q --manifest-path tray/Cargo.toml # 89 tests, well under a second
|
|
63
|
+
python3 -m pytest test/ -q
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The Rust suite covers the menu, the snapshot reader, the spool and the unread
|
|
67
|
+
badge — everything except the platform backends, which have no headless test and
|
|
68
|
+
are the reason loop 4 exists.
|
|
69
|
+
|
|
70
|
+
## 4. A real icon in the menu bar
|
|
71
|
+
|
|
72
|
+
Only for what the first three cannot show: the drawn cell, the notification
|
|
73
|
+
banner, the click paths, the icon itself.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
./scripts/build-tray.sh --dev # writes tray/target/release/CDX.app
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The companion is single-instance — a pid lock in `TMPDIR` — so the development
|
|
80
|
+
build will not start while the installed one holds it. Quit the installed
|
|
81
|
+
companion from its own menu first, then:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
CDX_TRAY_CDX="$PWD/bin/cdx" open -n tray/target/release/CDX.app
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
To go back to the installed companion afterwards:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
cdx tray launch # stops whatever is running, starts the installed one
|
|
91
|
+
cdx tray doctor # confirms which one is running, and against which CDX
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
For the repeatable local cycle, use the checkout helper instead. It builds and
|
|
95
|
+
verifies the development bundle, then restores the recorded installed companion
|
|
96
|
+
without replacing it:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
./scripts/tray-dev.sh load
|
|
100
|
+
./scripts/tray-dev.sh unload
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
See `logics/runbook/run_003_load_and_unload_the_local_macos_tray_companion.md`
|
|
104
|
+
for prerequisites, verification, and rollback.
|
|
105
|
+
|
|
106
|
+
`--dev` is signed ad-hoc, so **its identity changes at every rebuild**. Two
|
|
107
|
+
consequences: macOS forgets the notification authorization each time, and the
|
|
108
|
+
banner loses the app's own icon. Anything about notification *appearance* has to
|
|
109
|
+
be tested with a real signed build (`CDX_TRAY_SIGN_IDENTITY=… ./scripts/build-tray.sh`).
|
|
110
|
+
`--dev` refuses `--package` outright, and nothing built this way should ever be
|
|
111
|
+
distributed. See `adr_005`.
|
|
112
|
+
|
|
113
|
+
## Firing an alert without waiting for an agent
|
|
114
|
+
|
|
115
|
+
The whole notification chain — hook, privacy filter, spool, companion, banner —
|
|
116
|
+
in one command:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
echo '{"hook_event_name":"Stop","cwd":"'"$PWD"'","last_assistant_message":"Smoke test."}' \
|
|
120
|
+
| CDX_SESSION_NAME=runbook CDX_NOTIFY_PREVIEW=1 ./bin/cdx notify
|
|
121
|
+
tail -1 "${CDX_HOME:-$HOME/.cdx}/tray/events.jsonl" # what the companion was handed
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Swap `hook_event_name` for `StopFailure` (add `"error_type"`, `"error_message"`)
|
|
125
|
+
or `PermissionRequest` (add `"tool_name"`, `"tool_input"`) to exercise the other
|
|
126
|
+
two alert shapes. Drop `CDX_NOTIFY_PREVIEW=1` to see what a session without
|
|
127
|
+
response previews shows.
|
|
128
|
+
|
|
129
|
+
This writes a real line to the spool and raises a real banner. Harmless, but it
|
|
130
|
+
is not a dry run.
|
|
131
|
+
|
|
132
|
+
## Testing against a store that is not yours
|
|
133
|
+
|
|
134
|
+
To exercise the menu without touching real sessions:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
export CDX_HOME=/tmp/cdx-dev
|
|
138
|
+
./bin/cdx tray status --json # empty store: icon "unknown", reason "no_sessions"
|
|
139
|
+
cp ~/.cdx/sessions.json "$CDX_HOME/" # or copy real rows in for a realistic menu
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`CDX_HOME` is read by every CDX path, including `cdx notify`, so the isolated
|
|
143
|
+
store gets its own spool and its own preferences.
|
|
144
|
+
|
|
145
|
+
## Checking what is actually installed
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
cdx tray doctor
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
It names the installed companion, whether it is running and under which pid,
|
|
152
|
+
which `cdx` binary that companion calls, and whether the hook store the
|
|
153
|
+
notification path writes to is the one the companion reads. A tray that receives
|
|
154
|
+
no alert while everything looks fine is almost always a `hook_store` mismatch.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdx-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Terminal session manager for Codex and Claude accounts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexandre Agostini",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"!bin/**/*.pyc",
|
|
31
31
|
"checksums",
|
|
32
32
|
"changelogs",
|
|
33
|
+
"docs",
|
|
33
34
|
"src",
|
|
34
35
|
"!src/**/__pycache__",
|
|
35
36
|
"!src/**/*.pyc",
|
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -260,7 +260,7 @@ def _print_help(use_color=False):
|
|
|
260
260
|
f" {_style('cdx resume <name> [--json]', '36', use_color)}",
|
|
261
261
|
f" {_style('cdx can-resume <name> [--json]', '36', use_color)}",
|
|
262
262
|
f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
|
|
263
|
-
f" {_style('cdx handoff <source> <target> [--source-conversation ID | --source-transcript PATH] [--json]', '36', use_color)}",
|
|
263
|
+
f" {_style('cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]', '36', use_color)}",
|
|
264
264
|
f" {_style('cdx add [provider] <name> [--model MODEL] [--json]', '36', use_color)}",
|
|
265
265
|
f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
|
|
266
266
|
f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
|
package/src/cli_args.py
CHANGED
|
@@ -30,7 +30,7 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
|
|
|
30
30
|
IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--allow-authless-force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
|
|
31
31
|
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text...] [--json]"
|
|
32
32
|
MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
|
|
33
|
-
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-transcript PATH] [--json]"
|
|
33
|
+
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]"
|
|
34
34
|
LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
|
|
35
35
|
SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]"
|
|
36
36
|
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
|
package/src/commands/launch.py
CHANGED
|
@@ -191,6 +191,8 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
191
191
|
if force_json is not None:
|
|
192
192
|
json_flag = force_json
|
|
193
193
|
warnings = _update_notice_warnings(ctx)
|
|
194
|
+
active_runtime = ctx["service"].get("active_session_runtime")
|
|
195
|
+
overlap_at_start = bool(active_runtime and active_runtime(command))
|
|
194
196
|
_warn_if_session_already_running(command, ctx)
|
|
195
197
|
session = ctx["service"]["get_session"](command)
|
|
196
198
|
if not session:
|
|
@@ -258,6 +260,18 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
258
260
|
{"status": "stopped", "returncode": info.get("returncode")},
|
|
259
261
|
)
|
|
260
262
|
|
|
263
|
+
# A resumed transcript may grow outside CDX between launches. Capture its
|
|
264
|
+
# cumulative value immediately before starting the provider.
|
|
265
|
+
baseline, baseline_path, baseline_match, _ = extract_interactive_usage(
|
|
266
|
+
session.get("provider"), _get_auth_home(session), None, _conversation_id(session),
|
|
267
|
+
)
|
|
268
|
+
if baseline_match != "conversation_id":
|
|
269
|
+
baseline = baseline_path = None
|
|
270
|
+
def overlap_reason():
|
|
271
|
+
current = active_runtime(session["name"]) if active_runtime else None
|
|
272
|
+
if overlap_at_start or (runtime_run_id and current and current.get("runId") != runtime_run_id):
|
|
273
|
+
return "overlapping_session"
|
|
274
|
+
return None
|
|
261
275
|
try:
|
|
262
276
|
run_info = _run_interactive_provider_command(
|
|
263
277
|
session, "resume" if resume else "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
|
|
@@ -270,7 +284,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
270
284
|
except CdxError as error:
|
|
271
285
|
run_info = getattr(error, "run_info", {}) or {}
|
|
272
286
|
run_info = _attach_interactive_usage(
|
|
273
|
-
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=
|
|
287
|
+
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=None),
|
|
288
|
+
baseline=(baseline_path, baseline),
|
|
289
|
+
uncertain_reason=overlap_reason(),
|
|
274
290
|
)
|
|
275
291
|
if not json_flag and error.exit_code in INTERRUPT_EXIT_CODES:
|
|
276
292
|
ctx["out"](f"{_goodbye_line(session, run_info, ctx['use_color'])}\n")
|
|
@@ -290,7 +306,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
290
306
|
})
|
|
291
307
|
raise
|
|
292
308
|
success_run_info = _attach_interactive_usage(
|
|
293
|
-
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=
|
|
309
|
+
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=None),
|
|
310
|
+
baseline=(baseline_path, baseline),
|
|
311
|
+
uncertain_reason=overlap_reason(),
|
|
294
312
|
)
|
|
295
313
|
ctx["service"]["record_launch_history"](session["name"], {
|
|
296
314
|
"status": "success",
|
|
@@ -332,7 +350,7 @@ def _goodbye_line(session, run_info, use_color):
|
|
|
332
350
|
return _dim(" · ".join(parts), use_color)
|
|
333
351
|
|
|
334
352
|
|
|
335
|
-
def _attach_interactive_usage(session, run_info, history=None):
|
|
353
|
+
def _attach_interactive_usage(session, run_info, history=None, baseline=None, uncertain_reason=None):
|
|
336
354
|
"""Attach this run's own token usage without affecting launch outcome.
|
|
337
355
|
|
|
338
356
|
Both interactive readers report a cumulative figure, so what a run *stores*
|
|
@@ -369,7 +387,19 @@ def _attach_interactive_usage(session, run_info, history=None):
|
|
|
369
387
|
# `model` is optional and usually unset, because the session takes the
|
|
370
388
|
# provider's default.
|
|
371
389
|
run_info["usage_model"] = model
|
|
372
|
-
|
|
390
|
+
if uncertain_reason:
|
|
391
|
+
run_info["usage_attribution"] = uncertain_reason
|
|
392
|
+
return run_info
|
|
393
|
+
baseline_path, baseline_usage = baseline or (None, None)
|
|
394
|
+
if baseline_usage is not None and baseline_path == provider_transcript:
|
|
395
|
+
previous = baseline_usage
|
|
396
|
+
run_info["usage_attribution"] = "launch_boundary"
|
|
397
|
+
elif baseline is not None:
|
|
398
|
+
previous = None
|
|
399
|
+
run_info["usage_attribution"] = "new_transcript" if not transcript_predates_run(
|
|
400
|
+
provider_transcript, started_at) else "uncertain_transcript"
|
|
401
|
+
else:
|
|
402
|
+
previous = _previous_cumulative(history, provider_transcript)
|
|
373
403
|
if previous is not None:
|
|
374
404
|
delta = usage_delta(cumulative, previous)
|
|
375
405
|
if delta is not None:
|
|
@@ -402,7 +432,7 @@ def _parse_handoff_args(rest):
|
|
|
402
432
|
arg = args[index]
|
|
403
433
|
if arg.startswith("--"):
|
|
404
434
|
key, sep, value = arg.partition("=")
|
|
405
|
-
if key not in ("--source-conversation", "--source-transcript") or key in flags:
|
|
435
|
+
if key not in ("--source-conversation", "--source-transcript", "--source-native-transcript") or key in flags:
|
|
406
436
|
raise CdxError(HANDOFF_USAGE)
|
|
407
437
|
if not sep:
|
|
408
438
|
index += 1
|
|
@@ -420,14 +450,15 @@ def _parse_handoff_args(rest):
|
|
|
420
450
|
|
|
421
451
|
def _handoff_source(source, workspace, flags, ctx, json_flag):
|
|
422
452
|
try:
|
|
423
|
-
return resolve_source(source, workspace, flags.get("--source-conversation"),
|
|
453
|
+
return resolve_source(source, workspace, flags.get("--source-conversation"),
|
|
454
|
+
flags.get("--source-transcript"), flags.get("--source-native-transcript"))
|
|
424
455
|
except HandoffSourceError as error:
|
|
425
456
|
candidates = error.candidates
|
|
426
457
|
# JSON and non-interactive callers always make an explicit second call.
|
|
427
458
|
if json_flag or not ctx["stdin_is_tty"] or not candidates or flags:
|
|
428
459
|
raise
|
|
429
460
|
ctx["out"](str(error) + "\n" + "".join(
|
|
430
|
-
f" {i + 1}. {c['conversation_id']} | {c['workspace']} | last event {c['last_event_at'] or 'unknown'}\n"
|
|
461
|
+
f" {i + 1}. {c['conversation_id']} | {c['path']} | {c['workspace']} | last event {c['last_event_at'] or 'unknown'}\n"
|
|
431
462
|
for i, c in enumerate(candidates)
|
|
432
463
|
))
|
|
433
464
|
ask = ctx["options"].get("input") or input
|
|
@@ -439,7 +470,11 @@ def _handoff_source(source, workspace, flags, ctx, json_flag):
|
|
|
439
470
|
raise CdxError("Handoff cancelled.", exit_code=130) from None
|
|
440
471
|
if not answer.isdigit() or not 1 <= int(answer) <= len(candidates):
|
|
441
472
|
raise CdxError("Handoff cancelled: choose a listed source.") from None
|
|
442
|
-
|
|
473
|
+
chosen = candidates[int(answer) - 1]
|
|
474
|
+
exact = resolve_source(source, workspace, native_path=chosen["path"])
|
|
475
|
+
if (exact["sha256"], exact["extent_bytes"]) != (chosen["sha256"], chosen["extent_bytes"]):
|
|
476
|
+
raise HandoffSourceError("Selected native transcript changed during selection.") from None
|
|
477
|
+
return exact
|
|
443
478
|
|
|
444
479
|
|
|
445
480
|
def handle_handoff(rest, ctx):
|
|
@@ -464,7 +499,7 @@ def handle_handoff(rest, ctx):
|
|
|
464
499
|
transcript = _handoff_source(source, workspace, flags, ctx, json_flag)
|
|
465
500
|
except HandoffSourceError as error:
|
|
466
501
|
if not json_flag:
|
|
467
|
-
choices = "\n".join(f" {c['conversation_id']} | {c['workspace']} | {c['last_event_at'] or 'unknown'}" for c in error.candidates)
|
|
502
|
+
choices = "\n".join(f" {c['conversation_id']} | {c['path']} | {c['workspace']} | {c['last_event_at'] or 'unknown'}" for c in error.candidates)
|
|
468
503
|
raise CdxError(str(error) + ("\nCandidates:\n" + choices if choices else "")) from error
|
|
469
504
|
_write_json(ctx, _json_failure("handoff", "handoff_source_unresolved", str(error), candidates=error.candidates))
|
|
470
505
|
return error.exit_code
|
package/src/commands/runs.py
CHANGED
|
@@ -44,7 +44,7 @@ from ..provider_runtime import (
|
|
|
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
46
|
from ..run_registry import RunRegistry, build_code_review_report
|
|
47
|
-
from ..run_usage import
|
|
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
|
|
50
50
|
|
|
@@ -390,10 +390,20 @@ def _reconcile_background_runs(ctx):
|
|
|
390
390
|
status=status,
|
|
391
391
|
final_payload={
|
|
392
392
|
"usage": outcome["usage"],
|
|
393
|
+
"usage_model": outcome["usage_model"],
|
|
393
394
|
"result": outcome["result"],
|
|
394
395
|
"background_path": BACKGROUND_PATH_PROVIDER,
|
|
395
396
|
},
|
|
396
397
|
)
|
|
398
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
399
|
+
"status": "success" if status == "succeeded" else "failed",
|
|
400
|
+
"action": "run",
|
|
401
|
+
"run_id": run["run_id"],
|
|
402
|
+
"attempt": 1,
|
|
403
|
+
"cwd": run.get("cwd"),
|
|
404
|
+
"usage": outcome["usage"],
|
|
405
|
+
"usage_model": outcome["usage_model"],
|
|
406
|
+
})
|
|
397
407
|
except Exception:
|
|
398
408
|
continue
|
|
399
409
|
|
|
@@ -645,8 +655,19 @@ def handle_run(rest, ctx):
|
|
|
645
655
|
spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
|
|
646
656
|
run_id=run_id,
|
|
647
657
|
)
|
|
648
|
-
usage =
|
|
649
|
-
|
|
658
|
+
usage, usage_model = extract_run_observation(
|
|
659
|
+
run_session.get("provider"), run_info.get("stdout_path"),
|
|
660
|
+
)
|
|
661
|
+
run_info = {**run_info, "usage": usage, "usage_model": usage_model}
|
|
662
|
+
ctx["service"]["record_launch_history"](run_session["name"], {
|
|
663
|
+
"status": "success" if run_info.get("returncode") == 0 else "failed",
|
|
664
|
+
"action": "run",
|
|
665
|
+
"run_id": run_id,
|
|
666
|
+
"attempt": len(attempted),
|
|
667
|
+
"cwd": cwd,
|
|
668
|
+
"exit_code": run_info.get("returncode"),
|
|
669
|
+
**run_info,
|
|
670
|
+
})
|
|
650
671
|
if run_info.get("returncode") == 0 or not parsed.get("failover"):
|
|
651
672
|
break
|
|
652
673
|
reason = failover_reason(
|
|
@@ -714,12 +735,6 @@ def handle_run(rest, ctx):
|
|
|
714
735
|
run_info=run_info,
|
|
715
736
|
task_report=task_report,
|
|
716
737
|
)
|
|
717
|
-
ctx["service"]["record_launch_history"](session["name"], {
|
|
718
|
-
"status": "success",
|
|
719
|
-
"cwd": cwd,
|
|
720
|
-
"exit_code": 0,
|
|
721
|
-
**run_info,
|
|
722
|
-
})
|
|
723
738
|
_write_json(ctx, final_payload)
|
|
724
739
|
return 0
|
|
725
740
|
if failover_error_code == "failover_exhausted":
|
|
@@ -754,18 +769,25 @@ def handle_run(rest, ctx):
|
|
|
754
769
|
error=final_payload.get("error"),
|
|
755
770
|
task_report=build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None,
|
|
756
771
|
)
|
|
757
|
-
ctx["service"]["record_launch_history"](session["name"], {
|
|
758
|
-
"status": "failed",
|
|
759
|
-
"cwd": cwd,
|
|
760
|
-
"error": str(error),
|
|
761
|
-
"exit_code": error.exit_code,
|
|
762
|
-
**run_info,
|
|
763
|
-
})
|
|
764
772
|
_write_json(ctx, final_payload)
|
|
765
773
|
return error.exit_code or 1
|
|
766
774
|
except CdxError as error:
|
|
767
775
|
run_info = getattr(error, "run_info", None)
|
|
768
776
|
cancelled = bool(getattr(error, "cancelled", False))
|
|
777
|
+
if run_info and run_id:
|
|
778
|
+
usage, usage_model = extract_run_observation(
|
|
779
|
+
run_session.get("provider"), run_info.get("stdout_path"),
|
|
780
|
+
)
|
|
781
|
+
run_info = {**run_info, "usage": usage, "usage_model": usage_model}
|
|
782
|
+
ctx["service"]["record_launch_history"](run_session["name"], {
|
|
783
|
+
"status": "failed",
|
|
784
|
+
"action": "run",
|
|
785
|
+
"run_id": run_id,
|
|
786
|
+
"attempt": len(attempted) if "attempted" in locals() else 1,
|
|
787
|
+
"cwd": cwd,
|
|
788
|
+
"exit_code": error.exit_code,
|
|
789
|
+
**run_info,
|
|
790
|
+
})
|
|
769
791
|
final_payload = run_result_payload(
|
|
770
792
|
API_SCHEMA_VERSION,
|
|
771
793
|
False,
|
package/src/commands/status.py
CHANGED
|
@@ -276,6 +276,8 @@ def _summarize_stats(entries):
|
|
|
276
276
|
"usage_runs": 0,
|
|
277
277
|
"cost_usd": 0.0,
|
|
278
278
|
"weighted_tokens": 0,
|
|
279
|
+
"weighted_runs": 0,
|
|
280
|
+
"unweighted_runs": 0,
|
|
279
281
|
"priced_runs": 0,
|
|
280
282
|
"unpriced_models": set(),
|
|
281
283
|
"unvouched_runs": 0,
|
|
@@ -318,8 +320,12 @@ def _summarize_stats(entries):
|
|
|
318
320
|
# actually covered.
|
|
319
321
|
# Weighted per entry, not on the summed row: output and cache-read
|
|
320
322
|
# ratios vary by model, and a session's runs can span models.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
+
weighted = weighted_usage(entry.get("usage"), entry.get("usage_model"))
|
|
324
|
+
if weighted is None:
|
|
325
|
+
row["unweighted_runs"] += 1
|
|
326
|
+
else:
|
|
327
|
+
row["weighted_tokens"] += weighted
|
|
328
|
+
row["weighted_runs"] += 1
|
|
323
329
|
cost = estimate_cost(entry.get("usage"), entry.get("usage_model"))
|
|
324
330
|
if cost is not None:
|
|
325
331
|
row["cost_usd"] += cost
|
|
@@ -355,37 +361,6 @@ def _summarize_stats(entries):
|
|
|
355
361
|
output_tokens=row["output_tokens"],
|
|
356
362
|
)
|
|
357
363
|
row["total_tokens"] = derived["total_tokens"] or 0
|
|
358
|
-
if not row["weighted_tokens"]:
|
|
359
|
-
# Records written before the model was recorded: weigh the row as a
|
|
360
|
-
# whole, at the default ratio, rather than reporting nothing.
|
|
361
|
-
row["weighted_tokens"] = weighted_usage(derived) or 0
|
|
362
|
-
row["unpriced_models"] = sorted(row["unpriced_models"])
|
|
363
|
-
for row in rows.values():
|
|
364
|
-
# Derive from the summed parts rather than summing each run's stored
|
|
365
|
-
# total. Records written before the definition was settled carry a
|
|
366
|
-
# total that excluded cache, and adding those to correct ones produced
|
|
367
|
-
# a row that contradicted itself -- CACHE in the hundreds of millions
|
|
368
|
-
# beside a TOTAL in the low millions.
|
|
369
|
-
#
|
|
370
|
-
# Those records also lack the creation/read split, so the fused
|
|
371
|
-
# `cached_input_tokens` is the one field both eras share. Where the
|
|
372
|
-
# split is missing it is read as cache read: that is what the
|
|
373
|
-
# overwhelming majority of cached tokens are, and weighting the whole
|
|
374
|
-
# of it at the creation rate would overstate legacy sessions by more
|
|
375
|
-
# than reading it as reads understates them.
|
|
376
|
-
split = row["cache_creation_tokens"] + row["cache_read_tokens"]
|
|
377
|
-
legacy_cache = max(0, row["cached_input_tokens"] - split)
|
|
378
|
-
derived = normalize_usage(
|
|
379
|
-
input_tokens=row["input_tokens"],
|
|
380
|
-
cache_creation_tokens=row["cache_creation_tokens"],
|
|
381
|
-
cache_read_tokens=row["cache_read_tokens"] + legacy_cache,
|
|
382
|
-
output_tokens=row["output_tokens"],
|
|
383
|
-
)
|
|
384
|
-
row["total_tokens"] = derived["total_tokens"] or 0
|
|
385
|
-
if not row["weighted_tokens"]:
|
|
386
|
-
# Records written before the model was recorded: weigh the row as a
|
|
387
|
-
# whole, at the default ratio, rather than reporting nothing.
|
|
388
|
-
row["weighted_tokens"] = weighted_usage(derived) or 0
|
|
389
364
|
row["unpriced_models"] = sorted(row["unpriced_models"])
|
|
390
365
|
return sorted(
|
|
391
366
|
rows.values(),
|
|
@@ -407,6 +382,8 @@ def _stats_totals(rows):
|
|
|
407
382
|
"usage_runs": sum(row["usage_runs"] for row in rows),
|
|
408
383
|
**{key: sum(row[key] for row in rows) for key in USAGE_KEYS},
|
|
409
384
|
"weighted_tokens": sum(row["weighted_tokens"] for row in rows),
|
|
385
|
+
"weighted_runs": sum(row["weighted_runs"] for row in rows),
|
|
386
|
+
"unweighted_runs": sum(row["unweighted_runs"] for row in rows),
|
|
410
387
|
"cost_usd": sum(row["cost_usd"] for row in rows),
|
|
411
388
|
"priced_runs": sum(row["priced_runs"] for row in rows),
|
|
412
389
|
"unpriced_models": sorted({m for row in rows for m in row["unpriced_models"]}),
|
|
@@ -517,7 +494,8 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
517
494
|
_style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
|
|
518
495
|
_style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
|
|
519
496
|
_style(_format_token_count(row["total_tokens"]), "96" if row["total_tokens"] else "2", use_color),
|
|
520
|
-
_style(_format_token_count(row["weighted_tokens"])
|
|
497
|
+
_style(_format_token_count(row["weighted_tokens"]) if row["weighted_runs"] else "-",
|
|
498
|
+
"1;95" if row["weighted_runs"] else "2", use_color),
|
|
521
499
|
_style(_format_usd(row["cost_usd"]) if row["priced_runs"] else "-",
|
|
522
500
|
"1;33" if row["priced_runs"] else "2", use_color),
|
|
523
501
|
_style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
|
|
@@ -534,7 +512,9 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
534
512
|
"Totals: "
|
|
535
513
|
f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
|
|
536
514
|
f"{_format_token_count(totals['total_tokens'])} tokens "
|
|
537
|
-
f"({_format_token_count(totals['weighted_tokens'])} cost-equivalent"
|
|
515
|
+
f"({_format_token_count(totals['weighted_tokens']) if totals['weighted_runs'] else '-'} cost-equivalent"
|
|
516
|
+
+ (f" on {totals['weighted_runs']}/{totals['usage_runs']} measured runs"
|
|
517
|
+
if totals["usage_runs"] else "")
|
|
538
518
|
+ (f", {_format_usd(totals['cost_usd'])} at list on {totals['priced_runs']} priced run"
|
|
539
519
|
f"{'s' if totals['priced_runs'] != 1 else ''} [{token_prices()[1]}]"
|
|
540
520
|
if totals["priced_runs"] else ", no run priced")
|
|
@@ -630,6 +610,13 @@ def handle_stats(rest, ctx):
|
|
|
630
610
|
period=_public_history_period(parsed["period"]),
|
|
631
611
|
stats=rows,
|
|
632
612
|
totals=totals,
|
|
613
|
+
accounting={
|
|
614
|
+
"launches_unit": "history_attempts",
|
|
615
|
+
"period_policy": "whole_run_overlap",
|
|
616
|
+
"weighted_coverage": "weighted_runs/usage_runs",
|
|
617
|
+
"price_coverage": "priced_runs/usage_runs",
|
|
618
|
+
"price_basis": token_prices()[1],
|
|
619
|
+
},
|
|
633
620
|
))
|
|
634
621
|
return 0
|
|
635
622
|
active_sessions = _active_session_names(ctx)
|
package/src/context_store.py
CHANGED
|
@@ -8,6 +8,7 @@ from urllib.parse import quote, unquote
|
|
|
8
8
|
|
|
9
9
|
from .errors import CdxError
|
|
10
10
|
from .fs_utils import atomic_write
|
|
11
|
+
from .run_registry import _registry_lock
|
|
11
12
|
from .session_store import _ensure_dir
|
|
12
13
|
|
|
13
14
|
DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
|
|
@@ -127,7 +128,7 @@ def append_context_path(path, note):
|
|
|
127
128
|
note = str(note or "").strip()
|
|
128
129
|
if not note:
|
|
129
130
|
raise CdxError("Memory append requires text.")
|
|
130
|
-
with _append_lock(path):
|
|
131
|
+
with _append_lock(path), _registry_lock(path, timeout_seconds=10, label="memory"):
|
|
131
132
|
current = read_context_path(path).rstrip()
|
|
132
133
|
content = f"{current}\n{note}" if current else note
|
|
133
134
|
return write_context_path(path, content)
|
package/src/handoff.py
CHANGED
|
@@ -11,7 +11,6 @@ from uuid import uuid4
|
|
|
11
11
|
from .context_store import get_context_path, read_context
|
|
12
12
|
from .errors import CdxError
|
|
13
13
|
from .fs_utils import atomic_write
|
|
14
|
-
from .interactive_usage import conversation_transcript
|
|
15
14
|
from .provider_runtime import _get_auth_home, _list_launch_transcript_paths
|
|
16
15
|
|
|
17
16
|
CANDIDATE_LIMIT = 20
|
|
@@ -25,13 +24,19 @@ class HandoffSourceError(CdxError):
|
|
|
25
24
|
|
|
26
25
|
def _native_paths(session):
|
|
27
26
|
home = _get_auth_home(session)
|
|
27
|
+
root = os.path.realpath(home)
|
|
28
|
+
def owned(path):
|
|
29
|
+
try:
|
|
30
|
+
return os.path.commonpath((root, os.path.realpath(path))) == root
|
|
31
|
+
except ValueError:
|
|
32
|
+
return False
|
|
28
33
|
if session['provider'] == 'codex':
|
|
29
|
-
paths = glob.glob(os.path.join(home, 'sessions', '**', '*.jsonl'), recursive=True)
|
|
30
|
-
return [p for p in paths if 'subagents' not in os.path.relpath(p, home).split(os.sep)]
|
|
34
|
+
paths = glob.glob(os.path.join(glob.escape(home), 'sessions', '**', '*.jsonl'), recursive=True)
|
|
35
|
+
return [p for p in paths if owned(p) and 'subagents' not in os.path.relpath(p, home).split(os.sep)]
|
|
31
36
|
if session['provider'] == 'claude':
|
|
32
37
|
return sorted(set(
|
|
33
|
-
p for
|
|
34
|
-
for p in glob.glob(os.path.join(home, *
|
|
38
|
+
p for layout in (('.claude', 'projects'), ('projects',))
|
|
39
|
+
for p in glob.glob(os.path.join(glob.escape(home), *layout, '*', '*.jsonl')) if owned(p)
|
|
35
40
|
))
|
|
36
41
|
return []
|
|
37
42
|
|
|
@@ -119,7 +124,7 @@ def source_candidates(session, workspace):
|
|
|
119
124
|
return sorted(candidates, key=lambda c: (c['last_event_at'] or '', c['path']), reverse=True)[:CANDIDATE_LIMIT]
|
|
120
125
|
|
|
121
126
|
|
|
122
|
-
def resolve_source(session, workspace, conversation_id=None, terminal_path=None):
|
|
127
|
+
def resolve_source(session, workspace, conversation_id=None, terminal_path=None, native_path=None):
|
|
123
128
|
workspace = os.path.realpath(workspace)
|
|
124
129
|
if terminal_path:
|
|
125
130
|
path = os.path.realpath(terminal_path)
|
|
@@ -150,26 +155,37 @@ def resolve_source(session, workspace, conversation_id=None, terminal_path=None)
|
|
|
150
155
|
reason = 'No recorded conversation identity.'
|
|
151
156
|
if session['provider'] not in ('codex', 'claude'):
|
|
152
157
|
raise HandoffSourceError('No supported native transcript for this provider. Select a known launch log with --source-transcript PATH for degraded recovery.')
|
|
158
|
+
paths = _native_paths(session)
|
|
159
|
+
if native_path:
|
|
160
|
+
path = os.path.realpath(native_path)
|
|
161
|
+
allowed = {os.path.realpath(p) for p in paths}
|
|
162
|
+
if path not in allowed:
|
|
163
|
+
raise HandoffSourceError('Native transcript must be a top-level source file in the source profile.')
|
|
164
|
+
try:
|
|
165
|
+
info = inspect_transcript(path, session['provider'])
|
|
166
|
+
except CdxError as error:
|
|
167
|
+
raise HandoffSourceError(str(error)) from error
|
|
168
|
+
if info['workspace'] != workspace:
|
|
169
|
+
raise HandoffSourceError('Native transcript workspace does not match the target workspace.')
|
|
170
|
+
info['provenance'] = 'explicit_native_path'
|
|
171
|
+
return info
|
|
153
172
|
if selected:
|
|
154
173
|
if not isinstance(selected, str) or not re.fullmatch(r'[A-Za-z0-9_-]+', selected):
|
|
155
174
|
raise HandoffSourceError('Invalid source conversation ID.')
|
|
156
|
-
path = conversation_transcript(session['provider'], _get_auth_home(session), selected)
|
|
157
|
-
# The native resolver may walk nested subagent directories; only top-level
|
|
158
|
-
# conversations are eligible for handoff, even when explicitly named.
|
|
159
|
-
paths = _native_paths(session)
|
|
160
|
-
if not path:
|
|
161
|
-
matches = [p for p in paths if os.path.basename(p) == selected + '.jsonl']
|
|
162
|
-
path = matches[0] if len(matches) == 1 else None
|
|
163
175
|
reason = f'Conversation {selected} is missing or is not a top-level source.'
|
|
164
|
-
|
|
176
|
+
matches = []
|
|
177
|
+
for path in paths:
|
|
165
178
|
try:
|
|
166
179
|
info = inspect_transcript(path, session['provider'])
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
180
|
+
except CdxError:
|
|
181
|
+
continue
|
|
182
|
+
if info['conversation_id'] == selected and info['workspace'] == workspace:
|
|
183
|
+
matches.append(info)
|
|
184
|
+
if len(matches) > 1:
|
|
185
|
+
raise HandoffSourceError(f'Conversation {selected} has multiple native transcripts; select --source-native-transcript PATH.', matches)
|
|
186
|
+
if matches:
|
|
187
|
+
matches[0]['provenance'] = 'explicit_identity' if conversation_id else 'recorded_identity'
|
|
188
|
+
return matches[0]
|
|
173
189
|
candidates = source_candidates(session, workspace)
|
|
174
190
|
raise HandoffSourceError(
|
|
175
191
|
reason + ' Select the intended ID with --source-conversation ID; no recency fallback is used.',
|