cdx-manager 0.24.1 → 0.24.3

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.3-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.24.1
1
+ 0.24.3
@@ -0,0 +1,14 @@
1
+ # CDX Manager 0.24.2
2
+
3
+ Release date: 2026-10-29
4
+
5
+ This patch makes busy-session handling explicit and surfaces headless permission denials without marking successful runs as failures.
6
+
7
+ ## Sessions
8
+
9
+ - Add `--on-busy fail|ask` to session launches and resumes. Use `fail` to refuse a profile with a live session immediately with a non-zero exit and `session_busy` error. The default `ask` keeps interactive confirmation; without a terminal or with `--json`, it refuses the duplicate launch. The policy also works with task, directory, and prompt options. (#55)
10
+
11
+ ## Runs
12
+
13
+ - Report nonempty Claude `permission_denials` result records as structured run warnings, including the provider and denied tools. A provider exit code of 0 still reports success (`ok: true`), since another tool may have completed the work. Empty or absent denials produce no warning. (#54)
14
+ - Clarify that Claude's `--scrub-env on` forces its default permission mode: unattended runs must explicitly allow tools that need approval, using `--allowedTools` through `--extra-args`. (#54)
@@ -0,0 +1,13 @@
1
+ # CDX Manager 0.24.3
2
+
3
+ Release date: 2026-10-29
4
+
5
+ This patch preserves observed serving models in run history and reports for more reliable usage attribution and pricing. (#57)
6
+
7
+ ## Runs and statistics
8
+
9
+ - Expose `usage_model` separately from the requested `model` in completed run JSON and registry reports, and retain observed model evidence in interactive history even when token usage is unavailable.
10
+ - Recover Codex model evidence from the exact rollout identified by `thread.started.thread_id`, while keeping token totals from the run's own output. Configured defaults alone do not establish the serving model; missing or mixed model evidence stays unpriced.
11
+ - Group Claude's `[1m]` context variants with their base model in `cdx stats --by model`, preserving the original model string and per-run pricing.
12
+
13
+ These changes apply to newly recorded runs; existing history is not rewritten.
@@ -584,6 +584,28 @@
584
584
  "x86_64-pc-windows-msvc": "90772ff35a25db86ebfbe4187c39f0705001a9f10b6a7fe0c04a431b13ccbcb8",
585
585
  "x86_64-unknown-linux-musl": "f7be523211fc4dba33724c2759f267f35669b8aff14136fc7ec97d398ebb1f87"
586
586
  }
587
+ },
588
+ "v0.24.1": {
589
+ "github_tarball_sha256": "5ea0ba5fe38590f5bc8e4ae6d299ebce1dc2ef960501914d5520a2f2901888f5",
590
+ "github_zip_sha256": "d48ebf567fe85ff36a516cd84e9cc34a4af2eb1145185e3f3d3ea30ef5eaf71c",
591
+ "tray_assets": {
592
+ "aarch64-apple-darwin": "bf231ab3618ea189333b74a1a9cc71622dc79b00c7e2549967d1da5ddb22dc3e",
593
+ "aarch64-unknown-linux-musl": "009a4e2d4b54ac67f3991d879d5875c82dddf6161b0649986240ebc57285a9e8",
594
+ "x86_64-apple-darwin": "256cb6644394ab7140f6353480aedf000f47f930d2b2aa3f905b6a57bf86714f",
595
+ "x86_64-pc-windows-msvc": "89a7e43654ea5f0d4efc20a14c1398377a1d48485f1b9867b199e85aa9c25daf",
596
+ "x86_64-unknown-linux-musl": "a36a3589a3766d67e44a0466b11a18bda4297963416eec7b5b9d23a5c9f363a4"
597
+ }
598
+ },
599
+ "v0.24.2": {
600
+ "github_tarball_sha256": "90ea3d050c278500b975bbc8bfe1b185f90fc35dfcf6b07d84eb59439bb24e58",
601
+ "github_zip_sha256": "3c53f0f0550416ebe3d7281e9c823a682a2c3560130df6fafec48d35278f4f8a",
602
+ "tray_assets": {
603
+ "aarch64-apple-darwin": "8fc48c8772b5ea50700933f0836e153104ef328b395de059749bd5a5aa864076",
604
+ "aarch64-unknown-linux-musl": "ce7708f4196cfdf7b7226ba3199e772e767c2d470c37d19589cb41bfc35de7af",
605
+ "x86_64-apple-darwin": "2d1d6e84ee08d9dea5a707332a39509edbe68b05e3d062bdae93e9c6704db543",
606
+ "x86_64-pc-windows-msvc": "eecc8f8c46d2ce50e6cf6ec6c48c10a10e165ec7ec0058cfb55c8aa486d17d7b",
607
+ "x86_64-unknown-linux-musl": "4cf3289c5d9d7a540a032a11d27e93093d69ec507e1796a726f429c0670f7bc5"
608
+ }
587
609
  }
588
610
  }
