cdx-manager 0.18.3 → 0.18.5

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,10 @@
1
+ <img src="docs/assets/icon.png" alt="CDX Manager icon" width="64" align="left" />
2
+
1
3
  # CDX Manager
2
4
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.18.3-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
5
+ <br clear="left"/>
6
+
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.18.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
8
 
5
9
  **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
10
 
@@ -374,10 +378,33 @@ configuration and, on Codex, puts an approval prompt in front of you. That is
374
378
  not something to do to sessions you already own without being asked.
375
379
 
376
380
  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
+ notification includes at most 180 characters of the agent's final response, and
382
+ a permission request may quote the provider's own one-line description of what
383
+ it wants to do. That text can be visible on a lock screen, so enable it only for
384
+ sessions whose output is safe to expose. Previews never read transcripts, and
385
+ the command a permission request is about is never included.
386
+
387
+ Both providers are subscribed to the same two hooks: `Stop` when a turn ends and
388
+ `PermissionRequest` when one blocks on your approval. Claude Code is also
389
+ subscribed to `Notification`, for the "still waiting for you" states that no
390
+ other hook reports; the permission notifications it also sends are dropped,
391
+ because `PermissionRequest` already reported the same call immediately and by
392
+ name.
393
+
394
+ Claude Code additionally reports a turn that ended on a provider error rather
395
+ than on an answer — a rate limit, an overload, an authentication or billing
396
+ problem. Those alerts are marked apart from completions, name the error class,
397
+ and say whether the failure is one that usually clears on its own or one that
398
+ needs you. Codex documents no equivalent hook, so a Codex turn killed by its
399
+ provider stays silent; that is a gap in what the provider publishes, not a
400
+ setting.
401
+
402
+ A running tray companion receives each alert as fields rather than as a
403
+ sentence, so its menu can name the session, the project and the tool, and
404
+ clicking a recent alert opens the session it came from. The tool's arguments,
405
+ the transcript path and the provider's own session identifiers are never part of
406
+ that — only what cdx already knew and what the providers document as safe
407
+ metadata.
381
408
 
382
409
  `cdx notify` is the internal hook target, called by the provider; it is not the
383
410
  setup command. `cdx ready` is separate: it notifies you when the next cooling-
@@ -515,12 +542,15 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
515
542
  | `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
516
543
  | `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
544
  | `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 |
545
+ | `cdx tray install [--json] [--yes] [--autostart\|--no-autostart] [--alerts\|--no-alerts]` | Download the companion for this OS and architecture, verify its published checksum before unpacking, record what was written, and start it. Asks two separate questions: whether to launch it at login, and whether to notify you when an agent finishes a turn or waits for you. `--yes` answers both, each pair of flags answers one deterministically, and a non-interactive or `--json` run declines rather than acquiring a login item or writing provider hooks by accident. Accepting alerts turns them on for every supported session and for sessions created later. Refuses when no checksum is published for the asset |
519
546
  | `cdx tray uninstall [--json]` | Remove exactly what `install` recorded, and nothing else |
547
+ | — | `cdx update` also moves an installed companion to the new release, and says so. A companion already running keeps executing the build it started with, so it reports that too rather than leaving you to wonder why the menu looks unchanged |
520
548
  | `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
549
  | `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
550
  | `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
523
551
  | `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 |
552
+ | `cdx tray terminal [status\|set <app>\|clear] [--json]` | Choose which terminal a tray session row opens — `cdx tray terminal set iTerm`, `cdx tray terminal clear` to go back to the platform default. The value is an application name, never a command: anything that could be read as one is refused rather than sanitized. macOS opens it with `open -a`, Linux prefers it ahead of the emulators it already tries, and Windows honours `wt` because Windows Terminal is the one that documents how to be handed a command. A named terminal that is not installed falls back to the default rather than leaving the click doing nothing |
553
+ | `cdx tray plugin [status\|enable <name>\|disable <name>\|run <action>] [--json]` | Turn a tray integration on or off, and list what is enabled. Opt-in in both directions and reversible; nothing enables one implicitly. `logics` is the only one that exists, and `status` says once when it is installed but not enabled. `run` is what the tray calls back with a card's action id and is not meant to be typed |
524
554
  | `cdx tray alerts [on\|off\|status] [--json]` | Silence agent alerts, or let them through again — the tray menu has the same switch. Muting stops the banner, not the record: events still reach a running companion, so the menu shows what you missed. It applies to the direct notification path too, so quitting the tray does not silently un-mute you. Distinct from the per-session `--notify` opt-in, which says which sessions may alert at all |
525
555
  | `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 |
