cdx-manager 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.23.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.0-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
 
@@ -38,6 +38,7 @@ cdx work
38
38
  | Continue work in another session | `cdx handoff source target` | [Context handoff](docs/features.md#context-handoff) |
39
39
  | Run a prompt from a script | `cdx run --prompt "..." --json` | [Headless automation](docs/features.md#headless-automation) |
40
40
  | Inspect usage or get an alert | `cdx stats`, `cdx ready` | [Maintenance and notifications](docs/sessions-and-notifications.md) |
41
+ | See which agents wait for you | `cdx agents --json` | [Agent state](docs/sessions-and-notifications.md#agent-state) |
41
42
 
42
43
  CDX checks authentication before interactive launches. Handoff keeps the source transcript in place and asks the receiving agent to verify the conversation and workspace before acting. No account switch or prompt submission happens just because you run `cdx next`.
43
44
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.23.0
1
+ 0.24.0
@@ -0,0 +1,16 @@
1
+ # CDX Manager 0.24.0
2
+
3
+ This release lets a dashboard, a terminal board or a chat agent ask cdx which sessions are working, waiting for their user, done or stopped by a usage limit, and lets detached background runs fail over to another account.
4
+
5
+ ## Agent state
6
+
7
+ - `cdx set <name> --agent-state on` installs the provider hooks that record each session's state, without desktop alerts; sessions with `--notify on` record it too.
8
+ - Every hook event updates one small record per session under `$CDX_HOME/state/agents/`, written atomically, even when alerts are muted.
9
+ - `cdx agents [--max-age-hours N] [--json]` lists them, waiting first: `working`, `waiting`, `done`, `failed`, `limit` (with `reset_at` when known) or `ended` when the launching process is gone. Records older than N hours (24 by default) are dropped.
10
+ - A `limit` state needs the same two signals as run failover: the provider's own exhaustion error and a refreshed account status that confirms it.
11
+ - The prompt head (120 characters) is kept only with `--notify-preview on`.
12
+ - Codex has no failure hook, so a Codex session never reaches `failed` or `limit`. Codex plugins installed before this release keep their old hooks until reinstalled (`--notify off`, one launch, `--notify on`).
13
+
14
+ ## Runs
15
+
16
+ - `cdx run --detach --failover` is now accepted: the detached child watches the run and applies the failover rule, and the run registry records each move under the same `run_id`.
@@ -562,6 +562,17 @@
562
562
  "x86_64-pc-windows-msvc": "37ddbc3464014a04a4fefee4cebeb24f16b3d06995844369bee8360526538d43",
563
563
  "x86_64-unknown-linux-musl": "6602e175632b7a191434b627bfef1e430d32da90ce0e23379bdf38700ea9ba7c"
564
564
  }
565
+ },
566
+ "v0.23.0": {
567
+ "github_tarball_sha256": "45daac760229b4bc5355628f1f9e5f46c6e61eecc5a320a2ecc31fec2f261f85",
568
+ "github_zip_sha256": "d5f1750548fe8445066fd0328f8c76a8035278537c29d1b39756fbdef8212915",
569
+ "tray_assets": {
570
+ "aarch64-apple-darwin": "98a68a5e98a1708475164fd252eb4e3fdf185cf2c62486f92a625d42c18c69ee",
571
+ "aarch64-unknown-linux-musl": "3dfa8764947edcb7da33e72af70cfc0ce3d4007c9ce0a1fcd4884848af0f5dd7",
572
+ "x86_64-apple-darwin": "00444fd894998db84ad6ab608f528061f6a9f0b50648ecfe61a91c6efdc4e969",
573
+ "x86_64-pc-windows-msvc": "bf35f8d25bbc34730533807df0d9c0e3f7ea2a271f268856759ae87bc9e116fe",
574
+ "x86_64-unknown-linux-musl": "60400fd7d10b0563503afc5bd741f7fefff4276d077fc6e4927f087390d46ccd"
575
+ }
565
576
  }
566
577
  }
567
578
  }
package/docs/cli.md CHANGED
@@ -24,8 +24,8 @@
24
24
  | `cdx config <name> [--json]` | Show persistent launch settings for a session |
25
25
  | `cdx configs [--json]` | Show persistent launch settings for all sessions in one table |
