cdx-manager 0.17.1 → 0.18.0

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.
@@ -0,0 +1,120 @@
1
+ """What the desktop can actually do for the tray, read at the moment asked.
2
+
3
+ Two capabilities decide whether the companion's output ever reaches a person,
4
+ and both fail silently when absent, which is why `cdx tray doctor` has to name
5
+ them rather than leave the user to notice nothing happening:
6
+
7
+ - Windows shows a toast from a non-packaged program only when a Start Menu
8
+ shortcut carrying its AppUserModelID exists. Without one the notification
9
+ API succeeds and nothing appears. A success code and no toast is the worst
10
+ kind of failure to debug from the outside.
11
+ - Linux draws a tray icon only where something owns the
12
+ `org.kde.StatusNotifierWatcher` name. A desktop without it is not broken,
13
+ it simply has no system tray, and saying so beats a companion that appears
14
+ to start and then cannot be seen.
15
+
16
+ Nothing here repairs anything, and nothing runs unbounded: every probe is local
17
+ and time-limited, because a diagnostic that hangs is worse than one that admits
18
+ it does not know.
19
+ """
20
+ import os
21
+ import platform
22
+ import subprocess
23
+
24
+ # Matches the identifier the companion registers with, and the name a user would
25
+ # recognise in the Start Menu. Both have to agree for a toast to be delivered.
26
+ SHORTCUT_NAME = "CDX.lnk"
27
+ PROBE_TIMEOUT_SECONDS = 3
28
+
29
+
30
+ def shortcut_path(env=None):
31
+ """Where Windows looks for the shortcut, or None off Windows."""
32
+ env = os.environ if env is None else env
33
+ appdata = (env.get("APPDATA") or "").strip()
34
+ if not appdata:
35
+ return None
36
+ return os.path.join(appdata, "Microsoft", "Windows", "Start Menu", "Programs", SHORTCUT_NAME)
37
+
38
+
39
+ def toast_capability(env=None, system=None):
40
+ """Whether a toast raised by the companion could actually be shown.
41
+
42
+ Only Windows makes this conditional. Elsewhere the answer is "nothing stands
43
+ in the way", which is reported as such rather than as a pass on a check that
44
+ was never run.
45
+ """
46
+ system = platform.system() if system is None else system
47
+ if system != "Windows":
48
+ return {
49
+ "supported": True,
50
+ "gated": False,
51
+ "present": True,
52
+ "path": None,
53
+ "detail": "no Start Menu shortcut is required on this platform",
54
+ }
55
+ path = shortcut_path(env)
56
+ if not path:
57
+ return {
58
+ "supported": True,
59
+ "gated": True,
60
+ "present": False,
61
+ "path": None,
62
+ "detail": "APPDATA is unset, so the Start Menu location cannot be resolved",
63
+ }
64
+ present = os.path.isfile(path)
65
+ return {
66
+ "supported": True,
67
+ "gated": True,
68
+ "present": present,
69
+ "path": path,
70
+ "detail": path if present else f"missing: {path}. Toasts are dropped silently without it",
71
+ }
72
+
73
+
74
+ def desktop_capability(env=None, system=None, run=None):
75
+ """Whether this desktop can draw a tray icon at all.
76
+
77
+ macOS and Windows always can. Linux depends on a StatusNotifierItem watcher,
78
+ which is a property of the running session rather than of the distribution,
79
+ so it is resolved by asking the bus and never by matching a name.
80
+ """
81
+ system = platform.system() if system is None else system
82
+ if system in ("Darwin", "Windows"):
83
+ return {"available": True, "detail": "native tray"}
84
+
85
+ env = os.environ if env is None else env
86
+ if not (env.get("DBUS_SESSION_BUS_ADDRESS") or "").strip():
87
+ return {"available": False, "detail": "no D-Bus session bus: this is not a desktop session"}
88
+
89
+ runner = run or _run_dbus_send
90
+ try:
91
+ names = runner()
92
+ except Exception: # noqa: BLE001 - a diagnostic never propagates a failure
93
+ return {"available": None, "detail": "could not query the session bus"}
94
+ if names is None:
95
+ return {"available": None, "detail": "could not query the session bus"}
96
+ if "org.kde.StatusNotifierWatcher" in names:
97
+ return {"available": True, "detail": "StatusNotifierItem watcher present"}
98
+ return {
99
+ "available": False,
100
+ "detail": "no org.kde.StatusNotifierWatcher: this desktop has no system tray. "
101
+ "On GNOME, install the AppIndicator extension",
102
+ }
103
+
104
+
105
+ def _run_dbus_send():
106
+ """The same question the Linux backend asks, asked the same way."""
107
+ try:
108
+ completed = subprocess.run(
109
+ [
110
+ "dbus-send", "--session", "--dest=org.freedesktop.DBus",
111
+ "--type=method_call", "--print-reply", "/org/freedesktop/DBus",
112
+ "org.freedesktop.DBus.ListNames",
113
+ ],
114
+ capture_output=True, text=True, timeout=PROBE_TIMEOUT_SECONDS, check=False,
115
+ )
116
+ except (OSError, subprocess.SubprocessError):
117
+ return None
118
+ if completed.returncode != 0:
119
+ return None
120
+ return completed.stdout
@@ -0,0 +1,229 @@
1
+ """The snapshot a tray companion reads, and the display state it renders.
2
+
3
+ A tray consumer needs three things the CLI status rows do not give it: one
4
+ urgency state for a closed icon, a per-session line short enough for a menu, and
5
+ an honest word for "this number is old". This module derives all three from the
6
+ rows `cdx status` already produces, so the companion never talks to a provider.
7
+
8
+ The refresh policy is the load-bearing part. `src/codex_usage.py` serializes
9
+ live probes on `codex_auth_lock` because Codex rotates its OAuth refresh token,
10
+ and the launcher holds that lock for a whole interactive session. A background
11
+ consumer polling for fresh quota would either be locked out or race the token,
12
+ so the tray reads the cache and says so. `auth_locked` is a first-class state
13
+ here, not an error.
14
+ """
15
+ from datetime import datetime, timezone
16
+
17
+ from .session_status import (
18
+ CLAUDE_STATUS_CACHE_TTL_SECONDS,
19
+ CODEX_STATUS_CACHE_TTL_SECONDS,
20
+ STATUS_CACHE_TTL_SECONDS,
21
+ )
22
+
23
+ SCHEMA_NAME = "cdx.tray.snapshot"
24
+ SCHEMA_MAJOR = 1
25
+ SCHEMA_MINOR = 0
26
+
27
+ # Below this much remaining capacity a session cannot usefully take work. Shared
28
+ # with the ranking's own usability threshold so the tray and `cdx next` do not
29
+ # disagree about what "blocked" means.
30
+ CRITICAL_PCT = 5
31
+ LOW_PCT = 25
32
+
33
+ ICON_OK = "ok"
34
+ ICON_LOW = "low"
35
+ ICON_CRITICAL = "critical"
36
+ ICON_UNKNOWN = "unknown"
37
+
38
+ FRESH = "fresh"
39
+ STALE = "stale"
40
+ AUTH_LOCKED = "auth_locked"
41
+ UNKNOWN = "unknown"
42
+
43
+ _PROVIDER_TTL = {
44
+ "codex": CODEX_STATUS_CACHE_TTL_SECONDS,
45
+ "claude": CLAUDE_STATUS_CACHE_TTL_SECONDS,
46
+ }
47
+
48
+
49
+ def ttl_seconds_for(provider):
50
+ return _PROVIDER_TTL.get(provider, STATUS_CACHE_TTL_SECONDS)
51
+
52
+
53
+ def parse_iso(value):
54
+ """Rows carry local-offset ISO strings; comparison needs a real instant."""
55
+ if not value:
56
+ return None
57
+ try:
58
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")).astimezone(timezone.utc)
59
+ except ValueError:
60
+ return None
61
+
62
+
63
+ def _age_seconds(updated_at, now):
64
+ moment = parse_iso(updated_at)
65
+ if moment is None:
66
+ return None
67
+ return max(0.0, (now - moment).total_seconds())
68
+
69
+
70
+ def session_freshness(row, now):
71
+ """How much this row's numbers can be trusted, and whether that is fixable.
72
+
73
+ A running session holds the provider auth lock, so its quota cannot be
74
+ refreshed until it exits. Reporting that as `stale` would invite the user to
75
+ hit refresh forever; `auth_locked` tells them why it will not help.
76
+ """
77
+ age = _age_seconds(row.get("updated_at"), now)
78
+ if age is None:
79
+ return UNKNOWN, None
80
+ if age <= ttl_seconds_for(row.get("provider")):
81
+ return FRESH, age
82
+ return (AUTH_LOCKED if row.get("active") else STALE), age
83
+
84
+
85
+ def icon_state_for_pct(available_pct):
86
+ if available_pct is None:
87
+ return ICON_UNKNOWN
88
+ if available_pct < CRITICAL_PCT:
89
+ return ICON_CRITICAL
90
+ if available_pct < LOW_PCT:
91
+ return ICON_LOW
92
+ return ICON_OK
93
+
94
+
95
+ def _eligible(row):
96
+ return row.get("enabled", True) is not False
97
+
98
+
99
+ def menu_session(row, now):
100
+ """One menu line. Everything the tray shows once the user opens it."""
101
+ freshness, age = session_freshness(row, now)
102
+ return {
103
+ "name": row.get("session_name"),
104
+ "label": row.get("label"),
105
+ "provider": row.get("provider"),
106
+ "available_pct": row.get("available_pct"),
107
+ "remaining_5h_pct": row.get("remaining_5h_pct"),
108
+ "remaining_week_pct": row.get("remaining_week_pct"),
109
+ "reset_at": row.get("reset_at"),
110
+ "active": bool(row.get("active")),
111
+ "state": icon_state_for_pct(row.get("available_pct")),
112
+ "freshness": freshness,
113
+ "age_seconds": None if age is None else int(age),
114
+ "updated_at": row.get("updated_at"),
115
+ }
116
+
117
+
118
+ def _worst(sessions):
119
+ """The session whose state the closed icon should show.
120
+
121
+ Ordered by severity, then by least remaining capacity, so a tie between two
122
+ critical sessions still names the one closest to empty. Unknown loses to
123
+ every known state: an icon that cries wolf because one session never
124
+ reported is worse than one that shows the worst thing it actually knows.
125
+ """
126
+ severity = {ICON_CRITICAL: 0, ICON_LOW: 1, ICON_OK: 2, ICON_UNKNOWN: 3}
127
+ return min(
128
+ sessions,
129
+ key=lambda s: (
130
+ severity[s["state"]],
131
+ s["available_pct"] if s["available_pct"] is not None else 101,
132
+ ),
133
+ )
134
+
135
+
136
+ _STATE_WORDS = {
137
+ ICON_OK: "capacity ok",
138
+ ICON_LOW: "capacity low",
139
+ ICON_CRITICAL: "capacity critical",
140
+ ICON_UNKNOWN: "capacity unknown",
141
+ }
142
+
143
+ _FRESHNESS_WORDS = {
144
+ STALE: "figures are stale",
145
+ AUTH_LOCKED: "cannot refresh while a session runs",
146
+ UNKNOWN: "never reported",
147
+ }
148
+
149
+
150
+ def tooltip_for(worst):
151
+ """What the closed icon may say on hover.
152
+
153
+ Deliberately without the session name. `req_035` AC3 forbids exposing
154
+ accounts before the menu is opened and `req_038` AC4 asks the tooltip to
155
+ name the limiting source and its reset; a tooltip appears on hover, without
156
+ a click, and shows up in a screen share. Naming the provider and the reset
157
+ satisfies the second without breaking the first. The session name is one
158
+ click away in the menu.
159
+
160
+ It is also the accessibility path: every state the glyph conveys is written
161
+ here in words, so nothing depends on telling two shapes apart.
162
+ """
163
+ if worst is None:
164
+ return "CDX · no enabled sessions"
165
+ parts = [_STATE_WORDS.get(worst["state"], worst["state"])]
166
+ if worst["available_pct"] is not None:
167
+ parts.append(f"{int(worst['available_pct'])}% left")
168
+ note = _FRESHNESS_WORDS.get(worst["freshness"])
169
+ if note:
170
+ parts.append(note)
171
+ if worst.get("reset_at"):
172
+ parts.append(f"resets {worst['reset_at']}")
173
+ return "CDX · " + " · ".join(parts)
174
+
175
+
176
+ def build_snapshot(rows, now, cdx_version, refreshable=True):
177
+ """The whole contract, from the rows `cdx status` already returns.
178
+
179
+ `icon` carries no session name, account, or figure: it is what shows while
180
+ the menu is closed, and `req_035` AC3 forbids leaking anything there.
181
+ """
182
+ sessions = [menu_session(row, now) for row in rows if _eligible(row)]
183
+ if not sessions:
184
+ icon = {
185
+ "state": ICON_UNKNOWN,
186
+ "reason": "no_sessions",
187
+ "session_count": 0,
188
+ "tooltip": tooltip_for(None),
189
+ }
190
+ else:
191
+ worst = _worst(sessions)
192
+ icon = {
193
+ "state": worst["state"],
194
+ "reason": worst["freshness"],
195
+ "session_count": len(sessions),
196
+ # Everything the closed icon is allowed to say, in words.
197
+ "tooltip": tooltip_for(worst),
198
+ }
199
+ return {
200
+ "schema": {"name": SCHEMA_NAME, "major": SCHEMA_MAJOR, "minor": SCHEMA_MINOR},
201
+ "cdx_version": cdx_version,
202
+ "generated_at": now.isoformat(),
203
+ "icon": icon,
204
+ "sessions": sessions,
205
+ "refreshable": bool(refreshable),
206
+ "actions": ["refresh", "open_terminal"],
207
+ }
208
+
209
+
210
+ def read_snapshot(payload):
211
+ """Read a snapshot that may be newer than this reader.
212
+
213
+ A companion and the CLI are updated separately, so version drift is the
214
+ normal state. An unknown major version keeps every field this reader
215
+ understands and adds one hint, rather than refusing to render.
216
+ """
217
+ schema = (payload or {}).get("schema") or {}
218
+ major = schema.get("major")
219
+ if schema.get("name") != SCHEMA_NAME or not isinstance(major, int):
220
+ return {"ok": False, "reason": "not_a_cdx_tray_snapshot", "snapshot": None, "update_hint": None}
221
+ known = {
222
+ key: payload.get(key)
223
+ for key in ("schema", "cdx_version", "generated_at", "icon", "sessions", "refreshable", "actions")
224
+ if key in payload
225
+ }
226
+ hint = None
227
+ if major > SCHEMA_MAJOR:
228
+ hint = f"This CDX reads tray snapshot v{SCHEMA_MAJOR}; the snapshot is v{major}. Update CDX to see everything."
229
+ return {"ok": True, "reason": None, "snapshot": known, "update_hint": hint}
@@ -0,0 +1,210 @@
1
+ """The handoff from a provider hook to a running tray, and back.
2
+
3
+ `cdx notify` runs inside a provider's hook. It cannot wait on a GUI, it may be
4
+ killed the moment the turn ends, and several sessions can fire at once. Those
5
+ three facts decide the whole design:
6
+
7
+ - **Append-only JSONL, opened `O_APPEND`.** A small append is atomic, so two
8
+ hooks firing together cannot interleave or lose each other's event. A
9
+ read-modify-write of one JSON array would silently drop one of them, and
10
+ the loser would be a notification the user simply never gets.
11
+ - **A heartbeat, not a process check.** The tray says it is alive by writing a
12
+ file; `notify` publishes only when that file is fresh. A pid check would
13
+ claim a companion in a different WSL namespace is reachable when it is not.
14
+ - **Freshness wider than one poll period.** The tray writes its heartbeat once
15
+ per poll, so a window narrower than that period reads a perfectly healthy
16
+ tray as stale every time — and the alert would then be delivered twice, once
17
+ by the tray and once by the direct fallback.
18
+
19
+ Everything degrades to the existing direct notification. A tray that is absent,
20
+ stale, too old to understand the schema, or backed by unreadable state is not an
21
+ error: it means `notify` keeps doing exactly what it did before.
22
+ """
23
+ import json
24
+ import os
25
+ import time
26
+
27
+ SCHEMA_VERSION = 1
28
+ # The tray heartbeats once per poll: 30s native, 60s across WSL. This window has
29
+ # to clear the slowest of those with room for a late tick, or a live tray reads
30
+ # as stale and the user gets the same alert twice.
31
+ HEARTBEAT_FRESH_SECONDS = 150
32
+ # Old alerts are worth less than new ones, so the spool drops the oldest rather
33
+ # than growing without bound. A tray that has been down for an hour should not
34
+ # flood the user on return.
35
+ MAX_SPOOLED_EVENTS = 32
36
+ # Compaction threshold. Acknowledged lines stay on disk until this many have
37
+ # accumulated, so the common path is one append and no rewrite at all.
38
+ COMPACT_AFTER_LINES = 128
39
+
40
+
41
+ def _tray_dir(base_dir):
42
+ return os.path.join(base_dir, "tray")
43
+
44
+
45
+ def events_path(base_dir):
46
+ return os.path.join(_tray_dir(base_dir), "events.jsonl")
47
+
48
+
49
+ def heartbeat_path(base_dir):
50
+ return os.path.join(_tray_dir(base_dir), "heartbeat.json")
51
+
52
+
53
+ def _write_atomic(path, text):
54
+ """Replace a file in one step, so a killed writer never leaves half of one."""
55
+ os.makedirs(os.path.dirname(path), exist_ok=True)
56
+ temp = f"{path}.{os.getpid()}.tmp"
57
+ with open(temp, "w", encoding="utf-8") as handle:
58
+ handle.write(text)
59
+ os.replace(temp, path)
60
+
61
+
62
+ # --- the tray's side -------------------------------------------------------
63
+
64
+ def write_heartbeat(base_dir, pid=None, now=None):
65
+ """Say the tray is alive, and which schema it speaks."""
66
+ now = time.time() if now is None else now
67
+ _write_atomic(heartbeat_path(base_dir), json.dumps({
68
+ "schema": SCHEMA_VERSION,
69
+ "pid": os.getpid() if pid is None else pid,
70
+ "at": now,
71
+ }))
72
+
73
+
74
+ def read_heartbeat(base_dir):
75
+ """The heartbeat, or None. Unreadable and malformed both read as absent."""
76
+ try:
77
+ with open(heartbeat_path(base_dir), encoding="utf-8") as handle:
78
+ beat = json.load(handle)
79
+ except (OSError, ValueError):
80
+ return None
81
+ if not isinstance(beat, dict) or not isinstance(beat.get("at"), (int, float)):
82
+ return None
83
+ return beat
84
+
85
+
86
+ def tray_is_listening(base_dir, now=None):
87
+ """Whether a tray should own this notification instead of the direct path.
88
+
89
+ A schema this CDX does not speak counts as not listening. Publishing to a
90
+ companion that cannot read the event would lose the notification entirely,
91
+ where falling back merely delivers it the old way.
92
+ """
93
+ now = time.time() if now is None else now
94
+ beat = read_heartbeat(base_dir)
95
+ if not beat:
96
+ return False
97
+ if beat.get("schema") != SCHEMA_VERSION:
98
+ return False
99
+ return (now - beat["at"]) <= HEARTBEAT_FRESH_SECONDS
100
+
101
+
102
+ # --- the hook's side -------------------------------------------------------
103
+
104
+ def publish(base_dir, title, message, kind="complete", now=None, event_id=None):
105
+ """Hand one event to the tray. Returns True only if it was written.
106
+
107
+ False means the caller must deliver it directly, so every failure here has
108
+ to be a False and never an exception: the alternative is a provider hook
109
+ that raises inside someone's agent turn.
110
+ """
111
+ now = time.time() if now is None else now
112
+ try:
113
+ if not tray_is_listening(base_dir, now=now):
114
+ return False
115
+ event = {
116
+ "id": event_id or f"{int(now * 1000)}-{os.getpid()}",
117
+ "at": now,
118
+ "kind": kind,
119
+ "title": title,
120
+ "message": message,
121
+ }
122
+ path = events_path(base_dir)
123
+ os.makedirs(os.path.dirname(path), exist_ok=True)
124
+ line = json.dumps(event, ensure_ascii=False) + "\n"
125
+ # O_APPEND makes this atomic against other hooks firing at the same
126
+ # instant, which a read-modify-write of a JSON array would not be.
127
+ with open(path, "a", encoding="utf-8") as handle:
128
+ handle.write(line)
129
+ return True
130
+ except OSError:
131
+ return False
132
+
133
+
134
+ # --- shared -----------------------------------------------------------------
135
+
136
+ def read_events(base_dir, include_acknowledged=False):
137
+ """Every event still on the spool, oldest first.
138
+
139
+ A malformed line is skipped rather than fatal. The spool is written by a
140
+ process that can be killed mid-write, so a partial last line is an expected
141
+ state, not corruption.
142
+ """
143
+ try:
144
+ with open(events_path(base_dir), encoding="utf-8") as handle:
145
+ lines = handle.readlines()
146
+ except OSError:
147
+ return []
148
+ events = []
149
+ for line in lines:
150
+ line = line.strip()
151
+ if not line:
152
+ continue
153
+ try:
154
+ event = json.loads(line)
155
+ except ValueError:
156
+ continue
157
+ if not isinstance(event, dict) or not event.get("id"):
158
+ continue
159
+ if event.get("acknowledged") and not include_acknowledged:
160
+ continue
161
+ events.append(event)
162
+ # Keep the newest when the spool has overflowed: a stale alert helps nobody.
163
+ return events[-MAX_SPOOLED_EVENTS:]
164
+
165
+
166
+ def acknowledge(base_dir, ids, now=None):
167
+ """Mark events consumed, idempotently, and compact when it is worth it.
168
+
169
+ Acknowledging is what stops one event being shown twice, so it has to
170
+ survive being called with ids already acknowledged, ids that never existed,
171
+ and a tray that crashed between showing and acknowledging.
172
+ """
173
+ now = time.time() if now is None else now
174
+ wanted = set(ids or [])
175
+ if not wanted:
176
+ return {"acknowledged": 0, "compacted": False}
177
+
178
+ try:
179
+ with open(events_path(base_dir), encoding="utf-8") as handle:
180
+ lines = handle.readlines()
181
+ except OSError:
182
+ return {"acknowledged": 0, "compacted": False}
183
+
184
+ kept = []
185
+ marked = 0
186
+ for line in lines:
187
+ stripped = line.strip()
188
+ if not stripped:
189
+ continue
190
+ try:
191
+ event = json.loads(stripped)
192
+ except ValueError:
193
+ continue
194
+ if not isinstance(event, dict) or not event.get("id"):
195
+ continue
196
+ if event["id"] in wanted and not event.get("acknowledged"):
197
+ event = {**event, "acknowledged": now}
198
+ marked += 1
199
+ kept.append(event)
200
+
201
+ compact = len(kept) >= COMPACT_AFTER_LINES
202
+ if compact:
203
+ # Drop what the tray has already shown; unacknowledged events stay,
204
+ # because dropping one would be losing a notification outright.
205
+ kept = [event for event in kept if not event.get("acknowledged")]
206
+ _write_atomic(
207
+ events_path(base_dir),
208
+ "".join(json.dumps(event, ensure_ascii=False) + "\n" for event in kept),
209
+ )
210
+ return {"acknowledged": marked, "compacted": compact}