526
556
  | `cdx --help` | Show usage |
@@ -563,6 +593,12 @@ The companion is a native binary distributed as a release asset, not part of the
563
593
 
564
594
  - **Where the checksum comes from.** Releases older than the one you are running are vouched for by `checksums/release-archives.json`, committed to the repository before those assets existed. Your own release is a special case: its tray assets are built after the tag, so no package built at tag time can carry their checksums. For that one version CDX reads the ledger the release publishes beside the assets — the publisher's own assertion over the same channel, which catches a truncated download or a wrong-architecture asset but does not defend against someone who can rewrite the release itself.
565
595
  - **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.
596
+ - **Tray integrations are cards, not plugins.** `cdx tray plugin enable logics` adds one section to the menu: a blocked/ready summary and at most two rows — the first blocked document, because it is what stops work, and the highest-priority task in progress, because it is what to do instead. Clicking a row opens the Logics viewer focused on it.
597
+
598
+ There is deliberately no marketplace, no discovery, no installation from a URL, and no plugin auto-update. An adapter is a function in this repository, named in a registry CDX owns, so nothing unknown can run. Adapters produce data and never behaviour: a card is a summary, rows, and action ids from a fixed vocabulary, and the tray hands an id back to CDX rather than running anything itself. Adding a capability is a change to CDX, reviewed like any other.
599
+
600
+ A card is built from `logics-manager status --format json` and from nothing else, so nothing about your documents is read or stored. It is refreshed at most once a minute, or on demand from the menu — an integration never pays the tray's polling rate. If `logics-manager` is absent, slow, failing or in a directory that is not a Logics repository, the card is simply not there: an integration cannot degrade the session rows the tray exists to show.
601
+
566
602
  - **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.
567
603
  - **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.
568
604
  - **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.
