cdx-manager 0.20.13 → 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.
@@ -0,0 +1,92 @@
1
+ # Technical reference
2
+
3
+ ## Technical Overview
4
+
5
+ - Python 3.9+.
6
+ - Environment isolation per session:
7
+ - Codex sessions override `CODEX_HOME` to a dedicated profile directory.
8
+ - Claude sessions override `HOME` to a dedicated profile directory and disable Claude Code commit co-author attribution by default.
9
+ - On macOS, Claude credentials use a profile-specific keychain entry. Quota refresh reads that entry before legacy credential files; `cdx cp` and `cdx ren` transfer only the profile's entry, never the system keychain directory. Rename refuses existing destination entries and reports any old-entry cleanup failure after the new session becomes usable; `cdx rm` deletes the profile's entry with the profile.
10
+ - Antigravity sessions launch `agy` with a dedicated `HOME` for file-based settings; Google account credentials may still be stored in the OS keyring by Antigravity itself.
11
+ - Ollama sessions use the local Ollama server and launch `ollama run <model>`; set a model with `cdx set <name> --model <model>`.
12
+ - New Codex sessions seed their auth home from your existing global `~/.codex/auth.json` when available, so an already logged-in Codex CLI can be reused without giving up per-session isolation afterward.
13
+ - Persistence:
14
+ - Session registry at `~/.cdx/sessions.json` (versioned JSON store).
15
+ - Per-session state at `~/.cdx/state/<name>.json`.
16
+ - Per-workspace shared context at `~/.cdx/contexts/<workspace-hash>/context.md`.
17
+ - Auth and provider data under `~/.cdx/profiles/<name>/`.
18
+ - All paths are URL-encoded to support arbitrary session names.
19
+ - Status resolution pipeline:
20
+ - Primary source: recorded status fields on the session record.
21
+ - Codex live source: `codex app-server` JSON-RPC `account/rateLimits/read`, normalized into 5-hour, weekly, reset, credit, and plan fields.
22
+ - Fallback: `status-source` scans provider JSONL history files and terminal log transcripts, strips ANSI/OSC sequences, and extracts `usage%`, `5h remaining%`, and `week remaining%` via pattern matching.
23
+ - Status latency controls: use `cdx status --cached` to skip live probes, `cdx status --timeout SECONDS` for one command, or `CDX_STATUS_TIMEOUT_SECONDS` to lower the default Codex live-probe timeout.
24
+ - Claude status refreshes are cached briefly by default; pass `--refresh` to force a live rate-limit probe.
25
+ - On Linux, transcript capture uses the `util-linux` `script -c` command form.
26
+ - If `script` is unavailable, Codex launch falls back to running without transcript capture.
27
+ - On Windows, transcript capture is optional. If no compatible `script` wrapper is installed, Codex still launches normally without transcript capture.
28
+ - Auth probe: synchronous subprocess call to `codex login status` or `claude auth status` before any interactive launch.
29
+ - Signal forwarding: `SIGINT`, `SIGTERM`, and `SIGHUP` are forwarded to the child process and produce clean exit codes.
30
+ - Test stack: `pytest`, `pytest-cov`, and `ruff` via the documented `dev` dependency extra.
31
+
32
+ ## Project Structure
33
+
34
+ ```text
35
+ bin/
36
+ cdx.js # Node launcher used by npm
37
+ python-runner.js # Shared Python resolver and process wrapper
38
+ cdx # Python entrypoint invoked by the launcher
39
+
40
+ src/
41
+ cli.py # Top-level command router
42
+ cli_commands.py # Command handlers and argument handling
43
+ cli_render.py # Terminal formatting, tables, colors, and errors
44
+ backup_bundle.py # Portable session bundle encoding/decoding + auth encryption
45
+ status_view.py # Status table/detail rendering and priority ranking
46
+ provider_runtime.py # Provider launch/auth commands, transcripts, signals
47
+ claude_refresh.py # Claude usage refresh orchestration
48
+ session_service.py # Session lifecycle: create, copy, rename, launch, remove, status
49
+ # resolution, auth state management
50
+ session_store.py # JSON persistence layer: sessions.json + per-session
51
+ # state files
52
+ status_source.py # Status artifact discovery: scans JSONL history files
53
+ # and terminal log transcripts, strips ANSI sequences,
54
+ # extracts usage metrics via pattern matching
55
+ config.py # CDX_HOME resolution (env override or ~/.cdx)
56
+ errors.py # CdxError with optional exit code
57
+ __init__.py # Public Python exports
58
+
59
+ test/
60
+ test_cli_py.py # CLI command dispatch tests
61
+ test_session_service_py.py # Session service unit tests
62
+ ```
63
+
64
+ ## Data Layout
65
+
66
+ All session data lives under `CDX_HOME` (default: `~/.cdx/`):
67
+
68
+ ```text
69
+ ~/.cdx/
70
+ sessions.json # Session registry (versioned, all sessions)
71
+ state/
72
+ <encoded-name>.json # Per-session rehydration state
73
+ launch_history.jsonl # Append-only launch history
74
+ contexts/
75
+ <workspace-hash>/context.md # Current-workspace shared context / memory
76
+ global/context.md # Global cdx memory
77
+ projects/<encoded-name>/context.md # Named project memory from cdx memory --project NAME
78
+ profiles/
79
+ <encoded-name>/ # Codex session: CODEX_HOME points here
80
+ log/
81
+ cdx-session.log # Terminal transcript (written by script(1))
82
+ <encoded-name>/
83
+ claude-home/ # Claude session: HOME points here
84
+ log/
85
+ cdx-session.log
86
+ ```
87
+
88
+ Session names are URL-encoded when used as directory or file names. CLI command names such as `add`, `disk`, `status`, and `login` are reserved and cannot be used as new session names.
89
+
90
+ `cdx memory` is the user-facing interface for explicit memory. `--project NAME` uses a stable free-form project name; `--project /path/to/repo` resolves to the same workspace-hash memory that repo would use as the current workspace. `append` adds the text as-is with a clean newline boundary; it does not add dates or headings. `list` reports global memory, named project memories, and the current workspace memory when present; it does not scan your filesystem for repos.
91
+
92
+ Provider-private memory files, such as Codex SQLite memory databases or Claude Code project memory files, are not edited by `cdx memory`.
@@ -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.20.13",
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.20.13"
7
+ version = "0.21.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
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]"
@@ -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=50)
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=50)
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
- previous = _previous_cumulative(history, provider_transcript)
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"), flags.get("--source-transcript"))
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
- return resolve_source(source, workspace, candidates[int(answer) - 1]["conversation_id"])
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
@@ -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 extract_run_usage
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 = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
649
- run_info = {**run_info, "usage": usage}
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,
@@ -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,
@@ -316,11 +318,14 @@ def _summarize_stats(entries):
316
318
  # model cdx never saw stays unpriced rather than being charged at a