26
26
  | `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
27
- | `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] [--notify on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--shared-memory on\|off] [--retro on\|off] [--policy use\|ask-first\|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
28
- | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--notify-preview\|--scrub-env\|--shared-memory\|--retro\|--policy\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
27
+ | `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] [--notify on\|off] [--agent-state on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--shared-memory on\|off] [--retro on\|off] [--policy use\|ask-first\|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
28
+ | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--agent-state\|--notify-preview\|--scrub-env\|--shared-memory\|--retro\|--policy\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
29
29
  | `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
30
30
  | `cdx last [--json]` | Launch the most recent existing session from launch history |
31
31
  | `cdx resume <name> [--conversation <id>] [--json]` | Resume the provider-native conversation for a session using the named command form; `--conversation` reopens that exact conversation and refuses with `conversation_not_found` if the profile has no such transcript |
@@ -50,10 +50,11 @@
50
50
  | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
51
51
  | `cdx update all [--yes] [--json]` | Check installed providers, RTK, and Ponytail across Codex profiles; show the plan and ask once before applying safe native/Homebrew updates and missing RTK/Ponytail setup |
52
52
  | `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
53
- | `cdx notify` | Internal provider hook target for agent alerts; use `cdx set <name> --notify on` to enable it |
53
+ | `cdx notify` | Internal provider hook target for agent alerts and agent state; use `cdx set <name> --notify on` or `--agent-state on` to enable it |
54
+ | `cdx agents [--max-age-hours N] [--json]` | Each session's last known agent state (working, waiting, done, failed, limit, ended), waiting first; records older than N hours (default 24) are dropped. See [Agent state](sessions-and-notifications.md#agent-state) |
54
55
  | `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
55
56
  | `cdx select --provider PROVIDER [--min-reasoning-effort minimal\|low\|medium\|high\|xhigh] [--min-power minimal\|low\|medium\|high\|xhigh] [--require-ready] [--refresh] --json` | Select a suitable session for headless automation |
56
- | `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant\|code-review\|retro] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json` | Run one headless task and return a stable JSON result; `--detach` returns the `run_id` at launch without waiting, `--failover` continues the task on the next account when this one hits its rate limit, `--prompt-file -` reads the prompt from stdin, `--label` names the run in `cdx runs`, `--max-parallel N` (default `CDX_MAX_RUNS`; unset, invalid, 0 or negative means no limit) refuses to start with `too_many_runs` when N runs are already running, `--kind` (default `assistant`) labels the run record and `code-review` adds a review report to its final payload |
57
+ | `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant\|code-review\|retro] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json` | Run one headless task and return a stable JSON result; `--detach` returns the `run_id` at launch without waiting, `--failover` continues the task on the next account when this one hits its rate limit (with `--detach` too: the detached cdx child applies it and the run's `occupancies` record each move), `--prompt-file -` reads the prompt from stdin, `--label` names the run in `cdx runs`, `--max-parallel N` (default `CDX_MAX_RUNS`; unset, invalid, 0 or negative means no limit) refuses to start with `too_many_runs` when N runs are already running, `--kind` (default `assistant`) labels the run record and `code-review` adds a review report to its final payload |
57
58
  | `cdx run-report <run_id> --json` | Full report, transcript metadata, and final payload for a run; `final_message` is the run's last assistant message, read from Claude's `result` envelope or Codex's last `agent_message` (null when neither exists) |
58
59
  | `cdx run-status <run_id> --json` | Status of one run by id |
59
60
  | `cdx run-tail <run_id> [--lines N] --json` | Last lines of a run's own output, while it is still running or after it finished |
package/docs/features.md CHANGED
@@ -29,7 +29,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
29
29
  ## Headless automation
30
30
 
31
31
  - **One task, one JSON result.** `cdx run` executes a prompt against a chosen or auto-selected session and returns a stable payload; `--detach` returns a `run_id` immediately.
32
- - **Survives a rate limit.** `cdx run --failover` notices that a run stopped because its account ran out, moves the task to the next account the ranking allows, and carries on — one `run_id` for the whole task, with every account it occupied listed in `cdx run-report`. A spent account is always confirmed against its own refreshed quota first, so a healthy account is never abandoned. From there two routes migrate a run: the provider said so in words cdx recognises, or — since that wording differs between an enterprise workspace out of credits, a personal plan hitting its 5-hour window, and Claude's own phrasing — simply that the account's quota is gone and the run failed. `cdx run-report` names which route applied. Exhausting every account reports `failover_exhausted`, which a caller can tell apart from the task itself failing.
32
+ - **Survives a rate limit.** `cdx run --failover` notices that a run stopped because its account ran out, moves the task to the next account the ranking allows, and carries on — one `run_id` for the whole task, with every account it occupied listed in `cdx run-report`. A spent account is always confirmed against its own refreshed quota first, so a healthy account is never abandoned. From there two routes migrate a run: the provider said so in words cdx recognises, or — since that wording differs between an enterprise workspace out of credits, a personal plan hitting its 5-hour window, and Claude's own phrasing — simply that the account's quota is gone and the run failed. `cdx run-report` names which route applied. Exhausting every account reports `failover_exhausted`, which a caller can tell apart from the task itself failing. It combines with `--detach`: the detached cdx process supervises the run, applies the same rule, and records each move in the run registry; such a run always uses cdx's own detached child rather than the provider's background agent, which nothing would be watching.
33
33
  - **Native background delegation (opt-in).** With `CDX_EXPERIMENTAL_NATIVE_BG=1`, `cdx run --detach` hands the run to Claude Code's own background agents (`--bg`) instead of cdx's detached launcher. The run keeps its cdx `run_id`, closes out with its token usage and final answer read from the provider's own session transcript, and the parked session is stopped once its task is done. Off by default for one release while it settles; the in-house launcher is unchanged and is what every run takes otherwise. Codex keeps the in-house path while its `cloud`/`exec-server` surfaces are marked experimental.
34
34
  - **Observable runs.** `cdx run-status`, `cdx run-tail`, `cdx run-report`, and `cdx runs --since` follow a run while it happens and after it ends.
35
35
  - **Selection without launching.** `cdx select --provider ... --require-ready --json` answers "which session should this job use?" for orchestrators.
@@ -38,8 +38,9 @@ visible on a lock screen; disable it per session with
38
38
  `cdx set <name> --notify-preview off`. Previews never read transcripts, and the
39
39
  command a permission request is about is never included.
40
40
 
41
- Both providers are subscribed to the same two hooks: `Stop` when a turn ends and
42
- `PermissionRequest` when one blocks on your approval. Claude Code is also
41
+ Both providers are subscribed to the same two alerting hooks: `Stop` when a turn
42
+ ends and `PermissionRequest` when one blocks on your approval, plus
43
+ `UserPromptSubmit`, which raises no alert and only feeds the agent state below. Claude Code is also
43
44
  subscribed to `Notification`, for the "still waiting for you" states that no
44
45
  other hook reports; the permission notifications it also sends are dropped,
45
46
  because `PermissionRequest` already reported the same call immediately and by
@@ -71,6 +72,48 @@ disabled — cdx installs no hooks at all rather than asking you to approve
71
72
  something that could show you nothing. Headless `cdx run` never notifies: its
72
73
  caller already learns of completion from the return value.
73
74
 
75
+ ## Agent state
76
+
77
+ Every hook event `cdx notify` receives also rewrites one small record for that
78
+ session under `$CDX_HOME/state/agents/<session>.json`, whether alerts are on,
79
+ muted or off. `cdx agents` lists them, so a dashboard, a chat agent or a
80
+ terminal board can ask which sessions are working, waiting for you, done,
81
+ failed or stopped by a usage limit without reading transcripts or terminals:
82
+
83
+ ```bash
84
+ cdx set work1 --agent-state on # hooks for state only, no desktop alerts
85
+ cdx agents # table, waiting sessions first
86
+ cdx agents --json # the same for scripts
87
+ cdx agents --max-age-hours 4 # drop records older than 4 hours (default 24)
88
+ ```
89
+
90
+ Sessions with `--notify on` keep state already; `--agent-state on` is for
91
+ sessions that want state without banners. It installs the same hooks, and it
92
+ installs them even where no notification could be shown (an SSH login, a
93
+ headless WSL), since a remote dashboard is exactly what watches those.
94
+
95
+ | State | Set by |
96
+ | --- | --- |
97
+ | `working` | a prompt was submitted (`UserPromptSubmit`) |
98
+ | `waiting` | a permission request, or Claude's idle or needs-input notification |
99
+ | `done` | the turn ended (`Stop`) |
100
+ | `failed` | the turn ended on a provider error (Claude's `StopFailure`); `reason` is the error class |
101
+ | `limit` | that error says the account ran out and the account's own refreshed status agrees, the same two-signal rule as `cdx run --failover`; `reset_at` is the account's reset time when known |
102
+ | `ended` | computed by `cdx agents`: the cdx process that launched the session is gone; `last_state` keeps what it was |
103
+
104
+ `cdx agents --json` returns `{"ok": true, "action": "agents", "max_age_hours": 24,
105
+ "agents": [...]}`, each entry carrying `session`, `provider`, `cwd`, `project`,
106
+ `state`, `reason`, `prompt`, `reset_at`, `pid` and `updated_at` (ISO 8601, UTC).
107
+ `prompt` is the head of the last prompt, at most 120 characters, and only for
108
+ sessions with `--notify-preview on`; otherwise it is `null`. Records older than
109
+ the maximum age are deleted when listed.
110
+
111
+ A waiting session stays `waiting` until its next prompt or the end of the turn:
112
+ cdx does not subscribe to every tool call to notice that you answered. A Codex
113
+ profile whose cdx plugin was installed before this release keeps its old hooks
114
+ (no `working` state) until the plugin is reinstalled with `cdx set <name>
115
+ --notify off`, a launch, then `--notify on` and another launch.
116
+
74
117
  Use `cdx ready` when every useful assistant is cooling down and you want your terminal back. cdx picks the next known reset, registers a native OS notification, and exits immediately. If no future reset is known, it exits successfully and explains that no notification was scheduled.
75
118
 
76
119
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
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.23.0"
7
+ version = "0.24.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -15,10 +15,12 @@ import re
15
15
  import shutil
16
16
  import subprocess
17
17
 
18
+ from . import agent_state
18
19
  from .config import get_cdx_home
19
20
  from .fs_utils import atomic_write
20
21
  from .notify import notification_channel, send_desktop_notification
21
22
  from .tray_alerts import alerts_enabled
23
+ from .tray_defaults import alerts_default
22
24
  from .tray_events import publish
23
25
 
24
26
  # Set on the provider process at launch; the hook runs as its child and inherits both.
@@ -121,6 +123,10 @@ def _is_reportable(payload, event):
121
123
  reports exactly — `permission_prompt` duplicates PermissionRequest — or an
122
124
  internal state the user has no action to take on.
123
125
  """
126
+ if event == "userpromptsubmit":
127
+ # Subscribed for the agent state only; a prompt the user just typed is
128
+ # not news to them.
129
+ return False
124
130
  if event != "notification":
125
131
  return True
126
132
  kind = str(payload.get("notification_type") or "").lower()
@@ -291,8 +297,9 @@ def handle_notify(rest, ctx):
291
297
  # call being asked about, so a stray line would allow or deny it. The
292
298
  # notification paths below print nothing; this is the statement of why
293
299
  # they must not start.
300
+ _record_state(ctx, payload, env)
294
301
  composed = compose_notification(payload, env, ctx.get("cwd"))
295
- if composed:
302
+ if composed and _alerts_wanted(ctx, env.get(SESSION_ENV)):
296
303
  # Muted stops the banner, not the record: the event still reaches a
297
304
  # running tray so the menu can show what was missed, and the direct
298
305
  # path stays quiet too — otherwise quitting the tray would un-mute.
@@ -313,6 +320,43 @@ def handle_notify(rest, ctx):
313
320
  return 0
314
321
 
315
322
 
323
+ def _record_state(ctx, payload, env):
324
+ """Keep this event as the session's state, before and apart from any alert.
325
+
326
+ Recorded whether alerts are on, muted or off: a dashboard asking which
327
+ session waits for its user must not go blind because the banner was muted.
328
+ Its own guard, so a failed write never costs the alert either.
329
+ """
330
+ try:
331
+ event = hook_event(payload)
332
+ prompt = ""
333
+ if event == "userpromptsubmit" and env.get(PREVIEW_ENV) == "1":
334
+ prompt = _notification_text(payload.get("prompt"), agent_state._PROMPT_LIMIT)
335
+ directory = payload.get("cwd") or payload.get("workspace") or ctx.get("cwd") or os.getcwd()
336
+ agent_state.record(
337
+ ctx, env.get(SESSION_ENV), payload, event, error_class(payload), directory, env, prompt=prompt,
338
+ )
339
+ except Exception: # noqa: BLE001 - the caller is an agent turn
340
+ pass
341
+
342
+
343
+ def _alerts_wanted(ctx, name):
344
+ """False only for a session whose hooks exist for its state alone.
345
+
346
+ `--agent-state on` installs the same hooks as `--notify on`, so without this
347
+ a session that asked for state only would start raising desktop alerts.
348
+ Anything unreadable keeps the alert: that was the behaviour before state.
349
+ """
350
+ service = ctx.get("service") or {}
351
+ try:
352
+ session = service["get_session"](name)
353
+ except Exception: # noqa: BLE001 - a missing store is not a reason to drop alerts
354
+ return True
355
+ if not agent_state.agent_state_enabled(session):
356
+ return True
357
+ return notifications_enabled(session) or alerts_default(service["base_dir"])
358
+
359
+
316
360
  def launch_notify_env(session, enabled, env=None):
317
361
  """Env the provider is launched with, carrying who we are and privacy settings.
318
362
 
@@ -325,7 +369,7 @@ def launch_notify_env(session, enabled, env=None):
325
369
  running tray that never receives an alert, with nothing reporting an error.
326
370
  """
327
371
  env = os.environ if env is None else env
328
- values = {SESSION_ENV: session["name"], "CDX_HOME": get_cdx_home(env)}
372
+ values = {SESSION_ENV: session["name"], "CDX_HOME": get_cdx_home(env), agent_state.PID_ENV: str(os.getpid())}
329
373
  if enabled and (session.get("launch") or {}).get("notify_preview") is True:
330
374
  values[PREVIEW_ENV] = "1"
331
375
  return values
@@ -343,7 +387,7 @@ def notifications_enabled(session):
343
387
  return launch.get("notify") is True
344
388
 
345
389
 
346
- def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=None):
390
+ def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=None, keep_state=False):
347
391
  """Install or remove cdx's notification hooks in this session's own home.
348
392
 
349
393
  Idempotent, so an existing session picks the hooks up on its next launch and
@@ -356,7 +400,9 @@ def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=
356
400
  anywhere under `CODEX_HOME` is never looked at.
357
401
  """
358
402
  env = env or os.environ
359
- if enabled and not notification_channel(env):
403
+ # `keep_state` needs the hooks whether or not anything can be shown here:
404
+ # a session driven over SSH is exactly the one a remote dashboard watches.
405
+ if enabled and not keep_state and not notification_channel(env):
360
406
  # Nothing could be delivered on this host (an SSH login, say), so
361
407
  # installing a hook would only buy an approval prompt for a feature that
362
408
  # shows nothing. Removing existing hooks is no better: Codex would ask to
@@ -458,7 +504,7 @@ def _write_codex_plugin(auth_home, command, base_dir=None):
458
504
  os.path.join(root, "hooks", "cdx-hooks.json"): {
459
505
  "hooks": {
460
506
  event: [{"hooks": [{"type": "command", "command": f"{command} {HOOK_ARG}"}]}]
461
- for event in ("Stop", "PermissionRequest")
507
+ for event in ("UserPromptSubmit", "Stop", "PermissionRequest")
462
508
  },
463
509
  },
464
510
  }
@@ -509,7 +555,9 @@ def _apply_hooks(path, enabled, command):
509
555
  # StopFailure is Claude-only: Codex documents no equivalent, so a turn its
510
556
  # provider kills stays silent there and the documentation says so rather
511
557
  # than implying a parity that does not exist.
512
- for event in ("Stop", "StopFailure", "Notification", "PermissionRequest"):
558
+ # UserPromptSubmit is there for the agent state only: it is what says a
559
+ # session went back to work, and `cdx notify` raises no alert for it.
560
+ for event in ("UserPromptSubmit", "Stop", "StopFailure", "Notification", "PermissionRequest"):
513
561
  # Rebuilt from whatever is there minus our own entry, so user-authored
514
562
  # hooks on the same event survive both installing and removing.
515
563
  entries = [entry for entry in hooks.get(event, []) if not _is_ours(entry)]
@@ -0,0 +1,197 @@
1
+ """What each cdx session's agent is doing right now, kept from its hook events.
2
+
3
+ `cdx notify` hears every hook event and used to forget it once the alert was
4
+ out. Here the same event also rewrites one small record per session, so a
5
+ dashboard, a chat agent or a terminal board can ask `cdx agents --json` which
6
+ sessions are working, waiting for their user, done, failed or out of quota
7
+ without parsing transcripts or terminals.
8
+
9
+ One file per session rather than one shared file: two sessions finishing in
10
+ the same instant then never race over a single document.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ from datetime import datetime, timedelta, timezone
16
+
17
+ from .errors import CdxError
18
+ from .fs_utils import atomic_write
19
+ from .run_failover import _mentions_exhaustion, status_confirms_exhaustion
20
+ from .session_helpers import _process_is_running
21
+
22
+ AGENTS_USAGE = "Usage: cdx agents [--max-age-hours N] [--json]"
23
+ DEFAULT_MAX_AGE_HOURS = 24
24
+ # Set on the provider at launch: the cdx process that waits on it. The hook is
25
+ # a grandchild at best (Claude Code runs it through a shell), so its own parent
26
+ # is no proof the agent is alive; the launcher is.
27
+ PID_ENV = "CDX_AGENT_PID"
28
+ _PROMPT_LIMIT = 120
29
+ _WAITING_NOTIFICATIONS = {"", "idle_prompt", "agent_needs_input", "permission_prompt"}
30
+
31
+
32
+ def agent_state_enabled(session):
33
+ """Whether this session asked for its hooks to keep state, alerts or not."""
34
+ return ((session or {}).get("launch") or {}).get("agent_state") is True
35
+
36
+
37
+ def state_dir(base_dir):
38
+ return os.path.join(base_dir, "state", "agents")
39
+
40
+
41
+ def classify(payload, event, error_class):
42
+ """(state, reason) for this event, or None when it changes nothing."""
43
+ if event == "userpromptsubmit":
44
+ return "working", None
45
+ if event == "permissionrequest":
46
+ tool = payload.get("tool_name")
47
+ return "waiting", f"permission ({tool})" if isinstance(tool, str) and tool else "permission"
48
+ if event == "notification":
49
+ kind = str(payload.get("notification_type") or "").lower()
50
+ return ("waiting", kind or "notification") if kind in _WAITING_NOTIFICATIONS else None
51
+ if event in ("stop", "agentturncomplete"):
52
+ return "done", None
53
+ if event == "stopfailure":
54
+ return "failed", error_class or "turn failed"
55
+ return None
56
+
57
+
58
+ def confirmed_limit(ctx, name, payload, error_class):
59
+ """The status row proving a usage limit, or None.
60
+
61
+ The run_failover rule, both halves: the provider has to call the failure
62
+ exhaustion in its own words, and the account's own status has to agree. A
63
+ turn that merely failed, or an account whose status cannot be read, stays
64
+ `failed`; marking a healthy session `limit` would send someone to wait out a
65
+ reset that is not coming.
66
+ """
67
+ message = payload.get("error_message")
68
+ if not _mentions_exhaustion([error_class] + ([message] if isinstance(message, str) else [])):
69
+ return None
70
+ # Imported here: commands sit above this module, and the refresh-then-cache
71
+ # read is theirs to own.
72
+ from .commands.runs import _status_row_for
73
+ row = _status_row_for(ctx, name)
74
+ return row if status_confirms_exhaustion(row) else None
75
+
76
+
77
+ def record(ctx, name, payload, event, error_class, cwd, env, prompt=None):
78
+ """Rewrite this session's record for one hook event. Writes nothing to stdout."""
79
+ base_dir = (ctx.get("service") or {}).get("base_dir")
80
+ # The name comes from the environment; it must stay a plain file name.
81
+ if not base_dir or not name or os.path.basename(name) != name or name in (".", ".."):
82
+ return None
83
+ classified = classify(payload, event, error_class)
84
+ if not classified:
85
+ return None
86
+ state, reason = classified
87
+ path = os.path.join(state_dir(base_dir), f"{name}.json")
88
+ current = _read(path) or {}
89
+ if state == "working":
90
+ # Only under the preview opt-in, like any other free text a hook carries.
91
+ current["prompt"] = prompt or None
92
+ current.setdefault("prompt", None)
93
+ reset_at = None
94
+ if state == "failed":
95
+ row = confirmed_limit(ctx, name, payload, error_class)
96
+ if row is not None:
97
+ state, reset_at = "limit", row.get("reset_at")
98
+ provider = current.get("provider")
99
+ get_session = (ctx.get("service") or {}).get("get_session")
100
+ if get_session:
101
+ provider = (get_session(name) or {}).get("provider") or provider
102
+ pid = env.get(PID_ENV)
103
+ current.update({
104
+ "session": name,
105
+ "provider": provider,
106
+ "cwd": str(cwd),
107
+ "project": os.path.basename(str(cwd).rstrip("/\\")) or str(cwd),
108
+ "state": state,
109
+ "reason": reason,
110
+ "reset_at": reset_at,
111
+ "pid": int(pid) if pid and pid.isdigit() else current.get("pid"),
112
+ "updated_at": datetime.now(timezone.utc).isoformat(),
113
+ })
114
+ atomic_write(path, json.dumps(current, indent=2) + "\n", mode=0o600)
115
+ return current
116
+
117
+
118
+ def _read(path):
119
+ try:
120
+ with open(path, encoding="utf-8") as handle:
121
+ document = json.load(handle)
122
+ except (OSError, ValueError):
123
+ return None
124
+ return document if isinstance(document, dict) else None
125
+
126
+
127
+ def list_agents(base_dir, max_age_hours=DEFAULT_MAX_AGE_HOURS, now=None, is_running=_process_is_running):
128
+ """Live records, waiting first then newest; stale ones are deleted on the way."""
129
+ now = now or datetime.now(timezone.utc)
130
+ cutoff = now - timedelta(hours=max_age_hours)
131
+ directory = state_dir(base_dir)
132
+ try:
133
+ names = sorted(os.listdir(directory))
134
+ except OSError:
135
+ return []
136
+ agents = []
137
+ for file_name in names:
138
+ if not file_name.endswith(".json"):
139
+ continue
140
+ path = os.path.join(directory, file_name)
141
+ entry = _read(path)
142
+ try:
143
+ updated = datetime.fromisoformat(entry["updated_at"])
144
+ except (TypeError, KeyError, ValueError):
145
+ updated = None
146
+ if updated is None or updated.tzinfo is None or updated < cutoff:
147
+ try:
148
+ os.remove(path)
149
+ except OSError:
150
+ pass
151
+ continue
152
+ if entry.get("pid") and not is_running(entry["pid"]):
153
+ entry["last_state"], entry["state"] = entry.get("state"), "ended"
154
+ agents.append(entry)
155
+ agents.sort(key=lambda entry: entry["updated_at"], reverse=True)
156
+ agents.sort(key=lambda entry: entry.get("state") != "waiting")
157
+ return agents
158
+
159
+
160
+ def handle_agents(rest, ctx):
161
+ # Imported here: this module is loaded by the hook path through
162
+ # agent_notify, and the CLI helpers import provider_runtime, which imports
163
+ # agent_notify back.
164
+ from .cli_args import _parse_flag_args
165
+ from .cli_helpers import _json_success, _write_json
166
+ from .cli_render import _pad_table
167
+
168
+ parsed = _parse_flag_args(rest, {
169
+ "--max-age-hours": {"key": "max_age_hours", "type": "str", "default": None},
170
+ "--json": {"key": "json", "type": "bool", "default": False},
171
+ }, AGENTS_USAGE)
172
+ max_age = DEFAULT_MAX_AGE_HOURS
173
+ if parsed["max_age_hours"] is not None:
174
+ try:
175
+ max_age = int(parsed["max_age_hours"])
176
+ except ValueError:
177
+ max_age = 0
178
+ if max_age < 1:
179
+ raise CdxError(AGENTS_USAGE)
180
+ agents = list_agents(ctx["service"]["base_dir"], max_age)
181
+ if parsed["json"]:
182
+ _write_json(ctx, _json_success("agents", "Agents loaded.", agents=agents, max_age_hours=max_age))
183
+ return 0
184
+ if not agents:
185
+ ctx["out"]("No agent state recorded. Turn it on with: cdx set <name> --agent-state on\n")
186
+ return 0
187
+ rows = [["SESSION", "STATE", "PROJECT", "REASON", "UPDATED"]]
188
+ for entry in agents:
189
+ rows.append([
190
+ entry.get("session") or "",
191
+ entry.get("state") or "",
192
+ entry.get("project") or "",
193
+ entry.get("reason") or "",
194
+ entry.get("updated_at") or "",
195
+ ])
196
+ ctx["out"](_pad_table(rows) + "\n")
197
+ return 0
package/src/cli.py CHANGED
@@ -6,6 +6,7 @@ import sys
6
6
  from datetime import datetime, timezone
7
7
 
8
8
  from .agent_notify import handle_notify
9
+ from .agent_state import handle_agents
9
10
  from .cli_args import SET_USAGE
10
11
  from .cli_commands import (
11
12
  API_SCHEMA_VERSION,
@@ -153,6 +154,7 @@ __all__ = [
153
154
  _COMMAND_HANDLERS = {
154
155
  "auth": handle_auth,
155
156
  "add": handle_add,
157
+ "agents": handle_agents,
156
158
  "cp": handle_copy,
157
159
  "clean": handle_clean,
158
160
  "config": handle_config,
@@ -239,6 +241,7 @@ def _print_help(use_color=False):
239
241
  f" {_style('cdx status --small|-s [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
240
242
  f" {_style('cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
241
243
  f" {_style('cdx next [--json] [--refresh]', '36', use_color)}",
244
+ f" {_style('cdx agents [--max-age-hours N] [--json]', '36', use_color)}",
242
245
  f" {_style('cdx reset <name> [--yes] [--json]', '36', use_color)}",
243
246
  f" {_style('cdx auth refresh <name|all> [--json]', '36', use_color)}",
244
247
  f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json', '36', use_color)}",
package/src/cli_args.py CHANGED
@@ -33,8 +33,8 @@ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text..
33
33
  MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
34
34
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]"
35
35
  LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
36
- 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] [--notify on|off] [--notify-preview on|off] [--scrub-env on|off] [--shared-memory on|off] [--retro on|off] [--policy use|ask-first|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]"
37
- UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--scrub-env|--shared-memory|--retro|--policy|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
36
+ 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] [--notify on|off] [--agent-state on|off] [--notify-preview on|off] [--scrub-env on|off] [--shared-memory on|off] [--retro on|off] [--policy use|ask-first|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]"
37
+ UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--agent-state|--notify-preview|--scrub-env|--shared-memory|--retro|--policy|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
38
38
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
39
39
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
40
40
  CONFIGS_USAGE = "Usage: cdx configs [--json]"
@@ -53,7 +53,6 @@ RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
53
53
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
54
54
  RUN_CWD_REQUIRED = "cdx run: --cwd PATH is required."
55
55
  RUN_PROMPT_SOURCE_REQUIRED = "cdx run: specify exactly one prompt source: --prompt TEXT or --prompt-file PATH."
56
- RUN_DETACH_FAILOVER_CONFLICT = "cdx run: --failover needs to watch the run, which --detach gives up."
57
56
  RUN_KIND_VALUES = ("assistant", "code-review", "retro")
58
57
  # Aliases of the shared definitions in config, kept only so the existing
59
58
  # RUN_* spellings in this module keep reading naturally. They are references,
@@ -275,6 +274,7 @@ def _parse_set_args(args):
275
274
  "--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _on_off("rtk")},
276
275
  "--logics": {"key": "logics", "type": "str", "default": None, "transform": _on_off("logics")},
277
276
  "--notify": {"key": "notify", "type": "str", "default": None, "transform": _on_off("notify")},
277
+ "--agent-state": {"key": "agent_state", "type": "str", "default": None, "transform": _on_off("agent state")},
278
278
  "--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _on_off("notify preview")},
279
279
  "--scrub-env": {"key": "scrub_env", "type": "str", "default": None, "transform": _on_off("scrub env")},
280
280
  "--shared-memory": {"key": "shared_memory", "type": "str", "default": None, "transform": _on_off("shared memory")},
@@ -301,7 +301,7 @@ def _parse_set_args(args):
301
301
  raise CdxError(SET_USAGE)
302
302
  settings = {
303
303
  key: parsed[key]
304
- for key in ("power", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy")
304
+ for key in ("power", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy")
305
305
  if parsed[key] is not None
306
306
  }
307
307
  if not settings:
@@ -324,6 +324,7 @@ def _parse_unset_args(args):
324
324
  "--rtk": {"key": "rtk", "type": "bool", "default": False},
325
325
  "--logics": {"key": "logics", "type": "bool", "default": False},
326
326
  "--notify": {"key": "notify", "type": "bool", "default": False},
327
+ "--agent-state": {"key": "agent_state", "type": "bool", "default": False},
327
328
  "--notify-preview": {"key": "notify_preview", "type": "bool", "default": False},
328
329
  "--scrub-env": {"key": "scrub_env", "type": "bool", "default": False},
329
330
  "--shared-memory": {"key": "shared_memory", "type": "bool", "default": False},
@@ -350,7 +351,7 @@ def _parse_unset_args(args):
350
351
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
351
352
  raise CdxError(UNSET_USAGE)
352
353
  _UNSET_KEYS = (
353
- "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "shared_memory", "retro",
354
+ "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro",
354
355
  "model", "fallback_model", "budget", "extra_args", "priority", "policy",
355
356
  )
356
357
  keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
@@ -506,11 +507,6 @@ def cdx_schema():
506
507
  "arguments": ["--prompt-file", "--prompt"],
507
508
  "reason": "Exactly one prompt source.",
508
509
  },
509
- {
510
- "command": "run",
511
- "arguments": ["--detach", "--failover"],
512
- "reason": "Failover has to watch the run to react to a rate limit; --detach stops watching.",
513
- },
514
510
  ],
515
511
  # Not mutually exclusive: both may be given, and cdx accepts them when
516
512
  # they agree. A caller generating validation from `mutually_exclusive`
@@ -694,12 +690,6 @@ def _parse_run_args(args):
694
690
  code=ARG_CODE_MUTUALLY_EXCLUSIVE,
695
691
  arguments=["--prompt-file", "--prompt"],
696
692
  )
697
- if parsed["detach"] and parsed["failover"]:
698
- raise CdxArgumentError(
699
- RUN_DETACH_FAILOVER_CONFLICT,
700
- code=ARG_CODE_MUTUALLY_EXCLUSIVE,
701
- arguments=["--detach", "--failover"],
702
- )
703
693
  if not parsed["prompt_file"] and not parsed["prompt"]:
704
694
  raise CdxArgumentError(
705
695
  RUN_PROMPT_SOURCE_REQUIRED,
@@ -312,6 +312,7 @@ def _format_launch_config(session, use_color=False):
312
312
  ("rtk", "RTK"),
313
313
  ("logics", "Logics"),
314
314
  ("notify", "Agent alerts"),
315
+ ("agent_state", "Agent state"),
315
316
  ("notify_preview", "Response preview"),
316
317
  ("scrub_env", "Scrub agent env"),
317
318
  ("shared_memory", "Shared memory"),
@@ -343,14 +344,14 @@ def _format_launch_settings_hint(name="<name>"):
343
344
  )
344
345
 
345
346
  def _format_launch_setting_value(launch, key, use_color=False):
346
- if key in ("fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "shared_memory", "retro"):
347
+ if key in ("fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro"):
347
348
  if launch.get(key) is True:
348
349
  return _style("on", "32", use_color)
349
350
  if launch.get(key) is False:
350
351
  return _style("off", "2", use_color)
351
352
  if key == "logics":
352
353
  return _dim("auto", use_color)
353
- if key in ("notify", "notify_preview", "scrub_env", "shared_memory", "retro"):
354
+ if key in ("notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro"):
354
355
  return _style("off", "2", use_color)
355
356
  return _dim("default", use_color)
356
357
  value = launch.get(key)
@@ -8,6 +8,7 @@ import shlex
8
8
 
9
9
  from .. import retro, shared_memory
10
10
  from ..agent_notify import notifications_enabled, provision, supports_agent_alerts
11
+ from ..agent_state import agent_state_enabled
11
12
  from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag, _pop_value_flag
12
13
  from ..cli_helpers import (
13
14
  API_SCHEMA_VERSION,
@@ -301,13 +302,15 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
301
302
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
302
303
  _write_update_notice(ctx)
303
304
  alerts_allowed = _notification_provisioning_enabled(session, ctx, json_flag)
305
+ keep_state = agent_state_enabled(session)
304
306
  installed = provision(
305
307
  _get_auth_home(session),
306
308
  session["provider"],
307
- alerts_allowed,
309
+ alerts_allowed or keep_state,
308
310
  ctx.get("env"),
309
311
  spawn_sync=ctx.get("spawn_sync"),
310
312
  base_dir=ctx["service"]["base_dir"],
313
+ keep_state=keep_state,
311
314
  )
312
315
  if installed and not json_flag:
313
316
  notice = f"Notification hooks installed for {session['name']}"
@@ -204,6 +204,10 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
204
204
  argv += ["--reasoning-effort", str(parsed["reasoning_effort"])]
205
205
  if parsed.get("timeout_seconds"):
206
206
  argv += ["--timeout-seconds", str(parsed["timeout_seconds"])]
207
+ # The child is the supervisor: it watches the run like any blocking one, so
208
+ # it applies the failover rule and records each move in the registry.
209
+ if parsed.get("failover"):
210
+ argv.append("--failover")
207
211
  argv.append("--json")
208
212
  return argv
209
213
 
@@ -637,7 +641,9 @@ def handle_run(rest, ctx):
637
641
  # agent and report on it; fall back to the in-house launcher
638
642
  # otherwise, and whenever the provider declines to start one.
639
643
  launch = None
640
- if supports_native_background(
644
+ # A provider's own background agent has no cdx process watching it,
645
+ # so nothing could fail it over; --failover keeps the in-house child.
646
+ if not parsed.get("failover") and supports_native_background(
641
647
  run_session.get("provider"),
642
648
  env=ctx.get("env"),
643
649
  spawn_sync=ctx.get("spawn_text"),
@@ -12,6 +12,7 @@ import uuid
12
12
  from datetime import datetime, timezone
13
13
 
14
14
  from .agent_notify import launch_notify_env, notifications_enabled
15
+ from .agent_state import agent_state_enabled
15
16
  from .claude_credentials import peek_keychain_credentials, read_claude_login_file
16
17
  from .claude_usage import _clean_oauth_token, _decode_jwt_claims, _secure_storage_overrides, claude_profile_store_dir
17
18
  from .codex_usage import codex_auth_lock, diagnostic_needs_codex_login, fetch_codex_rate_limit_diagnostic
@@ -716,7 +717,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
716
717
  for key, value in _shared_tool_cache_overrides(env).items():
717
718
  env.setdefault(key, value)
718
719
  _shared_rtk_history(env)
719
- env.update(launch_notify_env(session, notifications_enabled(session)))
720
+ env.update(launch_notify_env(session, notifications_enabled(session) or agent_state_enabled(session)))
720
721
  env.update(_scrub_env(session))
721
722
  preference_args = _launch_preference_args(session, env=env)
722
723
  if session["provider"] == PROVIDER_CLAUDE:
@@ -24,6 +24,7 @@ MAX_SESSION_NAME_LENGTH = 64
24
24
 
25
25
  RESERVED_SESSION_NAMES = {
26
26
  "add",
27
+ "agents",
27
28
  "can-resume",
28
29
  "clean",
29
30
  "context",
@@ -148,7 +148,7 @@ def _seed_codex_auth_from_global(auth_home, env=None):
148
148
  return True
149
149
 
150
150
 
151
- _ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview", "scrub_env": "scrub env", "shared_memory": "shared memory", "retro": "retro"}
151
+ _ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "agent_state": "agent state", "notify_preview": "notify preview", "scrub_env": "scrub env", "shared_memory": "shared memory", "retro": "retro"}
152
152
 
153
153
 
154
154
  def _has_control_chars(text):
@@ -323,7 +323,7 @@ def unset_launch_settings(store, name, keys):
323
323
  if not keys:
324
324
  raise CdxError("At least one launch setting is required.")
325
325
  allowed = {
326
- "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
326
+ "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview",
327
327
  "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy",
328
328
  }
329
329
  unknown = [key for key in keys if key not in allowed]