cdx-manager 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.17.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.18.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
6
6
 
@@ -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 · waiting for you
353
- ✓ codex-a cdx-manager · finished
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
@@ -489,7 +500,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
489
500
  | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
490
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 |
491
502
  | `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
492
- | `cdx notify` | Hook target: raises the desktop notification when a session's agent finishes a turn or waits for you. Called by the provider, not by you |
503
+ | `cdx notify` | Internal provider hook target for agent alerts; use `cdx set <name> --notify on` to enable it |
493
504
  | `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
494
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 |
495
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 |
@@ -502,11 +513,60 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
502
513
  | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; `--cached` skips live provider probes and returns only stored status |
503
514
  | `cdx status --small [--refresh\|--cached] [--timeout SECONDS]` / `cdx status -s [--refresh\|--cached] [--timeout SECONDS]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
504
515
  | `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
516
+ | `cdx tray status [--json] [--refresh]` | Emit the tray snapshot: one icon state, one line per enabled session, and how fresh each figure is. Reads stored status by default; `--refresh` is the only path that probes a provider |
517
+ | `cdx tray launch [--json]` | Start the installed tray companion, detached. Reports `tray_companion_not_installed` when nothing is recorded, or `tray_companion_missing` when the recorded companion is gone. `CDX_TRAY_BIN` points it at a locally built one |
518
+ | `cdx tray install [--json]` | Download the companion for this OS and architecture, verify its published checksum before unpacking, and record what was written. Refuses when no checksum is published for the asset |
519
+ | `cdx tray uninstall [--json]` | Remove exactly what `install` recorded, and nothing else |
520
+ | `cdx tray autostart [on\|off\|status] [--json]` | Start the companion at login, or stop doing so. Off until asked, idempotent, and the state is read back from the platform rather than from what CDX last intended |
521
+ | `cdx tray events [--json]` | List agent alerts a running companion has not shown yet. The companion reads these instead of the spool file, so a Windows tray can collect events from CDX in WSL over the same interop it already uses |
522
+ | `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
523
+ | `cdx tray heartbeat [--json]` | Record that a companion is alive. `cdx notify` publishes to the tray only while this is fresh, and delivers directly otherwise |
524
+ | `cdx tray doctor [--json]` | Report companion, executable, version, running instance, autostart, and the two capabilities that fail silently — whether this desktop can draw a tray icon at all, and on Windows whether the Start Menu shortcut toasts depend on exists. Reads only; it never repairs |
505
525
  | `cdx --help` | Show usage |
506
526
  | `cdx --version` | Show version |
507
527
 
508
528
  ---
509
529
 
530
+ ### Tray snapshot
531
+
532
+ `cdx tray status --json` is the contract an optional desktop tray companion reads. It adds nothing to the quota pipeline: it interprets the status CDX already stores.
533
+
534
+ - **Cached by default.** A tray polls, and a live provider probe serializes on the Codex auth lock, which the launcher holds for a whole interactive session. Polling it would be locked out at best and race a rotating OAuth refresh token at worst. Only `--refresh`, a deliberate user gesture, probes.
535
+ - **Freshness is a value, not an error.** Each session reports `fresh`, `stale`, `auth_locked`, or `unknown`. `auth_locked` means a session is running and its quota cannot be refreshed until it exits — pressing refresh will not help, and the snapshot says so instead of ageing silently.
536
+ - **The icon leaks nothing.** `icon` holds a state, a reason, and a count. Session names, providers, and figures live in `sessions`, which a companion shows only once the user opens the menu.
537
+ - **Versions drift on purpose.** The payload carries `schema.major`. A reader meeting a newer major keeps every field it understands and surfaces one update hint, so upgrading CDX never breaks an installed companion.
538
+
539
+ ```json
540
+ {
541
+ "schema": { "name": "cdx.tray.snapshot", "major": 1, "minor": 0 },
542
+ "icon": { "state": "low", "reason": "fresh", "session_count": 2 },
543
+ "sessions": [
544
+ { "name": "work", "provider": "codex", "available_pct": 18, "state": "low", "freshness": "fresh", "reset_at": "..." }
545
+ ],
546
+ "refreshable": true,
547
+ "actions": ["refresh", "open_terminal"]
548
+ }
549
+ ```
550
+
551
+ Icon states are `ok` at 25% remaining and above, `low` below 25%, `critical` below 5%, and `unknown` when nothing was ever reported. The closed icon shows the most urgent session, and a session that never reported never outranks one with real news.
552
+
553
+ ### Setup hints
554
+
555
+ `cdx` and `cdx status` may end with one dim line pointing at an optional surface you have not set up yet — the tray companion, or agent alerts. They exist because neither is discoverable from the output of the commands you already run.
556
+
557
+ They are deliberately hard to become noise. One hint per invocation at most, not again for six hours, and each one retires for good after three showings: if you saw it three times and did not act, that is an answer. A hint never appears in `--json`, never appears alongside an update notice, and is answered entirely from local state — nothing here probes a provider or the network. `CDX_NO_HINTS=1` turns them off outright.
558
+
559
+ ### Tray companion: what vouches for it
560
+
561
+ The companion is a native binary distributed as a release asset, not part of the pip package. Its trust model is worth stating plainly rather than leaving you to assume the usual one.
562
+
563
+ - **The checksum is the vouching, not Apple or Microsoft.** The macOS bundle is signed with a *self-signed* certificate. That signature exists because Apple Silicon refuses to execute unsigned arm64 code at all — it is not a claim that Apple reviewed anything. What actually vouches for the asset is the SHA-256 published in `checksums/release-archives.json`, verified before a single byte is unpacked. An asset with no published checksum is refused rather than installed hopefully.
564
+ - **Install through `cdx tray install`, and no download link is offered.** This is a security property, not a convenience. A file fetched by CDX carries no macOS quarantine attribute and no Windows Mark-of-the-Web, so nothing puts Gatekeeper or SmartScreen between you and a companion you asked for. The same file fetched through a browser would carry both. That is why no direct download URL is published: it would be a worse path wearing the same clothes.
565
+ - **Nothing is silently replaced.** An update downloads, verifies, and *proves the replacement starts* before the working companion is touched. A corrupt asset or one built for the wrong architecture costs an error message, not a tray you can no longer launch. An interrupted update is a named, recoverable state that `cdx tray doctor` reports.
566
+ - **Uninstall removes only what install recorded**, from a record CDX wrote itself. A damaged record reads as absent rather than being guessed at, because that record drives deletion.
567
+ - **Linux carries no C library.** The Linux asset is statically linked against musl, so there is no glibc version to match and no `gtk`, `libxdo`, or `appindicator` package to install. Tray support is resolved at runtime by looking for the `org.kde.StatusNotifierWatcher` D-Bus name — never by matching a distribution or desktop name. A session without it is told so, and nothing is registered to start at login.
568
+ - **Version drift between CDX and the companion is normal**, since pip upgrades one and `cdx tray install` the other. Whichever is older keeps serving every field it understands and shows one update hint.
569
+
510
570
  ## JSON Output
511
571
 
512
572
  `cdx-manager` can be consumed by other apps through its CLI JSON contract.
@@ -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`
@@ -0,0 +1,82 @@
1
+ # CDX Manager 0.18.0
2
+
3
+ ## Features
4
+
5
+ ### An optional desktop tray companion
6
+
7
+ A native menu bar companion now shows remaining quota across every enabled
8
+ session without a terminal open. It is optional, off until installed, and adds
9
+ nothing to the quota pipeline: it reads the status CDX already stores through
10
+ `cdx tray status --json`.
11
+
12
+ `cdx tray` gained `status`, `install`, `launch`, `uninstall`, `autostart`,
13
+ `doctor`, `events`, `ack`, and `heartbeat`.
14
+
15
+ The polling is cached by default and never probes a provider on a timer. A live
16
+ probe serializes on the Codex auth lock, which an interactive session holds for
17
+ its whole run, and races a rotating OAuth refresh token. Only an explicit
18
+ refresh — a user gesture from the menu — probes, and the menu says so when a
19
+ running session makes refreshing pointless.
20
+
21
+ Freshness is reported rather than hidden. Each session reads `fresh`, `stale`,
22
+ `auth_locked`, or `unknown`, and a figure is never invented for a state that
23
+ knows nothing.
24
+
25
+ ### Agent alerts delivered through the tray
26
+
27
+ When a companion is running, an agent finishing a turn raises one notification
28
+ from the tray instead of two from the tray and the direct path. The tray keeps a
29
+ bounded list of recent alerts and marks new ones beside the icon for 45 seconds.
30
+
31
+ Delivery falls back to the previous direct path whenever a companion is absent,
32
+ stale, or speaks a schema this CDX does not, so upgrading one side never leaves
33
+ the other silent.
34
+
35
+ On macOS the alert is posted from the signed CDX bundle, so it carries the CDX
36
+ name and icon and can be turned off under CDX in System Settings. On Windows it
37
+ goes out under CDX's own AppUserModelID once `cdx tray install` has written the
38
+ Start Menu shortcut, and under PowerShell's identifier until then.
39
+
40
+ ### Setup hints in `cdx` and `cdx status`
41
+
42
+ Both commands may end with one dim line pointing at an optional surface not yet
43
+ set up — the tray companion, or agent alerts. One hint per invocation, not again
44
+ for six hours, retired for good after three showings, never in `--json`, never
45
+ alongside an update notice, and `CDX_NO_HINTS=1` turns them off.
46
+
47
+ ## Platform support
48
+
49
+ - **macOS**: signed bundle, menu bar item, native notifications, LaunchAgent
50
+ autostart.
51
+ - **Windows**: taskbar icon that picks its glyph tint from the taskbar theme,
52
+ promotes itself out of the notification overflow once, and reads CDX from WSL
53
+ through interop with no X server and no localhost.
54
+ - **Linux**: StatusNotifierItem through ksni, resolved at runtime by looking for
55
+ `org.kde.StatusNotifierWatcher` rather than by matching a distribution name. A
56
+ desktop without one is told so instead of being left with an invisible icon.
57
+ The asset is statically linked against musl, so there is no glibc version to
58
+ match and no gtk, libxdo, or appindicator package to install.
59
+
60
+ ## Trust model
61
+
62
+ `cdx tray install` fetches a release asset and verifies its published SHA-256
63
+ before a single byte is unpacked. The macOS bundle is self-signed: that
64
+ signature exists because Apple Silicon refuses to execute unsigned arm64 code,
65
+ not because Apple reviewed anything, and the published checksum is what vouches
66
+ for the asset. Installing through CDX is also what keeps the file free of the
67
+ macOS quarantine attribute and the Windows Mark of the Web, which is why no
68
+ browser download link is published.
69
+
70
+ An update downloads, verifies, and proves the replacement starts before the
71
+ working companion is touched. A failure leaves the previous one running, and
72
+ `cdx tray doctor` names the interrupted state.
73
+
74
+ ## Validation
75
+
76
+ - `npm run lint`
77
+ - `npm test` — 766 Python tests
78
+ - `cargo test` — 45 Rust tests; `cargo clippy` clean with zero warnings on
79
+ aarch64-apple-darwin, x86_64-pc-windows-msvc, and x86_64-unknown-linux-gnu
80
+ - `scripts/tray-smoke.sh` on three hosts: macOS arm64 native, Windows serving
81
+ CDX through Ubuntu WSL, and Linux natively in that same WSL
82
+ - `logics-manager lint` and `logics-manager audit`
@@ -184,6 +184,14 @@
184
184
  "v0.16.0": {
185
185
  "github_tarball_sha256": "bdcd2808922410b20567ba86589d49166c5c2b9404325195b28457df80ca3dad",
186
186
  "github_zip_sha256": "e89cb8c964304f0805d06da9d355d543f8a590e0febbf07e585a692ccf73ef99"
187
+ },
188
+ "v0.17.0": {
189
+ "github_tarball_sha256": "6f321106aa426590ce809788fab4f26401b4cc499a6c518a56a4f24f8bfb6c2e",
190
+ "github_zip_sha256": "72665a0006df40bb27301140b0ed6600a0d91b456facbb5a2aad2a917357ae75"
191
+ },
192
+ "v0.17.1": {
193
+ "github_tarball_sha256": "f1cc36d55f17d8e5c8ad0b0ebc419e812b8788742c3e45ce1137303924114dd7",
194
+ "github_zip_sha256": "29acb306ad76535d0db701504a53b9887b8a6b0b95d968634139f85952a34ec3"
187
195
  }
