cdx-manager 0.18.2 → 0.18.4

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.2-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.4-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
 
@@ -515,12 +519,14 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
515
519
  | `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
516
520
  | `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
521
  | `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 |
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 — `--yes` answers without a prompt, `--no-autostart` declines, and a non-interactive run declines rather than acquiring a login item by accident. Refuses when no checksum is published for the asset |
519
523
  | `cdx tray uninstall [--json]` | Remove exactly what `install` recorded, and nothing else |
524
+ | — | `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
525
  | `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
526
  | `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
527
  | `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
523
528
  | `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 |
529
+ | `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
530
  | `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
531
  | `cdx --help` | Show usage |
526
532
  | `cdx --version` | Show version |
@@ -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
@@ -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
@@ -213,6 +213,28 @@
213
213
  "x86_64-pc-windows-msvc": "738eeb165c1c2fde58bd952cdc0a59561ca96856a3fb8d0cffc024bfb78b9ca6",
214
214
  "x86_64-unknown-linux-musl": "0b3c02b007aa0655a5b95c7794eba49a37bdb9868c447bb0cfbdfbd9d371d094"
215
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
+ }
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
+ }
216
238
  }
217
239
  }
218
240
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.18.2"
7
+ version = "0.18.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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
- values = {SESSION_ENV: session["name"]}
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
@@ -587,6 +587,51 @@ def handle_repair(rest, ctx):
587
587
  ctx["out"](f"{_dim('Tip: run cdx repair --force to apply safe repairs.', ctx['use_color'])}\n")
588
588
  return 0
589
589
 
590
+ def _align_tray_companion(ctx, target_version):
591
+ """Move an installed tray companion to the release CDX just moved to.
592
+
593
+ Never raises and never fails the update: CDX has already been replaced
594
+ successfully by this point, and a companion that could not follow is a
595
+ warning, not a reason to call a good update bad.
596
+ """
597
+ from ..tray_install import align_companion
598
+ from ..tray_instance import companion_instance
599
+
600
+ try:
601
+ base_dir = ctx["service"]["base_dir"]
602
+ result = align_companion(base_dir, target_version, download=ctx.get("download_asset"))
603
+ except Exception as error: # noqa: BLE001 - the update already succeeded
604
+ return [{"code": "tray_companion_not_aligned", "message": f"The tray companion was left as it was: {error}"}]
605
+
606
+ if not result["aligned"]:
607
+ if result.get("reason") in ("no companion is installed", "already aligned"):
608
+ return []
609
+ return [{
610
+ "code": "tray_companion_not_aligned",
611
+ "message": (
612
+ f"The tray companion is still on {result.get('previous') or 'its previous version'}: "
613
+ f"{result['reason']} Run: cdx tray install"
614
+ ),
615
+ }]
616
+
617
+ warnings = [{
618
+ "code": "tray_companion_updated",
619
+ "message": f"Updated the tray companion to {target_version} as well.",
620
+ }]
621
+ # A running companion keeps executing the binary it started with. Saying so
622
+ # beats leaving someone to wonder why the menu still looks old, and beats
623
+ # quitting an application they did not ask us to touch.
624
+ try:
625
+ if companion_instance(env=ctx.get("env")).get("pid"):
626
+ warnings.append({
627
+ "code": "tray_companion_restart_pending",
628
+ "message": "The running tray is still the previous build. Quit it from its menu and run: cdx tray launch",
629
+ })
630
+ except Exception: # noqa: BLE001
631
+ pass
632
+ return warnings
633
+
634
+
590
635
  def handle_update(rest, ctx):
591
636
  parsed = _parse_update_args(rest)
592
637
  if parsed["all"]:
@@ -704,6 +749,12 @@ def handle_update(rest, ctx):
704
749
  if version_warning:
705
750
  warnings.append(version_warning)
706
751
 
752
+ # The tray companion is a separate artifact on a separate release, so it
753
+ # drifts unless something moves it. Reported rather than silent either way:
754
+ # an install that did not happen is as worth knowing as one that did.
755
+ for tray_warning in _align_tray_companion(ctx, target_version):
756
+ warnings.append(tray_warning)
757
+
707
758
  message = f"Updated cdx-manager to {target_version}"
708
759
  if json_flag:
