cdx-manager 0.15.1 → 0.17.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
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.15.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.17.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
6
6
 
@@ -70,7 +70,8 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
70
70
 
71
71
  - **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, last-updated timestamps, priority guidance, and the last launched session in one aligned table.
72
72
  - **Pick for me.** `cdx next` selects the best available assistant from your pinned priorities and the live quota picture, shows the reason, and prints the exact command to run.
73
- - **Next-ready notification.** `cdx ready` schedules a native system notification for the next assistant that comes back from cooldown, then returns immediately. `cdx notify` covers reset times and scheduled wake-ups.
73
+ - **Agent notifications.** When a session finishes a turn or blocks waiting for you, cdx raises a desktop notification naming the session and the repository — so you can run several in parallel without watching any of them. Opt in per session with `cdx set <name> --notify on`.
74
+ - **Next-ready notification.** `cdx ready` schedules a native system notification for the next assistant that comes back from cooldown, then returns immediately.
74
75
  - **Passive status resolution.** Codex status is read from the local Codex app-server rate-limit API when available, with legacy transcript/history parsing kept as a fallback.
75
76
  - **Banked reset visibility.** Eligible Codex accounts show the number of manually redeemable bonus resets in `cdx status` and expose reset details in JSON output.
76
77
 
@@ -342,7 +343,43 @@ cdx disk profiles --candidates
342
343
 
343
344
  ### Next-Ready Notifications
344
345
 
345
- Use `cdx ready` when every useful assistant is cooling down and you want your terminal back. It is shorthand for `cdx notify --schedule --next-ready`: 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.
346
+ ### Agent notifications
347
+
348
+ When a session's agent finishes a turn, or stops to wait for you, cdx raises a
349
+ desktop notification naming the session and the repository:
350
+
351
+ ```text
352
+ ✓ work1 logics-manager · waiting for you
353
+ ✓ codex-a cdx-manager · finished
354
+ ```
355
+
356
+ Off until you ask for it, per session:
357
+
358
+ ```bash
359
+ cdx set work1 --notify on # turn it on for one session
360
+ cdx set --all --notify on # turn it on everywhere
361
+ cdx set work1 --notify off # and back off; cdx removes what it installed
362
+ ```
363
+
364
+ Turning it on records the setting. The wiring happens at that session's **next
365
+ launch**: cdx writes the hook into the session's own home and says so once.
366
+ Claude Code honours it straight away. Codex only runs hooks that come from an
367
+ installed plugin, so cdx generates one per session and installs it — and Codex
368
+ asks you to approve it once, which cdx leaves to you rather than writing that
369
+ trust on your behalf.
370
+
371
+ It is off by default because turning it on writes into the provider's own
372
+ configuration and, on Codex, puts an approval prompt in front of you. That is
373
+ not something to do to sessions you already own without being asked.
374
+
375
+ Per platform: macOS uses `osascript`, Linux `notify-send`, Windows a toast, and
376
+ WSL reaches the Windows notification centre through interop. On a host with no
377
+ way to deliver one — a headless or SSH Linux session, or WSL with interop
378
+ disabled — cdx installs no hooks at all rather than asking you to approve
379
+ something that could show you nothing. Headless `cdx run` never notifies: its
380
+ caller already learns of completion from the return value.
381
+
382
+ 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.
346
383
 