188
196
  }
189
197
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.17.0"
7
+ version = "0.18.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -15,17 +15,26 @@ import shutil
15
15
  import subprocess
16
16
 
17
17
  from .notify import notification_channel, send_desktop_notification
18
+ from .tray_events import publish
18
19
 
19
20
  # Set on the provider process at launch; the hook runs as its child and inherits both.
20
21
  SESSION_ENV = "CDX_SESSION_NAME"
21
22
  ENABLED_ENV = "CDX_NOTIFY"
23
+ PREVIEW_ENV = "CDX_NOTIFY_PREVIEW"
22
24
 
23
25
  # Our entries are recognised by the command they run, not by a marker key of our
24
26
  # own: both providers validate this file against their own schema, and an extra
25
27
  # key they do not know is a rejection risk for no gain.
26
28
  HOOK_ARG = "notify"
27
29
 
28
- _WAITING_EVENTS = {"notification", "permissionrequest", "userpromptsubmit"}
30
+ _WAITING_EVENTS = {"notification", "permissionrequest"}
31
+ _PREVIEW_LIMIT = 180
32
+ _TOOL_NAME_LIMIT = 80
33
+
34
+
35
+ def supports_agent_alerts(provider):
36
+ """Whether cdx can provision provider hooks for this session."""
37
+ return provider in {"claude", "codex"}
29
38
 