317
319
  # default tier, and `priced_runs` says how much of the figure is
318
320
  # actually covered.
319
- # Weighted per entry, not on the summed row: the output ratio is
320
- # the one multiplier that differs between vendors, and a session's
321
- # runs can span models.
322
- row["weighted_tokens"] += weighted_usage(
323
- entry.get("usage"), entry.get("usage_model")) or 0
321
+ # Weighted per entry, not on the summed row: output and cache-read
322
+ # ratios vary by model, and a session's runs can span models.
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
324
329
  cost = estimate_cost(entry.get("usage"), entry.get("usage_model"))
325
330
  if cost is not None:
326
331
  row["cost_usd"] += cost
@@ -356,37 +361,6 @@ def _summarize_stats(entries):
356
361
  output_tokens=row["output_tokens"],
357
362
  )
358
363
  row["total_tokens"] = derived["total_tokens"] or 0
359
- if not row["weighted_tokens"]:
360
- # Records written before the model was recorded: weigh the row as a
361
- # whole, at the default ratio, rather than reporting nothing.
362
- row["weighted_tokens"] = weighted_usage(derived) or 0
363
- row["unpriced_models"] = sorted(row["unpriced_models"])
364
- for row in rows.values():
365
- # Derive from the summed parts rather than summing each run's stored
366
- # total. Records written before the definition was settled carry a
367
- # total that excluded cache, and adding those to correct ones produced
368
- # a row that contradicted itself -- CACHE in the hundreds of millions
369
- # beside a TOTAL in the low millions.
370
- #
371
- # Those records also lack the creation/read split, so the fused
372
- # `cached_input_tokens` is the one field both eras share. Where the
373
- # split is missing it is read as cache read: that is what the
374
- # overwhelming majority of cached tokens are, and weighting the whole
375
- # of it at the creation rate would overstate legacy sessions by more
376
- # than reading it as reads understates them.
377
- split = row["cache_creation_tokens"] + row["cache_read_tokens"]
378
- legacy_cache = max(0, row["cached_input_tokens"] - split)
379
- derived = normalize_usage(
380
- input_tokens=row["input_tokens"],
381
- cache_creation_tokens=row["cache_creation_tokens"],
382
- cache_read_tokens=row["cache_read_tokens"] + legacy_cache,
383
- output_tokens=row["output_tokens"],
384
- )
385
- row["total_tokens"] = derived["total_tokens"] or 0
386
- if not row["weighted_tokens"]:
387
- # Records written before the model was recorded: weigh the row as a
388
- # whole, at the default ratio, rather than reporting nothing.
389
- row["weighted_tokens"] = weighted_usage(derived) or 0
390
364
  row["unpriced_models"] = sorted(row["unpriced_models"])
