cdx-manager 0.18.4 → 0.18.6
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 +39 -7
- package/VERSION +1 -0
- package/changelogs/CHANGELOGS_0_18_5.md +147 -0
- package/changelogs/CHANGELOGS_0_18_6.md +8 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +192 -12
- package/src/cli.py +28 -11
- package/src/cli_args.py +3 -2
- package/src/commands/launch.py +56 -5
- package/src/commands/maintenance.py +24 -13
- package/src/commands/tray.py +252 -6
- package/src/session_service.py +25 -1
- package/src/session_status.py +4 -1
- package/src/tray_contract.py +11 -2
- package/src/tray_defaults.py +48 -0
- package/src/tray_events.py +9 -1
- package/src/tray_install.py +101 -8
- package/src/tray_logics.py +163 -0
- package/src/tray_plugin_actions.py +62 -0
- package/src/tray_plugins.py +163 -0
- package/src/tray_restart.py +152 -0
- package/src/tray_terminal.py +80 -0
- package/src/update_manager.py +1 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
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.
|
|
10
10
|
|
|
@@ -377,11 +377,35 @@ It is off by default because turning it on writes into the provider's own
|
|
|
377
377
|
configuration and, on Codex, puts an approval prompt in front of you. That is
|
|
378
378
|
not something to do to sessions you already own without being asked.
|
|
379
379
|
|
|
380
|
-
|
|
381
|
-
notification includes at most 180 characters
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
380
|
+
New sessions inherit response previews when agent alerts were accepted during
|
|
381
|
+
tray installation. A completion notification includes at most 180 characters
|
|
382
|
+
of the agent's final response, and a permission request may quote the
|
|
383
|
+
provider's own one-line description of what it wants to do. That text can be
|
|
384
|
+
visible on a lock screen; disable it per session with
|
|
385
|
+
`cdx set <name> --notify-preview off`. Previews never read transcripts, and the
|
|
386
|
+
command a permission request is about is never included.
|
|
387
|
+
|
|
388
|
+
Both providers are subscribed to the same two hooks: `Stop` when a turn ends and
|
|
389
|
+
`PermissionRequest` when one blocks on your approval. Claude Code is also
|
|
390
|
+
subscribed to `Notification`, for the "still waiting for you" states that no
|
|
391
|
+
other hook reports; the permission notifications it also sends are dropped,
|
|
392
|
+
because `PermissionRequest` already reported the same call immediately and by
|
|
393
|
+
name.
|
|
394
|
+
|
|
395
|
+
Claude Code additionally reports a turn that ended on a provider error rather
|
|
396
|
+
than on an answer — a rate limit, an overload, an authentication or billing
|
|
397
|
+
problem. Those alerts are marked apart from completions, name the error class,
|
|
398
|
+
and say whether the failure is one that usually clears on its own or one that
|
|
399
|
+
needs you. Codex documents no equivalent hook, so a Codex turn killed by its
|
|
400
|
+
provider stays silent; that is a gap in what the provider publishes, not a
|
|
401
|
+
setting.
|
|
402
|
+
|
|
403
|
+
A running tray companion receives each alert as fields rather than as a
|
|
404
|
+
sentence, so its menu can name the session, the project and the tool, and
|
|
405
|
+
clicking a recent alert opens the session it came from. The tool's arguments,
|
|
406
|
+
the transcript path and the provider's own session identifiers are never part of
|
|
407
|
+
that — only what cdx already knew and what the providers document as safe
|
|
408
|
+
metadata.
|
|
385
409
|
|
|
386
410
|
`cdx notify` is the internal hook target, called by the provider; it is not the
|
|
387
411
|
setup command. `cdx ready` is separate: it notifies you when the next cooling-
|
|
@@ -519,13 +543,15 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
519
543
|
| `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
|
|
520
544
|
| `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 |
|
|
521
545
|
| `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 |
|
|
522
|
-
| `cdx tray install [--json] [--yes] [--no-autostart]` | Download the companion for this OS and architecture, verify its published checksum before unpacking, record what was written, and start it. Asks whether to launch it at login
|
|
546
|
+
| `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 |
|
|
523
547
|
| `cdx tray uninstall [--json]` | Remove exactly what `install` recorded, and nothing else |
|
|
524
548
|
| — | `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 |
|
|
525
549
|
| `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 |
|
|
526
550
|
| `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 |
|
|
527
551
|
| `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
|
|
528
552
|
| `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 |
|
|
553
|
+
| `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 |
|
|
554
|
+
| `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 |
|
|
529
555
|
| `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 |
|
|
530
556
|
| `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 |
|
|
531
557
|
| `cdx --help` | Show usage |
|
|
@@ -568,6 +594,12 @@ The companion is a native binary distributed as a release asset, not part of the
|
|
|
568
594
|
|
|
569
595
|
- **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.
|
|
570
596
|
- **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.
|
|
597
|
+
- **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.
|
|
598
|
+
|
|
599
|
+
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.
|
|
600
|
+
|
|
601
|
+
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.
|
|
602
|
+
|
|
571
603
|
- **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.
|
|
572
604
|
- **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.
|
|
573
605
|
- **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.6
|
|
@@ -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.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# CDX Manager 0.18.6
|
|
2
|
+
|
|
3
|
+
## Features
|
|
4
|
+
|
|
5
|
+
- Launches can use an explicit `--dir`; each run records its directory for status and tray surfaces.
|
|
6
|
+
- The Logics tray card derives repositories from active runs and shows basename-only rows.
|
|
7
|
+
- `cdx update` names its install prefix, while `cdx tray doctor` reports the companion CDX command and hook-store alignment.
|
|
8
|
+
- Windows Terminal, GNOME Terminal, and Konsole open new tabs where their documented interfaces support it; other terminals retain the working window fallback.
|
|
@@ -235,6 +235,28 @@
|
|
|
235
235
|
"x86_64-pc-windows-msvc": "e50250d21d11e6986a310a00d4f3ab5b1a13d2960684750c569926d5bda923f9",
|
|
236
236
|
"x86_64-unknown-linux-musl": "edbdb02b7e9df5709b5b9512233e2b25b51f7652bd1b78c68edb85dbfa65d708"
|
|
237
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
|
+
}
|
|
249
|
+
},
|
|
250
|
+
"v0.18.5": {
|
|
251
|
+
"github_tarball_sha256": "d4251f1bdeb5aba31308cf749bf51733435bf2559f50a923ce090f0940c59876",
|
|
252
|
+
"github_zip_sha256": "36bc9d4e0a3cad306ccd405d7b4e4091418e21fe897b5ebcad4e275133615837",
|
|
253
|
+
"tray_assets": {
|
|
254
|
+
"aarch64-apple-darwin": "b0eb6653c3bbe1aba93d8b1966377e379eecea063ad01a10a11cdfd52d48e5ac",
|
|
255
|
+
"aarch64-unknown-linux-musl": "0cbca29099f1d320e917cb60e463fe0eb8fef9b78396db32520b8574ae4bb4a3",
|
|
256
|
+
"x86_64-apple-darwin": "a3e3d485552191f2456573dfbfee4259e430e7432b6d307eea00d5c527c86011",
|
|
257
|
+
"x86_64-pc-windows-msvc": "31a1743b0b6ec420a85c1679e93fcbd32c982a36e080a59d563276204682f152",
|
|
258
|
+
"x86_64-unknown-linux-musl": "49fba26d3deeab1e46af542528a5c71262e6c4d82898fcf989d7222a09a11d59"
|
|
259
|
+
}
|
|
238
260
|
}
|
|
239
261
|
}
|
|
240
262
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdx-manager",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.6",
|
|
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
package/src/agent_notify.py
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
package/src/cli.py
CHANGED
|
@@ -84,7 +84,7 @@ from .status_view import (
|
|
|
84
84
|
from .update_check import check_for_update, check_logics_manager_for_update
|
|
85
85
|
|
|
86
86
|
|
|
87
|
-
def _resolve_version():
|
|
87
|
+
def _resolve_version(package_root=None, metadata_lookup=None):
|
|
88
88
|
"""The version, resolved rather than restated.
|
|
89
89
|
|
|
90
90
|
This was a hardcoded string, a fourth copy alongside VERSION, package.json,
|
|
@@ -95,23 +95,38 @@ def _resolve_version():
|
|
|
95
95
|
happens in a checkout, where it is the thing being edited and the installed
|
|
96
96
|
metadata is whatever was last `pip install`ed. Installed packages have no
|
|
97
97
|
such file and fall through to their own metadata.
|
|
98
|
+
|
|
99
|
+
An npm install has neither: no Python distribution metadata, and — until
|
|
100
|
+
VERSION was added to the published `files` list — no VERSION either. That
|
|
101
|
+
combination reported 0.0.0, which reads as permanently out of date and makes
|
|
102
|
+
`cdx update` succeed without ever silencing itself. package.json ships in
|
|
103
|
+
every npm tarball by construction, so reading it is the branch that cannot
|
|
104
|
+
go missing again.
|
|
98
105
|
"""
|
|
99
|
-
|
|
100
|
-
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
101
|
-
)
|
|
106
|
+
if package_root is None:
|
|
107
|
+
package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
102
108
|
try:
|
|
103
|
-
with open(
|
|
109
|
+
with open(os.path.join(package_root, "VERSION"), encoding="utf-8") as handle:
|
|
104
110
|
text = handle.read().strip()
|
|
105
111
|
if text:
|
|
106
112
|
return text
|
|
107
113
|
except OSError:
|
|
108
114
|
pass
|
|
109
115
|
try:
|
|
110
|
-
|
|
116
|
+
if metadata_lookup is None:
|
|
117
|
+
from importlib.metadata import version as metadata_lookup
|
|
111
118
|
|
|
112
|
-
return
|
|
113
|
-
except Exception: # noqa: BLE001 -
|
|
114
|
-
|
|
119
|
+
return metadata_lookup("cdx-manager")
|
|
120
|
+
except Exception: # noqa: BLE001 - not installed as a Python distribution
|
|
121
|
+
pass
|
|
122
|
+
try:
|
|
123
|
+
with open(os.path.join(package_root, "package.json"), encoding="utf-8") as handle:
|
|
124
|
+
text = str(json.load(handle).get("version") or "").strip()
|
|
125
|
+
if text:
|
|
126
|
+
return text
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
pass
|
|
129
|
+
return "0.0.0"
|
|
115
130
|
|
|
116
131
|
|
|
117
132
|
VERSION = _resolve_version()
|
|
@@ -604,8 +619,10 @@ def main(argv, options=None):
|
|
|
604
619
|
if handler:
|
|
605
620
|
return handler(rest, ctx)
|
|
606
621
|
|
|
607
|
-
|
|
608
|
-
|
|
622
|
+
from .commands.launch import _parse_launch_args
|
|
623
|
+
directory, launch_args = _parse_launch_args(rest)
|
|
624
|
+
if all(arg in ("--json", "-r", "--resume") for arg in launch_args):
|
|
625
|
+
return handle_launch(command, ctx, resume=("-r" in launch_args or "--resume" in launch_args), directory=directory)
|
|
609
626
|
|
|
610
627
|
raise CdxError(f"Unknown command: {command}. Use cdx --help.")
|
|
611
628
|
|