709
760
  _write_json(ctx, _json_success(
@@ -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
@@ -26,6 +27,7 @@ from ..tray_events import (
26
27
  acknowledge as ack_events,
27
28
  )
28
29
  from ..tray_events import (
30
+ events_path,
29
31
  read_events,
30
32
  write_heartbeat,
31
33
  )
@@ -41,7 +43,10 @@ from ..tray_install import (
41
43
  )
42
44
  from ..tray_instance import companion_instance
43
45
 
44
- TRAY_ACTIONS = ("status", "install", "launch", "uninstall", "autostart", "doctor", "events", "ack", "heartbeat")
46
+ TRAY_ACTIONS = (
47
+ "status", "install", "launch", "uninstall", "autostart", "doctor",
48
+ "events", "ack", "heartbeat", "alerts",
49
+ )
45
50
 
46
51
  # Codes rather than prose, so a caller can branch on them.
47
52
  COMPANION_UNAVAILABLE = "tray_companion_not_available"
@@ -79,6 +84,8 @@ def handle_tray(rest, ctx):
79
84
  return _tray_ack(args, ctx)
80
85
  if action == "heartbeat":
81
86
  return _tray_heartbeat(args, ctx)
87
+ if action == "alerts":
88
+ return _tray_alerts(args, ctx)
82
89
  return _tray_install(args, ctx)
83
90
 
84
91
 
@@ -109,6 +116,12 @@ def _tray_status(args, ctx):
109
116
  # needs.
110
117
  base_dir = ctx["service"]["base_dir"]
111
118
  snapshot["events"] = read_events(base_dir)
119
+ snapshot["alerts_enabled"] = alerts_enabled(base_dir)
120
+ # Where the alerts land, so a companion on this machine can watch the file
121
+ # instead of waiting for its next poll. Meaningless across WSL — a Windows
122
+ # process cannot stat a path in the Linux filesystem — and the companion
123
+ # ignores it there rather than CDX pretending otherwise.
124
+ snapshot["spool_path"] = events_path(base_dir)
112
125
  # `--beat` is what claims ownership of alerts, so it is opt-in: a caller
113
126
  # that merely looks at the status must not make `cdx notify` believe a tray
114
127
  # is listening.
@@ -192,10 +205,18 @@ def _tray_uninstall(args, ctx):
192
205
  return 0
193
206
 
194
207
 
208
+ def _confirm_autostart():
209
+ answer = input("Start the CDX tray automatically at login? [Y/n] ")
210
+ return answer.strip().lower() in ("", "y", "yes")
211
+
212
+
195
213
  def _tray_install(args, ctx):
196
214
  parsed = _parse_flag_args(args, {
197
215
  "--json": {"key": "json", "type": "bool", "default": False},
216
+ "--yes": {"key": "yes", "type": "bool", "default": False},
217
+ "--no-autostart": {"key": "no_autostart", "type": "bool", "default": False},
198
218
  }, TRAY_USAGE, positionals_key="args", max_positionals=0)
219
+ env = ctx.get("env")
199
220
  target = current_target()
200
221
  if not target:
201
222
  return _refuse(
@@ -215,14 +236,50 @@ def _tray_install(args, ctx):
215
236
  raise CdxError(str(error)) from error
216
237
 
217
238
  message = f"Installed the {state['target']} tray companion for CDX {state['cdx_version']}"
239
+
240
+ # Installing something you asked for should show you the thing. The tray is
241
+ # started here rather than left for a second command, and a failure to start
242
+ # is reported without undoing an install that worked.
243
+ started = False
244
+ try:
245
+ spawn = ctx.get("spawn_detached") or _spawn_detached
246
+ spawn(launch_command(state["executable"]))
247
+ started = True
248
+ except Exception as error: # noqa: BLE001 - the install itself succeeded
249
+ ctx_error = str(error)
250
+ else:
251
+ ctx_error = None
252
+
253
+ # Startup is asked for, never assumed. `req_038` AC1 originally forbade it
254
+ # outright, for a good reason — an install that quietly adds a login item is
255
+ # what people resent about desktop software. Asking keeps the reason and
256
+ # drops the friction; a non-interactive run declines rather than deciding.
257
+ autostart_on = False
258
+ autostart_reason = None
259
+ if parsed["no_autostart"]:
260
+ autostart_reason = "skipped with --no-autostart"
261
+ elif parsed["yes"] or (ctx["stdin_is_tty"] and not parsed["json"] and _confirm_autostart()):
262
+ try:
263
+ autostart_enable(state["executable"], env=env, run=ctx.get("spawn_sync"))
264
+ autostart_on = True
265
+ except Exception as error: # noqa: BLE001
266
+ autostart_reason = str(error)
267
+ else:
268
+ autostart_reason = "not enabled; run: cdx tray autostart on"
269
+
218
270
  if parsed["json"]:
219
271
  _write_json(ctx, _json_success(
220
272
  "tray.install", message,
221
273
  target=state["target"], executable=state["executable"],
222
274
  sha256=state["sha256"], applied=True,
275
+ started=started, autostart=autostart_on,
223
276
  ))
224
277
  return 0
225
278
  ctx["out"](f"{message}\n{_dim(state['executable'], ctx['use_color'])}\n")
279
+ ctx["out"](f"{'Started it.' if started else f'It is installed but did not start: {ctx_error}'}\n")
280
+ ctx["out"](
281
+ f"{'It will start at login.' if autostart_on else f'Startup: {autostart_reason}'}\n"
282
+ )
226
283
  return 0
227
284
 
228
285
 
@@ -292,7 +349,7 @@ def _tray_doctor(args, ctx):
292
349
  executable, source = companion_path(base_dir, env=env)
293
350
  state = read_state(base_dir)
294
351
  autostart = autostart_status(env=env, run=ctx.get("spawn_sync"))
295
- instance = companion_instance()
352
+ instance = companion_instance(env=env)
296
353
  desktop = desktop_capability(env=env)
297
354
  toasts = toast_capability(env=env)
298
355
 
@@ -301,10 +358,17 @@ def _tray_doctor(args, ctx):
301
358
  executable or "run: cdx tray install"),
302
359
  ("executable", "present" if executable and os.path.exists(executable) else "missing",
303
360
  executable or "-"),
304
- ("cdx_version", ctx["version"], (state or {}).get("cdx_version") or "-"),
361
+ # Named rather than left as two numbers to compare: drift is the state
362
+ # that makes someone reinstall by hand, and `cdx update` now fixes it,
363
+ # so the report should say which one you are in.
364
+ ("cdx_version",
365
+ "aligned" if not state or state.get("cdx_version") == ctx["version"] else "drifted",
366
+ f"cdx {ctx['version']}, companion {(state or {}).get('cdx_version') or 'not installed'}"),
305
367
  ("target", (state or {}).get("target") or "-", (state or {}).get("sha256") or "-"),
306
368
  ("running", "yes" if instance.get("pid") else "no", str(instance.get("pid") or "-")),
307
369
  ("autostart", "on" if autostart["enabled"] else "off", autostart["artifact"] or "unsupported"),
370
+ ("alerts", "on" if alerts_enabled(base_dir) else "muted",
371
+ "cdx tray alerts on|off"),
308
372
  ("update", "interrupted" if interrupted_update(base_dir) else "clean",
309
373
  "a staged companion was never promoted; run: cdx tray install"
310
374
  if interrupted_update(base_dir) else "-"),
@@ -443,3 +507,31 @@ def _tray_heartbeat(args, ctx):
443
507
  return 0
444
508
  ctx["out"]("Recorded the tray heartbeat.\n")
445
509
  return 0
510
+
511
+
512
+ def _tray_alerts(args, ctx):
513
+ """Silence agent alerts, or let them through again.
514
+
515
+ Separate from the per-session `--notify` preference on purpose: that says
516
+ which sessions may alert at all, and spending it to go quiet for a meeting
517
+ would be a poor trade. This is the temporary switch, and it is reported
518
+ rather than remembered — `cdx tray doctor` reads it back.
519
+ """
520
+ parsed = _parse_flag_args(args, {
521
+ "--json": {"key": "json", "type": "bool", "default": False},
522
+ }, TRAY_USAGE, positionals_key="args", max_positionals=1)
523
+ mode = (parsed["args"] or ["status"])[0]
524
+ if mode not in ("on", "off", "status"):
525
+ raise CdxError(TRAY_USAGE)
526
+ base_dir = ctx["service"]["base_dir"]
527
+ if mode != "status":
528
+ set_alerts(base_dir, mode == "on")
529
+ enabled = alerts_enabled(base_dir)
530
+ message = f"Agent alerts are {'on' if enabled else 'muted'}"
531
+ if parsed["json"]:
532
+ _write_json(ctx, _json_success(
533
+ "tray.alerts", message, enabled=enabled, applied=mode != "status",
534
+ ))
535
+ return 0
536
+ ctx["out"](f"{message}\n")
537
+ 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)
@@ -19,6 +19,7 @@ from .session_status import (
19
19
  CODEX_STATUS_CACHE_TTL_SECONDS,
20
20
  STATUS_CACHE_TTL_SECONDS,
21
21
  )
22
+ from .status_view import _format_reset_time
22
23
 
23
24
  SCHEMA_NAME = "cdx.tray.snapshot"
24
25
  SCHEMA_MAJOR = 1
@@ -96,6 +97,26 @@ def _eligible(row):
96
97
  return row.get("enabled", True) is not False
97
98
 
98
99
 
100
+ def _ago(seconds):
101
+ """How long ago, in the shortest form that stays unambiguous.
102
+
103
+ `None` when nothing was ever reported: "never" is a state the caller already
104
+ names, and inventing an age for it would be worse than saying nothing.
105
+ """
106
+ if seconds is None:
107
+ return None
108
+ seconds = max(0, int(seconds))
109
+ if seconds < 60:
110
+ return "just now"
111
+ minutes = seconds // 60
112
+ if minutes < 60:
113
+ return f"{minutes}m ago"
114
+ hours = minutes // 60
115
+ if hours < 24:
116
+ return f"{hours}h ago"
117
+ return f"{hours // 24}d ago"
118
+
119
+
99
120
  def menu_session(row, now):
100
121
  """One menu line. Everything the tray shows once the user opens it."""
101
122
  freshness, age = session_freshness(row, now)
@@ -107,6 +128,12 @@ def menu_session(row, now):
107
128
  "remaining_5h_pct": row.get("remaining_5h_pct"),
108
129
  "remaining_week_pct": row.get("remaining_week_pct"),
109
130
  "reset_at": row.get("reset_at"),
131
+ # The same wording `cdx status` uses — "in 5h", "passed 3h ago" — rather
132
+ # than a second vocabulary for the same idea. An absolute stamp costs a
133
+ # dozen characters per row and still makes the reader do the subtraction
134
+ # that matters: how long until it comes back.
135
+ "reset_in": _format_reset_time(row.get("reset_at")),
136
+ "updated_ago": _ago(age),
110
137
  "active": bool(row.get("active")),
111
138
  "state": icon_state_for_pct(row.get("available_pct")),
112
139
  "freshness": freshness,
@@ -115,6 +142,29 @@ def menu_session(row, now):
115
142
  }