391
365
  return sorted(
392
366
  rows.values(),
@@ -408,6 +382,8 @@ def _stats_totals(rows):
408
382
  "usage_runs": sum(row["usage_runs"] for row in rows),
409
383
  **{key: sum(row[key] for row in rows) for key in USAGE_KEYS},
410
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),
411
387
  "cost_usd": sum(row["cost_usd"] for row in rows),
412
388
  "priced_runs": sum(row["priced_runs"] for row in rows),
413
389
  "unpriced_models": sorted({m for row in rows for m in row["unpriced_models"]}),
@@ -518,7 +494,8 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
518
494
  _style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
519
495
  _style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
520
496
  _style(_format_token_count(row["total_tokens"]), "96" if row["total_tokens"] else "2", use_color),
521
- _style(_format_token_count(row["weighted_tokens"]), "1;95" if row["weighted_tokens"] else "2", use_color),
497
+ _style(_format_token_count(row["weighted_tokens"]) if row["weighted_runs"] else "-",
498
+ "1;95" if row["weighted_runs"] else "2", use_color),
522
499
  _style(_format_usd(row["cost_usd"]) if row["priced_runs"] else "-",
523
500
  "1;33" if row["priced_runs"] else "2", use_color),
524
501
  _style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
@@ -535,7 +512,9 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
535
512
  "Totals: "
536
513
  f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
537
514
  f"{_format_token_count(totals['total_tokens'])} tokens "
538
- 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 "")
539
518
  + (f", {_format_usd(totals['cost_usd'])} at list on {totals['priced_runs']} priced run"
540
519
  f"{'s' if totals['priced_runs'] != 1 else ''} [{token_prices()[1]}]"
541
520
  if totals["priced_runs"] else ", no run priced")
@@ -631,6 +610,13 @@ def handle_stats(rest, ctx):
631
610
  period=_public_history_period(parsed["period"]),
632
611
  stats=rows,
633
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
+ },
634
620
  ))
635
621
  return 0
636
622
  active_sessions = _active_session_names(ctx)
@@ -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)