30
39
 
31
40
  def resolve_hook_command(env=None):
@@ -70,9 +79,29 @@ def compose_notification(payload, env=None, cwd=None):
70
79
  return None
71
80
  directory = payload.get("cwd") or payload.get("workspace") or cwd or os.getcwd()
72
81
  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"
82
+ event = event.replace("_", "").replace("-", "")
83
+ state = "needs your attention" if event in _WAITING_EVENTS else "turn complete"
84
+ tool_name = _notification_text(payload.get("tool_name"), _TOOL_NAME_LIMIT)
85
+ if event == "permissionrequest" and tool_name:
86
+ state += f" ({tool_name})"
74
87
  where = os.path.basename(str(directory).rstrip("/\\")) or directory
75
- return f"✓ {session}", f"{where} · {state}"
88
+ preview = _notification_preview(payload, event, env)
89
+ return f"✓ {session}", f"{where} · {state}" + (f" — {preview}" if preview else "")
90
+
91
+
92
+ def _notification_preview(payload, event, env):
93
+ if event != "stop" or env.get(PREVIEW_ENV) != "1":
94
+ return ""
95
+ return _notification_text(payload.get("last_assistant_message"), _PREVIEW_LIMIT)
96
+
97
+
98
+ def _notification_text(value, limit):
99
+ if not isinstance(value, str):
100
+ return ""
101
+ value = " ".join("".join(char if char.isprintable() else " " for char in value).split())
102
+ if len(value) <= limit:
103
+ return value
104
+ return value[:limit - 1].rstrip() + "…"
76
105
 