116
143
 
117
144
 
145
+ def _by_urgency(sessions):
146
+ """Most constrained first, so the menu opens on what the icon is warning about.
147
+
148
+ The order the rows arrive in is the store's, which is not the order a person
149
+ reads for. With a dozen accounts the one at 1% sat tenth, below five at
150
+ 100%, while the closed icon showed critical — the menu contradicted the
151
+ thing that made you open it.
152
+
153
+ Same ranking the icon uses, for the same reason: severity first, then least
154
+ remaining, and never-reported last. A session that never reported is not
155
+ urgent, it is unknown, and putting it on top would bury real news.
156
+ """
157
+ severity = {ICON_CRITICAL: 0, ICON_LOW: 1, ICON_OK: 2, ICON_UNKNOWN: 3}
158
+ return sorted(
159
+ sessions,
160
+ key=lambda s: (
161
+ severity[s["state"]],
162
+ s["available_pct"] if s["available_pct"] is not None else 101,
163
+ s["name"] or "",
164
+ ),
165
+ )
166
+
167
+
118
168
  def _worst(sessions):
119
169
  """The session whose state the closed icon should show.
120
170
 
@@ -179,7 +229,7 @@ def build_snapshot(rows, now, cdx_version, refreshable=True):
179
229
  `icon` carries no session name, account, or figure: it is what shows while
180
230
  the menu is closed, and `req_035` AC3 forbids leaking anything there.
181
231
  """