347
384
  ```bash
348
385
  cdx ready
@@ -384,6 +421,12 @@ cdx model provider:ollama default
384
421
 
385
422
  `--model` maps to Codex `--model`, Claude `--model`, and Ollama `ollama run <model>`. `--power` maps to Codex `model_reasoning_effort` and Claude `--effort`; supported values are `minimal`, `low`, `medium`, `high`, and `xhigh`. `--permission` maps to provider-native permission flags. Codex Fast mode is separate from reasoning effort: `--fast on` opts a Codex session into the Codex Fast service tier, while new sessions, `--fast off`, and default launches force the non-Fast `flex` tier. Use `--power low` when you want low reasoning effort without enabling Codex Fast credits. Existing legacy sessions that stored `fast=on` before this split continue to behave as low effort unless the user explicitly sets `--fast on` again. `--priority` is a 0..100 selector preference used as a tie-breaker after readiness and availability. `--rtk on` injects a launch instruction that encourages assistants to use RTK (`rtk <command>`) for noisy terminal commands when RTK is available, while keeping raw commands for exact output. Logics guidance is auto-enabled when `logics-manager` is available; use `--logics off` to disable that guidance for a session, or `--logics on` to pin it explicitly.
386
423
 
424
+ ### Terminal Titles
425
+
426
+ Interactive launches and resumes set the terminal window title to `session — folder`, where `folder` is the basename of the launch directory - for example `work — cdx-manager`. The convention is identical for Claude, Codex, and Antigravity: it comes from the shared launch runtime, not from provider flags, and cdx keeps re-asserting it so a provider TUI cannot take the title back mid-session.
427
+
428
+ The title is only written when stdout is a terminal. Nothing is emitted for `--json` launches, redirected or piped output, `cdx run` and other headless runs, login flows, or Ollama sessions. Session names and directories are stripped of escape and control characters before the title is emitted, and cdx does not restore the previous title when the session ends.
429
+
387
430
  ### Launch History
388
431
 
389
432
  Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including success/failure, duration, cwd, launch settings, and transcript path.
@@ -446,8 +489,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
446
489
  | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
447
490
  | `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 |
448
491
  | `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
449
- | `cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait for a session reset time or schedule an OS wake-up notification when due |
450
- | `cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait until the recommended session is usable, or schedule the next known reset notification |
492
+ | `cdx notify` | Hook target: raises the desktop notification when a session's agent finishes a turn or waits for you. Called by the provider, not by you |
451
493
  | `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
452
494
  | `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 |
453
495
  | `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds 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 |
@@ -499,7 +541,6 @@ Commands with machine-readable output:
499
541
  - `cdx view --json`
500
542
  - `cdx update --json`
501
543
  - `cdx ready --json`
502
- - `cdx notify ... --json`
503
544
  - `cdx select ... --json`
504
545
  - `cdx run ... --json`
505
546
 
