cdx-manager 0.24.0 → 0.24.2

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.0-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.2-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.0
1
+ 0.24.2
@@ -0,0 +1,7 @@
1
+ # CDX Manager 0.24.1
2
+
3
+ This patch makes resumed sessions report their state like launched ones.
4
+
5
+ ## Sessions
6
+
7
+ - `cdx resume <name>` (and `cdx <name> --resume`) now gives the provider the same agent and notify environment as a launch: a resumed Claude session sets `CDX_SESSION_NAME` for its hooks and appears in `cdx agents`, and it also gets the shared tool cache and scrub-env settings.
@@ -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)
@@ -573,6 +573,28 @@
573
573
  "x86_64-pc-windows-msvc": "bf35f8d25bbc34730533807df0d9c0e3f7ea2a271f268856759ae87bc9e116fe",
574
574
  "x86_64-unknown-linux-musl": "60400fd7d10b0563503afc5bd741f7fefff4276d077fc6e4927f087390d46ccd"
575
575
  }
576
+ },
577
+ "v0.24.0": {
578
+ "github_tarball_sha256": "6206df477d34ad2360144bbf57d3aedf7bf88891502cf6dcee96c77e131e4a97",
579
+ "github_zip_sha256": "db4be4051a18bf0f857cbc31615972eff592f60a38c03c161707c02268302e1f",
580
+ "tray_assets": {
581
+ "aarch64-apple-darwin": "208a7c36641d17b221250b0791e956dad769477d694dd68a153f3046460d562a",
582
+ "aarch64-unknown-linux-musl": "704ccea4fc8426a8f84107e44de83d00344b22c5665a4f04a73c6d7f0ed2accd",
583
+ "x86_64-apple-darwin": "8745cc6572dbdfba68ca181fb631ced91d9bb1bc1cee9bb0e67026c88b7dc5cc",
584
+ "x86_64-pc-windows-msvc": "90772ff35a25db86ebfbe4187c39f0705001a9f10b6a7fe0c04a431b13ccbcb8",
585
+ "x86_64-unknown-linux-musl": "f7be523211fc4dba33724c2759f267f35669b8aff14136fc7ec97d398ebb1f87"
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
+ }
576
598
  }
577
599
  }
578
600
  }
@@ -0,0 +1,13 @@
1
+ # Agent notes
2
+
3
+ Pitfalls and operator rules for any agent working in this repo (tracked here because AGENTS.md is local and ignored).
4
+
5
+ ## Operator machine and test hosts (moved here from agent memory, 2026-10-28)
6
+ - Never run `pip` against cdx-manager on the operator's machines (install, update, uninstall, even a throwaway venv to test a published artifact): the operator manages the install. The README's `pip install -e` is for a dev checkout only. To verify a release, ask how it should be checked.
7
+ - Cross-platform checks run on Tower (`aagos@kdesktop`, Windows) and its WSL guest (`wsl.exe -d Ubuntu -u aagos -- ...`); an agent runs the scriptable part itself and only asks the operator to look at what appears on screen (notifications, windows). On Windows Tower the full pytest stops around 21% in `test_commands_runs_py` (`cdx run --detach` tests spawning real children): an environment quirk, not a regression; run single files or `-k` subsets synchronously and read GitHub CI for whole-suite Windows evidence. In cmd.exe `&` is a separator, Python buffers redirected stdout (use `-u`), and a killed SSH session leaves a truncated output that reads like a hang.
8
+ - In Tower's WSL the `codex` on PATH was the Windows npm install through interop (Windows-only native package); a Linux build lives at `~/.npm-global/bin/codex` (`npm i -g --prefix ~/.npm-global @openai/codex`): export that path first.
9
+
10
+ ## Releases and Tower installs (moved here from Codex memory, 2026-10-28)
11
+ - A pushed `main` or a source-checkout deploy is not a release. Call a release ready only once the CI and CodeQL runs for the exact pushed SHA are green, the tag's assets and `release-archives.json` checksums are published, npm and PyPI both serve the version, and `logics-manager release validate <version>` passes. Delete a stale `dist/` before building: old artifacts make `twine check` pass on the wrong files.
12
+ - Python tests live in `test/` (not `tests/`).
13
+ - On Tower, `make install` can fail with `EACCES` on `/usr/local/lib/node_modules`: install the tagged release with the checksum-verified user-scoped `install.sh` instead. On a source-linked install, `cdx update --yes --version <tag>` only switches the checkout. Tower's daily `auto-update` job (`cdx update all --yes --json`) can finish without every provider converging: check the installed versions afterwards.
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 |
@@ -422,3 +423,5 @@ It returns accepted values for `permission` (with its aliases and canonical form
422
423
  ```
423
424
 
424
425
  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.
426
+
427
+ 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.0",
3
+ "version": "0.24.2",
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.0"
7
+ version = "0.24.2"
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}")
@@ -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")
@@ -693,7 +693,7 @@ def handle_last(rest, ctx):
693
693
  if not json_flag:
694
694
  message = f"Launching last session: {session['name']}"
695
695
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
696
- return handle_launch(session["name"], ctx)
696
+ return handle_launch(session["name"], ctx, on_busy="ask")
697
697
 
698
698
  def handle_status(rest, ctx):
699
699
  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
@@ -907,7 +908,13 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
907
908
  cwd = cwd or os.getcwd()
908
909
  env_override = env_override or {}
909
910
  env = {**os.environ, **env_override}
911
+ for key, value in _shared_tool_cache_overrides(env).items():
912
+ env.setdefault(key, value)
910
913
  _shared_rtk_history(env)
914
+ # Same as a launch: without CDX_SESSION_NAME the hooks of a resumed session
915
+ # cannot say whose agent state they record, so `cdx agents` never lists it.
916
+ env.update(launch_notify_env(session, notifications_enabled(session) or agent_state_enabled(session)))
917
+ env.update(_scrub_env(session))
911
918
  capability = get_resume_capability(session, cwd=cwd)
912
919
  if not capability["resumable"]:
913
920
  raise CdxError(f"Provider {session.get('provider')} does not support native resume through cdx.")
@@ -1178,6 +1185,22 @@ def _terminate_child_tree(child):
1178
1185
  pass
1179
1186
 
1180
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
+
1181
1204
  def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
1182
1205
  timeout_seconds=None, spawn=None, run_id=None):
1183
1206
  spawn = spawn or subprocess.Popen
@@ -1271,6 +1294,10 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1271
1294
  "pid": getattr(child, "pid", None),
1272
1295
  "returncode": returncode,
1273
1296
  "timed_out": timed_out,
1297
+ "permission_denials": (
1298
+ _read_permission_denials(paths["stdout_path"])
1299
+ if session.get("provider") == PROVIDER_CLAUDE else []
1300
+ ),
1274
1301
  }
1275
1302
 
1276
1303
 
@@ -192,6 +192,17 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
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,7 @@ 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
+ "warnings": warnings,
214
225
  "error": None if ok else {
215
226
  "source": error_source or "cdx",
216
227
  "code": error_code or "cdx_error",