package/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.18.5
@@ -0,0 +1,78 @@
1
+ # CDX Manager 0.18.4
2
+
3
+ ## Fixes
4
+
5
+ ### Agent alerts arrive when they happen
6
+
7
+ An alert reached the tray on the next poll, so up to thirty seconds passed
8
+ between a turn finishing and the banner appearing — where the direct path had
9
+ been instant. The poll period is a cost budget, not a latency target, and it was
10
+ the wrong one for the single event a user actively waits for.
11
+
12
+ The companion now notices the spool changing between polls. No file watcher was
13
+ needed: the event loop already wakes five times a second, so a `stat` costs
14
+ microseconds at that rate. Measured at 0.57s from hook to consumption, including
15
+ the polling in the measurement itself.
16
+
17
+ Unchanged across the Windows-to-WSL boundary, and deliberately: a Windows
18
+ process cannot watch a path inside the Linux filesystem, so the poll period
19
+ remains the latency there.
20
+
21
+ ### The menu bar icon stays visible after the first poll
22
+
23
+ `tray-icon`'s macOS `set_icon` clears the template flag unconditionally, so every
24
+ refresh dropped the setting that lets macOS invert the glyph for the current
25
+ theme. The icon started white and turned black about thirty seconds later, which
26
+ on a dark menu bar means invisible.
27
+
28
+ ### `cdx update` brings the tray companion with it
29
+
30
+ The companion is a separate artifact on a separate release, so it drifted and
31
+ had to be reinstalled by hand. `cdx update` now moves it too, through the staged
32
+ path that verifies the asset and proves the replacement starts before the
33
+ working one is touched — and never fails the update if it cannot: the previous
34
+ companion stays installed and the reason is reported.
35
+
36
+ `cdx tray doctor` names the state now, rather than printing two version numbers
37
+ and leaving the comparison to you: `aligned` or `drifted`.
38
+
39
+ ## Features
40
+
41
+ ### The menu is readable at a dozen accounts
42
+
43
+ Rows are selectable rather than informational — macOS renders disabled items
44
+ grey, so a dozen arrived as a wall of grey text — and clicking one opens a
45
+ terminal on that session. The list is sorted by urgency, most constrained first;
46
+ it previously arrived in storage order, so an account at 1% could sit tenth
47
+ while the icon showed critical.
48
+
49
+ Sessions are grouped under their provider instead of repeating it on every row,
50
+ and each carries a gauge. On macOS the rows are drawn: name, a gauge coloured by
51
+ severity, and the figure aligned on a fixed column — a native menu font is
52
+ proportional, so text alone cannot align. Windows and Linux keep the text rows,
53
+ which say everything the drawing does.
54
+
55
+ Times are distances, not stamps: `↻ in 6d 23h` rather than `↻ Aug 18 03:23`, and
56
+ a stale row says how stale. The wording is `cdx status`'s own.
57
+
58
+ ### A switch to silence agent alerts
59
+
60
+ In the tray menu and as `cdx tray alerts on|off|status`. Muting stops the
61
+ banner, not the record — events keep reaching a running companion, so the menu
62
+ shows what you missed — and it applies to the direct path too, so quitting the
63
+ tray does not silently un-mute you.
64
+
65
+ ### `cdx tray install` starts what it installed
66
+
67
+ And asks whether to launch it at login. `--yes` answers without a prompt,
68
+ `--no-autostart` declines, and a non-interactive run declines too: a script must
69
+ not acquire a startup entry by running an install.
70
+
71
+ ## Validation
72
+
73
+ - `npm run lint`, `npm test` — 790 Python tests
74
+ - `cargo test` — 52 Rust tests; `cargo clippy` clean with zero warnings on three
75
+ targets
76
+ - 143 Python tests on native Windows, plus the Windows and Linux companions
77
+ exercised on a managed host: grouped menu, gauges, alert switch, spool path,
78
+ and the unsupported-desktop path still refusing to draw
@@ -0,0 +1,147 @@
1
+ # CDX Manager 0.18.5
2
+
3
+ ## Fixes
4
+
5
+ ### An npm install knew which version it was
6
+
7
+ `cdx --version` reported `0.0.0` for every install done through npm, and the
8
+ update banner never went away: nothing is newer than a release that believes it
9
+ is version zero, so `cdx update` ran, succeeded, and changed nothing that could
10
+ be seen.
11
+
12
+ The version is resolved from the `VERSION` file, then from Python distribution
13
+ metadata. An npm install has neither — the file was not in the published package
14
+ and npm creates no Python metadata — so both lookups missed. `VERSION` ships
15
+ now, and `package.json` becomes the last resort below it, because an npm tarball
16
+ carries that file by construction and cannot lose it the way a file list can.
17
+
18
+ Installs through `install.sh` were never affected: they unpack the git archive,
19
+ which always contained `VERSION`.
20
+
21
+ ### The tray badge cleared when you read it
22
+
23
+ The count beside the icon could be raised and never lowered. `tray-icon`'s macOS
24
+ `set_title` ignores a `None` — it sets a title when given one and does nothing
25
+ otherwise — so every attempt to clear the marker was silently dropped. This
26
+ predates the unread work: the 45-second expiry it replaced cleared the same way,
27
+ so it never cleared either. Nobody noticed, because a marker that outstays its
28
+ welcome looks exactly like one that is still true.
29
+
30
+ ### The tray says which CDX it actually reached
31
+
32
+ A WSL user could see `cdx --version` report the current release in their
33
+ terminal while the companion insisted CDX was too old to have `cdx tray`. Both
34
+ were right: the companion runs `wsl.exe -- cdx`, which is not a login shell, so
35
+ it resolves the system PATH and can find an older CDX that the user's own shell
36
+ never sees. The message names the command that answered now, and points at
37
+ `CDX_TRAY_CDX`, which is the setting that fixes it.
38
+
39
+ ### The gauge is legible on Windows and Linux
40
+
41
+ The empty part of the capacity bar was a dither pattern, which on a dark Windows
42
+ menu reads as corrupted text rather than as an empty gauge. It is a thin rule
43
+ now, in the same Unicode block as the filled part so the column still aligns in
44
+ the proportional font a native menu uses.
45
+
46
+ ## Features
47
+
48
+ ### The tray menu is ordered by remaining capacity, and by nothing else
49
+
50
+ Sessions were grouped under a provider heading, which reordered the list it was
51
+ meant to organise: a group took the rank of its most constrained session, so a
52
+ healthy account could sit above the one that made the icon turn. The headings
53
+ are gone and each row names its provider.
54
+
55
+ That also removed an older defect underneath. A click carried a position into
56
+ the snapshot, which only worked while the order was stable — and ordering by
57
+ capacity makes it change under you. A row now carries the session's name, so a
58
+ poll landing between the draw and the click can no longer open the wrong one.
59
+
60
+ ### Both limit windows are visible at once
61
+
62
+ Both providers meter a five-hour window and a weekly one, and the tray showed
63
+ whichever was worse. A week almost spent was invisible behind a five-hour window
64
+ that had just reset, and the figure silently changed meaning between two polls.
65
+
66
+ Each window now has its own line, its own bar and its own name. On macOS the row
67
+ also says how old its figure is and when the limit resets — the two facts you
68
+ need before trusting a percentage, which the text rows had all along and the
69
+ drawn one had never been given.
70
+
71
+ ### Every session row opens into its own actions
72
+
73
+ A row that launched on click had space for exactly one meaning. Each is a
74
+ submenu now: **Open session**, which is the launch it always did, and **Launch
75
+ settings (next launch)**, a view of `cdx config <name>`. The wording is the
76
+ promise — CDX stores those settings for the next start, and no provider accepts
77
+ them for an assistant already running, so the tray never implies otherwise.
78
+
79
+ ### Agent alerts say what happened, and open the session that sent them
80
+
81
+ Both providers are now subscribed to the same two hooks, `Stop` and
82
+ `PermissionRequest`, so a permission request is reported immediately and by
83
+ tool name rather than through a delayed generic notification. The duplicate that
84
+ combination used to produce is filtered where the duplication is.
85
+
86
+ Alerts reach the tray as fields rather than as a sentence, so the menu can name
87
+ the session, the project and the tool — and clicking one opens the session it
88
+ came from. The tool's arguments, the transcript path and provider session
89
+ identifiers are never part of that.
90
+
91
+ A turn that dies on a provider error also raises an alert now, where it used to
92
+ be silent: a rate limit, an overload, an authentication or billing problem. They
93
+ are marked apart from completions and say whether the failure usually clears on
94
+ its own or needs you. Claude publishes this; Codex documents no equivalent, so a
95
+ Codex turn killed by its provider stays silent.
96
+
97
+ ### The alert badge and the alert list are the same thing
98
+
99
+ The marker beside the icon expired on a timer while the menu kept a list that
100
+ never cleared, so the two could not agree and neither meant anything precise.
101
+ There is one unread list now: the badge counts it, the menu shows it, and
102
+ opening the menu is what marks it read. An alert that arrives while the menu is
103
+ open stays unread, because nobody has seen it.
104
+
105
+ ### `cdx update` replaces a running companion
106
+
107
+ It used to move the files and tell you to quit the tray from its menu and start
108
+ it again. It now asks the running companion to stop, replaces it, and starts the
109
+ replacement — verifying that it actually came up rather than trusting that it
110
+ was launched. If the replacement will not start, the companion that was working
111
+ is put back and restarted.
112
+
113
+ A companion that does not stop within ten seconds is left alone and reported,
114
+ never killed: an open menu blocks it on Windows, and a tray you are reading is
115
+ not a process to terminate over a version number.
116
+
117
+ ### Installing the tray asks about alerts, and means it for later sessions
118
+
119
+ `cdx tray install` asks two separate questions — start at login, and notify you
120
+ when an agent finishes or waits for you. Each has its own flags, an explicit
121
+ flag never triggers a prompt, and a piped or `--json` run declines rather than
122
+ deciding for you. Accepting alerts turns them on for every supported session and
123
+ for sessions created afterwards; on Codex, the approval it will ask for at the
124
+ next launch is stated at install time.
125
+
126
+ ### A Logics card in the tray, off unless you ask
127
+
128
+ `cdx tray plugin enable logics` adds one section to the menu: how much is
129
+ blocked and in progress, the first blocked document, and the highest-priority
130
+ task in progress. Clicking a row opens the Logics viewer focused on it.
131
+
132
+ There is deliberately no marketplace, no discovery and no plugin auto-update. An
133
+ adapter is a function in this repository, named in a registry CDX owns, so
134
+ nothing unknown can run. It produces data and never behaviour: a card is a
135
+ summary, rows, and action ids from a fixed vocabulary. The card is built from
136
+ `logics-manager status --format json` and nothing else, refreshed at most once a
137
+ minute, and simply absent when Logics is unavailable.
138
+
139
+ ### Choose the terminal a session row opens
140
+
141
+ `cdx tray terminal set iTerm` — or Ghostty, WezTerm, kitty, `wt`. What is stored
142
+ is an application name, never a command: anything that could be read as one is
143
+ refused rather than sanitized, and the rule is applied when the value is read as
144
+ well as when it is written. macOS opens it with `open -a`, Linux prefers it
145
+ ahead of the emulators it already tries, and Windows honours `wt`, which is the
146
+ one that documents how to be handed a command. A terminal that is not installed
147
+ falls back to the platform default rather than leaving the click doing nothing.
@@ -224,6 +224,28 @@
224
224
  "x86_64-pc-windows-msvc": "7a40ffc6a1908e0cda5e484b50ab7c11b6cb01f4c370905eefe23e40a8afb7bd",