@@ -0,0 +1,97 @@
1
+ # CDX Manager 0.16.0
2
+
3
+ > cdx now tells you when one of its sessions finishes or needs you, which is
4
+ > the one thing it could not do in the situation it was built for: several
5
+ > assistants running in parallel across different repositories. Getting there
6
+ > meant retiring the old `cdx notify` and reserving the name for this.
7
+
8
+ ## Breaking
9
+
10
+ ### `cdx notify` means something else
11
+
12
+ `cdx notify <name> --at-reset` and `cdx notify --next-ready` are gone. Those
13
+ answered "when does my quota come back", which `cdx status` already answers,
14
+ and in practice the only form anyone typed was `cdx ready`.
15
+
16
+ `cdx ready` is unchanged — same behaviour, same output, same flags. If you
17
+ scripted the retired flags, `cdx ready --json` is the replacement; there is no
18
+ equivalent for waiting on one named session's reset.
19
+
20
+ `cdx notify` is now the target a provider hook calls. You do not run it
21
+ yourself.
22
+
23
+ ## Features
24
+
25
+ ### Notifications when a session finishes or waits for you
26
+
27
+ ```text
28
+ ✓ work1 logics-manager · waiting for you
29
+ ✓ codex-a cdx-manager · finished
30
+ ```
31
+
32
+ The notification names the session and the repository, because the case this
33
+ exists for is several of them at once — a notification that cannot tell you
34
+ which one is not worth raising. Only cdx knows the session name, which is why
35
+ this lives here rather than in a hook you write by hand.
36
+
37
+ Off until you ask for it:
38
+
39
+ ```bash
40
+ cdx set work1 --notify on # one session
41
+ cdx set --all --notify on # all of them
42
+ ```
43
+
44
+ The setting is recorded straight away; the wiring happens at that session's
45
+ next launch, into the home directory cdx already owns, and cdx says so once.
46
+ Turning it back off removes what cdx installed and leaves any hooks you wrote
47
+ yourself alone.
48
+
49
+ It is opt-in rather than on by default because enabling it writes into the
50
+ provider's own configuration and, on Codex, puts an approval prompt in front of
51
+ you — not something to do unrequested to sessions you already own.
52
+
53
+ ### Codex goes through a generated plugin
54
+
55
+ Codex does not read hooks from a file. Not at its home root, not under a
56
+ `hooks/` directory, not from a project `.codex/` — all three were tried and
57
+ none is ever loaded. It runs only hooks that come from an installed plugin, so
58
+ cdx generates one per session and installs it with `codex plugin marketplace
59
+ add`.
60
+
61
+ The generated plugin lives outside `CODEX_HOME` on purpose. Rooted inside it,
62
+ the marketplace installs without an error and reports `installed, enabled`, and
63
+ then never runs a thing: Codex registers it pointing at the source directory
64
+ rather than the cache copy it executes from. Everything looks right and nothing
65
+ happens.
66
+
67
+ Codex also refuses to run a hook it has not been told to trust, so it asks you
68
+ once per session. cdx does not answer that for you.
69
+
70
+ ### Delivery on every platform, and silence where there is none
71
+
72
+ macOS uses `osascript`, Linux `notify-send`, native Windows a toast, and WSL
73
+ reaches the Windows notification centre through interop rather than being
74
+ treated as plain Linux — which is what it was before, and why it delivered
75
+ nothing at all there.
76
+
77
+ On a host with no way to show a notification — a headless or SSH Linux
78
+ session, or WSL with interop disabled — cdx installs no hooks. Approving a
79
+ hook that could never show you anything is a cost with no return. A host that
80
+ gains a channel later is provisioned on its next launch.
81
+
82
+ Headless `cdx run` never notifies, so a script issuing fifty runs stays quiet.
83
+ Its caller already learns of completion from the return value.
84
+
85
+ ## Fixes
86
+
87
+ ### The Windows notification no longer blocks
88
+
89
+ Windows notifications were a `MessageBox` spawned with no timeout. As an
90
+ occasional quota alert that was survivable. As a hook firing on every agent
91
+ turn it would have held the turn open until someone dismissed the dialog, and
92
+ a window stealing focus every turn is worse than no notification at all. It is
93
+ now a toast, which needs no dismissing.
94
+
95
+ Claude Code honours the hooks it finds immediately. Codex is the one that
96
+ gates them behind trust, so only Codex asks — cdx passes no bypass flag, the
97
+ guard is there on purpose.
@@ -0,0 +1,64 @@
1
+ # CDX Manager 0.17.0
2
+
3
+ > A terminal running four cdx sessions gave you four windows titled by whatever
4
+ > each provider felt like calling itself. The title now says which session it
5
+ > is and which repository it is in, and it says it the same way for Claude,
6
+ > Codex and Antigravity.
7
+
8
+ ## Features
9
+
10
+ ### Interactive sessions title the terminal `session — folder`
11
+
12
+ ```text
13
+ work — cdx-manager
14
+ perso — logics-manager
15
+ ```
16
+
17
+ Every interactive `cdx <name>` and `cdx resume <name>` sets the window title to
18
+ its session name and the basename of the launch directory. Both halves matter:
19
+ the session is the account you are spending, the folder is the work. Neither
20
+ alone tells you which window you are looking at when several are open.
21
+
22
+ The convention comes from cdx's own launch runtime rather than from provider
23
+ flags, which is why it is identical across Claude, Codex and Antigravity. No
24
+ provider argument changed, and no undocumented option is relied on — Codex and
25
+ Antigravity do not expose one, and depending on a private flag would make this
26
+ break on their next release.
27
+
28
+ A title written once at launch does not survive: a provider TUI sets its own
29
+ whenever it repaints. cdx keeps re-asserting it for as long as the session
30
+ runs, and releases it when the session ends — on the error and interrupt paths
31
+ as well as the normal one.
32
+
33
+ Nothing is emitted where there is no terminal to emit to. `--json` launches,
34
+ redirected or piped output, `cdx run` and other headless runs, and login flows
35
+ all stay byte-for-byte as they were; a title sequence in the middle of a JSON
36
+ document its caller has to parse is a bug, not a feature. Ollama is left alone
37
+ as a local model runner rather than a cdx-managed coding session.
38
+
39
+ Session names and directories are your data, so both are stripped of escape
40
+ and control characters before the title is written. A directory named with a
41
+ raw `ESC` would otherwise end the title string early and leave the rest of the
42
+ name to be read by the terminal as commands.
43
+
44
+ cdx does not restore the previous title when a session ends. Terminals do not
45
+ reliably report the title they had, so restoring it means guessing, and
46
+ guessing wrong is worse than leaving the last true statement in place.
47
+
48
+ ## Validation
49
+
50
+ - `ruff check .`
51
+ - `python3 -m pytest` — 679 tests.
52
+ - Verified against a real pty rather than a mocked stream: the launch emits
53
+ `\x1b]0;work — cdx-manager\x07`.
54
+ - The non-TTY, `--json`, login and Ollama paths are each pinned by a test that
55
+ asserts nothing at all is written.
56
+
57
+ ## Known limits
58
+
59
+ - The em dash is written as UTF-8. A terminal whose stdout encoding cannot
60
+ represent it — `LC_ALL=C`, for instance — gets no title rather than a
61
+ mangled one or a crash, since every title write is best-effort.
62
+ - The refresh interval is fixed at five seconds and is not configurable. A
63
+ provider that repaints its title more aggressively than that would show its
64
+ own for up to that long before cdx takes it back.
@@ -176,6 +176,14 @@
176
176
  "v0.15.0": {
177
177
  "github_tarball_sha256": "93d0a7305a5235f20e89a3e2a404029546926437952ee61d43a1f69c3ae98e4e",
178
178
  "github_zip_sha256": "23e4e42fb746f4af5fa407f853e979ffce91e147dbb623c250e276fdcaa9638a"
179
+ },
180
+ "v0.15.1": {
181
+ "github_tarball_sha256": "1dc7ea87600844e4853ab12c9f123dc67548ae904f511fea9a58ddf7b3eacfef",
182
+ "github_zip_sha256": "7ad41c63adffdd1ef12d02a72c86fb1b6a64cbcddd9970b0741ba5516f241707"
183
+ },
184
+ "v0.16.0": {
185
+ "github_tarball_sha256": "bdcd2808922410b20567ba86589d49166c5c2b9404325195b28457df80ca3dad",
186
+ "github_zip_sha256": "e89cb8c964304f0805d06da9d355d543f8a590e0febbf07e585a692ccf73ef99"
179
187
  }
180
188
  }
181
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.15.1",
3
+ "version": "0.17.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.15.1"
7
+ version = "0.17.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,324 @@
1
+ """Agent-event notifications: the hook target, and the per-session provisioning.
2
+
3
+ `cdx notify` is what a provider hook calls when an agent turn ends or blocks
4
+ waiting for input. It exists because the notification has to name *which* cdx
5
+ session, in *which* repository — the case the feature is for is several sessions
6
+ running in parallel — and only cdx knows the session name.
7
+
8
+ Provisioning writes the hook into the session's own home, which cdx already
9
+ owns because it sets HOME/CODEX_HOME per session at launch.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+
17
+ from .notify import notification_channel, send_desktop_notification
18
+
19
+ # Set on the provider process at launch; the hook runs as its child and inherits both.
20
+ SESSION_ENV = "CDX_SESSION_NAME"
21
+ ENABLED_ENV = "CDX_NOTIFY"
22
+
23
+ # Our entries are recognised by the command they run, not by a marker key of our
24
+ # own: both providers validate this file against their own schema, and an extra
25
+ # key they do not know is a rejection risk for no gain.
26
+ HOOK_ARG = "notify"
27
+
28
+ _WAITING_EVENTS = {"notification", "permissionrequest", "userpromptsubmit"}
29
+
30
+
31
+ def resolve_hook_command(env=None):
32
+ """Absolute path to cdx, not the bare name.
33
+
34
+ Codex spawns the hook without a shell, and a Windows npm install ships a
35
+ `.cmd` shim rather than an executable called `cdx`, so the bare name does
36
+ not resolve there. pipx and uv installs can likewise leave cdx off the PATH
37
+ the provider inherits.
38
+ """
39
+ env = env or os.environ
40
+ return env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
41
+
42
+
43
+ def read_hook_payload(args, stdin_text=None):
44
+ """Accept the payload however the provider hands it over.
45
+
46
+ Claude Code and current Codex hooks write JSON to stdin. Codex's older
47
+ `notify` key — which it now calls `legacy_notify` — passes it as an argv.
48
+ Anything unparseable is an empty payload, never an error: a hook that fails
49
+ loudly turns a missing notification into a broken agent turn.
50
+ """
51
+ for candidate in ([stdin_text] if stdin_text else []) + list(args or []):
52
+ try:
53
+ payload = json.loads(candidate)
54
+ except (TypeError, ValueError):
55
+ continue
56
+ if isinstance(payload, dict):
57
+ return payload
58
+ return {}
59
+
60
+
61
+ def compose_notification(payload, env=None, cwd=None):
62
+ """Title and message for this event, or None if we should stay quiet."""
63
+ env = os.environ if env is None else env
64
+ if env.get(ENABLED_ENV) == "0":
65
+ # Headless runs share the session's home, so they read the same hooks.
66
+ # Their caller already learns of completion from the return value.
67
+ return None
68
+ session = env.get(SESSION_ENV)
69
+ if not session:
70
+ return None
71
+ directory = payload.get("cwd") or payload.get("workspace") or cwd or os.getcwd()
72
+ event = str(payload.get("hook_event_name") or payload.get("eventName") or payload.get("type") or "").lower()
73
+ state = "waiting for you" if event.replace("_", "") in _WAITING_EVENTS else "finished"
74
+ where = os.path.basename(str(directory).rstrip("/\\")) or directory
75
+ return f"✓ {session}", f"{where} · {state}"
76
+
77
+
78
+ def handle_notify(rest, ctx):
79
+ """Hook target. Never fails: a broken notification is not a broken turn."""
80
+ try:
81
+ import sys
82
+ stdin_text = None
83
+ # "stdin" in ctx is the {"isTTY": ...} descriptor, not a stream.
84
+ reader = ctx.get("prompt_stdin") or sys.stdin
85
+ if reader is not None and not ctx.get("stdin_is_tty"):
86
+ stdin_text = reader.read()
87
+ env = os.environ if ctx.get("env") is None else ctx["env"]
88
+ composed = compose_notification(read_hook_payload(rest, stdin_text), env, ctx.get("cwd"))
89
+ if composed:
90
+ send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
91
+ except Exception: # noqa: BLE001 - deliberate: the caller is an agent turn
92
+ pass
93
+ return 0
94
+
95
+
96
+ def launch_notify_env(session, enabled, env=None):
97
+ """Env the provider is launched with, carrying who we are and whether to notify."""
98
+ values = {SESSION_ENV: session["name"]}
99
+ if not enabled:
100
+ values[ENABLED_ENV] = "0"
101
+ return values
102
+
103
+
104
+ def notifications_enabled(session):
105
+ """Off unless this session explicitly turned it on.
106
+
107
+ Opt-in rather than on-by-default: provisioning writes into the provider's
108
+ own configuration and, on Codex, asks the user to approve a hook. Doing
109
+ that to every session someone already owns, without being asked, is not a
110
+ default worth having.
111
+ """
112
+ launch = session.get("launch") or {}
113
+ return launch.get("notify") is True
114
+
115
+
116
+ def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=None):
117
+ """Install or remove cdx's notification hooks in this session's own home.
118
+
119
+ Idempotent, so an existing session picks the hooks up on its next launch and
120
+ repeated launches change nothing. Returns True when they were newly
121
+ installed, so the caller can say so once.
122
+
123
+ The two providers need different mechanisms, which is not a stylistic
124
+ choice: Claude Code reads hooks straight out of `settings.json`, while Codex
125
+ reads them only from an installed plugin — a free-standing hooks file
126
+ anywhere under `CODEX_HOME` is never looked at.
127
+ """
128
+ env = env or os.environ
129
+ if enabled and not notification_channel(env):
130
+ # Nothing could be delivered on this host, so installing a hook would only
131
+ # buy the user an approval prompt for a feature that shows nothing.
132
+ enabled = False
133
+ command = resolve_hook_command(env)
134
+ if provider == "claude":
135
+ return _apply_hooks(hook_config_path(auth_home, provider), enabled, command)
136
+ if provider == "codex":
137
+ return _provision_codex_plugin(auth_home, enabled, command, env, spawn_sync, base_dir)
138
+ return False
139
+
140
+
141
+ def hook_config_path(auth_home, provider, base_dir=None):
142
+ if provider == "claude":
143
+ return os.path.join(auth_home, ".claude", "settings.json")
144
+ if provider == "codex":
145
+ return os.path.join(codex_plugin_root(auth_home, base_dir), "hooks", "cdx-hooks.json")
146
+ return None
147
+
148
+
149
+ def codex_plugin_root(auth_home, base_dir=None):
150
+ """Where the generated Codex plugin lives — deliberately outside CODEX_HOME.
151
+
152
+ A marketplace rooted inside Codex's own home installs without complaint and
153
+ then never runs: Codex registers it pointing straight at the source
154
+ directory instead of taking the cache copy it executes from. Moving the same
155
+ plugin one directory out is the whole difference between working and
156
+ silently doing nothing.
157
+ """
158
+ if base_dir:
159
+ return os.path.join(base_dir, "state", "codex-plugins", os.path.basename(auth_home))
160
+ # Without a base directory, sit beside the home rather than guessing at cdx's
161
+ # layout by walking up from it: two levels above a home is `/` for anything
162
+ # not laid out as `<base>/profiles/<name>`, and writing there fails.
163
+ return os.path.join(os.path.dirname(auth_home), ".cdx-codex-plugins", os.path.basename(auth_home))
164
+
165
+
166
+ # Codex only runs hooks that come from an installed plugin, identified as
167
+ # `<plugin>@<marketplace>`. Generating one per session keeps the resolved cdx
168
+ # path baked into it.
169
+ CODEX_PLUGIN = "cdx-notify"
170
+ CODEX_MARKETPLACE = "cdx"
171
+ CODEX_PLUGIN_REF = f"{CODEX_PLUGIN}@{CODEX_MARKETPLACE}"
172
+
173
+
174
+ def _provision_codex_plugin(auth_home, enabled, command, env, spawn_sync=None, base_dir=None):
175
+ installed = _codex_plugin_installed(auth_home)
176
+ if enabled == installed:
177
+ return False
178
+ if not enabled:
179
+ _run_codex(["plugin", "remove", CODEX_PLUGIN_REF], auth_home, env, spawn_sync)
180
+ _run_codex(["plugin", "marketplace", "remove", CODEX_MARKETPLACE], auth_home, env, spawn_sync)
181
+ return False
182
+ root = _write_codex_plugin(auth_home, command, base_dir)
183
+ if not root:
184
+ return False
185
+ if not _run_codex(["plugin", "marketplace", "add", root], auth_home, env, spawn_sync):
186
+ return False
187
+ return _run_codex(["plugin", "add", CODEX_PLUGIN_REF], auth_home, env, spawn_sync)
188
+
189
+
190
+ def _codex_plugin_installed(auth_home):
191
+ """Whether Codex's own config already lists our plugin.
192
+
193
+ Read rather than remembered, so a plugin the user removed by hand is
194
+ reinstalled on the next launch instead of being assumed present.
195
+ """
196
+ try:
197
+ with open(os.path.join(auth_home, "config.toml"), encoding="utf-8") as handle:
198
+ return f'[plugins."{CODEX_PLUGIN_REF}"]' in handle.read()
199
+ except OSError:
200
+ return False
201
+
202
+
203
+ def _write_codex_plugin(auth_home, command, base_dir=None):
204
+ root = codex_plugin_root(auth_home, base_dir)
205
+ files = {
206
+ os.path.join(root, ".claude-plugin", "marketplace.json"): {
207
+ "name": CODEX_MARKETPLACE,
208
+ "description": "cdx agent notifications",
209
+ "owner": {"name": "cdx"},
210
+ "plugins": [{
211
+ "name": CODEX_PLUGIN,
212
+ "description": "Notify when this cdx session finishes a turn or waits for you",
213
+ "source": "./",
214
+ }],
215
+ },
216
+ # The "hooks" key is what makes Codex load the file at all; without it
217
+ # the plugin installs cleanly and never runs anything.
218
+ os.path.join(root, ".claude-plugin", "plugin.json"): {
219
+ "name": CODEX_PLUGIN,
220
+ "version": "1.0.0",
221
+ "description": "Notify when this cdx session finishes a turn or waits for you",
222
+ "hooks": "./hooks/cdx-hooks.json",
223
+ },
224
+ os.path.join(root, "hooks", "cdx-hooks.json"): {
225
+ "hooks": {
226
+ event: [{"hooks": [{"type": "command", "command": f"{command} {HOOK_ARG}"}]}]
227
+ for event in ("Stop", "Notification")
228
+ },
229
+ },
230
+ }
231
+ for path, document in files.items():
232
+ if not _write_json(path, document):
233
+ return None
234
+ return root
235
+
236
+
237
+ def _run_codex(args, auth_home, env, spawn_sync):
238
+ """Run one `codex` command, following the repo's (command, args, options)
239
+ spawn_sync contract rather than subprocess.run's, so the same injected
240
+ double serves here as everywhere else."""
241
+ options = {"env": {**env, "CODEX_HOME": auth_home}, "cwd": auth_home}
242
+ try:
243
+ result = (spawn_sync or _subprocess_spawn_sync)("codex", list(args), options)
244
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
245
+ # No codex on PATH, or it hung: a skipped provisioning step, never a
246
+ # failed launch.
247
+ return False
248
+ if isinstance(result, dict):
249
+ return not result.get("error") and result.get("status", 0) == 0
250
+ return getattr(result, "returncode", 0) == 0
251
+
252
+
253
+ def _subprocess_spawn_sync(command, args, options):
254
+ return subprocess.run(
255
+ [command, *args],
256
+ env=options.get("env"),
257
+ capture_output=True,
258
+ text=True,
259
+ timeout=60,
260
+ )
261
+
262
+
263
+ def _apply_hooks(path, enabled, command):
264
+ document = _read_json(path)
265
+ if document is None:
266
+ return False
267
+ hooks = document.get("hooks")
268
+ hooks = hooks if isinstance(hooks, dict) else {}
269
+ before = json.dumps(hooks, sort_keys=True)
270
+ for event in ("Stop", "Notification"):
271
+ # Rebuilt from whatever is there minus our own entry, so user-authored
272
+ # hooks on the same event survive both installing and removing.
273
+ entries = [entry for entry in hooks.get(event, []) if not _is_ours(entry)]
274
+ if enabled:
275
+ entries.append({
276
+ "hooks": [{"type": "command", "command": f"{command} {HOOK_ARG}"}],
277
+ })
278
+ if entries:
279
+ hooks[event] = entries
280
+ else:
281
+ hooks.pop(event, None)
282
+ if json.dumps(hooks, sort_keys=True) == before:
283
+ return False
284
+ document["hooks"] = hooks
285
+ return _write_json(path, document) and enabled
286
+
287
+
288
+ def _is_ours(entry):
289
+ """Ours if any of its commands is a cdx notify invocation."""
290
+ if not isinstance(entry, dict):
291
+ return False
292
+ for hook in entry.get("hooks") or []:
293
+ command = str((hook or {}).get("command") or "")
294
+ if command.endswith(f" {HOOK_ARG}") and "cdx" in command:
295
+ return True
296
+ return False
297
+
298
+
299
+ def _read_json(path):
300
+ """Existing content, {} when absent, or None when we must not touch it.
301
+
302
+ An unparseable file is someone else's, so we leave it exactly as it is
303
+ rather than replacing settings the user cares about.
304
+ """
305
+ try:
306
+ with open(path, encoding="utf-8") as handle:
307
+ document = json.load(handle)
308
+ except FileNotFoundError:
309
+ return {}
310
+ except (OSError, ValueError):
311
+ return None
312
+ return document if isinstance(document, dict) else None
313
+
314
+
315
+ def _write_json(path, document):
316
+ try:
317
+ os.makedirs(os.path.dirname(path), exist_ok=True)
318
+ with open(path, "w", encoding="utf-8") as handle:
319
+ json.dump(document, handle, indent=2)
320
+ handle.write("\n")
321
+ except OSError:
322
+ # An unwritable home is a skipped provisioning step, not a failed launch.
323
+ return False
324
+ return True
package/src/cli.py CHANGED
@@ -5,6 +5,7 @@ import os
5
5
  import sys
6
6
  from datetime import datetime, timezone
7
7
 
8
+ from .agent_notify import handle_notify
8
9
  from .cli_commands import (
9
10
  API_SCHEMA_VERSION,
10
11
  STATUS_USAGE,
@@ -34,7 +35,7 @@ from .cli_commands import (
34
35
  handle_logout,
35
36
  handle_memory,
36
37
  handle_next,
37
- handle_notify,
38
+ handle_ready,
38
39
  handle_remove,
39
40
  handle_rename,
40
41
  handle_repair,
@@ -258,8 +259,7 @@ def _print_help(use_color=False):
258
259
  f" {_style('cdx view [--json]', '36', use_color)}",
259
260
  f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
260
261
  f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
261
- f" {_style('cdx notify <name> --at-reset [--schedule] [--refresh] [--json]', '36', use_color)}",
262
- f" {_style('cdx notify --next-ready [--schedule] [--refresh] [--json]', '36', use_color)}",
262
+ f" {_style('cdx notify', '36', use_color)} # hook target, called by the provider",
263
263
  f" {_style('cdx <name> [--json]', '36', use_color)}",
264
264
  f" {_style('cdx --help', '36', use_color)}",
265
265
  f" {_style('cdx --version', '36', use_color)}",
@@ -576,9 +576,7 @@ def main(argv, options=None):
576
576
  }
577
577
 
578
578
  if resolved_command == "ready":
579
- if any(arg not in ("--refresh", "--json") for arg in rest):
580
- raise CdxError("Usage: cdx ready [--refresh] [--json]")
581
- return handle_notify(["--next-ready", "--schedule", *rest], ctx)
579
+ return handle_ready(rest, ctx)
582
580
 
583
581
  if resolved_command in ("power", "perm", "fast", "model"):
584
582
  return handle_launch_setting_alias(resolved_command, rest, ctx)