77
106
 
78
107
  def handle_notify(rest, ctx):
@@ -82,12 +111,22 @@ def handle_notify(rest, ctx):
82
111
  stdin_text = None
83
112
  # "stdin" in ctx is the {"isTTY": ...} descriptor, not a stream.
84
113
  reader = ctx.get("prompt_stdin") or sys.stdin
114
+ env = os.environ if ctx.get("env") is None else ctx["env"]
115
+ if ctx.get("stdin_is_tty") and not env.get(SESSION_ENV):
116
+ ctx["out"]("cdx notify is called by provider hooks. Enable agent alerts with: cdx set <name> --notify on\n")
117
+ return 0
85
118
  if reader is not None and not ctx.get("stdin_is_tty"):
86
119
  stdin_text = reader.read()
87
- env = os.environ if ctx.get("env") is None else ctx["env"]
88
- composed = compose_notification(read_hook_payload(rest, stdin_text), env, ctx.get("cwd"))
120
+ payload = read_hook_payload(rest, stdin_text)
121
+ composed = compose_notification(payload, env, ctx.get("cwd"))
89
122
  if composed:
90
- send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
123
+ # A live tray becomes the sole owner of this alert, so the user sees
124
+ # one notification rather than two. Publication happens below the
125
+ # composition boundary on purpose: the tray receives the same
126
+ # already-sanitized title and message the direct path would send,
127
+ # and cannot be handed anything the privacy rules removed.
128
+ if not _published_to_tray(ctx, composed, payload):
129
+ send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
91
130
  except Exception: # noqa: BLE001 - deliberate: the caller is an agent turn
92
131
  pass
93
132
  return 0