589
611
  }
package/docs/cli.md CHANGED
@@ -7,7 +7,8 @@
7
7
  | `cdx` | List all sessions with last-updated timestamps |
8
8
  | `cdx --json` | List all sessions as a machine-readable JSON payload |
9
9
  | `cdx <name>` | Launch a session (checks auth first) |
10
- | `cdx <name> [--json]` | Launch a session; `--json` returns a structured success payload after the interactive run ends |
10
+ | `cdx <name> [--on-busy fail\|ask] [--json]` | Launch a session; `--json` returns a structured success payload after the interactive run ends |
11
+ | `cdx <name> --on-busy fail` | Refuse a profile with a live session immediately, even with a terminal: non-zero exit and `session_busy` (`error.code` with `--json`). Works with `--task`, `--dir`, prompts and resume; `--on-busy=fail` is also accepted. The default `ask` keeps the confirmation prompt for terminal users; without a terminal or with `--json`, it fails instead of launching a duplicate. Handoff launches and `cdx last` use `ask` too. |
11
12
  | `cdx <name> --prompt TEXT` / `cdx <name> --prompt-file PATH` | Launch a session that starts on this first message (not with `--resume` or Ollama sessions; `--prompt-file -` is refused because the session needs standard input; a relative `--prompt-file` path is read from the current directory, not `--dir`) |
12
13
  | `cdx <name> --dir PATH --task TEXT [--end-state TEXT]` | Start a session on a task. `--end-state` names what your go-ahead covers ("CI green on a PR, merged"): the session takes every step it needs, commits, pushes and PRs included, and stops only for a decision outside it. Without `--end-state` it stops and shows the result before any commit, push or PR. Replaces `--prompt`; the first message also tells it to read the repository's instructions and use its skills. |
13
14
  | `cdx <name> -r` / `cdx <name> --resume` | Resume the provider-native conversation for a session when supported |
@@ -268,6 +269,8 @@ The figure uses standard public list prices, excluding Fast/priority, Batch/Flex
268
269
 
269
270
  `cdx stats` counts completed history attempts. A headless failover has one logical run in `cdx runs` but one history row per attempted session, including failed attempts that reported usage. Period filtering includes the entire run in every period it overlaps, so adjacent daily results can both include a run crossing midnight; adding daily totals may exceed a combined period. JSON `accounting` names this policy and the current price-table source. `usage_runs` and `priced_runs` show how much of the token total has a dollar estimate. Live runs are absent until their history row is written, and unknown usage is different from measured zero.
270
271
 