225
225
  "x86_64-unknown-linux-musl": "2f4b95c661cdb65bf58c2aa0d2c7a45e03bb5a8ec3d20404c688887dba33afe4"
226
226
  }
227
+ },
228
+ "v0.18.3": {
229
+ "github_tarball_sha256": "0099b87967b2a2f4f026c97888d2017473c0b58aa974b56dd35320231e67e248",
230
+ "github_zip_sha256": "f6f15a9f91a85696d4f865cf192bfb4b076be92937105df57329fbfc73147631",
231
+ "tray_assets": {
232
+ "aarch64-apple-darwin": "5bbd0d9764c0feae3fc60c24951fc8202f56c8cd69574440277a4b372ae93da5",
233
+ "aarch64-unknown-linux-musl": "c937af7a08701fbc102ff3d7e0458e488a57001e65f20acab7809221a0395310",
234
+ "x86_64-apple-darwin": "036dde97929ed4c3a5e0b29b4e8332caede15c124e907c573557d888439af748",
235
+ "x86_64-pc-windows-msvc": "e50250d21d11e6986a310a00d4f3ab5b1a13d2960684750c569926d5bda923f9",
236
+ "x86_64-unknown-linux-musl": "edbdb02b7e9df5709b5b9512233e2b25b51f7652bd1b78c68edb85dbfa65d708"
237
+ }
238
+ },
239
+ "v0.18.4": {
240
+ "github_tarball_sha256": "9709de784596783587a8fd5fcc75da32c43a0a19dd84dd2633e958bf38bd134d",
241
+ "github_zip_sha256": "a01abf6eac2627427eab2c1d3c3363b24ac93c6aadf049bdc6fd60987a6086ce",
242
+ "tray_assets": {
243
+ "aarch64-apple-darwin": "d8d11e6365f14cc72e7e433da75c6d577bdf0a98eac38747ff9bba98788e1e98",
244
+ "aarch64-unknown-linux-musl": "ff1c007f06262f510346a90458c9e26b4972e741b4b483da5bbebac83e6083b0",
245
+ "x86_64-apple-darwin": "cb46a11a1a0ffa0426f1afeb2a91cfd67c837a37b0afcc6b222473c02f20a817",
246
+ "x86_64-pc-windows-msvc": "cc33c0f5a53472c6c1c72f458f33052abc09b6fe4ff5dfb5edae497c234bb2d0",
247
+ "x86_64-unknown-linux-musl": "d8fb9958112329487bd91be7f7505f18f886676f4309e30aa3877340e42c6c15"
248
+ }
227
249
  }