@@ -98,6 +137,8 @@ def launch_notify_env(session, enabled, env=None):
98
137
  values = {SESSION_ENV: session["name"]}
99
138
  if not enabled:
100
139
  values[ENABLED_ENV] = "0"
140
+ elif (session.get("launch") or {}).get("notify_preview") is True:
141
+ values[PREVIEW_ENV] = "1"
101
142
  return values
102
143
 
103
144
 
@@ -130,6 +171,8 @@ def provision(auth_home, provider, enabled, env=None, spawn_sync=None, base_dir=
130
171
  # Nothing could be delivered on this host, so installing a hook would only
131
172
  # buy the user an approval prompt for a feature that shows nothing.
132
173
  enabled = False
174
+ if not supports_agent_alerts(provider):
175
+ return False
133
176
  command = resolve_hook_command(env)
134
177
  if provider == "claude":
135
178
  return _apply_hooks(hook_config_path(auth_home, provider), enabled, command)
@@ -224,7 +267,7 @@ def _write_codex_plugin(auth_home, command, base_dir=None):
224
267
  os.path.join(root, "hooks", "cdx-hooks.json"): {
225
268
  "hooks": {
226
269
  event: [{"hooks": [{"type": "command", "command": f"{command} {HOOK_ARG}"}]}]
227
- for event in ("Stop", "Notification")
270
+ for event in ("Stop", "PermissionRequest")
228
271
  },
229
272
  },
230
273
  }
@@ -322,3 +365,24 @@ def _write_json(path, document):
322
365
  # An unwritable home is a skipped provisioning step, not a failed launch.
323
366
  return False
324
367
  return True
368
+
369
+
370
+ def _published_to_tray(ctx, composed, payload):
371
+ """Hand the alert to a live tray, or say we did not.
372
+
373
+ False is the safe answer to everything: no base directory, no heartbeat, a
374
+ stale one, a schema mismatch, or an unwritable spool all mean the direct
375
+ path still runs. The only way to lose a notification here would be to
376
+ return True without having written it.
377
+ """
378
+ try:
379
+ base_dir = (ctx.get("service") or {}).get("base_dir")
380
+ if not base_dir:
381
+ return False
382
+ event = str(
383
+ payload.get("hook_event_name") or payload.get("eventName") or payload.get("type") or ""
384
+ ).lower().replace("_", "").replace("-", "")
385
+ kind = "attention" if event in _WAITING_EVENTS else "complete"
386
+ return publish(base_dir, composed[0], composed[1], kind=kind)
387
+ except Exception: # noqa: BLE001 - the caller is an agent turn
388
+ return False
package/src/cli.py CHANGED
@@ -51,6 +51,7 @@ from .cli_commands import (
51
51
  handle_set,
52
52
  handle_stats,
53
53
  handle_status,
54
+ handle_tray,
54
55
  handle_unset,
55
56
  handle_update,
56
57
  handle_view,
@@ -59,6 +60,7 @@ from .cli_commands import (
59
60
  _format_bytes as _format_disk_bytes,
60
61
  )
61
62
  from .cli_helpers import _update_notice_warnings
63
+ from .cli_hints import write_hint
62
64
  from .cli_render import (
63
65
  _format_sessions,
64
66
  _pad_table,
@@ -171,6 +173,7 @@ _COMMAND_HANDLERS = {
171
173
  "set": handle_set,
172
174
  "stats": handle_stats,
173
175
  "status": handle_status,
176
+ "tray": handle_tray,
174
177
  "unset": handle_unset,
175
178
  "update": handle_update,
176
179
  "view": handle_view,
@@ -231,8 +234,8 @@ def _print_help(use_color=False):
231
234
  f" {_style('cdx config <name> [--json]', '36', use_color)}",
232
235
  f" {_style('cdx configs [--json]', '36', use_color)}",
233
236
  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)}",
237
+ 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)}",
238
+ 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
239
  f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
237
240
  f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
238
241
  f" {_style('cdx last [--json]', '36', use_color)}",
@@ -257,9 +260,15 @@ def _print_help(use_color=False):
257
260
  f" {_style('cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--check-provider-flags] [--json]', '36', use_color)}",
258
261
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
259
262
  f" {_style('cdx view [--json]', '36', use_color)}",