182
- sessions = [menu_session(row, now) for row in rows if _eligible(row)]
232
+ sessions = _by_urgency([menu_session(row, now) for row in rows if _eligible(row)])
183
233
  if not sessions:
184
234
  icon = {
185
235
  "state": ICON_UNKNOWN,
@@ -251,6 +251,34 @@ def install(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, targe
251
251
  return state
252
252
 
253
253
 
254
+ def align_companion(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None, env=None, probe=None):
255
+ """Bring an installed companion up to the CDX release that just landed.
256
+
257
+ `item_081` AC2 asks for the two to stay aligned, and until now nothing did
258
+ it: `cdx update` moved CDX and left the companion behind, so the user had to
259
+ notice the drift and reinstall by hand.
260
+
261
+ Returns a dict describing what happened, and never raises. The CDX update
262
+ has already succeeded by the time this runs — failing it because a companion
263
+ could not be replaced would undo a good outcome for a worse reason. Every
264
+ refusal is reported instead.
265
+ """
266
+ state = read_state(base_dir)
267
+ if not state:
268
+ return {"aligned": False, "reason": "no companion is installed"}
269
+ if state.get("cdx_version") == version:
270
+ return {"aligned": False, "reason": "already aligned"}
271
+ try:
272
+ update(base_dir, version, download=download, ledger_path=ledger_path, target=target, probe=probe)
273
+ except TrayInstallError as error:
274
+ return {
275
+ "aligned": False,
276
+ "reason": str(error),
277
+ "previous": state.get("cdx_version"),
278
+ }
279
+ return {"aligned": True, "previous": state.get("cdx_version"), "version": version}
280
+
281
+
254
282
  def _extract(archive, destination):
255
283
  """Unpack, refusing any member that would escape the destination.
256
284