228
250
  }
229
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
@@ -24,6 +24,7 @@
24
24
  "node": ">=18"
25
25
  },
26
26
  "files": [
27
+ "VERSION",
27
28
  "bin",
28
29
  "!bin/__pycache__",
29
30
  "!bin/**/*.pyc",
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.18.3"
7
+ version = "0.18.5"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -30,6 +30,29 @@ PREVIEW_ENV = "CDX_NOTIFY_PREVIEW"
30
30
  HOOK_ARG = "notify"
31
31
 
32
32
  _WAITING_EVENTS = {"notification", "permissionrequest"}
33
+ # The Notification states that mean the agent is waiting for its user.
34
+ #
35
+ # Claude's Notification hook is a broad one: `permission_prompt` fires for the
36
+ # same tool call `PermissionRequest` already reports, exactly and immediately,
37
+ # so subscribing to both once produced two alerts for one request. Filtering
38
+ # here rather than unsubscribing keeps the idle states, which nothing else
39
+ # reports at all — an agent waiting on an answer would otherwise go silent.
40
+ _WAITING_NOTIFICATIONS = {"idle_prompt", "agent_needs_input"}
41
+ # Hook events that decide something. Anything this process writes to stdout on
42
+ # one of these is read by Claude Code as an allow or a deny for the tool call,
43
+ # so `cdx notify` has to stay silent on them whatever happens.
44
+ _DECIDING_EVENTS = {"permissionrequest"}
45
+ # A turn that ended on a provider error rather than on an answer.
46
+ #
47
+ # Claude Code publishes this; Codex documents no equivalent, so this is a
48
+ # Claude-only alert and the documentation says so rather than implying parity.
49
+ _FAILURE_EVENTS = {"stopfailure"}
50
+ # Error classes that usually clear on their own. The distinction is the whole
51
+ # point of the alert: an overloaded model and a billing error both stop the
52
+ # turn, and reacting to them the same way is what trains someone to ignore
53
+ # both. Anything unrecognised is treated as needing attention — a failure this
54
+ # build has never seen is not one to reassure the user about.
55
+ _TRANSIENT_ERRORS = {"rate_limit", "overloaded", "server_error", "max_output_tokens"}
33
56
  _PREVIEW_LIMIT = 180
34
57
  _TOOL_NAME_LIMIT = 80
35
58
 
@@ -69,6 +92,38 @@ def read_hook_payload(args, stdin_text=None):
69
92
  return {}
70
93
 
71
94
 
95
+ def hook_event(payload):
96
+ """The provider's event name, in one spelling.
97
+
98
+ Both providers publish the same names now, but they have each spelled them
99
+ differently over time and Codex's older `notify` used `type`. Normalising in
100
+ one place keeps every caller reading the same value.
101
+ """
102
+ raw = payload.get("hook_event_name") or payload.get("eventName") or payload.get("type") or ""
103
+ return str(raw).lower().replace("_", "").replace("-", "")
104
+
105
+
106
+ def is_deciding_event(payload):
107
+ """Whether this hook's output would be read as a permission decision."""
108
+ return hook_event(payload) in _DECIDING_EVENTS
109
+
110
+
111
+ def _is_reportable(payload, event):
112
+ """Whether this event is one the user should hear about at all.
113
+
114
+ A Notification that is not a waiting state is either something another hook
115
+ reports exactly — `permission_prompt` duplicates PermissionRequest — or an
116
+ internal state the user has no action to take on.
117
+ """
118
+ if event != "notification":
119
+ return True
120
+ kind = str(payload.get("notification_type") or "").lower()
121
+ # An older Claude sent no type at all. Reporting it is the safer default: a
122
+ # duplicate is a nuisance, a missed "the agent is waiting for you" is the
123
+ # failure this feature exists to prevent.
124
+ return not kind or kind in _WAITING_NOTIFICATIONS
125
+
126
+
72
127
  def compose_notification(payload, env=None, cwd=None):
73
128
  """Title and message for this event, or None if we should stay quiet."""
74
129
  env = os.environ if env is None else env
@@ -79,18 +134,124 @@ def compose_notification(payload, env=None, cwd=None):
79
134
  session = env.get(SESSION_ENV)
80
135
  if not session:
81
136
  return None
137
+ event = hook_event(payload)
138
+ if not _is_reportable(payload, event):
139
+ return None
82
140
  directory = payload.get("cwd") or payload.get("workspace") or cwd or os.getcwd()
83
- event = str(payload.get("hook_event_name") or payload.get("eventName") or payload.get("type") or "").lower()
84
- event = event.replace("_", "").replace("-", "")
141
+ where = os.path.basename(str(directory).rstrip("/\\")) or directory
142
+ if event in _FAILURE_EVENTS:
143
+ # A different mark, because the word alone is not enough: a glance at a
144
+ # notification centre reads the symbol first, and a failed turn that
145
+ # carries the completion tick is a turn the user believes finished.
146
+ state = _failure_state(payload)
147
+ detail = _failure_detail(payload, env)
148
+ return f"✕ {session}", f"{where} · {state}" + (f" — {detail}" if detail else "")
85
149
  state = "needs your attention" if event in _WAITING_EVENTS else "turn complete"
86
150
  tool_name = _notification_text(payload.get("tool_name"), _TOOL_NAME_LIMIT)
87
151
  if event == "permissionrequest" and tool_name:
88
152
  state += f" ({tool_name})"
89
- where = os.path.basename(str(directory).rstrip("/\\")) or directory
90
153
  preview = _notification_preview(payload, event, env)
91
154
  return f"✓ {session}", f"{where} · {state}" + (f" — {preview}" if preview else "")
92
155
 
93
156
 
157
+ def error_class(payload):
158
+ """The provider's error class, normalised. Empty when it sent none."""
159
+ return _notification_text(payload.get("error_type"), _TOOL_NAME_LIMIT).lower().replace(" ", "_")
160
+
161
+
162
+ def _failure_state(payload):
163
+ """What a failed turn says it is, in words that suggest what to do.
164
+
165
+ The class itself is included rather than translated: `rate_limit` and
166
+ `billing_error` mean different things to whoever reads them, and inventing
167
+ friendlier wording for a closed vocabulary loses the part that identifies
168
+ the problem.
169
+ """
170
+ kind = error_class(payload)
171
+ if not kind:
172
+ return "turn failed"
173
+ if kind in _TRANSIENT_ERRORS:
174
+ return f"turn interrupted ({kind})"
175
+ return f"turn failed ({kind}) — needs you"
176
+
177
+
178
+ def _failure_detail(payload, env):
179
+ """The provider's own message, under the same opt-in as any other free text."""
180
+ if env.get(PREVIEW_ENV) != "1":
181
+ return ""
182
+ return _notification_text(payload.get("error_message"), _PREVIEW_LIMIT)
183
+
184
+
185
+ def structured_details(payload, env=None, cwd=None):
186
+ """The event as fields, for a tray that should not parse a sentence.
187
+
188
+ What is deliberately absent is the point of this function. `tool_input`
189
+ carries command lines, paths and patch bodies; `transcript_path` points at
190
+ the whole conversation; `session_id` and `turn_id` identify a provider
191
+ session rather than a cdx one. None of them reach the spool. What crosses is
192
+ what cdx already knew (its own session name, the project directory) plus the
193
+ closed-vocabulary metadata the providers document: the event, the tool name,
194
+ the model, the permission category and the stop reason.
195
+ """
196
+ env = os.environ if env is None else env
197
+ event = hook_event(payload)
198
+ directory = payload.get("cwd") or payload.get("workspace") or cwd or os.getcwd()
199
+ details = {
200
+ "session": env.get(SESSION_ENV) or "",
201
+ "project": os.path.basename(str(directory).rstrip("/\\")) or str(directory),
202
+ "event": event,
203
+ }
204
+ for key, source, limit in (
205
+ ("tool", "tool_name", _TOOL_NAME_LIMIT),
206
+ ("error_type", "error_type", _TOOL_NAME_LIMIT),
207
+ ("model", "model", _TOOL_NAME_LIMIT),
208
+ ("category", "permission_category", _TOOL_NAME_LIMIT),
209
+ ("stop_reason", "stop_reason", _TOOL_NAME_LIMIT),
210
+ ):
211
+ value = _notification_text(payload.get(source), limit)
212
+ if value:
213
+ details[key] = value
214
+ preview = _notification_preview(payload, event, env)
215
+ if preview:
216
+ details["preview"] = preview
217
+ reason = _permission_reason(payload, event, env)
218
+ if reason:
219
+ details["reason"] = reason
220
+ failure = _failure_detail(payload, env) if event in _FAILURE_EVENTS else ""
221
+ if failure:
222
+ details["preview"] = failure
223
+ return details
224
+
225
+
226
+ def alert_kind(payload):
227
+ """Which of the three shapes this event is, for the tray's marker.
228
+
229
+ A failure is its own kind rather than borrowing `attention`: they call for
230
+ different reactions, and a companion that predates this shows the neutral
231
+ marker with a message that still says the turn failed.
232
+ """
233
+ event = hook_event(payload)
234
+ if event in _FAILURE_EVENTS:
235
+ return "failed"
236
+ return "attention" if event in _WAITING_EVENTS else "complete"
237
+
238
+
239
+ def _permission_reason(payload, event, env):
240
+ """The provider's own words for what it is asking permission to do.
241
+
242
+ The one field taken out of `tool_input`, and only this one: it is prose the
243
+ provider wrote for a human, where everything else in there is the command
244
+ itself. It follows the response-preview preference like any other free text,
245
+ because prose a provider composed can quote whatever it was asked to do.
246
+ """
247
+ if event != "permissionrequest" or env.get(PREVIEW_ENV) != "1":
248
+ return ""
249
+ tool_input = payload.get("tool_input")
250
+ if not isinstance(tool_input, dict):
251
+ return ""
252
+ return _notification_text(tool_input.get("description"), _PREVIEW_LIMIT)
253
+
254
+
94
255
  def _notification_preview(payload, event, env):
95
256
  if event != "stop" or env.get(PREVIEW_ENV) != "1":
96
257
  return ""
@@ -120,6 +281,11 @@ def handle_notify(rest, ctx):
120
281
  if reader is not None and not ctx.get("stdin_is_tty"):
121
282
  stdin_text = reader.read()
122
283
  payload = read_hook_payload(rest, stdin_text)
284
+ # From here on nothing may be written to stdout. On a PermissionRequest
285
+ # Claude Code reads this process's stdout as the decision for the tool
286
+ # call being asked about, so a stray line would allow or deny it. The
287
+ # notification paths below print nothing; this is the statement of why
288
+ # they must not start.
123
289
  composed = compose_notification(payload, env, ctx.get("cwd"))
124
290
  if composed:
125
291
  # Muted stops the banner, not the record: the event still reaches a
@@ -128,14 +294,14 @@ def handle_notify(rest, ctx):
128
294
  base_dir = (ctx.get("service") or {}).get("base_dir")
129
295
  muted = bool(base_dir) and not alerts_enabled(base_dir)
130
296
  if muted:
131
- _published_to_tray(ctx, composed, payload)
297
+ _published_to_tray(ctx, composed, payload, env, ctx.get("cwd"))
132
298
  return 0
133
299
  # A live tray becomes the sole owner of this alert, so the user sees
134
300
  # one notification rather than two. Publication happens below the
135
301
  # composition boundary on purpose: the tray receives the same
136
302
  # already-sanitized title and message the direct path would send,
137
303
  # and cannot be handed anything the privacy rules removed.
138
- if not _published_to_tray(ctx, composed, payload):
304
+ if not _published_to_tray(ctx, composed, payload, env, ctx.get("cwd")):
139
305
  send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
140
306
  except Exception: # noqa: BLE001 - deliberate: the caller is an agent turn
141
307
  pass
@@ -330,7 +496,15 @@ def _apply_hooks(path, enabled, command):
330
496
  hooks = document.get("hooks")
331
497
  hooks = hooks if isinstance(hooks, dict) else {}
332
498
  before = json.dumps(hooks, sort_keys=True)
333
- for event in ("Stop", "Notification"):
499
+ # PermissionRequest is what Codex has always used and Claude Code now
500
+ # publishes too: it fires immediately and names the tool, where Notification
501
+ # is delayed and does not. Notification stays subscribed for the waiting
502
+ # states nothing else reports; the duplicate it used to cause is filtered in
503
+ # `cdx notify` rather than avoided by unsubscribing.
504
+ # StopFailure is Claude-only: Codex documents no equivalent, so a turn its
505
+ # provider kills stays silent there and the documentation says so rather
506
+ # than implying a parity that does not exist.
507
+ for event in ("Stop", "StopFailure", "Notification", "PermissionRequest"):
334
508
  # Rebuilt from whatever is there minus our own entry, so user-authored
335
509
  # hooks on the same event survive both installing and removing.
336
510
  entries = [entry for entry in hooks.get(event, []) if not _is_ours(entry)]
@@ -387,22 +561,28 @@ def _write_json(path, document):
387
561
  return True
388
562
 
389
563
 
390
- def _published_to_tray(ctx, composed, payload):
564
+ def _published_to_tray(ctx, composed, payload, env=None, cwd=None):
391
565
  """Hand the alert to a live tray, or say we did not.
392
566
 
393
567
  False is the safe answer to everything: no base directory, no heartbeat, a
394
568
  stale one, a schema mismatch, or an unwritable spool all mean the direct
395
569
  path still runs. The only way to lose a notification here would be to
396
570
  return True without having written it.
571
+
572
+ The rendered title and message go too, unchanged. A companion older than the
573
+ structured fields reads exactly what it read before, and a newer one that is
574
+ handed a malformed event still has a sentence to show.
397
575
  """
398
576
  try:
399
577
  base_dir = (ctx.get("service") or {}).get("base_dir")
400
578
  if not base_dir:
401
579
  return False
402
- event = str(
403
- payload.get("hook_event_name") or payload.get("eventName") or payload.get("type") or ""
404
- ).lower().replace("_", "").replace("-", "")
405
- kind = "attention" if event in _WAITING_EVENTS else "complete"
406
- return publish(base_dir, composed[0], composed[1], kind=kind)
580
+ return publish(
581
+ base_dir,
582
+ composed[0],
583
+ composed[1],
584
+ kind=alert_kind(payload),
585
+ details=structured_details(payload, env, cwd),
586
+ )
407
587
  except Exception: # noqa: BLE001 - the caller is an agent turn
408
588
  return False