263
+ f" {_style('cdx tray status [--json] [--refresh]', '36', use_color)}",
264
+ f" {_style('cdx tray install|launch|uninstall|doctor [--json]', '36', use_color)}",
265
+ f" {_style('cdx tray autostart [on|off|status] [--json]', '36', use_color)}",
260
266
  f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
261
267
  f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
262
- f" {_style('cdx notify', '36', use_color)} # hook target, called by the provider",
268
+ "",
269
+ _style("Notifications:", "1", use_color),
270
+ f" {_style('cdx set <name> --notify on', '36', use_color)} # agent finishes or needs attention",
271
+ f" {_style('cdx ready', '36', use_color)} # next session to become ready",
263
272
  f" {_style('cdx <name> [--json]', '36', use_color)}",
264
273
  f" {_style('cdx --help', '36', use_color)}",
265
274
  f" {_style('cdx --version', '36', use_color)}",
@@ -548,6 +557,8 @@ def main(argv, options=None):
548
557
  warning_text = _update_warning_text(notices)
549
558
  if warning_text:
550
559
  out(f"{_style(warning_text, '33', use_color)}\n")
560
+ # Last line, after anything the user actually asked for.
561
+ write_hint({"env": env, "out": out, "service": service, "use_color": use_color, "update_notices": notices})
551
562
  return 0
552
563
 
553
564
  command, *rest = argv
package/src/cli_args.py CHANGED
@@ -63,6 +63,7 @@ RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
63
63
  RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
64
64
  RUN_TAIL_USAGE = "Usage: cdx run-tail <run_id> [--lines N] --json"
65
65
  SCHEMA_USAGE = "Usage: cdx schema --json"
66
+ TRAY_USAGE = "Usage: cdx tray status [--json] [--refresh] [--beat] | cdx tray install|launch|uninstall|doctor [--json] | cdx tray autostart [on|off|status] [--json] | cdx tray events [--json] | cdx tray ack <id>... [--json] | cdx tray heartbeat [--json]"
66
67
 
67
68
  RUN_TAIL_DEFAULT_LINES = 50
68
69
  RUN_TAIL_MAX_LINES = 2000
@@ -249,6 +250,7 @@ def _parse_set_args(args):
249
250
  "--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
250
251
  "--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
251
252
  "--notify": {"key": "notify", "type": "str", "default": None, "transform": _parse_fast_value},
253
+ "--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _parse_fast_value},
252
254
  "--model": {"key": "model", "type": "str", "default": None},
253
255
  "--fallback-model": {"key": "fallback_model", "type": "str", "default": None},
254
256
  "--budget": {"key": "budget", "type": "str", "default": None, "transform": _parse_budget_value},
@@ -270,7 +272,7 @@ def _parse_set_args(args):
270
272
  raise CdxError(SET_USAGE)
271
273
  settings = {
272
274
  key: parsed[key]
273
- for key in ("power", "permission", "fast", "rtk", "logics", "notify", "model", "fallback_model", "budget", "extra_args", "priority")
275
+ for key in ("power", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "model", "fallback_model", "budget", "extra_args", "priority")
274
276
  if parsed[key] is not None
275
277
  }
276
278
  if not settings:
@@ -293,6 +295,7 @@ def _parse_unset_args(args):
293
295
  "--rtk": {"key": "rtk", "type": "bool", "default": False},
294
296
  "--logics": {"key": "logics", "type": "bool", "default": False},
295
297
  "--notify": {"key": "notify", "type": "bool", "default": False},
298
+ "--notify-preview": {"key": "notify_preview", "type": "bool", "default": False},
296
299
  "--model": {"key": "model", "type": "bool", "default": False},
297
300
  "--fallback-model": {"key": "fallback_model", "type": "bool", "default": False},
298
301
  "--budget": {"key": "budget", "type": "bool", "default": False},
@@ -314,7 +317,7 @@ def _parse_unset_args(args):
314
317
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
315
318
  raise CdxError(UNSET_USAGE)
316
319
  _UNSET_KEYS = (
317
- "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify",
320
+ "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
318
321
  "model", "fallback_model", "budget", "extra_args", "priority",
319
322
  )
320
323
  keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
@@ -272,5 +272,8 @@ from .commands.status import (
272
272
  from .commands.status import (
273
273
  handle_status as handle_status,
274
274
  )
275
+ from .commands.tray import (
276
+ handle_tray as handle_tray,
277
+ )
275
278
 
276
279
 
@@ -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", "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 == "notify":
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)