cdx-manager 0.16.0 → 0.17.1
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 +21 -4
- package/changelogs/CHANGELOGS_0_17_0.md +64 -0
- package/changelogs/CHANGELOGS_0_17_1.md +29 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +41 -6
- package/src/cli.py +6 -3
- package/src/cli_args.py +4 -2
- package/src/cli_helpers.py +5 -4
- package/src/cli_render.py +1 -0
- package/src/commands/launch.py +3 -0
- package/src/commands/settings.py +29 -0
- package/src/commands/status.py +2 -1
- package/src/provider_runtime.py +137 -3
- 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
|
|
|
@@ -349,8 +349,8 @@ When a session's agent finishes a turn, or stops to wait for you, cdx raises a
|
|
|
349
349
|
desktop notification naming the session and the repository:
|
|
350
350
|
|
|
351
351
|
```text
|
|
352
|
-
✓ work1 logics-manager ·
|
|
353
|
-
✓ codex-a cdx-manager ·
|
|
352
|
+
✓ work1 logics-manager · needs your attention
|
|
353
|
+
✓ codex-a cdx-manager · turn complete
|
|
354
354
|
```
|
|
355
355
|
|
|
356
356
|
Off until you ask for it, per session:
|
|
@@ -359,6 +359,7 @@ Off until you ask for it, per session:
|
|
|
359
359
|
cdx set work1 --notify on # turn it on for one session
|
|
360
360
|
cdx set --all --notify on # turn it on everywhere
|
|
361
361
|
cdx set work1 --notify off # and back off; cdx removes what it installed
|
|
362
|
+
cdx set work1 --notify-preview on # include a short final-response preview
|
|
362
363
|
```
|
|
363
364
|
|
|
364
365
|
Turning it on records the setting. The wiring happens at that session's **next
|
|
@@ -372,6 +373,16 @@ It is off by default because turning it on writes into the provider's own
|
|
|
372
373
|
configuration and, on Codex, puts an approval prompt in front of you. That is
|
|
373
374
|
not something to do to sessions you already own without being asked.
|
|
374
375
|
|
|
376
|
+
Response previews are separately off by default. When enabled, a completion
|
|
377
|
+
notification includes at most 180 characters of the agent's final response.
|
|
378
|
+
That text can be visible on a lock screen, so enable it only for sessions whose
|
|
379
|
+
output is safe to expose. Previews never read transcripts and are not shown for
|
|
380
|
+
permission requests.
|
|
381
|
+
|
|
382
|
+
`cdx notify` is the internal hook target, called by the provider; it is not the
|
|
383
|
+
setup command. `cdx ready` is separate: it notifies you when the next cooling-
|
|
384
|
+
down session becomes usable.
|
|
385
|
+
|
|
375
386
|
Per platform: macOS uses `osascript`, Linux `notify-send`, Windows a toast, and
|
|
376
387
|
WSL reaches the Windows notification centre through interop. On a host with no
|
|
377
388
|
way to deliver one — a headless or SSH Linux session, or WSL with interop
|
|
@@ -421,6 +432,12 @@ cdx model provider:ollama default
|
|
|
421
432
|
|
|
422
433
|
`--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.
|
|
423
434
|
|
|
435
|
+
### Terminal Titles
|
|
436
|
+
|
|
437
|
+
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.
|
|
438
|
+
|
|
439
|
+
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.
|
|
440
|
+
|
|
424
441
|
### Launch History
|
|
425
442
|
|
|
426
443
|
Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including success/failure, duration, cwd, launch settings, and transcript path.
|
|
@@ -483,7 +500,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
483
500
|
| `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
|
|
484
501
|
| `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 |
|
|
485
502
|
| `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
|
|
486
|
-
| `cdx notify` |
|
|
503
|
+
| `cdx notify` | Internal provider hook target for agent alerts; use `cdx set <name> --notify on` to enable it |
|
|
487
504
|
| `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
|
|
488
505
|
| `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 |
|
|
489
506
|
| `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 |
|
|
@@ -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.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# CDX Manager 0.17.1
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
### Agent-alert setup now reports provider support accurately
|
|
6
|
+
|
|
7
|
+
`cdx set <name> --notify on` now distinguishes sessions where cdx can install
|
|
8
|
+
provider hooks (Claude and Codex) from providers that do not expose that
|
|
9
|
+
integration yet. A mixed bulk selection reports both outcomes instead of
|
|
10
|
+
promising a future hook installation that cannot happen.
|
|
11
|
+
|
|
12
|
+
The setting remains storable for unsupported providers, so bulk configuration
|
|
13
|
+
remains predictable and a future provider integration can honour the existing
|
|
14
|
+
opt-in.
|
|
15
|
+
|
|
16
|
+
### Permission-request notifications constrain tool names
|
|
17
|
+
|
|
18
|
+
Tool names supplied by provider hook payloads now use the same printable,
|
|
19
|
+
single-line normalization as response previews and are capped at 80 characters.
|
|
20
|
+
This keeps permission notifications readable and prevents malformed payloads
|
|
21
|
+
from expanding or controlling desktop notification text.
|
|
22
|
+
|
|
23
|
+
## Validation
|
|
24
|
+
|
|
25
|
+
- `npm run lint`
|
|
26
|
+
- `npm test`
|
|
27
|
+
- `npm run release:validate`
|
|
28
|
+
- `logics-manager lint --require-status`
|
|
29
|
+
- `logics-manager audit --legacy-cutoff-version 1.1.0 --group-by-doc`
|
|
@@ -180,6 +180,14 @@
|
|
|
180
180
|
"v0.15.1": {
|
|
181
181
|
"github_tarball_sha256": "1dc7ea87600844e4853ab12c9f123dc67548ae904f511fea9a58ddf7b3eacfef",
|
|
182
182
|
"github_zip_sha256": "7ad41c63adffdd1ef12d02a72c86fb1b6a64cbcddd9970b0741ba5516f241707"
|
|
183
|
+
},
|
|
184
|
+
"v0.16.0": {
|
|
185
|
+
"github_tarball_sha256": "bdcd2808922410b20567ba86589d49166c5c2b9404325195b28457df80ca3dad",
|
|
186
|
+
"github_zip_sha256": "e89cb8c964304f0805d06da9d355d543f8a590e0febbf07e585a692ccf73ef99"
|
|
187
|
+
},
|
|
188
|
+
"v0.17.0": {
|
|
189
|
+
"github_tarball_sha256": "6f321106aa426590ce809788fab4f26401b4cc499a6c518a56a4f24f8bfb6c2e",
|
|
190
|
+
"github_zip_sha256": "72665a0006df40bb27301140b0ed6600a0d91b456facbb5a2aad2a917357ae75"
|
|
183
191
|
}
|
|
184
192
|
}
|
|
185
193
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/agent_notify.py
CHANGED
|
@@ -19,13 +19,21 @@ from .notify import notification_channel, send_desktop_notification
|
|
|
19
19
|
# Set on the provider process at launch; the hook runs as its child and inherits both.
|
|
20
20
|
SESSION_ENV = "CDX_SESSION_NAME"
|
|
21
21
|
ENABLED_ENV = "CDX_NOTIFY"
|
|
22
|
+
PREVIEW_ENV = "CDX_NOTIFY_PREVIEW"
|
|
22
23
|
|
|
23
24
|
# Our entries are recognised by the command they run, not by a marker key of our
|
|
24
25
|
# own: both providers validate this file against their own schema, and an extra
|
|
25
26
|
# key they do not know is a rejection risk for no gain.
|
|
26
27
|
HOOK_ARG = "notify"
|
|
27
28
|
|
|
28
|
-
_WAITING_EVENTS = {"notification", "permissionrequest"
|
|
29
|
+
_WAITING_EVENTS = {"notification", "permissionrequest"}
|
|
30
|
+
_PREVIEW_LIMIT = 180
|
|
31
|
+
_TOOL_NAME_LIMIT = 80
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def supports_agent_alerts(provider):
|
|
35
|
+
"""Whether cdx can provision provider hooks for this session."""
|
|
36
|
+
return provider in {"claude", "codex"}
|
|
29
37
|
|
|
30
38
|
|
|
31
39
|
def resolve_hook_command(env=None):
|
|
@@ -60,7 +68,7 @@ def read_hook_payload(args, stdin_text=None):
|
|
|
60
68
|
|
|
61
69
|
def compose_notification(payload, env=None, cwd=None):
|
|
62
70
|
"""Title and message for this event, or None if we should stay quiet."""
|
|
63
|
-
env = env
|
|
71
|
+
env = os.environ if env is None else env
|
|
64
72
|
if env.get(ENABLED_ENV) == "0":
|
|
65
73
|
# Headless runs share the session's home, so they read the same hooks.
|
|
66
74
|
# Their caller already learns of completion from the return value.
|
|
@@ -70,9 +78,29 @@ def compose_notification(payload, env=None, cwd=None):
|
|
|
70
78
|
return None
|
|
71
79
|
directory = payload.get("cwd") or payload.get("workspace") or cwd or os.getcwd()
|
|
72
80
|
event = str(payload.get("hook_event_name") or payload.get("eventName") or payload.get("type") or "").lower()
|
|
73
|
-
|
|
81
|
+
event = event.replace("_", "").replace("-", "")
|
|
82
|
+
state = "needs your attention" if event in _WAITING_EVENTS else "turn complete"
|
|
83
|
+
tool_name = _notification_text(payload.get("tool_name"), _TOOL_NAME_LIMIT)
|
|
84
|
+
if event == "permissionrequest" and tool_name:
|
|
85
|
+
state += f" ({tool_name})"
|
|
74
86
|
where = os.path.basename(str(directory).rstrip("/\\")) or directory
|
|
75
|
-
|
|
87
|
+
preview = _notification_preview(payload, event, env)
|
|
88
|
+
return f"✓ {session}", f"{where} · {state}" + (f" — {preview}" if preview else "")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _notification_preview(payload, event, env):
|
|
92
|
+
if event != "stop" or env.get(PREVIEW_ENV) != "1":
|
|
93
|
+
return ""
|
|
94
|
+
return _notification_text(payload.get("last_assistant_message"), _PREVIEW_LIMIT)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _notification_text(value, limit):
|
|
98
|
+
if not isinstance(value, str):
|
|
99
|
+
return ""
|
|
100
|
+
value = " ".join("".join(char if char.isprintable() else " " for char in value).split())
|
|
101
|
+
if len(value) <= limit:
|
|
102
|
+
return value
|
|
103
|
+
return value[:limit - 1].rstrip() + "…"
|
|
76
104
|
|
|
77
105
|
|
|
78
106
|
def handle_notify(rest, ctx):
|
|
@@ -82,9 +110,12 @@ def handle_notify(rest, ctx):
|
|
|
82
110
|
stdin_text = None
|
|
83
111
|
# "stdin" in ctx is the {"isTTY": ...} descriptor, not a stream.
|
|
84
112
|
reader = ctx.get("prompt_stdin") or sys.stdin
|
|
113
|
+
env = os.environ if ctx.get("env") is None else ctx["env"]
|
|
114
|
+
if ctx.get("stdin_is_tty") and not env.get(SESSION_ENV):
|
|
115
|
+
ctx["out"]("cdx notify is called by provider hooks. Enable agent alerts with: cdx set <name> --notify on\n")
|
|
116
|
+
return 0
|
|
85
117
|
if reader is not None and not ctx.get("stdin_is_tty"):
|
|
86
118
|
stdin_text = reader.read()
|
|
87
|
-
env = ctx.get("env") or os.environ
|
|
88
119
|
composed = compose_notification(read_hook_payload(rest, stdin_text), env, ctx.get("cwd"))
|
|
89
120
|
if composed:
|
|
90
121
|
send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
|
|
@@ -98,6 +129,8 @@ def launch_notify_env(session, enabled, env=None):
|
|
|
98
129
|
values = {SESSION_ENV: session["name"]}
|
|
99
130
|
if not enabled:
|
|
100
131
|
values[ENABLED_ENV] = "0"
|
|
132
|
+
elif (session.get("launch") or {}).get("notify_preview") is True:
|
|
133
|
+
values[PREVIEW_ENV] = "1"
|
|
101
134
|
return values
|
|
102
135
|
|
|
103
136
|
|
|
@@ -130,6 +163,8 @@ def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=
|
|
|
130
163
|
# Nothing could be delivered on this host, so installing a hook would only
|
|
131
164
|
# buy the user an approval prompt for a feature that shows nothing.
|
|
132
165
|
enabled = False
|
|
166
|
+
if not supports_agent_alerts(provider):
|
|
167
|
+
return False
|
|
133
168
|
command = resolve_hook_command(env)
|
|
134
169
|
if provider == "claude":
|
|
135
170
|
return _apply_hooks(hook_config_path(auth_home, provider), enabled, command)
|
|
@@ -224,7 +259,7 @@ def _write_codex_plugin(auth_home, command, base_dir=None):
|
|
|
224
259
|
os.path.join(root, "hooks", "cdx-hooks.json"): {
|
|
225
260
|
"hooks": {
|
|
226
261
|
event: [{"hooks": [{"type": "command", "command": f"{command} {HOOK_ARG}"}]}]
|
|
227
|
-
for event in ("Stop", "
|
|
262
|
+
for event in ("Stop", "PermissionRequest")
|
|
228
263
|
},
|
|
229
264
|
},
|
|
230
265
|
}
|
package/src/cli.py
CHANGED
|
@@ -231,8 +231,8 @@ def _print_help(use_color=False):
|
|
|
231
231
|
f" {_style('cdx config <name> [--json]', '36', use_color)}",
|
|
232
232
|
f" {_style('cdx configs [--json]', '36', use_color)}",
|
|
233
233
|
f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
|
|
234
|
-
f" {_style('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] [--model MODEL] [--priority 0..100] [--json]', '36', use_color)}",
|
|
235
|
-
f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--model|--priority|--all) [--json]', '36', use_color)}",
|
|
234
|
+
f" {_style('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] [--model MODEL] [--priority 0..100] [--json]', '36', use_color)}",
|
|
235
|
+
f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--model|--priority|--all) [--json]', '36', use_color)}",
|
|
236
236
|
f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
237
237
|
f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
238
238
|
f" {_style('cdx last [--json]', '36', use_color)}",
|
|
@@ -259,7 +259,10 @@ def _print_help(use_color=False):
|
|
|
259
259
|
f" {_style('cdx view [--json]', '36', use_color)}",
|
|
260
260
|
f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
|
|
261
261
|
f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
|
|
262
|
-
|
|
262
|
+
"",
|
|
263
|
+
_style("Notifications:", "1", use_color),
|
|
264
|
+
f" {_style('cdx set <name> --notify on', '36', use_color)} # agent finishes or needs attention",
|
|
265
|
+
f" {_style('cdx ready', '36', use_color)} # next session to become ready",
|
|
263
266
|
f" {_style('cdx <name> [--json]', '36', use_color)}",
|
|
264
267
|
f" {_style('cdx --help', '36', use_color)}",
|
|
265
268
|
f" {_style('cdx --version', '36', use_color)}",
|
package/src/cli_args.py
CHANGED
|
@@ -249,6 +249,7 @@ def _parse_set_args(args):
|
|
|
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
251
|
"--notify": {"key": "notify", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
252
|
+
"--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
252
253
|
"--model": {"key": "model", "type": "str", "default": None},
|
|
253
254
|
"--fallback-model": {"key": "fallback_model", "type": "str", "default": None},
|
|
254
255
|
"--budget": {"key": "budget", "type": "str", "default": None, "transform": _parse_budget_value},
|
|
@@ -270,7 +271,7 @@ def _parse_set_args(args):
|
|
|
270
271
|
raise CdxError(SET_USAGE)
|
|
271
272
|
settings = {
|
|
272
273
|
key: parsed[key]
|
|
273
|
-
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
274
|
+
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
274
275
|
if parsed[key] is not None
|
|
275
276
|
}
|
|
276
277
|
if not settings:
|
|
@@ -293,6 +294,7 @@ def _parse_unset_args(args):
|
|
|
293
294
|
"--rtk": {"key": "rtk", "type": "bool", "default": False},
|
|
294
295
|
"--logics": {"key": "logics", "type": "bool", "default": False},
|
|
295
296
|
"--notify": {"key": "notify", "type": "bool", "default": False},
|
|
297
|
+
"--notify-preview": {"key": "notify_preview", "type": "bool", "default": False},
|
|
296
298
|
"--model": {"key": "model", "type": "bool", "default": False},
|
|
297
299
|
"--fallback-model": {"key": "fallback_model", "type": "bool", "default": False},
|
|
298
300
|
"--budget": {"key": "budget", "type": "bool", "default": False},
|
|
@@ -314,7 +316,7 @@ def _parse_unset_args(args):
|
|
|
314
316
|
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
315
317
|
raise CdxError(UNSET_USAGE)
|
|
316
318
|
_UNSET_KEYS = (
|
|
317
|
-
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify",
|
|
319
|
+
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
|
|
318
320
|
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
319
321
|
)
|
|
320
322
|
keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
|
package/src/cli_helpers.py
CHANGED
|
@@ -486,7 +486,8 @@ def _format_launch_config(session, use_color=False):
|
|
|
486
486
|
("fast", "Fast"),
|
|
487
487
|
("rtk", "RTK"),
|
|
488
488
|
("logics", "Logics"),
|
|
489
|
-
("notify", "
|
|
489
|
+
("notify", "Agent alerts"),
|
|
490
|
+
("notify_preview", "Response preview"),
|
|
490
491
|
("model", "Model"),
|
|
491
492
|
("fallback_model", "Fallback"),
|
|
492
493
|
("budget", "Budget"),
|
|
@@ -509,18 +510,18 @@ def _format_launch_config(session, use_color=False):
|
|
|
509
510
|
def _format_launch_settings_hint(name="<name>"):
|
|
510
511
|
return (
|
|
511
512
|
f"Set a value: cdx set {name} --power medium --permission auto "
|
|
512
|
-
"--fast on --rtk on --logics on --notify on --model MODEL --priority 80"
|
|
513
|
+
"--fast on --rtk on --logics on --notify on --notify-preview on --model MODEL --priority 80"
|
|
513
514
|
)
|
|
514
515
|
|
|
515
516
|
def _format_launch_setting_value(launch, key, use_color=False):
|
|
516
|
-
if key in ("fast", "rtk", "logics", "notify"):
|
|
517
|
+
if key in ("fast", "rtk", "logics", "notify", "notify_preview"):
|
|
517
518
|
if launch.get(key) is True:
|
|
518
519
|
return _style("on", "32", use_color)
|
|
519
520
|
if launch.get(key) is False:
|
|
520
521
|
return _style("off", "2", use_color)
|
|
521
522
|
if key == "logics":
|
|
522
523
|
return _dim("auto", use_color)
|
|
523
|
-
if key
|
|
524
|
+
if key in ("notify", "notify_preview"):
|
|
524
525
|
return _style("off", "2", use_color)
|
|
525
526
|
return _dim("default", use_color)
|
|
526
527
|
value = launch.get(key)
|
package/src/cli_render.py
CHANGED
|
@@ -175,6 +175,7 @@ def _format_sessions(service, use_color=False):
|
|
|
175
175
|
f" {_style('cdx configs', '36', use_color)}",
|
|
176
176
|
f" {_style('cdx stats', '36', use_color)}",
|
|
177
177
|
f" {_style('cdx ready', '36', use_color)}",
|
|
178
|
+
f" {_style('cdx set <name> --notify on', '36', use_color)}",
|
|
178
179
|
f" {_style('cdx perm all default', '36', use_color)}",
|
|
179
180
|
f" {_style('cdx handoff <source> <target>', '36', use_color)}",
|
|
180
181
|
f" {_style('cdx history', '36', use_color)}",
|
package/src/commands/launch.py
CHANGED
|
@@ -147,6 +147,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
147
147
|
session, "resume" if resume else "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
|
|
148
148
|
signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
|
|
149
149
|
lifecycle_callback=runtime_lifecycle,
|
|
150
|
+
# A JSON run owns stdout: a terminal title sequence would land in
|
|
151
|
+
# the middle of the document its caller has to parse.
|
|
152
|
+
title_enabled=not json_flag,
|
|
150
153
|
)
|
|
151
154
|
except CdxError as error:
|
|
152
155
|
run_info = getattr(error, "run_info", {}) or {}
|
package/src/commands/settings.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from ..agent_notify import supports_agent_alerts
|
|
6
7
|
from ..cli_args import _parse_launch_setting_alias_args, _parse_set_args, _parse_unset_args
|
|
7
8
|
from ..cli_helpers import _format_launch_config, _json_success, _write_json
|
|
8
9
|
from ..cli_render import _success
|
|
@@ -40,6 +41,28 @@ def _format_bulk_launch_summary(sessions):
|
|
|
40
41
|
return ", ".join(names)
|
|
41
42
|
return ", ".join(names[:8]) + f", +{len(names) - 8} more"
|
|
42
43
|
|
|
44
|
+
|
|
45
|
+
def _notification_setting_notice(settings, sessions, action):
|
|
46
|
+
if "notify" in settings:
|
|
47
|
+
supported = [session for session in sessions if supports_agent_alerts(session["provider"])]
|
|
48
|
+
unsupported = [session for session in sessions if not supports_agent_alerts(session["provider"])]
|
|
49
|
+
notices = []
|
|
50
|
+
if supported:
|
|
51
|
+
names = _format_bulk_launch_summary(supported)
|
|
52
|
+
if action == "set" and settings["notify"]:
|
|
53
|
+
notices.append(f"Agent alerts enabled for {names} — hooks install on the next interactive launch.")
|
|
54
|
+
else:
|
|
55
|
+
notices.append(f"Agent alerts disabled for {names} — cdx removes its hooks on the next interactive launch.")
|
|
56
|
+
if unsupported:
|
|
57
|
+
names = _format_bulk_launch_summary(unsupported)
|
|
58
|
+
notices.append(f"Agent alerts updated for {names} — this provider does not support notification hooks.")
|
|
59
|
+
return "\n".join(notices)
|
|
60
|
+
if "notify_preview" in settings:
|
|
61
|
+
if action == "set" and settings["notify_preview"]:
|
|
62
|
+
return f"Response previews enabled for {names} — completion text may be visible on the lock screen."
|
|
63
|
+
return f"Response previews disabled for {names}."
|
|
64
|
+
return None
|
|
65
|
+
|
|
43
66
|
def _apply_launch_settings(parsed, ctx, action="set"):
|
|
44
67
|
targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
|
|
45
68
|
sessions = [
|
|
@@ -63,6 +86,9 @@ def _apply_launch_settings(parsed, ctx, action="set"):
|
|
|
63
86
|
_write_json(ctx, payload)
|
|
64
87
|
return 0
|
|
65
88
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
89
|
+
notice = _notification_setting_notice(parsed["settings"], sessions, action)
|
|
90
|
+
if notice:
|
|
91
|
+
ctx["out"](f"{notice}\n")
|
|
66
92
|
if len(sessions) == 1:
|
|
67
93
|
ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
|
|
68
94
|
return 0
|
|
@@ -90,6 +116,9 @@ def _clear_launch_settings(parsed, ctx, action="unset"):
|
|
|
90
116
|
_write_json(ctx, payload)
|
|
91
117
|
return 0
|
|
92
118
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
119
|
+
notice = _notification_setting_notice({key: False for key in parsed["keys"]}, sessions, action)
|
|
120
|
+
if notice:
|
|
121
|
+
ctx["out"](f"{notice}\n")
|
|
93
122
|
if len(sessions) == 1:
|
|
94
123
|
ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
|
|
95
124
|
return 0
|
package/src/commands/status.py
CHANGED
|
@@ -126,7 +126,7 @@ def _format_launch_configs(sessions, use_color=False):
|
|
|
126
126
|
for key in ("fallback_model", "budget")
|
|
127
127
|
)
|
|
128
128
|
has_extra = any((session.get("launch") or {}).get("extra_args") for session in sessions)
|
|
129
|
-
headers = ["SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "MODEL"]
|
|
129
|
+
headers = ["SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "ALERTS", "MODEL"]
|
|
130
130
|
if has_headless:
|
|
131
131
|
headers += ["FALLBACK", "BUDGET"]
|
|
132
132
|
if has_extra:
|
|
@@ -143,6 +143,7 @@ def _format_launch_configs(sessions, use_color=False):
|
|
|
143
143
|
_format_launch_setting_value(launch, "fast", use_color),
|
|
144
144
|
_format_launch_setting_value(launch, "rtk", use_color),
|
|
145
145
|
_format_launch_setting_value(launch, "logics", use_color),
|
|
146
|
+
_format_launch_setting_value(launch, "notify", use_color),
|
|
146
147
|
_format_launch_setting_value(launch, "model", use_color),
|
|
147
148
|
]
|
|
148
149
|
if has_headless:
|
package/src/provider_runtime.py
CHANGED
|
@@ -7,6 +7,7 @@ import shutil
|
|
|
7
7
|
import signal
|
|
8
8
|
import subprocess
|
|
9
9
|
import sys
|
|
10
|
+
import threading
|
|
10
11
|
import uuid
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
|
|
@@ -24,6 +25,18 @@ from .config import (
|
|
|
24
25
|
from .errors import CdxError
|
|
25
26
|
|
|
26
27
|
LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
28
|
+
TERMINAL_TITLE_SEPARATOR = " — "
|
|
29
|
+
# Providers redraw their own title while their TUI runs, so a single write at
|
|
30
|
+
# launch does not survive. Re-asserting on this interval takes the title back
|
|
31
|
+
# without being frequent enough to matter to the terminal.
|
|
32
|
+
TERMINAL_TITLE_REFRESH_SECONDS = 5.0
|
|
33
|
+
TERMINAL_TITLE_ACTIONS = ("launch", "resume")
|
|
34
|
+
# Ollama is deliberately absent: it is a local model runner, not a cdx-managed
|
|
35
|
+
# coding session, and its window title is left to it.
|
|
36
|
+
TERMINAL_TITLE_PROVIDERS = (PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_ANTIGRAVITY)
|
|
37
|
+
# ESC plus the C0/C1 control ranges: anything that could close the OSC string
|
|
38
|
+
# early or start a new escape sequence from session or directory names.
|
|
39
|
+
_TERMINAL_TITLE_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
|
27
40
|
AUTH_PROBE_TIMEOUT_SECONDS = 15
|
|
28
41
|
CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS = 10
|
|
29
42
|
AUTH_PROBE_AUTHENTICATED = "authenticated"
|
|
@@ -1120,12 +1133,112 @@ def _signal_name(sig):
|
|
|
1120
1133
|
return str(sig)
|
|
1121
1134
|
|
|
1122
1135
|
|
|
1136
|
+
def _sanitize_terminal_title_component(value):
|
|
1137
|
+
"""Strip anything that could escape the OSC title string.
|
|
1138
|
+
|
|
1139
|
+
Session names and directories are user data, so they are the injection
|
|
1140
|
+
vector here: a raw ESC or BEL inside either would end the title string and
|
|
1141
|
+
let the rest be read as terminal commands.
|
|
1142
|
+
"""
|
|
1143
|
+
if value is None:
|
|
1144
|
+
return ""
|
|
1145
|
+
return _TERMINAL_TITLE_CONTROL_CHARS.sub("", str(value)).strip()
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def format_terminal_title(session_name, cwd=None):
|
|
1149
|
+
"""Build the `session -- folder` title shared by every interactive provider.
|
|
1150
|
+
|
|
1151
|
+
The folder is the basename of the effective launch cwd, which is what
|
|
1152
|
+
identifies the work; the full path would not fit a title bar. Missing parts
|
|
1153
|
+
are dropped rather than rendered as an empty half.
|
|
1154
|
+
"""
|
|
1155
|
+
session_part = _sanitize_terminal_title_component(session_name)
|
|
1156
|
+
directory = os.path.abspath(cwd or os.getcwd())
|
|
1157
|
+
folder_part = _sanitize_terminal_title_component(os.path.basename(directory) or directory)
|
|
1158
|
+
parts = [part for part in (session_part, folder_part) if part]
|
|
1159
|
+
return TERMINAL_TITLE_SEPARATOR.join(parts)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _stream_is_tty(stream):
|
|
1163
|
+
try:
|
|
1164
|
+
return bool(stream is not None and stream.isatty())
|
|
1165
|
+
except (AttributeError, ValueError, OSError):
|
|
1166
|
+
return False
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
class _TerminalTitleKeeper:
|
|
1170
|
+
"""Holds the terminal title for as long as the provider process runs.
|
|
1171
|
+
|
|
1172
|
+
A provider TUI sets its own title whenever it repaints, so this rewrites the
|
|
1173
|
+
OSC sequence on an interval instead of once. Every write is best-effort: a
|
|
1174
|
+
closed or unwritable stream ends the loop rather than the session.
|
|
1175
|
+
"""
|
|
1176
|
+
|
|
1177
|
+
def __init__(self, title, stream, interval=None):
|
|
1178
|
+
self.title = title
|
|
1179
|
+
self._sequence = f"\033]0;{title}\007"
|
|
1180
|
+
self._stream = stream
|
|
1181
|
+
self._interval = TERMINAL_TITLE_REFRESH_SECONDS if interval is None else interval
|
|
1182
|
+
self._stop_event = threading.Event()
|
|
1183
|
+
self._thread = None
|
|
1184
|
+
|
|
1185
|
+
def _write(self):
|
|
1186
|
+
try:
|
|
1187
|
+
self._stream.write(self._sequence)
|
|
1188
|
+
self._stream.flush()
|
|
1189
|
+
except Exception:
|
|
1190
|
+
return False
|
|
1191
|
+
return True
|
|
1192
|
+
|
|
1193
|
+
def start(self):
|
|
1194
|
+
if not self._write():
|
|
1195
|
+
return self
|
|
1196
|
+
if self._interval and self._interval > 0:
|
|
1197
|
+
self._thread = threading.Thread(target=self._refresh_loop, daemon=True)
|
|
1198
|
+
self._thread.start()
|
|
1199
|
+
return self
|
|
1200
|
+
|
|
1201
|
+
def _refresh_loop(self):
|
|
1202
|
+
while not self._stop_event.wait(self._interval):
|
|
1203
|
+
if not self._write():
|
|
1204
|
+
return
|
|
1205
|
+
|
|
1206
|
+
def stop(self):
|
|
1207
|
+
self._stop_event.set()
|
|
1208
|
+
thread = self._thread
|
|
1209
|
+
self._thread = None
|
|
1210
|
+
if thread is not None and thread is not threading.current_thread():
|
|
1211
|
+
thread.join(timeout=1)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _start_terminal_title(session, action, cwd=None, stream=None, enabled=True):
|
|
1215
|
+
"""Start title upkeep for an interactive launch/resume, or return None.
|
|
1216
|
+
|
|
1217
|
+
Returns None for every case that has no terminal to write to - auth
|
|
1218
|
+
actions, non-TTY stdout, JSON output, captured test streams - so callers can
|
|
1219
|
+
stop() unconditionally.
|
|
1220
|
+
"""
|
|
1221
|
+
if not enabled or action not in TERMINAL_TITLE_ACTIONS:
|
|
1222
|
+
return None
|
|
1223
|
+
if session.get("provider") not in TERMINAL_TITLE_PROVIDERS:
|
|
1224
|
+
return None
|
|
1225
|
+
stream = sys.stdout if stream is None else stream
|
|
1226
|
+
if not _stream_is_tty(stream):
|
|
1227
|
+
return None
|
|
1228
|
+
title = format_terminal_title(session.get("name"), cwd)
|
|
1229
|
+
if not title:
|
|
1230
|
+
return None
|
|
1231
|
+
return _TerminalTitleKeeper(title, stream).start()
|
|
1232
|
+
|
|
1233
|
+
|
|
1123
1234
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
1124
1235
|
env_override=None, signal_emitter=None,
|
|
1125
|
-
initial_prompt=None, lifecycle_callback=None
|
|
1236
|
+
initial_prompt=None, lifecycle_callback=None,
|
|
1237
|
+
title_stream=None, title_enabled=True):
|
|
1126
1238
|
kwargs = dict(spawn=spawn, cwd=cwd, env_override=env_override,
|
|
1127
1239
|
signal_emitter=signal_emitter, initial_prompt=initial_prompt,
|
|
1128
|
-
lifecycle_callback=lifecycle_callback
|
|
1240
|
+
lifecycle_callback=lifecycle_callback, title_stream=title_stream,
|
|
1241
|
+
title_enabled=title_enabled)
|
|
1129
1242
|
if session.get("provider") != PROVIDER_CODEX:
|
|
1130
1243
|
return _run_interactive_provider_command_impl(session, action, **kwargs)
|
|
1131
1244
|
# Hold the per-CODEX_HOME lock for the whole session so concurrent Codex
|
|
@@ -1142,7 +1255,28 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
1142
1255
|
|
|
1143
1256
|
def _run_interactive_provider_command_impl(session, action, spawn=None, cwd=None,
|
|
1144
1257
|
env_override=None, signal_emitter=None,
|
|
1145
|
-
initial_prompt=None, lifecycle_callback=None
|
|
1258
|
+
initial_prompt=None, lifecycle_callback=None,
|
|
1259
|
+
title_stream=None, title_enabled=True):
|
|
1260
|
+
# The title lives exactly as long as the provider process, including the
|
|
1261
|
+
# error and signal paths below, which is why it is held here rather than
|
|
1262
|
+
# inside the run itself.
|
|
1263
|
+
title_keeper = _start_terminal_title(
|
|
1264
|
+
session, action, cwd=cwd, stream=title_stream, enabled=title_enabled
|
|
1265
|
+
)
|
|
1266
|
+
try:
|
|
1267
|
+
return _run_interactive_provider_process(
|
|
1268
|
+
session, action, spawn=spawn, cwd=cwd, env_override=env_override,
|
|
1269
|
+
signal_emitter=signal_emitter, initial_prompt=initial_prompt,
|
|
1270
|
+
lifecycle_callback=lifecycle_callback,
|
|
1271
|
+
)
|
|
1272
|
+
finally:
|
|
1273
|
+
if title_keeper is not None:
|
|
1274
|
+
title_keeper.stop()
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _run_interactive_provider_process(session, action, spawn=None, cwd=None,
|
|
1278
|
+
env_override=None, signal_emitter=None,
|
|
1279
|
+
initial_prompt=None, lifecycle_callback=None):
|
|
1146
1280
|
spawn = spawn or subprocess.Popen
|
|
1147
1281
|
if action == "launch":
|
|
1148
1282
|
spec = _build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
|
package/src/session_service.py
CHANGED
|
@@ -223,6 +223,18 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
223
223
|
normalized["notify"] = False
|
|
224
224
|
else:
|
|
225
225
|
raise CdxError(f"Unsupported notify value: {settings['notify']}")
|
|
226
|
+
if "notify_preview" in settings and settings["notify_preview"] is not None:
|
|
227
|
+
value = settings["notify_preview"]
|
|
228
|
+
if isinstance(value, bool):
|
|
229
|
+
normalized["notify_preview"] = value
|
|
230
|
+
else:
|
|
231
|
+
text = str(value).strip().lower()
|
|
232
|
+
if text in ("on", "true", "1", "yes"):
|
|
233
|
+
normalized["notify_preview"] = True
|
|
234
|
+
elif text in ("off", "false", "0", "no"):
|
|
235
|
+
normalized["notify_preview"] = False
|
|
236
|
+
else:
|
|
237
|
+
raise CdxError(f"Unsupported notify preview value: {settings['notify_preview']}")
|
|
226
238
|
if "model" in settings and settings["model"] is not None:
|
|
227
239
|
model = str(settings["model"]).strip()
|
|
228
240
|
if not model:
|
|
@@ -361,7 +373,7 @@ def unset_launch_settings(store, name, keys):
|
|
|
361
373
|
if not keys:
|
|
362
374
|
raise CdxError("At least one launch setting is required.")
|
|
363
375
|
allowed = {
|
|
364
|
-
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify",
|
|
376
|
+
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
|
|
365
377
|
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
366
378
|
}
|
|
367
379
|
unknown = [key for key in keys if key not in allowed]
|