272
+ Completed run JSON and registry reports expose `usage_model` separately from the requested `model`. Pricing uses the observed model from provider output; Codex can also supply it through the exact rollout identified by `thread.started.thread_id`. Configured defaults alone never establish serving-model identity, and missing or mixed model evidence stays unpriced. `--by model` groups Claude's `[1m]` context variant with its base model while preserving the original model string and per-run pricing. These changes apply to newly recorded runs; existing history is not rewritten.
273
+
271
274
  The built-in price table ships with **cdx-manager**. Run `cdx update` to receive new model prices; updating Codex or Claude alone does not refresh it. Then check `cdx --version` and `cdx stats --json`. Existing runs with a recorded model are recalculated using the new table when stats are read. `CDX_TOKEN_PRICES` overrides still take precedence. Maintainers: follow [the model and pricing update runbook](https://github.com/AlexAgo83/cdx-manager/blob/main/logics/runbook/run_005_maintaining_the_token_price_table.md) for source checks, tests, release preparation and installed-version verification.
272
275
 
273
276
  A model absent from the table stays unpriced rather than being charged a default tier, and the totals line names it — which is exactly the key `CDX_TOKEN_PRICES` wants:
@@ -422,3 +425,5 @@ It returns accepted values for `permission` (with its aliases and canonical form
422
425
  ```
423
426
 
424
427
  Codex ties network access to its sandbox, so any permission below `full` leaves the run unable to resolve DNS — `gh`, `curl`, and package installs fail inside an otherwise successful run. Show these warnings to whoever reads your output rather than discarding them; a run that succeeds while quietly doing less is the case they exist for.
428
+
429
+ Claude result records with nonempty `permission_denials` produce a warning with code `permission_denials`, the `provider`, and the structured `permission_denials` list. Exit code 0 still means `ok: true`: a denied tool may have been replaced successfully. Empty or absent denials add no warning. With `--scrub-env on`, Claude forces its default permission mode; allow tools needed by unattended runs explicitly using `--allowedTools` via `--extra-args` (see [operations](operations.md)).
@@ -230,7 +230,7 @@ credential sync.
230
230
 
231
231
  - **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first. `cdx doctor` uses the same Codex app-server probe as `cdx auth refresh`, so a local auth file alone is never reported as live authentication; a rejected refresh token is reported as `login_required` with the matching login command.
232
232
  - **A Claude session has no claude.ai connectors (Microsoft 365, ...) in `/mcp`** — `cdx login <name>` stores a setup-token for Claude profiles, and Claude Code can only make model requests with it: it never loads claude.ai connectors. `cdx status` shows each Claude profile's auth type (`setup-token` or `full-login`; `auth_type` in `--json`). To switch, start the session and run `/login` inside it. Once the profile holds its own claude.ai login (its `.claude/.credentials.json`, or its profile-specific `Claude Code-credentials-<hash>` keychain entry on macOS), cdx stops passing the setup-token on later launches. If that login later expires, run `/login` again, or go back to a setup-token with `cdx login <name>`, which removes the profile's `/login`.
233
- - **Keep credentials away from what a Claude agent runs** — by default Claude Code passes its credentials, including the setup-token cdx injects, to the Bash commands, hooks and stdio MCP servers it starts. `cdx set <name> --scrub-env on` launches that session with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`, which strips credential-looking variables from those subprocesses (GitHub tokens and proxy settings stay). It is off by default: it also removes other secrets an agent may need (`NPM_TOKEN`, a `DATABASE_URL` with a password), and on Linux it isolates Bash from host processes, so `ps` and `kill` no longer see them. Worth turning on for unattended sessions that read untrusted content.
233
+ - **Keep credentials away from what a Claude agent runs** — by default Claude Code passes its credentials, including the setup-token cdx injects, to the Bash commands, hooks and stdio MCP servers it starts. `cdx set <name> --scrub-env on` launches that session with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`, which strips credential-looking variables from those subprocesses (GitHub tokens and proxy settings stay). It is off by default: it also removes other secrets an agent may need (`NPM_TOKEN`, a `DATABASE_URL` with a password), and on Linux it isolates Bash from host processes, so `ps` and `kill` no longer see them. For unattended sessions that read untrusted content, enable it only with explicit tool permissions: Claude forces its default permission mode when scrubbing is enabled, even if cdx requests `--permission full`. In headless runs, tools that require approval are denied unless explicitly allowed. For example, `cdx set <name> --scrub-env on --extra-args '--allowedTools "Read" "Bash(pwd)"'` allows those tools; adapt the allowlist to the task. cdx keeps scrubbing enabled and reports structured `permission_denials` as a run warning, without turning a successful provider exit into a failure: a denied tool can be replaced successfully.
234
234
  - **Share Claude's memory across accounts** — Claude Code keeps its auto-memory (`.claude/projects/<project>/memory`) in the profile home, so each account starts without what the others learned. `cdx set <name> --shared-memory on` links that folder, for the project being launched, to `~/.cdx/shared-memory/<project>`, shared by every profile with the setting on. Existing memory is copied in and the old folder kept as `memory.pre-cdx-<time>`; `MEMORY.md` indexes are merged line by line. A file with the same name and different content on two accounts is a conflict: cdx leaves that profile alone and names the files to merge by hand. macOS and Linux only (symlinks).
235
235
  - **One of two Codex accounts keeps asking for login** — run `cdx doctor --json` and inspect each Codex session's `codex_auth_file`, `codex_live_auth`, and `codex_stale_auth_logs` checks. If recent logs mention expired auth, repair only that isolated profile with `cdx login <name>`. Codex Business profiles can share the same `tokens.account_id`; `cdx doctor` reports `codex_shared_account_id` as a workspace-level clue, not proof that two profiles use the same user. Newly created sessions seed from the current global `~/.codex/auth.json` when one exists. For two separate Codex accounts, create or repair each session by running `cdx login <name>` for that session; `cdx login` does not log out first, so use `cdx logout <name>` explicitly only when you want to clear that isolated profile.
236
236
  - **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
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.24.1"
7
+ version = "0.24.3"
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
@@ -295,7 +295,8 @@ def _print_help(use_color=False):
295
295
  _style("Notifications:", "1", use_color),
296
296
  f" {_style('cdx set <name> --notify on', '36', use_color)} # agent finishes or needs attention",
297
297
  f" {_style('cdx ready', '36', use_color)} # next session to become ready",
298
- f" {_style('cdx <name> [--json]', '36', use_color)}",
298
+ f" {_style('cdx <name> [--on-busy fail|ask] [--json]', '36', use_color)}",
299
+ " --on-busy: fail immediately if already running; ask (default) prompts only with a terminal and without --json",
299
300
  f" {_style('cdx <name> --prompt TEXT|--prompt-file PATH', '36', use_color)} # start with a first message",
300
301
  f" {_style('cdx <name> --dir PATH --task TEXT --end-state TEXT', '36', use_color)} # start on a task, with what the go-ahead covers",
301
302
  f" {_style('cdx --help', '36', use_color)}",
@@ -637,7 +638,7 @@ def main(argv, options=None):
637
638
  return handler(rest, ctx)
638
639
 
639
640
  from .commands.launch import _parse_launch_args, _read_launch_prompt
640
- directory, launch_args, prompt = _parse_launch_args(rest)
641
+ directory, launch_args, prompt, on_busy = _parse_launch_args(rest)
641
642
  if all(arg in ("--json", "-r", "--resume") for arg in launch_args):
642
643
  resume = "-r" in launch_args or "--resume" in launch_args
643
644
  has_prompt = prompt["prompt"] is not None or prompt["prompt_file"] is not None
@@ -647,7 +648,7 @@ def main(argv, options=None):
647
648
  if has_prompt and (ctx["service"]["get_session"](command) or {}).get("provider") == PROVIDER_OLLAMA:
648
649
  raise CdxError("cdx <name>: --prompt and --prompt-file are not supported for Ollama sessions.")
649
650
  return handle_launch(command, ctx, initial_prompt=_read_launch_prompt(prompt), resume=resume,
650
- directory=directory)
651
+ directory=directory, on_busy=on_busy)
651
652
 
652
653
  raise CdxError(f"Unknown command: {command}. Use cdx --help.")
653
654
 
package/src/cli_args.py CHANGED
@@ -42,7 +42,7 @@ HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|t
42
42
  STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]"
43
43
  STATS_GROUPS = ("session", "project", "model")
44
44
  LAST_USAGE = "Usage: cdx last [--json]"
45
- RESUME_USAGE = "Usage: cdx resume <name> [--conversation <id>] [--json]"
45
+ RESUME_USAGE = "Usage: cdx resume <name> [--conversation <id>] [--on-busy fail|ask] [--json]"
46
46
  CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--conversation <id>] [--json]"
47
47
  SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
48
48
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
@@ -386,22 +386,22 @@ def _resume_capability_for_session(session, ctx):
386
386
  "command_preview": capability.get("command_preview") or [],
387
387
  }
388
388
 
389
- def _warn_if_session_already_running(name, ctx):
390
- # ponytail: concurrent sessions on one profile share its single OAuth
391
- # .credentials.json; both refresh near token expiry and the rotated refresh
392
- # token invalidates the other session -> surprise logout. Warn before adding
393
- # a second live session on the same profile.
389
+ def _warn_if_session_already_running(name, ctx, *, on_busy="ask", json_flag=False):
394
390
  active = ctx["service"].get("active_session_runtime")
395
391
  runtime = active(name) if active else None
396
392
  if not runtime:
397
393
  return
394
+ if on_busy == "fail" or not ctx["stdin_is_tty"] or json_flag:
395
+ raise CdxError(f"session_busy: Session {name} is already running.", code="session_busy")
398
396
  pid = runtime.get("pid")
399
397
  ctx["out"](f"{_warn(f'A session named {name} is already running (pid {pid}).', ctx['use_color'])}\n")
400
398
  ctx["out"](
401
399
  f"{_dim('Two sessions on one profile share its login and can log each other out. Use a different profile instead.', ctx['use_color'])}\n"
402
400
  )
403
- if not ctx["stdin_is_tty"]:
404
- return
405
- answer = input(f"Launch a second {name} session anyway? [y/N] ")
401
+ ask = ctx["options"].get("input") or input
402
+ try:
403
+ answer = ask(f"Launch a second {name} session anyway? [y/N] ")
404
+ except EOFError:
405
+ raise CdxError(f"session_busy: Session {name} is already running.", code="session_busy") from None
406
406
  if answer.strip().lower() not in ("y", "yes"):
407
407
  raise CdxError("Launch cancelled.")
@@ -36,7 +36,7 @@ from ..provider_runtime import (
36
36
  from ..run_command import PROMPT_STDIN, read_run_prompt
37
37
  from ..run_usage import estimate_cost
38
38
 
39
- LAUNCH_USAGE = "Usage: cdx <name> [--dir PATH] [--prompt TEXT|--prompt-file PATH|--task TEXT [--end-state TEXT]] [--resume] [--json]"
39
+ LAUNCH_USAGE = "Usage: cdx <name> [--dir PATH] [--prompt TEXT|--prompt-file PATH|--task TEXT [--end-state TEXT]] [--resume] [--on-busy fail|ask] [--json]"
40
40
 
41
41
  #: The first message of a session started with --task. The end state is what the
42
42
  #: user's go-ahead covers: a bare "show me first" made sessions stop after a commit
@@ -52,8 +52,8 @@ NO_END_STATE = """No end state was given: stop and show the result before any co
52
52
 
53
53
 
54
54
  def _parse_launch_args(args):
55
- """Return the per-launch directory, initial prompt source and ordinary launch flags."""
56
- values = {"--dir": None, "--prompt": None, "--prompt-file": None, "--task": None, "--end-state": None}
55
+ """Return the directory, remaining flags, prompt source and busy policy."""
56
+ values = {"--dir": None, "--prompt": None, "--prompt-file": None, "--task": None, "--end-state": None, "--on-busy": None}
57
57
  cleaned = []
58
58
  index = 0
59
59
  while index < len(args):
@@ -72,6 +72,9 @@ def _parse_launch_args(args):
72
72
  if values[flag] is not None or not value:
73
73
  raise CdxError(LAUNCH_USAGE)
74
74
  values[flag] = value
75
+ on_busy = values["--on-busy"] or "ask"
76
+ if on_busy not in ("fail", "ask"):
77
+ raise CdxError("cdx <name>: --on-busy must be fail or ask.")
75
78
  if values["--prompt"] is not None and values["--prompt-file"] is not None:
76
79
  raise CdxError("cdx <name>: --prompt and --prompt-file are mutually exclusive.")
77
80
  if values["--end-state"] is not None and values["--task"] is None:
@@ -84,7 +87,7 @@ def _parse_launch_args(args):
84
87
  if values["--prompt-file"] == PROMPT_STDIN:
85
88
  raise CdxError("cdx <name>: --prompt-file - is not supported; the interactive session needs standard input.")
86
89
  prompt = {"prompt": values["--prompt"], "prompt_file": values["--prompt-file"]}
87
- return values["--dir"], cleaned, prompt
90
+ return values["--dir"], cleaned, prompt, on_busy
88
91
 
89
92
 
90
93
  def _read_launch_prompt(prompt):
@@ -204,14 +207,14 @@ def handle_can_resume(rest, ctx):
204
207
 
205
208
  def handle_resume(rest, ctx):
206
209
  conversation_id, rest = _pop_value_flag(rest, "--conversation", RESUME_USAGE)
207
- directory, rest, prompt = _parse_launch_args(rest)
210
+ directory, rest, prompt, on_busy = _parse_launch_args(rest)
208
211
  if prompt["prompt"] is not None or prompt["prompt_file"] is not None:
209
212
  raise CdxError("cdx resume: --prompt and --prompt-file are not supported when resuming.")
210
213
  json_flag, args = _parse_json_flag(rest)
211
214
  if len(args) != 1:
212
215
  raise CdxError(RESUME_USAGE)
213
216
  return handle_launch(args[0], ctx, resume=True, force_json=json_flag, directory=directory,
214
- conversation_id=conversation_id)
217
+ conversation_id=conversation_id, on_busy=on_busy)
215
218
 
216
219
 
217
220
  def _notification_provisioning_enabled(session, ctx, json_flag):
@@ -255,14 +258,14 @@ def _link_shared_memory(session, ctx, cwd, json_flag):
255
258
 
256
259
 
257
260
  def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None,
258
- conversation_id=None):
261
+ conversation_id=None, on_busy="ask"):
259
262
  json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
260
263
  if force_json is not None:
261
264
  json_flag = force_json
262
265
  warnings = _update_notice_warnings(ctx)
263
266
  active_runtime = ctx["service"].get("active_session_runtime")
264
267
  overlap_at_start = bool(active_runtime and active_runtime(command))
265
- _warn_if_session_already_running(command, ctx)
268
+ _warn_if_session_already_running(command, ctx, on_busy=on_busy, json_flag=json_flag)
266
269
  session = ctx["service"]["get_session"](command)
267
270
  if not session:
268
271
  raise CdxError(f"Unknown session: {command}")
@@ -455,16 +458,16 @@ def _attach_interactive_usage(session, run_info, history=None, baseline=None, un
455
458
  # way as an id match would let a wrong attribution read as a
456
459
  # measurement, which is the failure this replaced.
457
460
  run_info["provider_transcript_match"] = match
458
- if not cumulative:
459
- return run_info
460
-
461
- run_info["usage_cumulative"] = cumulative
462
461
  if model:
463
462
  # Which model actually served the run, read from the transcript rather
464
463
  # than inferred from the session's configured launch settings -- where
465
464
  # `model` is optional and usually unset, because the session takes the
466
465
  # provider's default.
467
466
  run_info["usage_model"] = model
467
+ if not cumulative:
468
+ return run_info
469
+
470
+ run_info["usage_cumulative"] = cumulative
468
471
  if uncertain_reason:
469
472
  run_info["usage_attribution"] = uncertain_reason
470
473
  return run_info
@@ -595,4 +598,4 @@ def handle_handoff(rest, ctx):
595
598
  ))
596
599
  return 0
597
600
  ctx["out"](f"Handoff ready ({entry['mode']}): {path}\n")
598
- return handle_launch(target['name'], ctx, initial_prompt=prompt, directory=workspace)
601
+ return handle_launch(target['name'], ctx, initial_prompt=prompt, directory=workspace, on_busy="ask")
@@ -687,6 +687,7 @@ def handle_run(rest, ctx):
687
687
  )
688
688
  usage, usage_model = extract_run_observation(
689
689
  run_session.get("provider"), run_info.get("stdout_path"),
690
+ run_session.get("authHome"),
690
691
  )
691
692
  run_info = {**run_info, "usage": usage, "usage_model": usage_model}
692
693
  ctx["service"]["record_launch_history"](run_session["name"], {
@@ -807,6 +808,7 @@ def handle_run(rest, ctx):
807
808
  if run_info and run_id:
808
809
  usage, usage_model = extract_run_observation(
809
810
  run_session.get("provider"), run_info.get("stdout_path"),
811
+ run_session.get("authHome"),
810
812
  )
811
813
  run_info = {**run_info, "usage": usage, "usage_model": usage_model}
812
814
  ctx["service"]["record_launch_history"](run_session["name"], {
@@ -491,7 +491,8 @@ def _stats_group(entry, by):
491
491
  """The project (launch folder) or the model a run is counted under."""
492
492
  if by == "project":
493
493
  return str(entry.get("cwd") or "").rstrip("/\\") or "-"
494
- return entry.get("usage_model") or "-"
494
+ model = entry.get("usage_model") or "-"
495
+ return model.removesuffix("[1m]") if model.startswith("claude-") else model
495
496
 
496
497
 
497
498
  def _project_labels(paths):
@@ -693,7 +694,7 @@ def handle_last(rest, ctx):
693
694
  if not json_flag:
694
695
  message = f"Launching last session: {session['name']}"
695
696
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
696
- return handle_launch(session["name"], ctx)
697
+ return handle_launch(session["name"], ctx, on_busy="ask")
697
698
 
698
699
  def handle_status(rest, ctx):
699
700
  parsed = _parse_flag_args(rest, {
@@ -26,6 +26,7 @@ from .config import (
26
26
  split_extra_args,
27
27
  )
28
28
  from .errors import CdxError
29
+ from .run_usage import _parse_json_records
29
30
 
30
31
  TERMINAL_TITLE_SEPARATOR = " — "
31
32
  # Providers redraw their own title while their TUI runs, so a single write at
@@ -1184,6 +1185,22 @@ def _terminate_child_tree(child):
1184
1185
  pass
1185
1186
 
1186
1187
 
1188
+ def _read_permission_denials(stdout_path):
1189
+ denials = []
1190
+ try:
1191
+ with open(stdout_path, encoding="utf-8", errors="replace") as handle:
1192
+ records = _parse_json_records(handle.read())
1193
+ except OSError:
1194
+ return denials
1195
+ for record in records:
1196
+ if not isinstance(record, dict) or record.get("type") != "result":
1197
+ continue
1198
+ reported = record.get("permission_denials")
1199
+ if isinstance(reported, list):
1200
+ denials.extend(item for item in reported if isinstance(item, dict))
1201
+ return denials
1202
+
1203
+
1187
1204
  def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
1188
1205
  timeout_seconds=None, spawn=None, run_id=None):
1189
1206
  spawn = spawn or subprocess.Popen
@@ -1277,6 +1294,10 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1277
1294
  "pid": getattr(child, "pid", None),
1278
1295
  "returncode": returncode,
1279
1296
  "timed_out": timed_out,
1297
+ "permission_denials": (
1298
+ _read_permission_denials(paths["stdout_path"])
1299
+ if session.get("provider") == PROVIDER_CLAUDE else []
1300
+ ),
1280
1301
  }
1281
1302
 
1282
1303
 
@@ -4,7 +4,7 @@ import sys
4
4
  from .cli_args import ARG_CODE_INVALID_VALUE
5
5
  from .errors import CdxArgumentError, CdxError
6
6
  from .provider_runtime import headless_permission_disables_network
7
- from .run_usage import empty_usage, extract_run_usage
7
+ from .run_usage import empty_usage, extract_run_observation
8
8
 
9
9
  PROMPT_STDIN = "-"
10
10
 
@@ -182,16 +182,27 @@ def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
182
182
  def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
183
183
  error=None, error_source=None, error_code=None, selection_row=None):
184
184
  run_info = run_info or {}
185
- usage = run_info.get("usage") if isinstance(run_info.get("usage"), dict) else (
186
- extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
187
- if session else
188
- empty_usage()
189
- )
185
+ usage, usage_model = run_info.get("usage"), run_info.get("usage_model")
186
+ if not isinstance(usage, dict):
187
+ usage, usage_model = extract_run_observation(
188
+ session.get("provider"), run_info.get("stdout_path"), session.get("authHome"),
189
+ ) if session else (empty_usage(), None)
190
190
  message = None if ok else (
191
191
  _provider_failure_message(error, run_info, error_code)
192
192
  if error_source == "provider"
193
193
  else (str(error) if error else "Run failed.")
194
194
  )
195
+ warnings = run_warnings(parsed, session, selection_row)
196
+ if run_info.get("permission_denials"):
197
+ warnings.append({
198
+ "code": "permission_denials",
199
+ "message": (
200
+ "The provider reported denied tool calls. A denied tool may have been "
201
+ "replaced successfully; check the denials and transcript for impact."
202
+ ),
203
+ "provider": session.get("provider") if session else parsed.get("provider"),
204
+ "permission_denials": run_info["permission_denials"],
205
+ })
195
206
  return {
196
207
  "schema_version": api_schema_version,
197
208
  "ok": bool(ok),
@@ -210,7 +221,8 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
210
221
  "stdout_path": run_info.get("stdout_path"),
211
222
  "stderr_path": run_info.get("stderr_path"),
212
223
  "usage": usage,
213
- "warnings": run_warnings(parsed, session, selection_row),
224
+ "usage_model": usage_model,
225
+ "warnings": warnings,
214
226
  "error": None if ok else {
215
227
  "source": error_source or "cdx",
216
228
  "code": error_code or "cdx_error",
@@ -358,6 +358,7 @@ class RunRegistry:
358
358
  # what `cdx run-status` and `cdx run-report` publish, and it
359
359
  # used to store whichever shape the producing reader emitted.
360
360
  run["usage"] = coerce_usage(final_payload.get("usage"))
361
+ run["usage_model"] = final_payload.get("usage_model")
361
362
  run["final_payload"] = final_payload
362
363
  if error:
363
364
  run["error"] = error
package/src/run_usage.py CHANGED
@@ -413,7 +413,7 @@ def extract_run_usage(provider, stdout_path):
413
413
  return extract_run_observation(provider, stdout_path)[0]
414
414
 
415
415
 
416
- def extract_run_observation(provider, stdout_path):
416
+ def extract_run_observation(provider, stdout_path, auth_home=None):
417
417
  if not stdout_path or not provider:
418
418
  return empty_usage(), None
419
419
  if provider not in SUPPORTED_PROVIDERS:
@@ -444,9 +444,46 @@ def extract_run_observation(provider, stdout_path):
444
444
  model_usage = record.get("modelUsage")
445
445
  if isinstance(model_usage, dict):
446
446
  models.update(model_usage)
447
+ if provider == "codex" and auth_home:
448
+ models.update(_codex_run_models(records, auth_home))
447
449
  return usage, next(iter(models)) if len(models) == 1 else None
448
450
 
449
451
 
452
+ def _codex_run_models(records, auth_home):
453
+ # Exec's token totals belong to this run; a rollout's totals may include
454
+ # earlier turns. Read only model evidence from the exact thread's rollout.
455
+ from .interactive_usage import MAX_TRANSCRIPT_BYTES, conversation_transcript
456
+
457
+ thread_ids = {record.get("thread_id") for record in records
458
+ if isinstance(record, dict) and record.get("type") == "thread.started"
459
+ and isinstance(record.get("thread_id"), str) and record["thread_id"]}
460
+ if len(thread_ids) != 1:
461
+ return set()
462
+ path = conversation_transcript("codex", auth_home, next(iter(thread_ids)))
463
+ if not path:
464
+ return set()
465
+ models = set()
466
+ try:
467
+ with open(path, encoding="utf-8", errors="replace") as handle:
468
+ text = handle.read(MAX_TRANSCRIPT_BYTES + 1)
469
+ if len(text) > MAX_TRANSCRIPT_BYTES:
470
+ return set()
471
+ for line in text.splitlines():
472
+ try:
473
+ record = json.loads(line)
474
+ except ValueError:
475
+ continue
476
+ if not isinstance(record, dict) or record.get("type") != "turn_context":
477
+ continue
478
+ payload = record.get("payload")
479
+ model = payload.get("model") if isinstance(payload, dict) else None
480
+ if isinstance(model, str) and model:
481
+ models.add(model)
482
+ except OSError:
483
+ return set()
484
+ return models
485
+
486
+
450
487
  def _parse_json_records(text):
451
488
  stripped = text.strip()
452
489
  try: