cdx-manager 0.15.1 → 0.16.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 +41 -6
- package/changelogs/CHANGELOGS_0_16_0.md +97 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +324 -0
- package/src/cli.py +4 -6
- package/src/cli_args.py +4 -2
- package/src/cli_commands.py +1 -1
- package/src/cli_helpers.py +5 -2
- package/src/commands/auth.py +35 -52
- package/src/commands/launch.py +20 -1
- package/src/notify.py +125 -149
- package/src/provider_runtime.py +5 -0
- package/src/session_service.py +13 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
|
@@ -446,8 +483,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
446
483
|
| `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
|
|
447
484
|
| `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
485
|
| `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
|
|
449
|
-
| `cdx notify
|
|
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 |
|
|
486
|
+
| `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
487
|
| `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
|
|
452
488
|
| `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
489
|
| `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 +535,6 @@ Commands with machine-readable output:
|
|
|
499
535
|
- `cdx view --json`
|
|
500
536
|
- `cdx update --json`
|
|
501
537
|
- `cdx ready --json`
|
|
502
|
-
- `cdx notify ... --json`
|
|
503
538
|
- `cdx select ... --json`
|
|
504
539
|
- `cdx run ... --json`
|
|
505
540
|
|
|
@@ -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.
|
|
@@ -176,6 +176,10 @@
|
|
|
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"
|
|
179
183
|
}
|
|
180
184
|
}
|
|
181
185
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -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 = env or os.environ
|
|
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 = ctx.get("env") or os.environ
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/src/cli_args.py
CHANGED
|
@@ -248,6 +248,7 @@ def _parse_set_args(args):
|
|
|
248
248
|
"--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
249
249
|
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
250
250
|
"--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
251
|
+
"--notify": {"key": "notify", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
251
252
|
"--model": {"key": "model", "type": "str", "default": None},
|
|
252
253
|
"--fallback-model": {"key": "fallback_model", "type": "str", "default": None},
|
|
253
254
|
"--budget": {"key": "budget", "type": "str", "default": None, "transform": _parse_budget_value},
|
|
@@ -269,7 +270,7 @@ def _parse_set_args(args):
|
|
|
269
270
|
raise CdxError(SET_USAGE)
|
|
270
271
|
settings = {
|
|
271
272
|
key: parsed[key]
|
|
272
|
-
for key in ("power", "permission", "fast", "rtk", "logics", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
273
|
+
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
273
274
|
if parsed[key] is not None
|
|
274
275
|
}
|
|
275
276
|
if not settings:
|
|
@@ -291,6 +292,7 @@ def _parse_unset_args(args):
|
|
|
291
292
|
"--fast": {"key": "fast", "type": "bool", "default": False},
|
|
292
293
|
"--rtk": {"key": "rtk", "type": "bool", "default": False},
|
|
293
294
|
"--logics": {"key": "logics", "type": "bool", "default": False},
|
|
295
|
+
"--notify": {"key": "notify", "type": "bool", "default": False},
|
|
294
296
|
"--model": {"key": "model", "type": "bool", "default": False},
|
|
295
297
|
"--fallback-model": {"key": "fallback_model", "type": "bool", "default": False},
|
|
296
298
|
"--budget": {"key": "budget", "type": "bool", "default": False},
|
|
@@ -312,7 +314,7 @@ def _parse_unset_args(args):
|
|
|
312
314
|
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
313
315
|
raise CdxError(UNSET_USAGE)
|
|
314
316
|
_UNSET_KEYS = (
|
|
315
|
-
"power", "reasoning_effort", "permission", "fast", "rtk", "logics",
|
|
317
|
+
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify",
|
|
316
318
|
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
317
319
|
)
|
|
318
320
|
keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
|
package/src/cli_commands.py
CHANGED
package/src/cli_helpers.py
CHANGED
|
@@ -486,6 +486,7 @@ def _format_launch_config(session, use_color=False):
|
|
|
486
486
|
("fast", "Fast"),
|
|
487
487
|
("rtk", "RTK"),
|
|
488
488
|
("logics", "Logics"),
|
|
489
|
+
("notify", "Notify"),
|
|
489
490
|
("model", "Model"),
|
|
490
491
|
("fallback_model", "Fallback"),
|
|
491
492
|
("budget", "Budget"),
|
|
@@ -508,17 +509,19 @@ def _format_launch_config(session, use_color=False):
|
|
|
508
509
|
def _format_launch_settings_hint(name="<name>"):
|
|
509
510
|
return (
|
|
510
511
|
f"Set a value: cdx set {name} --power medium --permission auto "
|
|
511
|
-
"--fast on --rtk on --logics on --model MODEL --priority 80"
|
|
512
|
+
"--fast on --rtk on --logics on --notify on --model MODEL --priority 80"
|
|
512
513
|
)
|
|
513
514
|
|
|
514
515
|
def _format_launch_setting_value(launch, key, use_color=False):
|
|
515
|
-
if key in ("fast", "rtk", "logics"):
|
|
516
|
+
if key in ("fast", "rtk", "logics", "notify"):
|
|
516
517
|
if launch.get(key) is True:
|
|
517
518
|
return _style("on", "32", use_color)
|
|
518
519
|
if launch.get(key) is False:
|
|
519
520
|
return _style("off", "2", use_color)
|
|
520
521
|
if key == "logics":
|
|
521
522
|
return _dim("auto", use_color)
|
|
523
|
+
if key == "notify":
|
|
524
|
+
return _style("off", "2", use_color)
|
|
522
525
|
return _dim("default", use_color)
|
|
523
526
|
value = launch.get(key)
|
|
524
527
|
if value is None or value == "":
|
package/src/commands/auth.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Authentication domain commands: login, logout, reset,
|
|
1
|
+
"""Authentication domain commands: login, logout, reset, ready.
|
|
2
2
|
|
|
3
3
|
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
4
|
"""
|
|
@@ -20,13 +20,11 @@ from ..codex_usage import consume_codex_rate_limit_reset_credit
|
|
|
20
20
|
from ..config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
21
21
|
from ..errors import CdxError
|
|
22
22
|
from ..notify import (
|
|
23
|
-
format_notify_event,
|
|
24
23
|
format_scheduled_notification,
|
|
25
|
-
|
|
24
|
+
parse_ready_args,
|
|
26
25
|
resolve_notify_event,
|
|
27
26
|
schedule_notification_event,
|
|
28
27
|
send_desktop_notification,
|
|
29
|
-
wait_for_notification_event,
|
|
30
28
|
)
|
|
31
29
|
from ..provider_runtime import _ensure_session_authentication, _run_interactive_provider_command
|
|
32
30
|
|
|
@@ -35,61 +33,46 @@ def _confirm_reset(name):
|
|
|
35
33
|
answer = input(f"Consume one banked Codex reset for {name}? [y/N] ")
|
|
36
34
|
return answer.strip().lower() in ("y", "yes")
|
|
37
35
|
|
|
38
|
-
def
|
|
39
|
-
|
|
36
|
+
def handle_ready(rest, ctx):
|
|
37
|
+
"""`cdx ready`: schedule an OS notification for the next session to come back.
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
This is all that remains of the quota-reset flow: `cdx notify` now means
|
|
40
|
+
agent notifications, and the wait-and-poll surface that used to sit here was
|
|
41
|
+
unreachable once `cdx ready` became the only entry point.
|
|
42
|
+
"""
|
|
43
|
+
parsed = parse_ready_args(rest)
|
|
44
|
+
event = resolve_notify_event(
|
|
45
|
+
ctx["service"]["get_status_rows"](
|
|
46
|
+
progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
|
|
47
|
+
force_refresh=parsed["refresh"],
|
|
48
|
+
),
|
|
49
|
+
parsed,
|
|
50
|
+
(ctx["options"].get("now") or time.time)(),
|
|
51
|
+
)
|
|
52
|
+
if event["ready"]:
|
|
42
53
|
send_desktop_notification(
|
|
43
|
-
title,
|
|
44
|
-
|
|
45
|
-
spawn_sync=ctx.get("spawn_sync"),
|
|
46
|
-
env=ctx.get("env"),
|
|
54
|
+
event["title"], event["message"],
|
|
55
|
+
spawn_sync=ctx.get("spawn_sync"), env=ctx.get("env"),
|
|
47
56
|
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
schedule = {
|
|
58
|
+
"scheduled": False,
|
|
59
|
+
"backend": "immediate",
|
|
60
|
+
"message": event["message"],
|
|
61
|
+
"target_timestamp": event.get("target_timestamp"),
|
|
62
|
+
}
|
|
63
|
+
else:
|
|
64
|
+
schedule = schedule_notification_event(
|
|
65
|
+
ctx["service"]["base_dir"],
|
|
55
66
|
parsed,
|
|
56
|
-
|
|
67
|
+
event,
|
|
68
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
69
|
+
env=ctx.get("env"),
|
|
70
|
+
now_fn=ctx["options"].get("now"),
|
|
57
71
|
)
|
|
58
|
-
if event["ready"]:
|
|
59
|
-
notifier(event["title"], event["message"])
|
|
60
|
-
schedule = {
|
|
61
|
-
"scheduled": False,
|
|
62
|
-
"backend": "immediate",
|
|
63
|
-
"message": event["message"],
|
|
64
|
-
"target_timestamp": event.get("target_timestamp"),
|
|
65
|
-
}
|
|
66
|
-
else:
|
|
67
|
-
schedule = schedule_notification_event(
|
|
68
|
-
ctx["service"]["base_dir"],
|
|
69
|
-
parsed,
|
|
70
|
-
event,
|
|
71
|
-
spawn_sync=ctx.get("spawn_sync"),
|
|
72
|
-
env=ctx.get("env"),
|
|
73
|
-
now_fn=ctx["options"].get("now"),
|
|
74
|
-
)
|
|
75
|
-
if parsed["json"]:
|
|
76
|
-
_write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
|
|
77
|
-
else:
|
|
78
|
-
ctx["out"](f"{format_scheduled_notification(schedule)}\n")
|
|
79
|
-
return 0
|
|
80
|
-
|
|
81
|
-
event = wait_for_notification_event(
|
|
82
|
-
ctx["service"],
|
|
83
|
-
parsed,
|
|
84
|
-
notifier=notifier,
|
|
85
|
-
sleep_fn=ctx["options"].get("sleep"),
|
|
86
|
-
now_fn=ctx["options"].get("now"),
|
|
87
|
-
progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
|
|
88
|
-
)
|
|
89
72
|
if parsed["json"]:
|
|
90
|
-
_write_json(ctx, _json_success("notify", "
|
|
73
|
+
_write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
|
|
91
74
|
else:
|
|
92
|
-
ctx["out"](f"{
|
|
75
|
+
ctx["out"](f"{format_scheduled_notification(schedule)}\n")
|
|
93
76
|
return 0
|
|
94
77
|
|
|
95
78
|
def handle_reset(rest, ctx):
|
package/src/commands/launch.py
CHANGED
|
@@ -6,6 +6,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
|
6
6
|
import os
|
|
7
7
|
import shlex
|
|
8
8
|
|
|
9
|
+
from ..agent_notify import notifications_enabled, provision
|
|
9
10
|
from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag
|
|
10
11
|
from ..cli_helpers import (
|
|
11
12
|
API_SCHEMA_VERSION,
|
|
@@ -21,9 +22,10 @@ from ..cli_helpers import (
|
|
|
21
22
|
_write_update_notice,
|
|
22
23
|
)
|
|
23
24
|
from ..cli_render import _dim, _info, _success, _warn
|
|
25
|
+
from ..config import PROVIDER_CODEX
|
|
24
26
|
from ..context_store import install_context_for_session, write_context
|
|
25
27
|
from ..errors import CdxError
|
|
26
|
-
from ..provider_runtime import _ensure_session_authentication, _run_interactive_provider_command
|
|
28
|
+
from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
def _format_resume_capability(capability, use_color=False):
|
|
@@ -108,6 +110,23 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
108
110
|
if not json_flag:
|
|
109
111
|
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
110
112
|
_write_update_notice(ctx)
|
|
113
|
+
installed = provision(
|
|
114
|
+
_get_auth_home(session),
|
|
115
|
+
session["provider"],
|
|
116
|
+
notifications_enabled(session),
|
|
117
|
+
ctx.get("env"),
|
|
118
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
119
|
+
base_dir=ctx["service"]["base_dir"],
|
|
120
|
+
)
|
|
121
|
+
if installed and not json_flag:
|
|
122
|
+
notice = f"Notification hooks installed for {session['name']}"
|
|
123
|
+
if session["provider"] == PROVIDER_CODEX:
|
|
124
|
+
# Codex refuses to run a hook it has not been told to trust, so
|
|
125
|
+
# silence here would leave the user with a feature that quietly does
|
|
126
|
+
# nothing. Claude Code honours them straight away — verified against
|
|
127
|
+
# a real turn — so it gets no instruction it does not need.
|
|
128
|
+
notice += " — approve them once in Codex to enable"
|
|
129
|
+
ctx["out"](f"{_info(notice, ctx['use_color'])}\n")
|
|
111
130
|
cwd = ctx.get("cwd") or os.getcwd()
|
|
112
131
|
runtime_run_id = None
|
|
113
132
|
|
package/src/notify.py
CHANGED
|
@@ -10,126 +10,51 @@ from .errors import CdxError
|
|
|
10
10
|
from .session_ranking import rank_sessions
|
|
11
11
|
from .status_view import (
|
|
12
12
|
PRIORITY_EMPTY_AVAILABLE_THRESHOLD,
|
|
13
|
-
_parse_reset_timestamp,
|
|
14
13
|
_priority_reset_timestamp,
|
|
15
14
|
)
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def parse_notify_args(args):
|
|
21
|
-
json_flag = "--json" in args
|
|
22
|
-
once = "--once" in args
|
|
23
|
-
at_reset = "--at-reset" in args
|
|
24
|
-
next_ready = "--next-ready" in args
|
|
25
|
-
refresh = "--refresh" in args
|
|
26
|
-
schedule = "--schedule" in args
|
|
27
|
-
poll = 60
|
|
28
|
-
cleaned = []
|
|
29
|
-
i = 0
|
|
30
|
-
while i < len(args):
|
|
31
|
-
arg = args[i]
|
|
32
|
-
if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh", "--schedule"):
|
|
33
|
-
i += 1
|
|
34
|
-
continue
|
|
35
|
-
if arg == "--poll":
|
|
36
|
-
if i + 1 >= len(args):
|
|
37
|
-
raise CdxError(NOTIFY_USAGE)
|
|
38
|
-
try:
|
|
39
|
-
poll = max(1, int(args[i + 1]))
|
|
40
|
-
except ValueError as error:
|
|
41
|
-
raise CdxError("--poll must be a number of seconds") from error
|
|
42
|
-
i += 2
|
|
43
|
-
continue
|
|
44
|
-
if arg.startswith("--poll="):
|
|
45
|
-
try:
|
|
46
|
-
poll = max(1, int(arg.split("=", 1)[1]))
|
|
47
|
-
except ValueError as error:
|
|
48
|
-
raise CdxError("--poll must be a number of seconds") from error
|
|
49
|
-
i += 1
|
|
50
|
-
continue
|
|
51
|
-
if arg.startswith("-"):
|
|
52
|
-
raise CdxError(NOTIFY_USAGE)
|
|
53
|
-
cleaned.append(arg)
|
|
54
|
-
i += 1
|
|
55
|
-
if at_reset == next_ready:
|
|
56
|
-
raise CdxError(NOTIFY_USAGE)
|
|
57
|
-
if at_reset and len(cleaned) != 1:
|
|
58
|
-
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh]")
|
|
59
|
-
if next_ready and cleaned:
|
|
60
|
-
raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]")
|
|
61
|
-
return {
|
|
62
|
-
"name": cleaned[0] if cleaned else None,
|
|
63
|
-
"mode": "at-reset" if at_reset else "next-ready",
|
|
64
|
-
"poll": poll,
|
|
65
|
-
"once": once,
|
|
66
|
-
"json": json_flag,
|
|
67
|
-
"refresh": refresh,
|
|
68
|
-
"schedule": schedule,
|
|
69
|
-
}
|
|
16
|
+
READY_USAGE = "Usage: cdx ready [--refresh] [--json]"
|
|
70
17
|
|
|
71
18
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
notifier(event["title"], event["message"])
|
|
90
|
-
return event
|
|
91
|
-
if progress_callback:
|
|
92
|
-
progress_callback({
|
|
93
|
-
"event": "notify_waiting",
|
|
94
|
-
"message": event["message"],
|
|
95
|
-
"poll": parsed["poll"],
|
|
96
|
-
"session_name": event.get("session"),
|
|
97
|
-
"target_timestamp": event.get("target_timestamp"),
|
|
98
|
-
})
|
|
99
|
-
sleep_fn(parsed["poll"])
|
|
19
|
+
def parse_ready_args(args):
|
|
20
|
+
"""`cdx ready [--refresh] [--json]`.
|
|
21
|
+
|
|
22
|
+
This used to parse a much wider surface — --at-reset, --once, --poll and a
|
|
23
|
+
blocking wait loop — reachable through a `cdx notify` that no longer exists.
|
|
24
|
+
`cdx ready` always schedules the next-ready event, so none of it could run.
|
|
25
|
+
"""
|
|
26
|
+
unknown = [arg for arg in args if arg not in ("--refresh", "--json")]
|
|
27
|
+
if unknown:
|
|
28
|
+
raise CdxError(READY_USAGE)
|
|
29
|
+
return {
|
|
30
|
+
"name": None,
|
|
31
|
+
"mode": "next-ready",
|
|
32
|
+
"json": "--json" in args,
|
|
33
|
+
"refresh": "--refresh" in args,
|
|
34
|
+
"schedule": True,
|
|
35
|
+
}
|
|
100
36
|
|
|
101
37
|
|
|
102
38
|
def resolve_notify_event(rows, parsed, now_ts=None):
|
|
103
39
|
now_ts = time.time() if now_ts is None else now_ts
|
|
104
|
-
if
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return _event(False, "cdx", f"Waiting for {first['session_name']}", first["session_name"], timestamp)
|
|
123
|
-
|
|
124
|
-
row = next((item for item in rows if item["session_name"] == parsed["name"]), None)
|
|
125
|
-
if not row:
|
|
126
|
-
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
127
|
-
timestamp = _next_reset_timestamp(row)
|
|
128
|
-
if timestamp is None:
|
|
129
|
-
return _event(False, "cdx", f"No reset time known for {row['session_name']}", row["session_name"])
|
|
130
|
-
if timestamp <= now_ts:
|
|
131
|
-
return _event(True, "cdx", f"{row['session_name']} reset is due", row["session_name"], timestamp)
|
|
132
|
-
return _event(False, "cdx", f"Waiting for {row['session_name']} reset", row["session_name"], timestamp)
|
|
40
|
+
active_rows = [row for row in rows if row.get("enabled", True) is not False]
|
|
41
|
+
# Same ranking every other selector uses; the pre-filter is what makes
|
|
42
|
+
# this "next to come back" rather than "best right now".
|
|
43
|
+
priority, _decision = rank_sessions(
|
|
44
|
+
[
|
|
45
|
+
row for row in active_rows
|
|
46
|
+
if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
|
|
47
|
+
],
|
|
48
|
+
now_ts,
|
|
49
|
+
_priority_reset_timestamp,
|
|
50
|
+
)
|
|
51
|
+
if not priority:
|
|
52
|
+
return _event(False, "cdx", "No upcoming session reset available", None)
|
|
53
|
+
first = priority[0]
|
|
54
|
+
timestamp = _priority_reset_timestamp(first)
|
|
55
|
+
if _needs_refresh(first, timestamp, now_ts):
|
|
56
|
+
return _event(True, "cdx", f"{first['session_name']} reset is due; refresh status", first["session_name"])
|
|
57
|
+
return _event(False, "cdx", f"Waiting for {first['session_name']}", first["session_name"], timestamp)
|
|
133
58
|
|
|
134
59
|
|
|
135
60
|
def _is_usable_now(row):
|
|
@@ -144,18 +69,6 @@ def _needs_refresh(row, reset_timestamp, now_ts):
|
|
|
144
69
|
return reset_timestamp is not None and reset_timestamp < now_ts
|
|
145
70
|
|
|
146
71
|
|
|
147
|
-
def _next_reset_timestamp(row):
|
|
148
|
-
values = [row.get("reset_5h_at"), row.get("reset_week_at"), row.get("reset_at")]
|
|
149
|
-
timestamps = [
|
|
150
|
-
timestamp
|
|
151
|
-
for timestamp in (_parse_reset_timestamp(value) for value in values)
|
|
152
|
-
if timestamp is not None
|
|
153
|
-
]
|
|
154
|
-
if not timestamps:
|
|
155
|
-
return None
|
|
156
|
-
return min(timestamps)
|
|
157
|
-
|
|
158
|
-
|
|
159
72
|
def _event(ready, title, message, session_name, target_timestamp=None):
|
|
160
73
|
return {
|
|
161
74
|
"ready": ready,
|
|
@@ -166,22 +79,70 @@ def _event(ready, title, message, session_name, target_timestamp=None):
|
|
|
166
79
|
}
|
|
167
80
|
|
|
168
81
|
|
|
169
|
-
def
|
|
82
|
+
def notification_channel(env=None):
|
|
83
|
+
"""Name the delivery mechanism usable on this host, or None if there is none.
|
|
84
|
+
|
|
85
|
+
Two callers depend on this answer: `cdx notify` decides whether to bother
|
|
86
|
+
delivering, and launch provisioning decides whether to install hooks at all.
|
|
87
|
+
A host with no channel gets no hooks, so the user is never asked to approve
|
|
88
|
+
a hook that could not have shown them anything.
|
|
89
|
+
"""
|
|
170
90
|
import sys
|
|
171
|
-
spawn_sync = spawn_sync or subprocess.run
|
|
172
91
|
env = env or os.environ
|
|
92
|
+
path = env.get("PATH")
|
|
173
93
|
if sys.platform == "win32":
|
|
174
|
-
|
|
175
|
-
|
|
94
|
+
return "powershell" if shutil.which("powershell", path=path) else None
|
|
95
|
+
if _is_wsl(env):
|
|
96
|
+
# WSL has no notification daemon of its own; delivery crosses to Windows
|
|
97
|
+
# through interop, which /etc/wsl.conf can disable.
|
|
98
|
+
return "wsl" if shutil.which("powershell.exe", path=path) else None
|
|
99
|
+
if shutil.which("osascript", path=path):
|
|
100
|
+
return "osascript" if _has_desktop_session(env) else None
|
|
101
|
+
if shutil.which("notify-send", path=path):
|
|
102
|
+
return "notify-send" if _has_desktop_session(env) else None
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_wsl(env):
|
|
107
|
+
if env.get("WSL_DISTRO_NAME") or env.get("WSL_INTEROP"):
|
|
108
|
+
return True
|
|
109
|
+
try:
|
|
110
|
+
with open("/proc/version", encoding="utf-8") as handle:
|
|
111
|
+
return "microsoft" in handle.read().lower()
|
|
112
|
+
except OSError:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _has_desktop_session(env):
|
|
117
|
+
"""Whether anything is around to render a notification.
|
|
118
|
+
|
|
119
|
+
Over SSH or in a container there is no session bus and no display, so
|
|
120
|
+
notify-send is delivered nowhere and osascript has no session to talk to.
|
|
121
|
+
"""
|
|
122
|
+
import sys
|
|
123
|
+
if sys.platform == "darwin":
|
|
124
|
+
return not env.get("SSH_CONNECTION")
|
|
125
|
+
return bool(env.get("DISPLAY") or env.get("WAYLAND_DISPLAY") or env.get("DBUS_SESSION_BUS_ADDRESS"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def send_desktop_notification(title, message, spawn_sync=None, env=None):
|
|
129
|
+
spawn_sync = spawn_sync or subprocess.run
|
|
130
|
+
env = env or os.environ
|
|
131
|
+
channel = notification_channel(env)
|
|
132
|
+
if channel == "powershell":
|
|
133
|
+
_send_windows_notification(title, message, spawn_sync, env, ["powershell"])
|
|
134
|
+
elif channel == "wsl":
|
|
135
|
+
_send_windows_notification(title, message, spawn_sync, env, ["powershell.exe"])
|
|
136
|
+
elif channel == "osascript":
|
|
176
137
|
script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
|
|
177
138
|
_run_notification_command(
|
|
178
139
|
["osascript", "-e", script],
|
|
179
140
|
spawn_sync,
|
|
180
141
|
env,
|
|
181
142
|
)
|
|
182
|
-
elif
|
|
143
|
+
elif channel == "notify-send":
|
|
183
144
|
_run_notification_command(
|
|
184
|
-
["notify-send", str(title), str(message)],
|
|
145
|
+
["notify-send", "-a", "cdx", str(title), str(message)],
|
|
185
146
|
spawn_sync,
|
|
186
147
|
env,
|
|
187
148
|
)
|
|
@@ -190,26 +151,45 @@ def send_desktop_notification(title, message, spawn_sync=None, env=None):
|
|
|
190
151
|
def _run_notification_command(argv, spawn_sync, env):
|
|
191
152
|
try:
|
|
192
153
|
spawn_sync(argv, env=env, capture_output=True, text=True, timeout=5)
|
|
193
|
-
except (FileNotFoundError, OSError):
|
|
154
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
194
155
|
pass
|
|
195
156
|
|
|
196
157
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
158
|
+
# Windows only shows a toast on behalf of an application it knows. Registering our
|
|
159
|
+
# own identifier would mean installing a Start Menu shortcut, so we borrow the one
|
|
160
|
+
# PowerShell already ships with; an unknown identifier is dropped in silence.
|
|
161
|
+
_POWERSHELL_AUMID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"
|
|
162
|
+
|
|
163
|
+
_TOAST_SCRIPT = """
|
|
164
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null
|
|
165
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] > $null
|
|
166
|
+
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
167
|
+
$nodes = $template.GetElementsByTagName('text')
|
|
168
|
+
$nodes.Item(0).AppendChild($template.CreateTextNode('{title}')) > $null
|
|
169
|
+
$nodes.Item(1).AppendChild($template.CreateTextNode('{message}')) > $null
|
|
170
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
|
|
171
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{aumid}').Show($toast)
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _send_windows_notification(title, message, spawn_sync, env, launcher):
|
|
176
|
+
"""Raise a toast rather than a dialog.
|
|
177
|
+
|
|
178
|
+
The dialog this replaced blocked with no timeout, which was survivable for a
|
|
179
|
+
once-a-day quota alert and not for a hook that fires on every agent turn: an
|
|
180
|
+
undismissed window holds the turn open, and one that steals focus every turn
|
|
181
|
+
is worse than no notification at all.
|
|
182
|
+
"""
|
|
183
|
+
script = _TOAST_SCRIPT.format(
|
|
184
|
+
title=_escape_powershell(title),
|
|
185
|
+
message=_escape_powershell(message),
|
|
186
|
+
aumid=_POWERSHELL_AUMID,
|
|
187
|
+
)
|
|
188
|
+
_run_notification_command(
|
|
189
|
+
[*launcher, "-NoProfile", "-NonInteractive", "-Command", script],
|
|
190
|
+
spawn_sync,
|
|
191
|
+
env,
|
|
203
192
|
)
|
|
204
|
-
try:
|
|
205
|
-
spawn_sync(
|
|
206
|
-
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
207
|
-
env=env,
|
|
208
|
-
capture_output=True,
|
|
209
|
-
text=True,
|
|
210
|
-
)
|
|
211
|
-
except (FileNotFoundError, OSError):
|
|
212
|
-
pass
|
|
213
193
|
|
|
214
194
|
|
|
215
195
|
def _escape_powershell(value):
|
|
@@ -437,10 +417,6 @@ def _escape_xml(value):
|
|
|
437
417
|
return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
438
418
|
|
|
439
419
|
|
|
440
|
-
def format_notify_event(event):
|
|
441
|
-
return event["message"]
|
|
442
|
-
|
|
443
|
-
|
|
444
420
|
def format_scheduled_notification(schedule):
|
|
445
421
|
if schedule.get("scheduled"):
|
|
446
422
|
return f"Scheduled notification via {schedule['backend']} for {schedule['target_iso']}"
|
package/src/provider_runtime.py
CHANGED
|
@@ -10,6 +10,7 @@ import sys
|
|
|
10
10
|
import uuid
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
|
|
13
|
+
from .agent_notify import launch_notify_env, notifications_enabled
|
|
13
14
|
from .claude_usage import _clean_oauth_token, _decode_jwt_claims
|
|
14
15
|
from .codex_usage import codex_auth_lock
|
|
15
16
|
from .config import (
|
|
@@ -517,6 +518,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
517
518
|
cwd = cwd or os.getcwd()
|
|
518
519
|
env_override = env_override or {}
|
|
519
520
|
env = {**os.environ, **env_override}
|
|
521
|
+
env.update(launch_notify_env(session, notifications_enabled(session)))
|
|
520
522
|
initial_prompt = _with_launch_preferences(session, initial_prompt, env=env)
|
|
521
523
|
_validate_initial_prompt(initial_prompt)
|
|
522
524
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
@@ -724,6 +726,9 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
724
726
|
_validate_initial_prompt(initial_prompt)
|
|
725
727
|
cwd = cwd or os.getcwd()
|
|
726
728
|
env = {**os.environ, **(env_override or {})}
|
|
729
|
+
# Headless runs read the same home, and so the same hooks, as an interactive
|
|
730
|
+
# launch. Their caller already learns of completion from the return value.
|
|
731
|
+
env.update(launch_notify_env(session, enabled=False))
|
|
727
732
|
initial_prompt = _with_launch_preferences(session, initial_prompt, env=env)
|
|
728
733
|
_validate_initial_prompt(initial_prompt)
|
|
729
734
|
launch = session.get("launch") or {}
|
package/src/session_service.py
CHANGED
|
@@ -211,6 +211,18 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
211
211
|
normalized["logics"] = False
|
|
212
212
|
else:
|
|
213
213
|
raise CdxError(f"Unsupported logics value: {settings['logics']}")
|
|
214
|
+
if "notify" in settings and settings["notify"] is not None:
|
|
215
|
+
value = settings["notify"]
|
|
216
|
+
if isinstance(value, bool):
|
|
217
|
+
normalized["notify"] = value
|
|
218
|
+
else:
|
|
219
|
+
text = str(value).strip().lower()
|
|
220
|
+
if text in ("on", "true", "1", "yes"):
|
|
221
|
+
normalized["notify"] = True
|
|
222
|
+
elif text in ("off", "false", "0", "no"):
|
|
223
|
+
normalized["notify"] = False
|
|
224
|
+
else:
|
|
225
|
+
raise CdxError(f"Unsupported notify value: {settings['notify']}")
|
|
214
226
|
if "model" in settings and settings["model"] is not None:
|
|
215
227
|
model = str(settings["model"]).strip()
|
|
216
228
|
if not model:
|
|
@@ -349,7 +361,7 @@ def unset_launch_settings(store, name, keys):
|
|
|
349
361
|
if not keys:
|
|
350
362
|
raise CdxError("At least one launch setting is required.")
|
|
351
363
|
allowed = {
|
|
352
|
-
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics",
|
|
364
|
+
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify",
|
|
353
365
|
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
354
366
|
}
|
|
355
367
|
unknown = [key for key in keys if key not in allowed]
|