cdx-manager 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.17.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.18.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
6
6
 
@@ -513,11 +513,60 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
513
513
  | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; `--cached` skips live provider probes and returns only stored status |
514
514
  | `cdx status --small [--refresh\|--cached] [--timeout SECONDS]` / `cdx status -s [--refresh\|--cached] [--timeout SECONDS]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
515
515
  | `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
516
+ | `cdx tray status [--json] [--refresh]` | Emit the tray snapshot: one icon state, one line per enabled session, and how fresh each figure is. Reads stored status by default; `--refresh` is the only path that probes a provider |
517
+ | `cdx tray launch [--json]` | Start the installed tray companion, detached. Reports `tray_companion_not_installed` when nothing is recorded, or `tray_companion_missing` when the recorded companion is gone. `CDX_TRAY_BIN` points it at a locally built one |
518
+ | `cdx tray install [--json]` | Download the companion for this OS and architecture, verify its published checksum before unpacking, and record what was written. Refuses when no checksum is published for the asset |
519
+ | `cdx tray uninstall [--json]` | Remove exactly what `install` recorded, and nothing else |
520
+ | `cdx tray autostart [on\|off\|status] [--json]` | Start the companion at login, or stop doing so. Off until asked, idempotent, and the state is read back from the platform rather than from what CDX last intended |
521
+ | `cdx tray events [--json]` | List agent alerts a running companion has not shown yet. The companion reads these instead of the spool file, so a Windows tray can collect events from CDX in WSL over the same interop it already uses |
522
+ | `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
523
+ | `cdx tray heartbeat [--json]` | Record that a companion is alive. `cdx notify` publishes to the tray only while this is fresh, and delivers directly otherwise |
524
+ | `cdx tray doctor [--json]` | Report companion, executable, version, running instance, autostart, and the two capabilities that fail silently — whether this desktop can draw a tray icon at all, and on Windows whether the Start Menu shortcut toasts depend on exists. Reads only; it never repairs |
516
525
  | `cdx --help` | Show usage |
517
526
  | `cdx --version` | Show version |
518
527
 
519
528
  ---
520
529
 
530
+ ### Tray snapshot
531
+
532
+ `cdx tray status --json` is the contract an optional desktop tray companion reads. It adds nothing to the quota pipeline: it interprets the status CDX already stores.
533
+
534
+ - **Cached by default.** A tray polls, and a live provider probe serializes on the Codex auth lock, which the launcher holds for a whole interactive session. Polling it would be locked out at best and race a rotating OAuth refresh token at worst. Only `--refresh`, a deliberate user gesture, probes.
535
+ - **Freshness is a value, not an error.** Each session reports `fresh`, `stale`, `auth_locked`, or `unknown`. `auth_locked` means a session is running and its quota cannot be refreshed until it exits — pressing refresh will not help, and the snapshot says so instead of ageing silently.
536
+ - **The icon leaks nothing.** `icon` holds a state, a reason, and a count. Session names, providers, and figures live in `sessions`, which a companion shows only once the user opens the menu.
537
+ - **Versions drift on purpose.** The payload carries `schema.major`. A reader meeting a newer major keeps every field it understands and surfaces one update hint, so upgrading CDX never breaks an installed companion.
538
+
539
+ ```json
540
+ {
541
+ "schema": { "name": "cdx.tray.snapshot", "major": 1, "minor": 0 },
542
+ "icon": { "state": "low", "reason": "fresh", "session_count": 2 },
543
+ "sessions": [
544
+ { "name": "work", "provider": "codex", "available_pct": 18, "state": "low", "freshness": "fresh", "reset_at": "..." }
545
+ ],
546
+ "refreshable": true,
547
+ "actions": ["refresh", "open_terminal"]
548
+ }
549
+ ```
550
+
551
+ Icon states are `ok` at 25% remaining and above, `low` below 25%, `critical` below 5%, and `unknown` when nothing was ever reported. The closed icon shows the most urgent session, and a session that never reported never outranks one with real news.
552
+
553
+ ### Setup hints
554
+
555
+ `cdx` and `cdx status` may end with one dim line pointing at an optional surface you have not set up yet — the tray companion, or agent alerts. They exist because neither is discoverable from the output of the commands you already run.
556
+
557
+ They are deliberately hard to become noise. One hint per invocation at most, not again for six hours, and each one retires for good after three showings: if you saw it three times and did not act, that is an answer. A hint never appears in `--json`, never appears alongside an update notice, and is answered entirely from local state — nothing here probes a provider or the network. `CDX_NO_HINTS=1` turns them off outright.
558
+
559
+ ### Tray companion: what vouches for it
560
+
561
+ The companion is a native binary distributed as a release asset, not part of the pip package. Its trust model is worth stating plainly rather than leaving you to assume the usual one.
562
+
563
+ - **The checksum is the vouching, not Apple or Microsoft.** The macOS bundle is signed with a *self-signed* certificate. That signature exists because Apple Silicon refuses to execute unsigned arm64 code at all — it is not a claim that Apple reviewed anything. What actually vouches for the asset is the SHA-256 published in `checksums/release-archives.json`, verified before a single byte is unpacked. An asset with no published checksum is refused rather than installed hopefully.
564
+ - **Install through `cdx tray install`, and no download link is offered.** This is a security property, not a convenience. A file fetched by CDX carries no macOS quarantine attribute and no Windows Mark-of-the-Web, so nothing puts Gatekeeper or SmartScreen between you and a companion you asked for. The same file fetched through a browser would carry both. That is why no direct download URL is published: it would be a worse path wearing the same clothes.
565
+ - **Nothing is silently replaced.** An update downloads, verifies, and *proves the replacement starts* before the working companion is touched. A corrupt asset or one built for the wrong architecture costs an error message, not a tray you can no longer launch. An interrupted update is a named, recoverable state that `cdx tray doctor` reports.
566
+ - **Uninstall removes only what install recorded**, from a record CDX wrote itself. A damaged record reads as absent rather than being guessed at, because that record drives deletion.
567
+ - **Linux carries no C library.** The Linux asset is statically linked against musl, so there is no glibc version to match and no `gtk`, `libxdo`, or `appindicator` package to install. Tray support is resolved at runtime by looking for the `org.kde.StatusNotifierWatcher` D-Bus name — never by matching a distribution or desktop name. A session without it is told so, and nothing is registered to start at login.
568
+ - **Version drift between CDX and the companion is normal**, since pip upgrades one and `cdx tray install` the other. Whichever is older keeps serving every field it understands and shows one update hint.
569
+
521
570
  ## JSON Output
522
571
 
523
572
  `cdx-manager` can be consumed by other apps through its CLI JSON contract.
@@ -0,0 +1,82 @@
1
+ # CDX Manager 0.18.0
2
+
3
+ ## Features
4
+
5
+ ### An optional desktop tray companion
6
+
7
+ A native menu bar companion now shows remaining quota across every enabled
8
+ session without a terminal open. It is optional, off until installed, and adds
9
+ nothing to the quota pipeline: it reads the status CDX already stores through
10
+ `cdx tray status --json`.
11
+
12
+ `cdx tray` gained `status`, `install`, `launch`, `uninstall`, `autostart`,
13
+ `doctor`, `events`, `ack`, and `heartbeat`.
14
+
15
+ The polling is cached by default and never probes a provider on a timer. A live
16
+ probe serializes on the Codex auth lock, which an interactive session holds for
17
+ its whole run, and races a rotating OAuth refresh token. Only an explicit
18
+ refresh — a user gesture from the menu — probes, and the menu says so when a
19
+ running session makes refreshing pointless.
20
+
21
+ Freshness is reported rather than hidden. Each session reads `fresh`, `stale`,
22
+ `auth_locked`, or `unknown`, and a figure is never invented for a state that
23
+ knows nothing.
24
+
25
+ ### Agent alerts delivered through the tray
26
+
27
+ When a companion is running, an agent finishing a turn raises one notification
28
+ from the tray instead of two from the tray and the direct path. The tray keeps a
29
+ bounded list of recent alerts and marks new ones beside the icon for 45 seconds.
30
+
31
+ Delivery falls back to the previous direct path whenever a companion is absent,
32
+ stale, or speaks a schema this CDX does not, so upgrading one side never leaves
33
+ the other silent.
34
+
35
+ On macOS the alert is posted from the signed CDX bundle, so it carries the CDX
36
+ name and icon and can be turned off under CDX in System Settings. On Windows it
37
+ goes out under CDX's own AppUserModelID once `cdx tray install` has written the
38
+ Start Menu shortcut, and under PowerShell's identifier until then.
39
+
40
+ ### Setup hints in `cdx` and `cdx status`
41
+
42
+ Both commands may end with one dim line pointing at an optional surface not yet
43
+ set up — the tray companion, or agent alerts. One hint per invocation, not again
44
+ for six hours, retired for good after three showings, never in `--json`, never
45
+ alongside an update notice, and `CDX_NO_HINTS=1` turns them off.
46
+
47
+ ## Platform support
48
+
49
+ - **macOS**: signed bundle, menu bar item, native notifications, LaunchAgent
50
+ autostart.
51
+ - **Windows**: taskbar icon that picks its glyph tint from the taskbar theme,
52
+ promotes itself out of the notification overflow once, and reads CDX from WSL
53
+ through interop with no X server and no localhost.
54
+ - **Linux**: StatusNotifierItem through ksni, resolved at runtime by looking for
55
+ `org.kde.StatusNotifierWatcher` rather than by matching a distribution name. A
56
+ desktop without one is told so instead of being left with an invisible icon.
57
+ The asset is statically linked against musl, so there is no glibc version to
58
+ match and no gtk, libxdo, or appindicator package to install.
59
+
60
+ ## Trust model
61
+
62
+ `cdx tray install` fetches a release asset and verifies its published SHA-256
63
+ before a single byte is unpacked. The macOS bundle is self-signed: that
64
+ signature exists because Apple Silicon refuses to execute unsigned arm64 code,
65
+ not because Apple reviewed anything, and the published checksum is what vouches
66
+ for the asset. Installing through CDX is also what keeps the file free of the
67
+ macOS quarantine attribute and the Windows Mark of the Web, which is why no
68
+ browser download link is published.
69
+
70
+ An update downloads, verifies, and proves the replacement starts before the
71
+ working companion is touched. A failure leaves the previous one running, and
72
+ `cdx tray doctor` names the interrupted state.
73
+
74
+ ## Validation
75
+
76
+ - `npm run lint`
77
+ - `npm test` — 766 Python tests
78
+ - `cargo test` — 45 Rust tests; `cargo clippy` clean with zero warnings on
79
+ aarch64-apple-darwin, x86_64-pc-windows-msvc, and x86_64-unknown-linux-gnu
80
+ - `scripts/tray-smoke.sh` on three hosts: macOS arm64 native, Windows serving
81
+ CDX through Ubuntu WSL, and Linux natively in that same WSL
82
+ - `logics-manager lint` and `logics-manager audit`
@@ -0,0 +1,50 @@
1
+ # CDX Manager 0.18.1
2
+
3
+ ## Fixes
4
+
5
+ ### `cdx tray install` works against a published release
6
+
7
+ 0.18.0 shipped a tray installer that refused every asset the release published.
8
+ Three separate causes, each of which only appeared when installing from a real
9
+ release rather than from a locally built archive.
10
+
11
+ **The archive shape was rejected.** Release assets are written with
12
+ `tar -C dir .`, so they carry a member for the directory itself. The guard that
13
+ refuses an archive escaping its install directory treated that member as an
14
+ escape, when it resolves to the destination and nothing else. The destination is
15
+ now accepted; an archive that genuinely writes outside its directory is still
16
+ refused, and a link member still is.
17
+
18
+ **Windows asked for an asset nobody builds.** CDX looked for
19
+ `x86_64-pc-windows-gnu` while releases are built with the MSVC toolchain, so no
20
+ published checksum ever matched the name it asked for. Every Windows install
21
+ refused for want of a checksum that was published under a different target.
22
+
23
+ **ARM Linux was offered and never built.** `aarch64-unknown-linux-musl` was a
24
+ target CDX would install and the release did not produce. It is now built and
25
+ published alongside the others.
26
+
27
+ A test now asserts that every target CDX offers is one the release actually
28
+ builds, so this class of mismatch fails in CI rather than on a user's machine.
29
+
30
+ ### Windows toasts carry the CDX icon
31
+
32
+ The Start Menu shortcut CDX installs now points at an icon shipped with the
33
+ companion, so a notification shows the CDX logo rather than a generic one.
34
+ Uninstall removes the shortcut along with the companion.
35
+
36
+ ## Release safety
37
+
38
+ Publishing to npm and PyPI now waits for the tray companion assets to build
39
+ successfully. All four workflows fire on the same release event, so previously
40
+ they ran beside each other: a failed asset build would have left both registries
41
+ carrying a version whose release had no companion to install.
42
+
43
+ ## Validation
44
+
45
+ - `npm run lint`
46
+ - `npm test` — 771 Python tests
47
+ - `cargo test` — 45 Rust tests; `cargo clippy` clean with zero warnings on three
48
+ targets
49
+ - `cdx tray install`, `doctor`, `launch` and `uninstall` exercised against the
50
+ published v0.18.0 assets on macOS arm64, Windows, and Linux under WSL
@@ -188,6 +188,20 @@
188
188
  "v0.17.0": {
189
189
  "github_tarball_sha256": "6f321106aa426590ce809788fab4f26401b4cc499a6c518a56a4f24f8bfb6c2e",
190
190
  "github_zip_sha256": "72665a0006df40bb27301140b0ed6600a0d91b456facbb5a2aad2a917357ae75"
191
+ },
192
+ "v0.17.1": {
193
+ "github_tarball_sha256": "f1cc36d55f17d8e5c8ad0b0ebc419e812b8788742c3e45ce1137303924114dd7",
194
+ "github_zip_sha256": "29acb306ad76535d0db701504a53b9887b8a6b0b95d968634139f85952a34ec3"
195
+ },
196
+ "v0.18.0": {
197
+ "github_tarball_sha256": "ae666d8ec38a74859e1edc5023101474a2fb647ecb3ba285fd746c7b84699854",
198
+ "github_zip_sha256": "72ecdd02b449c2256a74f77afec7eda8cd9b53b52bb62858e45d3d6000a1bd24",
199
+ "tray_assets": {
200
+ "aarch64-apple-darwin": "45aa98624bda29dea26637aeb22a86b22fe018a95fb176bfffd3504c772c9988",
201
+ "x86_64-apple-darwin": "d310852919cd4e2ddce2108044d0039e088b5bdc3dfa029be4d81ee0df1c420b",
202
+ "x86_64-pc-windows-msvc": "86e29cbdbcf070aec7fd34e5596343a43f2b5092e634ca094aba96abf1d49884",
203
+ "x86_64-unknown-linux-musl": "36ef879c0e75f3c367ed48f1f5a67e25698ec5a04c70958b48ae0885f87487d8"
204
+ }
191
205
  }
192
206
  }
193
207
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.17.1"
7
+ version = "0.18.1"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -15,6 +15,7 @@ import shutil
15
15
  import subprocess
16
16
 
17
17
  from .notify import notification_channel, send_desktop_notification
18
+ from .tray_events import publish
18
19
 
19
20
  # Set on the provider process at launch; the hook runs as its child and inherits both.
20
21
  SESSION_ENV = "CDX_SESSION_NAME"
@@ -116,9 +117,16 @@ def handle_notify(rest, ctx):
116
117
  return 0
117
118
  if reader is not None and not ctx.get("stdin_is_tty"):
118
119
  stdin_text = reader.read()
119
- composed = compose_notification(read_hook_payload(rest, stdin_text), env, ctx.get("cwd"))
120
+ payload = read_hook_payload(rest, stdin_text)
121
+ composed = compose_notification(payload, env, ctx.get("cwd"))
120
122
  if composed:
121
- send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
123
+ # A live tray becomes the sole owner of this alert, so the user sees
124
+ # one notification rather than two. Publication happens below the
125
+ # composition boundary on purpose: the tray receives the same
126
+ # already-sanitized title and message the direct path would send,
127
+ # and cannot be handed anything the privacy rules removed.
128
+ if not _published_to_tray(ctx, composed, payload):
129
+ send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
122
130
  except Exception: # noqa: BLE001 - deliberate: the caller is an agent turn
123
131
  pass
124
132
  return 0
@@ -357,3 +365,24 @@ def _write_json(path, document):
357
365
  # An unwritable home is a skipped provisioning step, not a failed launch.
358
366
  return False
359
367
  return True
368
+
369
+
370
+ def _published_to_tray(ctx, composed, payload):
371
+ """Hand the alert to a live tray, or say we did not.
372
+
373
+ False is the safe answer to everything: no base directory, no heartbeat, a
374
+ stale one, a schema mismatch, or an unwritable spool all mean the direct
375
+ path still runs. The only way to lose a notification here would be to
376
+ return True without having written it.
377
+ """
378
+ try:
379
+ base_dir = (ctx.get("service") or {}).get("base_dir")
380
+ if not base_dir:
381
+ return False
382
+ event = str(
383
+ payload.get("hook_event_name") or payload.get("eventName") or payload.get("type") or ""
384
+ ).lower().replace("_", "").replace("-", "")
385
+ kind = "attention" if event in _WAITING_EVENTS else "complete"
386
+ return publish(base_dir, composed[0], composed[1], kind=kind)
387
+ except Exception: # noqa: BLE001 - the caller is an agent turn
388
+ return False
package/src/cli.py CHANGED
@@ -51,6 +51,7 @@ from .cli_commands import (
51
51
  handle_set,
52
52
  handle_stats,
53
53
  handle_status,
54
+ handle_tray,
54
55
  handle_unset,
55
56
  handle_update,
56
57
  handle_view,
@@ -59,6 +60,7 @@ from .cli_commands import (
59
60
  _format_bytes as _format_disk_bytes,
60
61
  )
61
62
  from .cli_helpers import _update_notice_warnings
63
+ from .cli_hints import write_hint
62
64
  from .cli_render import (
63
65
  _format_sessions,
64
66
  _pad_table,
@@ -171,6 +173,7 @@ _COMMAND_HANDLERS = {
171
173
  "set": handle_set,
172
174
  "stats": handle_stats,
173
175
  "status": handle_status,
176
+ "tray": handle_tray,
174
177
  "unset": handle_unset,
175
178
  "update": handle_update,
176
179
  "view": handle_view,
@@ -257,6 +260,9 @@ def _print_help(use_color=False):
257
260
  f" {_style('cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--check-provider-flags] [--json]', '36', use_color)}",
258
261
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
259
262
  f" {_style('cdx view [--json]', '36', use_color)}",
263
+ f" {_style('cdx tray status [--json] [--refresh]', '36', use_color)}",
264
+ f" {_style('cdx tray install|launch|uninstall|doctor [--json]', '36', use_color)}",
265
+ f" {_style('cdx tray autostart [on|off|status] [--json]', '36', use_color)}",
260
266
  f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
261
267
  f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
262
268
  "",
@@ -551,6 +557,8 @@ def main(argv, options=None):
551
557
  warning_text = _update_warning_text(notices)
552
558
  if warning_text:
553
559
  out(f"{_style(warning_text, '33', use_color)}\n")
560
+ # Last line, after anything the user actually asked for.
561
+ write_hint({"env": env, "out": out, "service": service, "use_color": use_color, "update_notices": notices})
554
562
  return 0
555
563
 
556
564
  command, *rest = argv
package/src/cli_args.py CHANGED
@@ -63,6 +63,7 @@ RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
63
63
  RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
64
64
  RUN_TAIL_USAGE = "Usage: cdx run-tail <run_id> [--lines N] --json"
65
65
  SCHEMA_USAGE = "Usage: cdx schema --json"
66
+ TRAY_USAGE = "Usage: cdx tray status [--json] [--refresh] [--beat] | cdx tray install|launch|uninstall|doctor [--json] | cdx tray autostart [on|off|status] [--json] | cdx tray events [--json] | cdx tray ack <id>... [--json] | cdx tray heartbeat [--json]"
66
67
 
67
68
  RUN_TAIL_DEFAULT_LINES = 50
68
69
  RUN_TAIL_MAX_LINES = 2000
@@ -272,5 +272,8 @@ from .commands.status import (
272
272
  from .commands.status import (
273
273
  handle_status as handle_status,
274
274
  )
275
+ from .commands.tray import (
276
+ handle_tray as handle_tray,
277
+ )
275
278
 
276
279
 
@@ -0,0 +1,167 @@
1
+ """One-line setup hints, shown only while they are still actionable.
2
+
3
+ CDX has optional surfaces a user cannot discover by reading the output of the
4
+ command they already run: agent alerts, and the tray companion. A hint is how
5
+ they learn, so the bar is that it must be worth interrupting for.
6
+
7
+ Four rules keep it from becoming noise, and each exists because the obvious
8
+ version of this feature fails without it:
9
+
10
+ - Never in `--json`. That is a contract, and a helpful line inside it is a
11
+ parse error somewhere else.
12
+ - At most one per invocation, and not again for six hours. Advice that repeats
13
+ on every command stops being read, including the day it matters.
14
+ - Retired after three showings. A user who saw it three times and did not act
15
+ has answered; asking a fourth time is nagging, not helping.
16
+ - Silent on any failure. A hint is the least important thing on the screen,
17
+ so it never breaks, slows, or interrupts the command that carried it.
18
+
19
+ Every condition is answered from local state already on disk. Nothing here
20
+ probes a provider, touches the network, or costs a measurable amount of time.
21
+ """
22
+ import json
23
+ import os
24
+ import time
25
+
26
+ HINT_STATE_VERSION = 1
27
+ # Long enough that a burst of commands shows nothing, short enough that a hint
28
+ # still reaches someone who uses CDX a few times a day.
29
+ QUIET_SECONDS = 6 * 60 * 60
30
+ MAX_SHOWINGS = 3
31
+
32
+
33
+ def _state_path(base_dir):
34
+ return os.path.join(base_dir, "hints.json")
35
+
36
+
37
+ def _read_state(base_dir):
38
+ """What was shown and when. A damaged file reads as empty, never as an error."""
39
+ try:
40
+ with open(_state_path(base_dir), encoding="utf-8") as handle:
41
+ state = json.load(handle)
42
+ except (OSError, ValueError):
43
+ return {"version": HINT_STATE_VERSION, "hints": {}}
44
+ if not isinstance(state, dict) or state.get("version") != HINT_STATE_VERSION:
45
+ return {"version": HINT_STATE_VERSION, "hints": {}}
46
+ if not isinstance(state.get("hints"), dict):
47
+ state["hints"] = {}
48
+ return state
49
+
50
+
51
+ def _write_state(base_dir, state):
52
+ try:
53
+ os.makedirs(base_dir, exist_ok=True)
54
+ with open(_state_path(base_dir), "w", encoding="utf-8") as handle:
55
+ json.dump(state, handle, indent=2)
56
+ except OSError:
57
+ # Unwritable state means hints repeat rather than disappear. That is the
58
+ # right way to fail: a lost preference is better than a lost command.
59
+ pass
60
+
61
+
62
+ def _tray_installed(base_dir, env):
63
+ if (env.get("CDX_TRAY_BIN") or "").strip():
64
+ return True
65
+ try:
66
+ from .tray_install import read_state as read_install_state
67
+ return bool(read_install_state(base_dir))
68
+ except Exception: # noqa: BLE001 - a hint never propagates a failure
69
+ return False
70
+
71
+
72
+ def _autostart_on(env):
73
+ try:
74
+ from .tray_autostart import status as autostart_status
75
+ return bool(autostart_status(env=env).get("enabled"))
76
+ except Exception: # noqa: BLE001
77
+ return False
78
+
79
+
80
+ def _alerts_on(sessions):
81
+ return any(
82
+ session.get("notify") and session.get("enabled", True) is not False
83
+ for session in sessions
84
+ )
85
+
86
+
87
+ def available_hints(base_dir, env, sessions):
88
+ """Every hint that is true right now, most useful first.
89
+
90
+ Ordered rather than scored: the tray is worth more than its autostart, and
91
+ both are worth more than nothing to alert about. A user with no sessions is
92
+ told nothing at all, because every hint here presumes there is something to
93
+ watch.
94
+ """
95
+ if not sessions:
96
+ return []
97
+
98
+ hints = []
99
+ installed = _tray_installed(base_dir, env)
100
+ if not installed:
101
+ hints.append((
102
+ "tray_not_installed",
103
+ "Did you know? A menu bar icon can watch your quota. Run: cdx tray install",
104
+ ))
105
+ elif not _autostart_on(env):
106
+ hints.append((
107
+ "tray_autostart_off",
108
+ "Did you know? The tray can start with your session. Run: cdx tray autostart on",
109
+ ))
110
+ if not _alerts_on(sessions):
111
+ hints.append((
112
+ "agent_alerts_off",
113
+ "Did you know? CDX can notify you when an agent finishes. Run: cdx set <name> --notify on",
114
+ ))
115
+ return hints
116
+
117
+
118
+ def next_hint(base_dir, env, sessions, now=None):
119
+ """The one hint to show, or None. Records the showing when it returns one."""
120
+ if (env.get("CDX_NO_HINTS") or "").strip():
121
+ return None
122
+
123
+ now = time.time() if now is None else now
124
+ state = _read_state(base_dir)
125
+ seen = state["hints"]
126
+
127
+ # "Never shown" is its own case, not a showing at the Unix epoch. Treating
128
+ # the two alike silences the very first hint, which is the one that matters.
129
+ shown_at = [entry["last"] for entry in seen.values() if isinstance(entry, dict) and entry.get("last")]
130
+ if shown_at and now - max(shown_at) < QUIET_SECONDS:
131
+ return None
132
+
133
+ for code, message in available_hints(base_dir, env, sessions):
134
+ entry = seen.get(code) if isinstance(seen.get(code), dict) else {}
135
+ if entry.get("shown", 0) >= MAX_SHOWINGS:
136
+ continue
137
+ seen[code] = {"shown": entry.get("shown", 0) + 1, "last": now}
138
+ _write_state(base_dir, state)
139
+ return message
140
+ return None
141
+
142
+
143
+ def write_hint(ctx):
144
+ """Print one hint after the command's own output. Never raises.
145
+
146
+ Yields to an update notice. Both want the last line, and one of them is
147
+ actionable news about the tool the user is running while the other is
148
+ optional discovery — so they never stack, and the hint waits for a quieter
149
+ run rather than pushing the notice up out of the eye's landing spot.
150
+ """
151
+ try:
152
+ if ctx.get("update_notices"):
153
+ return
154
+ env = ctx.get("env")
155
+ env = os.environ if env is None else env
156
+ service = ctx.get("service") or {}
157
+ base_dir = service.get("base_dir")
158
+ if not base_dir:
159
+ return
160
+ sessions = service["list_sessions"]()
161
+ message = next_hint(base_dir, env, sessions)
162
+ if not message:
163
+ return
164
+ from .cli_render import _dim
165
+ ctx["out"](f"{_dim(message, ctx.get('use_color', False))}\n")
166
+ except Exception: # noqa: BLE001 - deliberate: a hint must never break a command
167
+ pass
@@ -35,6 +35,7 @@ from ..cli_helpers import (
35
35
  _write_json,
36
36
  _write_update_notice,
37
37
  )
38
+ from ..cli_hints import write_hint
38
39
  from ..cli_render import _dim, _info, _pad_table, _style, _warn
39
40
  from ..commands.launch import handle_launch
40
41
  from ..config import PROVIDER_CLAUDE
@@ -600,6 +601,7 @@ def handle_status(rest, ctx):
600
601
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
601
602
  _write_refresh_warnings(refresh_errors, ctx, rows=[row])
602
603
  _write_update_notice(ctx)
604
+ write_hint(ctx)
603
605
  return 0
604
606
 
605
607
  rows = ctx["service"]["get_status_rows"](
@@ -619,6 +621,7 @@ def handle_status(rest, ctx):
619
621
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
620
622
  _write_refresh_warnings(refresh_errors, ctx, rows=rows)
621
623
  _write_update_notice(ctx)
624
+ write_hint(ctx)
622
625
  return 0
623
626
 
624
627
  def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):