cdx-manager 0.18.1 → 0.18.3
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 +3 -1
- package/changelogs/CHANGELOGS_0_18_2.md +43 -0
- package/changelogs/CHANGELOGS_0_18_3.md +63 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +9 -2
- package/src/agent_notify.py +22 -2
- package/src/cli_args.py +1 -1
- package/src/commands/tray.py +39 -2
- package/src/tray_alerts.py +55 -0
- package/src/tray_contract.py +24 -1
- package/src/tray_install.py +47 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
|
|
6
6
|
|
|
@@ -521,6 +521,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
521
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
522
|
| `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
|
|
523
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 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 |
|
|
524
525
|
| `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 |
|
|
525
526
|
| `cdx --help` | Show usage |
|
|
526
527
|
| `cdx --version` | Show version |
|
|
@@ -560,6 +561,7 @@ They are deliberately hard to become noise. One hint per invocation at most, not
|
|
|
560
561
|
|
|
561
562
|
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
|
|
|
564
|
+
- **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.
|
|
563
565
|
- **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
566
|
- **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
567
|
- **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.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# CDX Manager 0.18.2
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
### `cdx tray install` can install the companion for the release you are running
|
|
6
|
+
|
|
7
|
+
No release could ever install its own tray companion, and the cause was
|
|
8
|
+
structural rather than a bug. Tray assets are built after the tag exists, so
|
|
9
|
+
their checksums are recorded afterwards — which means a package built at tag
|
|
10
|
+
time ships a checksum ledger that stops at the previous release. Installing
|
|
11
|
+
refused with "No published checksum for … of CDX <this version>".
|
|
12
|
+
|
|
13
|
+
The release already publishes `release-archives.json` beside the assets. A
|
|
14
|
+
missing local entry now falls through to it.
|
|
15
|
+
|
|
16
|
+
What that is worth, stated plainly: it is the publisher's own assertion fetched
|
|
17
|
+
over the same HTTPS channel as the asset, so it does not defend against someone
|
|
18
|
+
able to rewrite the release. It does defend against a truncated download and an
|
|
19
|
+
asset built for another architecture, which are the failures that happen. A
|
|
20
|
+
checksum committed to the repository before the asset existed is the stronger
|
|
21
|
+
claim, so a local entry always wins and fetches nothing.
|
|
22
|
+
|
|
23
|
+
### The PyPI package can start
|
|
24
|
+
|
|
25
|
+
`pyproject.toml` declared only the root package, so `cdx_manager.commands` was
|
|
26
|
+
absent from every wheel since it was extracted and `pip install cdx-manager`
|
|
27
|
+
produced a CLI that failed at import. setuptools does not warn about a
|
|
28
|
+
subpackage it was never told about: the wheel builds cleanly and breaks on the
|
|
29
|
+
user's machine.
|
|
30
|
+
|
|
31
|
+
The npm package and the standalone installer were unaffected — both ship the
|
|
32
|
+
source tree wholesale.
|
|
33
|
+
|
|
34
|
+
A test now asserts every package under `src/` is declared for the wheel.
|
|
35
|
+
|
|
36
|
+
## Validation
|
|
37
|
+
|
|
38
|
+
- `npm run lint`
|
|
39
|
+
- `npm test` — 775 Python tests
|
|
40
|
+
- A wheel built from this tree installs into a clean virtualenv and runs
|
|
41
|
+
`cdx --version` and `cdx tray doctor`
|
|
42
|
+
- `cdx tray install` exercised against a checksum ledger with no entry for the
|
|
43
|
+
version, which is the shape every shipped package has
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# CDX Manager 0.18.3
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
### Agent alerts reach the tray companion
|
|
6
|
+
|
|
7
|
+
A running companion never received an agent alert. Notifications still arrived,
|
|
8
|
+
delivered the old way, and nothing reported an error — which is why it took
|
|
9
|
+
watching a real machine to notice.
|
|
10
|
+
|
|
11
|
+
`CDX_HOME` defaults to `~/.cdx`, resolved against `HOME`, and a provider session
|
|
12
|
+
runs with `HOME` redirected into its own profile because that is how cdx isolates
|
|
13
|
+
authentication. So a hook calling `cdx notify` resolved a different store from
|
|
14
|
+
the one the companion writes its heartbeat into: no heartbeat there, no tray,
|
|
15
|
+
deliver it directly. Both stores existed side by side on disk.
|
|
16
|
+
|
|
17
|
+
The launch environment now pins `CDX_HOME` explicitly, so a hook talks to the
|
|
18
|
+
same store whatever `HOME` it runs under. This takes effect for sessions started
|
|
19
|
+
after the upgrade; a session already open keeps the environment it was launched
|
|
20
|
+
with.
|
|
21
|
+
|
|
22
|
+
### The macOS menu bar icon stays visible
|
|
23
|
+
|
|
24
|
+
The icon started white and turned black about thirty seconds later, which on a
|
|
25
|
+
dark menu bar means invisible. `tray-icon`'s macOS `set_icon` clears the template
|
|
26
|
+
flag unconditionally, so every poll dropped the setting that lets macOS invert
|
|
27
|
+
the glyph for the current theme. It is now re-asserted after each update.
|
|
28
|
+
|
|
29
|
+
Not a bug, and worth separating: while the menu is open macOS highlights the
|
|
30
|
+
status item and inverts a template image, so the icon is meant to look dark
|
|
31
|
+
there.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
### A switch to silence agent alerts
|
|
36
|
+
|
|
37
|
+
The tray menu carries a checkmark item, and `cdx tray alerts on|off|status` does
|
|
38
|
+
the same from a terminal.
|
|
39
|
+
|
|
40
|
+
Muting stops the banner, not the record: events keep reaching a running
|
|
41
|
+
companion, so the menu shows what you missed. It applies to the direct
|
|
42
|
+
notification path too, so quitting the tray does not silently un-mute you. It is
|
|
43
|
+
separate from the per-session `--notify` opt-in, which says which sessions may
|
|
44
|
+
alert at all.
|
|
45
|
+
|
|
46
|
+
### A readable tray menu
|
|
47
|
+
|
|
48
|
+
Session rows are selectable rather than informational — macOS renders disabled
|
|
49
|
+
items grey, so a dozen of them arrived as a wall of grey text. Clicking one opens
|
|
50
|
+
a terminal on that session.
|
|
51
|
+
|
|
52
|
+
The list is sorted by urgency, most constrained first. It previously arrived in
|
|
53
|
+
storage order, so an account at 1% could sit tenth while the closed icon showed
|
|
54
|
+
critical: the menu buried the very thing that made you open it.
|
|
55
|
+
|
|
56
|
+
## Validation
|
|
57
|
+
|
|
58
|
+
- `npm run lint`
|
|
59
|
+
- `npm test` — 782 Python tests
|
|
60
|
+
- `cargo test` — 45 Rust tests; `cargo clippy` clean with zero warnings on three
|
|
61
|
+
targets
|
|
62
|
+
- The tray menu, its switch and the notification routing exercised against real
|
|
63
|
+
sessions on macOS
|
|
@@ -202,6 +202,28 @@
|
|
|
202
202
|
"x86_64-pc-windows-msvc": "86e29cbdbcf070aec7fd34e5596343a43f2b5092e634ca094aba96abf1d49884",
|
|
203
203
|
"x86_64-unknown-linux-musl": "36ef879c0e75f3c367ed48f1f5a67e25698ec5a04c70958b48ae0885f87487d8"
|
|
204
204
|
}
|
|
205
|
+
},
|
|
206
|
+
"v0.18.1": {
|
|
207
|
+
"github_tarball_sha256": "260529247233abca840ecba03c2948234425b4fe0d27f2e5c008bdeb89fb0bd5",
|
|
208
|
+
"github_zip_sha256": "bc14fc2fd27641a56089a023b51427abac854eaf527a17d9bc0571698fb19b9f",
|
|
209
|
+
"tray_assets": {
|
|
210
|
+
"aarch64-apple-darwin": "8a1cd7f819d21097d63f8c452479bae7bfbfe3468473c7e8f557043c7b7ab47c",
|
|
211
|
+
"aarch64-unknown-linux-musl": "a4a757bfc0f048aa24439b675cb9f269d70328a98be88efbe5cc3fea96cbd78d",
|
|
212
|
+
"x86_64-apple-darwin": "1003b7051d6dfbf1a19c1e82eca1c648d11363171a77ad0cd85822a167921573",
|
|
213
|
+
"x86_64-pc-windows-msvc": "738eeb165c1c2fde58bd952cdc0a59561ca96856a3fb8d0cffc024bfb78b9ca6",
|
|
214
|
+
"x86_64-unknown-linux-musl": "0b3c02b007aa0655a5b95c7794eba49a37bdb9868c447bb0cfbdfbd9d371d094"
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
"v0.18.2": {
|
|
218
|
+
"github_tarball_sha256": "c988bf1462e8b707394c787c2a78ce2f4af9f1e88d79e71fdf61e70fd3945430",
|
|
219
|
+
"github_zip_sha256": "82a211118f29385b92b19e14df5fa344455b247930b30b4c700976867eee6527",
|
|
220
|
+
"tray_assets": {
|
|
221
|
+
"aarch64-apple-darwin": "2a250f4b49011c90a16adcda76cce49b1191e744dfae0997cf394b0a345efd70",
|
|
222
|
+
"aarch64-unknown-linux-musl": "efeb6a60677f039e411865454e60693c0c42253b9d32afd44d69a4a8905d891c",
|
|
223
|
+
"x86_64-apple-darwin": "f163caa8149ef4cd6061904f3d28c00b24dc93908667bb86da57c51cdf41ab4f",
|
|
224
|
+
"x86_64-pc-windows-msvc": "7a40ffc6a1908e0cda5e484b50ab7c11b6cb01f4c370905eefe23e40a8afb7bd",
|
|
225
|
+
"x86_64-unknown-linux-musl": "2f4b95c661cdb65bf58c2aa0d2c7a45e03bb5a8ec3d20404c688887dba33afe4"
|
|
226
|
+
}
|
|
205
227
|
}
|
|
206
228
|
}
|
|
207
229
|
}
|
package/package.json
CHANGED
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.
|
|
7
|
+
version = "0.18.3"
|
|
8
8
|
description = "Terminal session manager for Codex and Claude accounts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -45,10 +45,17 @@ Changelog = "https://github.com/AlexAgo83/cdx-manager/tree/main/changelogs"
|
|
|
45
45
|
cdx = "cdx_manager.cli:cli_entry"
|
|
46
46
|
|
|
47
47
|
[tool.setuptools]
|
|
48
|
-
|
|
48
|
+
# Every package under src/, not just the root. Subpackages have to be named
|
|
49
|
+
# explicitly here, and one that is missing is not a build error: the wheel is
|
|
50
|
+
# produced happily and fails at import on the user's machine. src/commands has
|
|
51
|
+
# been absent from every wheel since it was extracted, so `pip install
|
|
52
|
+
# cdx-manager` gave an unusable CLI while the npm and standalone installs — both
|
|
53
|
+
# of which ship the source tree wholesale — worked fine.
|
|
54
|
+
packages = ["cdx_manager", "cdx_manager.commands"]
|
|
49
55
|
|
|
50
56
|
[tool.setuptools.package-dir]
|
|
51
57
|
cdx_manager = "src"
|
|
58
|
+
"cdx_manager.commands" = "src/commands"
|
|
52
59
|
|
|
53
60
|
[tool.pytest.ini_options]
|
|
54
61
|
testpaths = ["test"]
|
package/src/agent_notify.py
CHANGED
|
@@ -14,7 +14,9 @@ import os
|
|
|
14
14
|
import shutil
|
|
15
15
|
import subprocess
|
|
16
16
|
|
|
17
|
+
from .config import get_cdx_home
|
|
17
18
|
from .notify import notification_channel, send_desktop_notification
|
|
19
|
+
from .tray_alerts import alerts_enabled
|
|
18
20
|
from .tray_events import publish
|
|
19
21
|
|
|
20
22
|
# Set on the provider process at launch; the hook runs as its child and inherits both.
|
|
@@ -120,6 +122,14 @@ def handle_notify(rest, ctx):
|
|
|
120
122
|
payload = read_hook_payload(rest, stdin_text)
|
|
121
123
|
composed = compose_notification(payload, env, ctx.get("cwd"))
|
|
122
124
|
if composed:
|
|
125
|
+
# Muted stops the banner, not the record: the event still reaches a
|
|
126
|
+
# running tray so the menu can show what was missed, and the direct
|
|
127
|
+
# path stays quiet too — otherwise quitting the tray would un-mute.
|
|
128
|
+
base_dir = (ctx.get("service") or {}).get("base_dir")
|
|
129
|
+
muted = bool(base_dir) and not alerts_enabled(base_dir)
|
|
130
|
+
if muted:
|
|
131
|
+
_published_to_tray(ctx, composed, payload)
|
|
132
|
+
return 0
|
|
123
133
|
# A live tray becomes the sole owner of this alert, so the user sees
|
|
124
134
|
# one notification rather than two. Publication happens below the
|
|
125
135
|
# composition boundary on purpose: the tray receives the same
|
|
@@ -133,8 +143,18 @@ def handle_notify(rest, ctx):
|
|
|
133
143
|
|
|
134
144
|
|
|
135
145
|
def launch_notify_env(session, enabled, env=None):
|
|
136
|
-
"""Env the provider is launched with, carrying who we are and whether to notify.
|
|
137
|
-
|
|
146
|
+
"""Env the provider is launched with, carrying who we are and whether to notify.
|
|
147
|
+
|
|
148
|
+
`CDX_HOME` is pinned rather than inherited, and that is what makes tray
|
|
149
|
+
routing work at all. It defaults to `~/.cdx`, resolved against `HOME` — and
|
|
150
|
+
a provider session runs with `HOME` redirected into its own profile, which
|
|
151
|
+
is how cdx isolates auth. So a hook calling `cdx notify` would resolve a
|
|
152
|
+
*different* store from the one the companion writes its heartbeat into, find
|
|
153
|
+
no tray, and quietly deliver the notification itself. The symptom is a
|
|
154
|
+
running tray that never receives an alert, with nothing reporting an error.
|
|
155
|
+
"""
|
|
156
|
+
env = os.environ if env is None else env
|
|
157
|
+
values = {SESSION_ENV: session["name"], "CDX_HOME": get_cdx_home(env)}
|
|
138
158
|
if not enabled:
|
|
139
159
|
values[ENABLED_ENV] = "0"
|
|
140
160
|
elif (session.get("launch") or {}).get("notify_preview") is True:
|
package/src/cli_args.py
CHANGED
|
@@ -63,7 +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
|
+
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] | cdx tray alerts [on|off|status] [--json]"
|
|
67
67
|
|
|
68
68
|
RUN_TAIL_DEFAULT_LINES = 50
|
|
69
69
|
RUN_TAIL_MAX_LINES = 2000
|
package/src/commands/tray.py
CHANGED
|
@@ -12,6 +12,7 @@ from ..cli_args import TRAY_USAGE, _parse_flag_args
|
|
|
12
12
|
from ..cli_helpers import _json_success, _write_json
|
|
13
13
|
from ..cli_render import _dim, _pad_table, _style, _warn
|
|
14
14
|
from ..errors import CdxError
|
|
15
|
+
from ..tray_alerts import alerts_enabled, set_alerts
|
|
15
16
|
from ..tray_autostart import disable as autostart_disable
|
|
16
17
|
from ..tray_autostart import enable as autostart_enable
|
|
17
18
|
from ..tray_autostart import status as autostart_status
|
|
@@ -41,7 +42,10 @@ from ..tray_install import (
|
|
|
41
42
|
)
|
|
42
43
|
from ..tray_instance import companion_instance
|
|
43
44
|
|
|
44
|
-
TRAY_ACTIONS = (
|
|
45
|
+
TRAY_ACTIONS = (
|
|
46
|
+
"status", "install", "launch", "uninstall", "autostart", "doctor",
|
|
47
|
+
"events", "ack", "heartbeat", "alerts",
|
|
48
|
+
)
|
|
45
49
|
|
|
46
50
|
# Codes rather than prose, so a caller can branch on them.
|
|
47
51
|
COMPANION_UNAVAILABLE = "tray_companion_not_available"
|
|
@@ -79,6 +83,8 @@ def handle_tray(rest, ctx):
|
|
|
79
83
|
return _tray_ack(args, ctx)
|
|
80
84
|
if action == "heartbeat":
|
|
81
85
|
return _tray_heartbeat(args, ctx)
|
|
86
|
+
if action == "alerts":
|
|
87
|
+
return _tray_alerts(args, ctx)
|
|
82
88
|
return _tray_install(args, ctx)
|
|
83
89
|
|
|
84
90
|
|
|
@@ -109,6 +115,7 @@ def _tray_status(args, ctx):
|
|
|
109
115
|
# needs.
|
|
110
116
|
base_dir = ctx["service"]["base_dir"]
|
|
111
117
|
snapshot["events"] = read_events(base_dir)
|
|
118
|
+
snapshot["alerts_enabled"] = alerts_enabled(base_dir)
|
|
112
119
|
# `--beat` is what claims ownership of alerts, so it is opt-in: a caller
|
|
113
120
|
# that merely looks at the status must not make `cdx notify` believe a tray
|
|
114
121
|
# is listening.
|
|
@@ -292,7 +299,7 @@ def _tray_doctor(args, ctx):
|
|
|
292
299
|
executable, source = companion_path(base_dir, env=env)
|
|
293
300
|
state = read_state(base_dir)
|
|
294
301
|
autostart = autostart_status(env=env, run=ctx.get("spawn_sync"))
|
|
295
|
-
instance = companion_instance()
|
|
302
|
+
instance = companion_instance(env=env)
|
|
296
303
|
desktop = desktop_capability(env=env)
|
|
297
304
|
toasts = toast_capability(env=env)
|
|
298
305
|
|
|
@@ -305,6 +312,8 @@ def _tray_doctor(args, ctx):
|
|
|
305
312
|
("target", (state or {}).get("target") or "-", (state or {}).get("sha256") or "-"),
|
|
306
313
|
("running", "yes" if instance.get("pid") else "no", str(instance.get("pid") or "-")),
|
|
307
314
|
("autostart", "on" if autostart["enabled"] else "off", autostart["artifact"] or "unsupported"),
|
|
315
|
+
("alerts", "on" if alerts_enabled(base_dir) else "muted",
|
|
316
|
+
"cdx tray alerts on|off"),
|
|
308
317
|
("update", "interrupted" if interrupted_update(base_dir) else "clean",
|
|
309
318
|
"a staged companion was never promoted; run: cdx tray install"
|
|
310
319
|
if interrupted_update(base_dir) else "-"),
|
|
@@ -443,3 +452,31 @@ def _tray_heartbeat(args, ctx):
|
|
|
443
452
|
return 0
|
|
444
453
|
ctx["out"]("Recorded the tray heartbeat.\n")
|
|
445
454
|
return 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _tray_alerts(args, ctx):
|
|
458
|
+
"""Silence agent alerts, or let them through again.
|
|
459
|
+
|
|
460
|
+
Separate from the per-session `--notify` preference on purpose: that says
|
|
461
|
+
which sessions may alert at all, and spending it to go quiet for a meeting
|
|
462
|
+
would be a poor trade. This is the temporary switch, and it is reported
|
|
463
|
+
rather than remembered — `cdx tray doctor` reads it back.
|
|
464
|
+
"""
|
|
465
|
+
parsed = _parse_flag_args(args, {
|
|
466
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
467
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=1)
|
|
468
|
+
mode = (parsed["args"] or ["status"])[0]
|
|
469
|
+
if mode not in ("on", "off", "status"):
|
|
470
|
+
raise CdxError(TRAY_USAGE)
|
|
471
|
+
base_dir = ctx["service"]["base_dir"]
|
|
472
|
+
if mode != "status":
|
|
473
|
+
set_alerts(base_dir, mode == "on")
|
|
474
|
+
enabled = alerts_enabled(base_dir)
|
|
475
|
+
message = f"Agent alerts are {'on' if enabled else 'muted'}"
|
|
476
|
+
if parsed["json"]:
|
|
477
|
+
_write_json(ctx, _json_success(
|
|
478
|
+
"tray.alerts", message, enabled=enabled, applied=mode != "status",
|
|
479
|
+
))
|
|
480
|
+
return 0
|
|
481
|
+
ctx["out"](f"{message}\n")
|
|
482
|
+
return 0
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Whether agent alerts may raise a banner right now.
|
|
2
|
+
|
|
3
|
+
A quick way to go quiet during a meeting or a screen share, and back afterwards,
|
|
4
|
+
without touching the per-session `--notify` preferences — those say which
|
|
5
|
+
sessions may alert at all, and losing them to a temporary mute would be a poor
|
|
6
|
+
trade for two minutes of silence.
|
|
7
|
+
|
|
8
|
+
Two decisions are worth stating because they are what make it a *silence*:
|
|
9
|
+
|
|
10
|
+
- Muting stops the banner, not the recording. Events keep arriving in the
|
|
11
|
+
tray's recent list, so the menu is where you catch up on what you missed.
|
|
12
|
+
An switch that dropped them would be an off, not a mute.
|
|
13
|
+
- It applies to the direct notification path too, not only to a running tray.
|
|
14
|
+
Otherwise quitting the companion would silently un-mute you, which is
|
|
15
|
+
exactly the kind of surprise a mute exists to prevent.
|
|
16
|
+
|
|
17
|
+
Stored in CDX's own home rather than in the companion, because the hook is what
|
|
18
|
+
reads it and the hook is not the tray.
|
|
19
|
+
"""
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
STATE_VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def state_path(base_dir):
|
|
27
|
+
return os.path.join(base_dir, "tray", "alerts.json")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def alerts_enabled(base_dir):
|
|
31
|
+
"""On unless explicitly muted.
|
|
32
|
+
|
|
33
|
+
Anything unreadable or unrecognised reads as on. A mute that outlived a
|
|
34
|
+
damaged file would silence someone with no way to see why, and silence is
|
|
35
|
+
the failure that hides itself.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
with open(state_path(base_dir), encoding="utf-8") as handle:
|
|
39
|
+
state = json.load(handle)
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return True
|
|
42
|
+
if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
|
|
43
|
+
return True
|
|
44
|
+
return state.get("enabled") is not False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_alerts(base_dir, enabled):
|
|
48
|
+
"""Mute or unmute, and report what it now is."""
|
|
49
|
+
path = state_path(base_dir)
|
|
50
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
51
|
+
temp = f"{path}.{os.getpid()}.tmp"
|
|
52
|
+
with open(temp, "w", encoding="utf-8") as handle:
|
|
53
|
+
json.dump({"version": STATE_VERSION, "enabled": bool(enabled)}, handle, indent=2)
|
|
54
|
+
os.replace(temp, path)
|
|
55
|
+
return bool(enabled)
|
package/src/tray_contract.py
CHANGED
|
@@ -115,6 +115,29 @@ def menu_session(row, now):
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
def _by_urgency(sessions):
|
|
119
|
+
"""Most constrained first, so the menu opens on what the icon is warning about.
|
|
120
|
+
|
|
121
|
+
The order the rows arrive in is the store's, which is not the order a person
|
|
122
|
+
reads for. With a dozen accounts the one at 1% sat tenth, below five at
|
|
123
|
+
100%, while the closed icon showed critical — the menu contradicted the
|
|
124
|
+
thing that made you open it.
|
|
125
|
+
|
|
126
|
+
Same ranking the icon uses, for the same reason: severity first, then least
|
|
127
|
+
remaining, and never-reported last. A session that never reported is not
|
|
128
|
+
urgent, it is unknown, and putting it on top would bury real news.
|
|
129
|
+
"""
|
|
130
|
+
severity = {ICON_CRITICAL: 0, ICON_LOW: 1, ICON_OK: 2, ICON_UNKNOWN: 3}
|
|
131
|
+
return sorted(
|
|
132
|
+
sessions,
|
|
133
|
+
key=lambda s: (
|
|
134
|
+
severity[s["state"]],
|
|
135
|
+
s["available_pct"] if s["available_pct"] is not None else 101,
|
|
136
|
+
s["name"] or "",
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
118
141
|
def _worst(sessions):
|
|
119
142
|
"""The session whose state the closed icon should show.
|
|
120
143
|
|
|
@@ -179,7 +202,7 @@ def build_snapshot(rows, now, cdx_version, refreshable=True):
|
|
|
179
202
|
`icon` carries no session name, account, or figure: it is what shows while
|
|
180
203
|
the menu is closed, and `req_035` AC3 forbids leaking anything there.
|
|
181
204
|
"""
|
|
182
|
-
sessions = [menu_session(row, now) for row in rows if _eligible(row)]
|
|
205
|
+
sessions = _by_urgency([menu_session(row, now) for row in rows if _eligible(row)])
|
|
183
206
|
if not sessions:
|
|
184
207
|
icon = {
|
|
185
208
|
"state": ICON_UNKNOWN,
|
package/src/tray_install.py
CHANGED
|
@@ -60,19 +60,61 @@ def asset_name(version, target):
|
|
|
60
60
|
return f"cdx-tray-{version}-{target}.tar.gz"
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
RELEASE_LEDGER_URL = (
|
|
64
|
+
"https://github.com/AlexAgo83/cdx-manager/releases/download/v{version}/release-archives.json"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ledger_entry(ledger, version, target):
|
|
69
|
+
release = (ledger.get("releases") or {}).get(f"v{version}") or {}
|
|
70
|
+
return (release.get("tray_assets") or {}).get(target)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def expected_checksum(version, target, ledger_path=CHECKSUM_LEDGER, download=None):
|
|
64
74
|
"""The published sha256 for this asset, or None when none is recorded.
|
|
65
75
|
|
|
66
76
|
None is a refusal, not a fallback. An asset with no published checksum is
|
|
67
77
|
exactly the case this gate exists for.
|
|
78
|
+
|
|
79
|
+
The shipped ledger cannot contain the entry for its own release, and that is
|
|
80
|
+
structural rather than an oversight: the tray assets are built after the tag
|
|
81
|
+
exists, so their checksums are recorded afterwards. A package built at tag
|
|
82
|
+
time therefore ships a ledger that stops at the previous release, and
|
|
83
|
+
`cdx tray install` would refuse the companion for the very version the user
|
|
84
|
+
is running.
|
|
85
|
+
|
|
86
|
+
So a missing local entry falls through to the ledger the release publishes
|
|
87
|
+
alongside the assets. Be clear about what that is worth: it is the
|
|
88
|
+
publisher's own assertion, fetched over the same HTTPS channel as the asset,
|
|
89
|
+
so it does not defend against someone who can rewrite the release. It does
|
|
90
|
+
defend against a truncated or corrupted download and against an asset built
|
|
91
|
+
for another architecture, which are the failures that actually happen. The
|
|
92
|
+
strong guarantee — a checksum committed before the asset existed — is what
|
|
93
|
+
the local ledger gives for every release older than this one.
|
|
68
94
|
"""
|
|
69
95
|
try:
|
|
70
96
|
with open(ledger_path, encoding="utf-8") as handle:
|
|
71
97
|
ledger = json.load(handle)
|
|
72
98
|
except (OSError, ValueError):
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
ledger = {}
|
|
100
|
+
local = _ledger_entry(ledger, version, target)
|
|
101
|
+
if local:
|
|
102
|
+
return local
|
|
103
|
+
return _published_checksum(version, target, download=download)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _published_checksum(version, target, download=None):
|
|
107
|
+
"""The entry from the ledger attached to the release, or None."""
|
|
108
|
+
fetch = download or _download
|
|
109
|
+
with tempfile.TemporaryDirectory(prefix="cdx-tray-ledger-") as scratch:
|
|
110
|
+
destination = os.path.join(scratch, "release-archives.json")
|
|
111
|
+
try:
|
|
112
|
+
fetch(RELEASE_LEDGER_URL.format(version=version), destination)
|
|
113
|
+
with open(destination, encoding="utf-8") as handle:
|
|
114
|
+
published = json.load(handle)
|
|
115
|
+
except (OSError, ValueError):
|
|
116
|
+
return None
|
|
117
|
+
return _ledger_entry(published, version, target)
|
|
76
118
|
|
|
77
119
|
|
|
78
120
|
def _sha256(path):
|
|
@@ -147,7 +189,7 @@ def install(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, targe
|
|
|
147
189
|
raise TrayInstallError(
|
|
148
190
|
f"No tray companion is published for {platform.system()} {platform.machine()}."
|
|
149
191
|
)
|
|
150
|
-
expected = expected_checksum(version, target, ledger_path=ledger_path)
|
|
192
|
+
expected = expected_checksum(version, target, ledger_path=ledger_path, download=download)
|
|
151
193
|
if not expected:
|
|
152
194
|
raise TrayInstallError(
|
|
153
195
|
f"No published checksum for the {target} tray companion of CDX {version}. "
|