cdx-manager 0.18.7 → 0.19.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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.18.7-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.19.3-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
10
10
 
@@ -74,7 +74,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
74
74
 
75
75
  - **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, last-updated timestamps, priority guidance, and the last launched session in one aligned table.
76
76
  - **Pick for me.** `cdx next` selects the best available assistant from your pinned priorities and the live quota picture, shows the reason, and prints the exact command to run.
77
- - **Agent notifications.** When a session finishes a turn or blocks waiting for you, cdx raises a desktop notification naming the session and the repository — so you can run several in parallel without watching any of them. Opt in per session with `cdx set <name> --notify on`.
77
+ - **Agent notifications.** At the first interactive launch of a supported provider, cdx asks before installing its hook. Delivery starts muted; `cdx tray alerts on` enables the next event globally, with or without the tray running.
78
78
  - **Next-ready notification.** `cdx ready` schedules a native system notification for the next assistant that comes back from cooldown, then returns immediately.
79
79
  - **Passive status resolution.** Codex status is read from the local Codex app-server rate-limit API when available, with legacy transcript/history parsing kept as a fallback.
80
80
  - **Banked reset visibility.** Eligible Codex accounts show the number of manually redeemable bonus resets in `cdx status` and expose reset details in JSON output.
@@ -112,9 +112,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
112
112
  ### Maintenance
113
113
 
114
114
  - **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
115
- - **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period.
116
-
117
- ![cdx stats: runs, input and output tokens, total time and last use per session](docs/assets/stats.png)
115
+ - **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period. Interactive Codex and Claude launches also record provider-native token totals when their local session transcript is available.
118
116
 
119
117
  - **Disk usage and cleanup.** `cdx disk` reports `CDX_HOME` usage, `cdx disk profiles --candidates` identifies reclaimable profile caches/logs with evidence, and `cdx clean profiles ...` applies explicit cleanup actions.
120
118
  - **Health and repair.** `cdx doctor` inspects dependencies, permissions, and orphan profiles; `cdx repair` plans or applies safe fixes.
@@ -357,7 +355,7 @@ desktop notification naming the session and the repository:
357
355
  ✓ codex-a cdx-manager · turn complete
358
356
  ```
359
357
 
360
- Off until you ask for it, per session:
358
+ CDX asks once at the first eligible interactive launch before writing a provider hook. Consent is installation-wide, but each profile receives its hook only when it is next launched. Alert delivery then starts muted; use `cdx tray alerts on` when you want banners. The older per-session commands remain compatible:
361
359
 
362
360
  ```bash
363
361
  cdx set work1 --notify on # turn it on for one session
@@ -540,21 +538,22 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
540
538
  | `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
541
539
  | `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
542
540
  | `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known headless token usage by session |
543
- | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; `--cached` skips live provider probes and returns only stored status |
541
+ | `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status |
544
542
  | `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 |
545
543
  | `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
546
544
  | `cdx tray status [--json] [--refresh]` | Emit the tray snapshot: one icon state, one line per enabled session, and how fresh each figure is. Reads stored status by default; `--refresh` is the only path that probes a provider |
547
545
  | `cdx tray launch [--json]` | Start the installed tray companion, detached. Reports `tray_companion_not_installed` when nothing is recorded, or `tray_companion_missing` when the recorded companion is gone. `CDX_TRAY_BIN` points it at a locally built one |
548
- | `cdx tray install [--json] [--yes] [--autostart\|--no-autostart] [--alerts\|--no-alerts]` | Download the companion for this OS and architecture, verify its published checksum before unpacking, record what was written, and start it. Asks two separate questions: whether to launch it at login, and whether to notify you when an agent finishes a turn or waits for you. `--yes` answers both, each pair of flags answers one deterministically, and a non-interactive or `--json` run declines rather than acquiring a login item or writing provider hooks by accident. Accepting alerts turns them on for every supported session and for sessions created later. Refuses when no checksum is published for the asset |
546
+ | `cdx tray install [--json] [--yes] [--autostart\|--no-autostart] [--alerts\|--no-alerts]` | Download the companion for this OS and architecture, verify its published checksum before unpacking, record what was written, and start it. It asks whether to launch at login; provider-hook consent is requested separately at the first eligible interactive session launch. Refuses when no checksum is published for the asset |
549
547
  | `cdx tray uninstall [--json]` | Remove exactly what `install` recorded, and nothing else |
550
548
  | — | `cdx update` also moves an installed companion to the new release, and says so. A companion already running keeps executing the build it started with, so it reports that too rather than leaving you to wonder why the menu looks unchanged |
551
549
  | `cdx tray autostart [on\|off\|status] [--json]` | Start the companion at login, or stop doing so. Off until asked, idempotent, and the state is read back from the platform rather than from what CDX last intended |
552
550
  | `cdx tray events [--json]` | List agent alerts a running companion has not shown yet. The companion reads these instead of the spool file, so a Windows tray can collect events from CDX in WSL over the same interop it already uses |
553
551
  | `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
554
552
  | `cdx tray heartbeat [--json]` | Record that a companion is alive. `cdx notify` publishes to the tray only while this is fresh, and delivers directly otherwise |
555
- | `cdx tray terminal [status\|set <app>\|clear] [--json]` | Choose which terminal a tray session row opens — `cdx tray terminal set iTerm`, `cdx tray terminal clear` to go back to the platform default. The value is an application name, never a command: anything that could be read as one is refused rather than sanitized. macOS opens it with `open -a`, Linux prefers it ahead of the emulators it already tries, and Windows honours `wt` because Windows Terminal is the one that documents how to be handed a command. A named terminal that is not installed falls back to the default rather than leaving the click doing nothing |
553
+ | `cdx tray terminal [status\|list\|set <app>\|clear] [--json]` | Choose which terminal a tray session row opens; the tray exposes the same candidates plus **System default**. `cdx tray terminal list` reports candidates and the stored choice, and `clear` returns to the platform default. The value is an application name, never a command. A named terminal that disappears is reported unavailable and never silently replaced by another terminal. |
554
+ | `cdx tray sort [status\|set <capacity\|reset\|recent>] [--json]` | Persist the tray session order: current capacity-first order, nearest reset first, or most recently launched first. This affects only the tray menu, never `cdx next` or automatic session selection. |
556
555
  | `cdx tray plugin [status\|enable <name>\|disable <name>\|run <action>] [--json]` | Turn a tray integration on or off, and list what is enabled. Opt-in in both directions and reversible; nothing enables one implicitly. `logics` is the only one that exists, and `status` says once when it is installed but not enabled. `run` is what the tray calls back with a card's action id and is not meant to be typed |
557
- | `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 |
556
+ | `cdx tray alerts [on\|off\|status] [--json]` | Silence agent alerts, or let them through again. These commands work with or without a running tray: with one, muted events remain in its menu; without one, delivery falls back directly to the desktop notification channel. In both cases `off` suppresses banners and `on` restores delivery for the next event, including hooks already running with an older launch environment. Distinct from provider-hook consent, which controls configuration writes |
558
557
  | `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 |
559
558
  | `cdx --help` | Show usage |
560
559
  | `cdx --version` | Show version |
@@ -596,7 +595,9 @@ The companion is a native binary distributed as a release asset, not part of the
596
595
 
597
596
  - **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.
598
597
  - **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.
599
- - **Tray integrations are cards, not plugins.** `cdx tray plugin enable logics` adds one section to the menu: a blocked/ready summary and at most two rows — the first blocked document, because it is what stops work, and the highest-priority task in progress, because it is what to do instead. Clicking a row opens the Logics viewer focused on it.
598
+ - **Tray integrations are cards, not plugins.** `cdx tray plugin enable logics` adds one section to the menu: a blocked/ready summary and at most two rows — the first blocked document, because it is what stops work, and the highest-priority task in progress, because it is what to do instead. Clicking a row opens the Logics viewer focused on it. Tray openings ask Logics for a free local port, so an existing viewer is never displaced.
599
+
600
+ - **Alert return path.** When an alert originated in iTerm2, the macOS tray row and native CDX notification focus that exact iTerm session. macOS may ask once for CDX to control iTerm; declining leaves the alert visible but does not focus a different window. Script Editor fallback banners and Windows/Linux notifications remain delivery-only.
600
601
 
601
602
  There is deliberately no marketplace, no discovery, no installation from a URL, and no plugin auto-update. An adapter is a function in this repository, named in a registry CDX owns, so nothing unknown can run. Adapters produce data and never behaviour: a card is a summary, rows, and action ids from a fixed vocabulary, and the tray hands an id back to CDX rather than running anything itself. Adding a capability is a change to CDX, reviewed like any other.
602
603
 
@@ -607,6 +608,7 @@ The companion is a native binary distributed as a release asset, not part of the
607
608
  - **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.
608
609
  - **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.
609
610
  - **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.
611
+ - **About stays actionable.** The last tray action before Quit is `About (v<installed CDX version>)`; it opens the project’s GitHub page with the platform’s normal browser launcher.
610
612
 
611
613
  ## JSON Output
612
614
 
@@ -706,7 +708,7 @@ cdx run \
706
708
 
707
709
  The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
708
710
 
709
- Known headless token usage is persisted in launch history and can be aggregated later:
711
+ Known headless and completed interactive token usage is persisted in launch history and can be aggregated later. Interactive records are best-effort local data: Codex reads its latest cumulative rollout snapshot and Claude reads its assistant usage records. Cached input is retained separately from the provider's total, so it is never counted twice.
710
712
 
711
713
  ```bash
712
714
  cdx stats --since 7d --json
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.18.7
1
+ 0.19.3
@@ -0,0 +1,14 @@
1
+ # CDX Manager 0.19.0
2
+
3
+ ## Features
4
+
5
+ - Tray alerts can return to their originating iTerm session on macOS, including from a native notification banner.
6
+ - The tray can group enabled Logics actions by repository and opens its viewer on a free local port.
7
+ - The tray offers supported terminal choices and persists its session sort order.
8
+ - Interactive launches ask for an optional agent-alert hook and support a validated manually entered launch directory.
9
+
10
+ ## Fixes
11
+
12
+ - Tray session rows now show only meaningful quota reset columns and retain their selected terminal behavior across macOS, Windows, and WSL.
13
+ - Existing provider hooks read the current alert mute setting, so `cdx tray alerts on|off` takes effect without a relaunch.
14
+ - Local tray development helpers now use the checkout reliably on Windows and WSL.
@@ -0,0 +1,7 @@
1
+ # CDX Manager 0.19.1
2
+
3
+ ## Fixes
4
+
5
+ - Restored Linux tray companion builds and terminal fallback handling.
6
+ - Removed the unused GTK dependency tree from the tray companion, resolving a Dependabot security alert.
7
+ - Removed the outdated `cdx stats` image from the README.
@@ -0,0 +1,11 @@
1
+ # CDX Manager 0.19.2
2
+
3
+ ## Features
4
+
5
+ - Record Codex and Claude interactive CLI token usage, including cached input tokens, in existing run history and status statistics.
6
+
7
+ ## Fixes
8
+
9
+ - Make detached-run registry persistence durable and lock acquisition bounded on POSIX and Windows.
10
+ - Bound interactive transcript discovery and parsing while preserving fail-open behavior.
11
+ - Verify tray tests, macOS CI coverage, and installable npm/wheel package artifacts before release.
@@ -0,0 +1,6 @@
1
+ # CDX Manager 0.19.3
2
+
3
+ ## Fixes
4
+
5
+ - Make release-validation tests portable across Windows, macOS, and Linux.
6
+ - Includes all reliability, interactive token-usage, CI, and package-verification changes prepared for 0.19.2.
@@ -268,6 +268,36 @@
268
268
  "x86_64-pc-windows-msvc": "296f760a7e10b7ef05936157b9de2b9d3b5e13f7ea2f57b7616705352ea97bd2",
269
269
  "x86_64-unknown-linux-musl": "b360ea3ec14ab3aad0ccffcf477dee4bdadf6c1d4f150ef1cd13c7d293ae6829"
270
270
  }
271
+ },
272
+ "v0.18.7": {
273
+ "github_tarball_sha256": "913d43fc57f29b5dae486d1be2e30a48aa9abdd37cce8bd7219e23b49c0b4045",
274
+ "github_zip_sha256": "f24a4cd9943372b7e7ee67fc112da7873fd832438b88236aa10428f029bb20e5",
275
+ "tray_assets": {
276
+ "aarch64-apple-darwin": "a8d5b8c6f474594a609f7172ef9a307698f588ca907a6649b1229e8a2e71d99f",
277
+ "aarch64-unknown-linux-musl": "1d26111f25cc19986fdc4641c6f27875f20f55c19c700e6dc8edfdb6aab78ccc",
278
+ "x86_64-apple-darwin": "1e79a847ba780e9818173a0ced2172c4e37a5fc2121003679603900662e41609",
279
+ "x86_64-pc-windows-msvc": "dc6b9b951526837a8adfe58cf56683fa6072a18488a47c093bc0ba492b588ba7",
280
+ "x86_64-unknown-linux-musl": "4affb9acf4b6ce1384af883fa9943cca7d76bb049d5562aa8d5498d51e7204a2"
281
+ }
282
+ },
283
+ "v0.19.0": {
284
+ "github_tarball_sha256": "d9816c0e4a2313bc10d8d9b527795b3027798607af49c5ef820f491d167176b4",
285
+ "github_zip_sha256": "656090f68694695fd947b0e73fc8c1ef66785054fd04054f8659b03997daedfb"
286
+ },
287
+ "v0.19.1": {
288
+ "github_tarball_sha256": "351b8840a1dc073a91d198c2d565d9666f9ea7a8d545bac6a01e31ae41aec3ca",
289
+ "github_zip_sha256": "7e74a60fdf20b66faec25d11a8b8780fe4c1fad4a62b24f2395ea7d3667549ae",
290
+ "tray_assets": {
291
+ "aarch64-apple-darwin": "fd07386f7d7d8528927980929a260f3816b050a8d985186a18dae0ce8563af56",
292
+ "aarch64-unknown-linux-musl": "ca963298f96bb125892472cbcb125513f11941cb29258ad2b1ea3b9fb441ff74",
293
+ "x86_64-apple-darwin": "b22601c1c203a17f143ce83e19c5a4c54c546ea52f927f2ad8042ce5aa57a16c",
294
+ "x86_64-pc-windows-msvc": "7d5b01ab05a83c5e5a18f515a7010b799c19b3b6b6374ed39d8fa386ea66892b",
295
+ "x86_64-unknown-linux-musl": "2b318774e1ddccda5e070475042c395c6c90dda3ec89a6cf6498ba11dbdc04e8"
296
+ }
297
+ },
298
+ "v0.19.2": {
299
+ "github_tarball_sha256": "d644666c727b2a29fa022700da1c23483173a2110606b994d200f72feb959c3b",
300
+ "github_zip_sha256": "bca42637ddab4502e2dd6d9d63239eca322d953f8d910d8ada209fcfdd2f98d4"
271
301
  }
272
302
  }
273
303
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.18.7",
3
+ "version": "0.19.3",
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.7"
7
+ version = "0.19.3"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -21,7 +21,6 @@ from .tray_events import publish
21
21
 
22
22
  # Set on the provider process at launch; the hook runs as its child and inherits both.
23
23
  SESSION_ENV = "CDX_SESSION_NAME"
24
- ENABLED_ENV = "CDX_NOTIFY"
25
24
  PREVIEW_ENV = "CDX_NOTIFY_PREVIEW"
26
25
 
27
26
  # Our entries are recognised by the command they run, not by a marker key of our
@@ -55,6 +54,7 @@ _FAILURE_EVENTS = {"stopfailure"}
55
54
  _TRANSIENT_ERRORS = {"rate_limit", "overloaded", "server_error", "max_output_tokens"}
56
55
  _PREVIEW_LIMIT = 180
57
56
  _TOOL_NAME_LIMIT = 80
57
+ _TERMINAL_ID_LIMIT = 128
58
58
 
59
59
 
60
60
  def supports_agent_alerts(provider):
@@ -127,10 +127,6 @@ def _is_reportable(payload, event):
127
127
  def compose_notification(payload, env=None, cwd=None):
128
128
  """Title and message for this event, or None if we should stay quiet."""
129
129
  env = os.environ if env is None else env
130
- if env.get(ENABLED_ENV) == "0":
131
- # Headless runs share the session's home, so they read the same hooks.
132
- # Their caller already learns of completion from the return value.
133
- return None
134
130
  session = env.get(SESSION_ENV)
135
131
  if not session:
136
132
  return None
@@ -201,6 +197,9 @@ def structured_details(payload, env=None, cwd=None):
201
197
  "project": os.path.basename(str(directory).rstrip("/\\")) or str(directory),
202
198
  "event": event,
203
199
  }
200
+ terminal_id = _notification_text(env.get("ITERM_SESSION_ID"), _TERMINAL_ID_LIMIT)
201
+ if terminal_id:
202
+ details["terminal"] = {"kind": "iterm2", "id": terminal_id}
204
203
  for key, source, limit in (
205
204
  ("tool", "tool_name", _TOOL_NAME_LIMIT),
206
205
  ("error_type", "error_type", _TOOL_NAME_LIMIT),
@@ -309,7 +308,7 @@ def handle_notify(rest, ctx):
309
308
 
310
309
 
311
310
  def launch_notify_env(session, enabled, env=None):
312
- """Env the provider is launched with, carrying who we are and whether to notify.
311
+ """Env the provider is launched with, carrying who we are and privacy settings.
313
312
 
314
313
  `CDX_HOME` is pinned rather than inherited, and that is what makes tray
315
314
  routing work at all. It defaults to `~/.cdx`, resolved against `HOME` — and
@@ -321,9 +320,7 @@ def launch_notify_env(session, enabled, env=None):
321
320
  """
322
321
  env = os.environ if env is None else env
323
322
  values = {SESSION_ENV: session["name"], "CDX_HOME": get_cdx_home(env)}
324
- if not enabled:
325
- values[ENABLED_ENV] = "0"
326
- elif (session.get("launch") or {}).get("notify_preview") is True:
323
+ if enabled and (session.get("launch") or {}).get("notify_preview") is True:
327
324
  values[PREVIEW_ENV] = "1"
328
325
  return values
329
326
 
package/src/cli.py CHANGED
@@ -589,6 +589,7 @@ def main(argv, options=None):
589
589
  "signal_emitter": signal_emitter,
590
590
  "spawn": spawn,
591
591
  "spawn_detached": options.get("spawn_detached"),
592
+ "spawn_detached_runner": options.get("spawn_detached_runner"),
592
593
  "spawn_headless": options.get("spawn_headless"),
593
594
  # Stream `--prompt-file -` reads from. Distinct from "stdin", which is
594
595
  # the {"isTTY": ...} descriptor the interactive paths use.
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 [--json] [--yes] [--autostart|--no-autostart] [--alerts|--no-alerts] | cdx tray 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] | cdx tray plugin [status|enable <name>|disable <name>|run <action>] [--json] | cdx tray terminal [status|set <app>|clear] [--json]"
66
+ TRAY_USAGE = "Usage: cdx tray status [--json] [--refresh] [--beat] | cdx tray install [--json] [--yes] [--autostart|--no-autostart] [--alerts|--no-alerts] | cdx tray 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] | cdx tray plugin [status|enable <name>|disable <name>|run <action>] [--json] | cdx tray terminal [status|list|set <app>|clear] [--json] | cdx tray sort [status|set <capacity|reset|recent>] [--json]"
67
67
 
68
68
  RUN_TAIL_DEFAULT_LINES = 50
69
69
  RUN_TAIL_MAX_LINES = 2000
@@ -6,7 +6,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
6
6
  import os
7
7
  import shlex
8
8
 
9
- from ..agent_notify import notifications_enabled, provision
9
+ from ..agent_notify import notifications_enabled, provision, supports_agent_alerts
10
10
  from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag
11
11
  from ..cli_helpers import (
12
12
  API_SCHEMA_VERSION,
@@ -25,6 +25,7 @@ from ..cli_render import _dim, _info, _success, _warn
25
25
  from ..config import PROVIDER_CODEX
26
26
  from ..context_store import install_context_for_session, write_context
27
27
  from ..errors import CdxError
28
+ from ..interactive_usage import extract_interactive_usage
28
29
  from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
29
30
 
30
31
 
@@ -66,14 +67,41 @@ def _launch_directory(session, ctx, explicit, json_flag):
66
67
  choices = recent + [cwd]
67
68
  if len(choices) == 1:
68
69
  return cwd
69
- ctx["out"]("Choose launch directory:\n" + "".join(f" {index + 1}. {path}\n" for index, path in enumerate(choices)))
70
- answer = (ctx["options"].get("input") or input)(f"Directory [1-{len(choices)}] (default {len(choices)}): ").strip()
71
- if not answer:
72
- return cwd
73
- try:
74
- return choices[int(answer) - 1]
75
- except (ValueError, IndexError):
76
- raise CdxError("Launch cancelled: choose a listed directory.") from None
70
+ ask = ctx["options"].get("input") or input
71
+ other_index = len(choices) + 1
72
+ while True:
73
+ ctx["out"](
74
+ "Choose launch directory:\n"
75
+ + "".join(f" {index + 1}. {path}\n" for index, path in enumerate(choices))
76
+ + f" {other_index}. Other (enter a path)\n"
77
+ )
78
+ try:
79
+ answer = ask(f"Directory [1-{other_index}] (default {len(choices)}): ").strip()
80
+ except KeyboardInterrupt:
81
+ raise CdxError("Launch cancelled.", exit_code=130) from None
82
+ if not answer:
83
+ return cwd
84
+ try:
85
+ choice = int(answer)
86
+ except ValueError:
87
+ raise CdxError("Launch cancelled: choose a listed directory.") from None
88
+ if choice == other_index:
89
+ while True:
90
+ try:
91
+ entered = ask("Directory path (blank to return): ").strip()
92
+ except KeyboardInterrupt:
93
+ raise CdxError("Launch cancelled.", exit_code=130) from None
94
+ if not entered:
95
+ break
96
+ directory = os.path.realpath(entered)
97
+ if os.path.isdir(directory):
98
+ return directory
99
+ ctx["out"](f"Invalid directory: {entered}\n")
100
+ continue
101
+ try:
102
+ return choices[choice - 1]
103
+ except IndexError:
104
+ raise CdxError("Launch cancelled: choose a listed directory.") from None
77
105
 
78
106
 
79
107
  def _format_resume_capability(capability, use_color=False):
@@ -128,19 +156,51 @@ def handle_resume(rest, ctx):
128
156
  raise CdxError(RESUME_USAGE)
129
157
  return handle_launch(args[0], ctx, resume=True, force_json=json_flag, directory=directory)
130
158
 
159
+
160
+ def _notification_provisioning_enabled(session, ctx, json_flag):
161
+ """Ask once before the first supported profile receives a hook."""
162
+ enabled = notifications_enabled(session)
163
+ if enabled or not supports_agent_alerts(session["provider"]):
164
+ return enabled
165
+ from ..tray_defaults import alerts_default, set_alerts_default
166
+
167
+ base_dir = ctx["service"]["base_dir"]
168
+ if alerts_default(base_dir):
169
+ return True
170
+ if not ctx["stdin_is_tty"] or json_flag:
171
+ return False
172
+ ask = ctx.get("options", {}).get("input") or input
173
+ try:
174
+ answer = ask("Allow CDX to install this provider's agent-alert hook? [y/N] ").strip().lower()
175
+ except EOFError:
176
+ return False
177
+ if answer not in ("y", "yes"):
178
+ return False
179
+ set_alerts_default(base_dir, True)
180
+ from ..tray_alerts import set_alerts
181
+ set_alerts(base_dir, False)
182
+ return True
183
+
184
+
131
185
  def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None):
132
186
  json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
133
187
  if force_json is not None:
134
188
  json_flag = force_json
135
189
  warnings = _update_notice_warnings(ctx)
136
190
  _warn_if_session_already_running(command, ctx)
137
- session = ctx["service"]["launch_session"](command)
191
+ session = ctx["service"]["get_session"](command)
192
+ if not session:
193
+ raise CdxError(f"Unknown session: {command}")
194
+ if session.get("enabled", True) is False:
195
+ raise CdxError(f"Session is disabled: {command}")
138
196
  capability = _resume_capability_for_session(session, ctx) if resume else None
139
197
  if capability and not capability["resumable"]:
140
198
  raise CdxError(
141
199
  f"Provider {session['provider']} does not support native resume through cdx."
142
200
  )
143
201
  cwd = _launch_directory(session, ctx, directory, json_flag)
202
+ session = ctx["service"]["launch_session"](command)
203
+ capability = _resume_capability_for_session(session, ctx) if resume else None
144
204
  if not json_flag and not ctx["stdin_is_tty"]:
145
205
  ctx["out"](f"Using directory: {cwd}\n")
146
206
  _ensure_session_authentication(
@@ -162,10 +222,11 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
162
222
  if not json_flag:
163
223
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
164
224
  _write_update_notice(ctx)
225
+ alerts_allowed = _notification_provisioning_enabled(session, ctx, json_flag)
165
226
  installed = provision(
166
227
  _get_auth_home(session),
167
228
  session["provider"],
168
- notifications_enabled(session),
229
+ alerts_allowed,
169
230
  ctx.get("env"),
170
231
  spawn_sync=ctx.get("spawn_sync"),
171
232
  base_dir=ctx["service"]["base_dir"],
@@ -204,6 +265,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
204
265
  )
205
266
  except CdxError as error:
206
267
  run_info = getattr(error, "run_info", {}) or {}
268
+ _attach_interactive_usage(session, run_info)
207
269
  if runtime_run_id:
208
270
  ctx["service"]["finish_session_runtime"](
209
271
  session["name"],
@@ -224,7 +286,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
224
286
  "action": "resume" if resume else "launch",
225
287
  "cwd": cwd,
226
288
  "exit_code": 0,
227
- **run_info,
289
+ **_attach_interactive_usage(session, run_info),
228
290
  })
229
291
  if json_flag:
230
292
  extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
@@ -233,6 +295,19 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
233
295
  _write_json(ctx, _json_success("resume" if resume else "launch", message, warnings=warnings, **extra))
234
296
  return 0
235
297
 
298
+
299
+ def _attach_interactive_usage(session, run_info):
300
+ """Attach best-effort local provider usage without affecting launch outcome."""
301
+ run_info = dict(run_info or {})
302
+ usage, provider_transcript = extract_interactive_usage(
303
+ session.get("provider"), _get_auth_home(session), run_info.get("started_at")
304
+ )
305
+ if usage:
306
+ run_info["usage"] = usage
307
+ if provider_transcript:
308
+ run_info["provider_transcript_path"] = provider_transcript
309
+ return run_info
310
+
236
311
  def handle_handoff(rest, ctx):
237
312
  json_flag, args = _parse_json_flag(rest)
238
313
  if len(args) not in (1, 2):
@@ -253,6 +253,7 @@ def _summarize_stats(entries):
253
253
  "duration_ms": 0,
254
254
  "usage_runs": 0,
255
255
  "input_tokens": 0,
256
+ "cached_input_tokens": 0,
256
257
  "output_tokens": 0,
257
258
  "reasoning_tokens": 0,
258
259
  "total_tokens": 0,
@@ -270,7 +271,7 @@ def _summarize_stats(entries):
270
271
  usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
271
272
  parsed_usage = {
272
273
  key: _token_value(usage, key)
273
- for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
274
+ for key in ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
274
275
  }
275
276
  if any(value is not None for value in parsed_usage.values()):
276
277
  row["usage_runs"] += 1
@@ -295,6 +296,7 @@ def _stats_totals(rows):
295
296
  "duration_ms": sum(row["duration_ms"] for row in rows),
296
297
  "usage_runs": sum(row["usage_runs"] for row in rows),
297
298
  "input_tokens": sum(row["input_tokens"] for row in rows),
299
+ "cached_input_tokens": sum(row["cached_input_tokens"] for row in rows),
298
300
  "output_tokens": sum(row["output_tokens"] for row in rows),
299
301
  "reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
300
302
  "total_tokens": sum(row["total_tokens"] for row in rows),
@@ -389,7 +391,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
389
391
  if not rows:
390
392
  return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
391
393
  table = [[_style(value, "1", use_color) for value in [
392
- "SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"
394
+ "SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "TIME", "LAST"
393
395
  ]]]
394
396
  for row in rows:
395
397
  session_name = row["session_name"]
@@ -400,6 +402,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
400
402
  _style(str(row["launches"]), "1", use_color),
401
403
  _style(str(row["usage_runs"]), "32" if row["usage_runs"] else "2", use_color),
402
404
  _style(_format_token_count(row["input_tokens"]), "96" if row["input_tokens"] else "2", use_color),
405
+ _style(_format_token_count(row["cached_input_tokens"]), "96" if row["cached_input_tokens"] else "2", use_color),
403
406
  _style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
404
407
  _style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
405
408
  _style(_format_token_count(row["total_tokens"]), "1;96" if row["total_tokens"] else "2", use_color),
@@ -46,11 +46,11 @@ from ..tray_install import (
46
46
  from ..tray_instance import companion_instance
47
47
  from ..tray_plugins import ADAPTERS, collect_cards, enabled_plugins, set_plugin_enabled
48
48
  from ..tray_terminal import REFUSAL as TERMINAL_REFUSAL
49
- from ..tray_terminal import clear_terminal, set_terminal, terminal_preference
49
+ from ..tray_terminal import clear_terminal, set_sort, set_terminal, sort_preference, terminal_candidates, terminal_preference
50
50
 
51
51
  TRAY_ACTIONS = (
52
52
  "status", "install", "launch", "uninstall", "autostart", "doctor",
53
- "events", "ack", "heartbeat", "alerts", "plugin", "terminal",
53
+ "events", "ack", "heartbeat", "alerts", "plugin", "terminal", "sort",
54
54
  )
55
55
 
56
56
  # Codes rather than prose, so a caller can branch on them.
@@ -95,6 +95,8 @@ def handle_tray(rest, ctx):
95
95
  return _tray_plugin(args, ctx)
96
96
  if action == "terminal":
97
97
  return _tray_terminal(args, ctx)
98
+ if action == "sort":
99
+ return _tray_sort(args, ctx)
98
100
  return _tray_install(args, ctx)
99
101
 
100
102
 
@@ -138,6 +140,8 @@ def _tray_status(args, ctx):
138
140
  refreshable=not any(row.get("active") for row in rows),
139
141
  plugins=cards,
140
142
  terminal=terminal_preference(ctx["service"]["base_dir"]),
143
+ terminal_candidates=terminal_candidates(),
144
+ sort=sort_preference(ctx["service"]["base_dir"]),
141
145
  )
142
146
  # Pending alerts ride along with the snapshot rather than costing their own
143
147
  # call. Across WSL every invocation is a `wsl.exe` crossing measured at
@@ -423,6 +427,14 @@ def _tray_terminal(args, ctx):
423
427
  set_terminal(base_dir, positional[1])
424
428
  except ValueError:
425
429
  return _refuse(ctx, parsed["json"], "tray.terminal", TERMINAL_INVALID, TERMINAL_REFUSAL)
430
+ elif mode == "list":
431
+ chosen = terminal_preference(base_dir)
432
+ candidates = terminal_candidates()
433
+ if parsed["json"]:
434
+ _write_json(ctx, _json_success("tray.terminal", "Listed launchable terminals", terminal=chosen, candidates=candidates, applied=False))
435
+ return 0
436
+ ctx["out"]("Launchable terminals: " + (", ".join(candidates) or "none") + "\n")
437
+ return 0
426
438
  elif mode != "status":
427
439
  raise CdxError(TRAY_USAGE)
428
440
 
@@ -445,6 +457,28 @@ def _tray_terminal(args, ctx):
445
457
  return 0
446
458
 
447
459
 
460
+ def _tray_sort(args, ctx):
461
+ parsed = _parse_flag_args(args, {"--json": {"key": "json", "type": "bool", "default": False}}, TRAY_USAGE, positionals_key="args", max_positionals=2)
462
+ positional = parsed["args"] or ["status"]
463
+ mode = positional[0]
464
+ base_dir = ctx["service"]["base_dir"]
465
+ if mode == "set" and len(positional) == 2:
466
+ try:
467
+ sort = set_sort(base_dir, positional[1])
468
+ except ValueError as error:
469
+ raise CdxError(str(error)) from None
470
+ elif mode == "status":
471
+ sort = sort_preference(base_dir)
472
+ else:
473
+ raise CdxError(TRAY_USAGE)
474
+ message = f"Tray sessions sort by {sort}."
475
+ if parsed["json"]:
476
+ _write_json(ctx, _json_success("tray.sort", message, sort=sort, applied=mode == "set"))
477
+ else:
478
+ ctx["out"](f"{message}\n")
479
+ return 0
480
+
481
+
448
482
  def _tray_plugin(args, ctx):
449
483
  """Turn an integration on or off, and say what is on.
450
484
 
@@ -454,6 +488,7 @@ def _tray_plugin(args, ctx):
454
488
  """
455
489
  parsed = _parse_flag_args(args, {
456
490
  "--json": {"key": "json", "type": "bool", "default": False},
491
+ "--root": {"key": "root", "type": "str", "default": None},
457
492
  }, TRAY_USAGE, positionals_key="args", max_positionals=2)
458
493
  positional = parsed["args"] or ["status"]
459
494
  mode = positional[0]
@@ -515,7 +550,7 @@ def _tray_plugin_run(rest, parsed, ctx):
515
550
 
516
551
  if not rest:
517
552
  raise CdxError(TRAY_USAGE)
518
- result = perform_action(rest[0], ctx)
553
+ result = perform_action(rest[0], ctx, root=parsed["root"])
519
554
  if not result["ok"]:
520
555
  return _refuse(ctx, parsed["json"], "tray.plugin.run", result["code"], result["message"])
521
556
  if parsed["json"]:
package/src/fs_utils.py CHANGED
@@ -28,6 +28,15 @@ def atomic_write(path, data, mode=None):
28
28
  if mode is not None and os.name != "nt":
29
29
  os.chmod(temp_path, mode)
30
30
  os.replace(temp_path, path)
31
+ if os.name != "nt":
32
+ try:
33
+ directory_fd = os.open(directory, os.O_RDONLY)
34
+ try:
35
+ os.fsync(directory_fd)
36
+ finally:
37
+ os.close(directory_fd)
38
+ except OSError:
39
+ pass
31
40
  except Exception:
32
41
  try:
33
42
  os.unlink(temp_path)
@@ -0,0 +1,124 @@
1
+ """Best-effort usage readers for interactive provider sessions."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+
7
+ MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024
8
+ MAX_TRANSCRIPT_CANDIDATES = 1000
9
+ MAX_TRANSCRIPT_SCAN_SECONDS = 1
10
+
11
+ def extract_interactive_usage(provider, auth_home, started_at=None):
12
+ """Return the newest provider-native usage after an interactive session.
13
+
14
+ Provider transcripts are not stable public APIs. A missing or changed
15
+ record is therefore ordinary absence, never a launch failure.
16
+ """
17
+ path = _latest_transcript(provider, auth_home, started_at)
18
+ if not path:
19
+ return None, None
20
+ try:
21
+ if os.path.getsize(path) > MAX_TRANSCRIPT_BYTES:
22
+ return None, path
23
+ with open(path, encoding="utf-8", errors="replace") as handle:
24
+ return (_codex_usage(handle) if provider == "codex" else _claude_usage(handle)), path
25
+ except OSError:
26
+ return None, path
27
+
28
+
29
+ def _latest_transcript(provider, auth_home, started_at):
30
+ if not auth_home:
31
+ return None
32
+ root = os.path.join(auth_home, "sessions") if provider == "codex" else os.path.join(auth_home, ".claude", "projects")
33
+ if not os.path.isdir(root):
34
+ return None
35
+ cutoff = _timestamp(started_at)
36
+ candidates = []
37
+ deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
38
+ for directory, subdirs, names in os.walk(root):
39
+ if time.monotonic() >= deadline:
40
+ return max(candidates, default=(None, None))[1]
41
+ if provider == "claude":
42
+ subdirs[:] = [name for name in subdirs if name != "subagents"]
43
+ for name in names:
44
+ if not name.endswith(".jsonl"):
45
+ continue
46
+ path = os.path.join(directory, name)
47
+ try:
48
+ modified = os.path.getmtime(path)
49
+ except OSError:
50
+ continue
51
+ if cutoff is None or modified >= cutoff:
52
+ candidates.append((modified, path))
53
+ if len(candidates) > MAX_TRANSCRIPT_CANDIDATES:
54
+ return max(candidates)[1]
55
+ return max(candidates, default=(None, None))[1]
56
+
57
+
58
+ def _timestamp(value):
59
+ if not value:
60
+ return None
61
+ try:
62
+ from datetime import datetime
63
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
64
+ except ValueError:
65
+ return None
66
+
67
+
68
+ def _codex_usage(handle):
69
+ latest = None
70
+ for line in handle:
71
+ try:
72
+ record = json.loads(line)
73
+ payload = record.get("payload") or {}
74
+ usage = ((payload.get("info") or {}).get("total_token_usage") or {}) if payload.get("type") == "token_count" else {}
75
+ candidate = _normalize(usage, cached_key="cached_input_tokens", reasoning_key="reasoning_output_tokens")
76
+ if candidate:
77
+ latest = candidate
78
+ except (TypeError, ValueError):
79
+ continue
80
+ return latest
81
+
82
+
83
+ def _claude_usage(handle):
84
+ totals = {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0}
85
+ seen = set()
86
+ for line in handle:
87
+ try:
88
+ record = json.loads(line)
89
+ message = record.get("message") or {}
90
+ usage = message.get("usage") or {}
91
+ identifier = record.get("uuid")
92
+ if record.get("type") != "assistant" or not usage or (identifier and identifier in seen):
93
+ continue
94
+ if identifier:
95
+ seen.add(identifier)
96
+ totals["input_tokens"] += _number(usage.get("input_tokens"))
97
+ totals["cached_input_tokens"] += _number(usage.get("cache_creation_input_tokens")) + _number(usage.get("cache_read_input_tokens"))
98
+ totals["output_tokens"] += _number(usage.get("output_tokens"))
99
+ except (TypeError, ValueError):
100
+ continue
101
+ if not seen and not any(totals.values()):
102
+ return None
103
+ totals["reasoning_tokens"] = None
104
+ totals["total_tokens"] = totals["input_tokens"] + totals["output_tokens"]
105
+ return totals
106
+
107
+
108
+ def _normalize(usage, cached_key, reasoning_key):
109
+ if not isinstance(usage, dict) or not usage:
110
+ return None
111
+ return {
112
+ "input_tokens": _number(usage.get("input_tokens")),
113
+ "cached_input_tokens": _number(usage.get(cached_key)),
114
+ "output_tokens": _number(usage.get("output_tokens")),
115
+ "reasoning_tokens": _number(usage.get(reasoning_key)) or None,
116
+ "total_tokens": _number(usage.get("total_tokens")) or None,
117
+ }
118
+
119
+
120
+ def _number(value):
121
+ try:
122
+ return max(0, int(value or 0))
123
+ except (TypeError, ValueError):
124
+ return 0
@@ -36,3 +36,13 @@ def run_logics_viewer(executable, cwd, env=None, extra_args=None, runner=None):
36
36
  if runner is subprocess.run:
37
37
  return subprocess.run(argv, cwd=cwd, env=merged_env)
38
38
  return runner(argv, cwd=cwd, env=merged_env)
39
+
40
+
41
+ def spawn_logics_viewer(executable, cwd, env=None, extra_args=None, runner=None):
42
+ """Start the long-running viewer without making a tray click wait for it."""
43
+ argv = [executable, "view"] + (extra_args or [])
44
+ merged_env = {**os.environ, **(env or {})}
45
+ return (runner or subprocess.Popen)(
46
+ argv, cwd=cwd, env=merged_env, stdin=subprocess.DEVNULL,
47
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
48
+ )
@@ -1,12 +1,12 @@
1
1
  import json
2
2
  import os
3
3
  import sys
4
- import tempfile
5
4
  import time
6
5
  from contextlib import contextmanager
7
6
  from datetime import datetime, timezone
8
7
 
9
8
  from .errors import CdxError
9
+ from .fs_utils import atomic_write
10
10
 
11
11
 
12
12
  def utc_now_iso():
@@ -53,6 +53,25 @@ def _acquire_windows_lock(handle, timeout_seconds):
53
53
  time.sleep(_LOCK_RETRY_SECONDS)
54
54
 
55
55
 
56
+ def _acquire_posix_lock(handle, timeout_seconds):
57
+ """Acquire a POSIX advisory lock within the same bounded wait as Windows."""
58
+ import fcntl
59
+
60
+ deadline = time.monotonic() + timeout_seconds
61
+ while True:
62
+ try:
63
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
64
+ return
65
+ except OSError:
66
+ if time.monotonic() >= deadline:
67
+ raise CdxError(
68
+ "Timed out waiting for the run registry lock after "
69
+ f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
70
+ 75,
71
+ ) from None
72
+ time.sleep(_LOCK_RETRY_SECONDS)
73
+
74
+
56
75
  @contextmanager
57
76
  def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
58
77
  os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -67,7 +86,7 @@ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
67
86
  msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
68
87
  else:
69
88
  import fcntl
70
- fcntl.flock(handle, fcntl.LOCK_EX)
89
+ _acquire_posix_lock(handle, timeout_seconds)
71
90
  try:
72
91
  yield
73
92
  finally:
@@ -89,18 +108,7 @@ def _read_registry(path):
89
108
 
90
109
 
91
110
  def _write_registry(path, data):
92
- os.makedirs(os.path.dirname(path), exist_ok=True)
93
- fd, temp_path = tempfile.mkstemp(prefix=".runs-", suffix=".json", dir=os.path.dirname(path))
94
- try:
95
- with os.fdopen(fd, "w", encoding="utf-8") as handle:
96
- json.dump(data, handle, indent=2, sort_keys=True)
97
- handle.write("\n")
98
- os.replace(temp_path, path)
99
- finally:
100
- try:
101
- os.unlink(temp_path)
102
- except FileNotFoundError:
103
- pass
111
+ atomic_write(path, json.dumps(data, indent=2, sort_keys=True) + "\n", mode=0o600)
104
112
 
105
113
 
106
114
  def _is_pid_alive(pid):
package/src/run_usage.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import json
2
2
 
3
- USAGE_KEYS = ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
3
+ USAGE_KEYS = ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
4
4
  SUPPORTED_PROVIDERS = {"claude", "codex"}
5
5
 
6
6
 
@@ -89,6 +89,12 @@ def _usage_from_dict(value):
89
89
  usage.get("cache_creation_input_tokens"),
90
90
  usage.get("cache_read_input_tokens"),
91
91
  )
92
+ cached_input_tokens = _first_int(usage.get("cached_input_tokens"))
93
+ if cached_input_tokens is None:
94
+ cached_input_tokens = _int_value(
95
+ usage.get("cache_creation_input_tokens"),
96
+ usage.get("cache_read_input_tokens"),
97
+ )
92
98
  output_tokens = _int_value(usage.get("output_tokens"), usage.get("completion_tokens"))
93
99
  reasoning_tokens = _int_value(
94
100
  usage.get("reasoning_tokens"),
@@ -102,6 +108,7 @@ def _usage_from_dict(value):
102
108
 
103
109
  return {
104
110
  "input_tokens": input_tokens,
111
+ "cached_input_tokens": cached_input_tokens,
105
112
  "output_tokens": output_tokens,
106
113
  "reasoning_tokens": reasoning_tokens,
107
114
  "total_tokens": total_tokens,
@@ -75,26 +75,38 @@ def _format_credits(value, empty="n/a"):
75
75
 
76
76
 
77
77
  def _format_status_rows(rows, use_color=False, small=False):
78
+ active_rows = [r for r in rows if r.get("enabled", True) is not False]
79
+ def positive(value):
80
+ try:
81
+ return float(value) > 0
82
+ except (TypeError, ValueError):
83
+ return False
84
+ reset_visible = [
85
+ any(positive(r.get("reset_credits_available")) for r in active_rows),
86
+ any(r.get("reset_5h_at") for r in active_rows),
87
+ any(r.get("reset_week_at") for r in active_rows),
88
+ ]
89
+ block_visible = any(_format_blocking_quota(r) not in ("-", "?") for r in active_rows)
90
+ credits_visible = any(r.get("credits") is not None for r in active_rows)
78
91
  has_provider = len({r["provider"] for r in rows}) > 1 and not small
79
92
  has_label = any(r.get("label") for r in rows) and not small
80
93
  if small:
81
- headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESETS", "RESET 5H", "RESET WEEK"]
94
+ headers = ["SESSION", "STATUS", "OK", "5H", "WEEK"] + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible]
82
95
  elif has_provider:
83
96
  headers = ["SESSION", "PROV."]
84
97
  if has_label:
85
98
  headers.append("LABEL")
86
- headers += ["STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
99
+ headers += ["STATUS", "AUTH", "OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
87
100
  else:
88
101
  headers = ["SESSION"]
89
102
  if has_label:
90
103
  headers.append("LABEL")
91
- headers += ["STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
104
+ headers += ["STATUS", "AUTH", "OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
92
105
  if not rows:
93
106
  if small:
94
107
  return "SESSION STATUS OK 5H WEEK RESETS RESET 5H RESET WEEK\nNo saved sessions yet."
95
108
  return "SESSION STATUS OK 5H WEEK BLOCK CR RESETS RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
96
109
  headers = [_style(header, "1", use_color) for header in headers]
97
- active_rows = [r for r in rows if r.get("enabled", True) is not False]
98
110
  priority = recommend_priority_rows(rows)
99
111
  disabled_rows = sorted(
100
112
  [r for r in rows if r.get("enabled", True) is False],
@@ -130,16 +142,16 @@ def _format_status_rows(rows, use_color=False, small=False):
130
142
  _style_reset_time(r.get("reset_week_at"), use_color),
131
143
  ]
132
144
  if small:
133
- base += usage_columns
145
+ base += usage_columns[:3] + [value for value, visible in zip(usage_columns[3:], reset_visible) if visible]
134
146
  else:
135
147
  block = "-" if r.get("enabled", True) is False else _format_blocking_quota(r)
136
148
  credits = _format_credits(r.get("credits"), empty="-")
137
- base += usage_columns[:3] + [
138
- _style(block, "33" if block not in ("?", "-") else "2", use_color),
139
- _style(credits, "33" if r.get("credits") is not None else "2", use_color),
140
- *usage_columns[3:],
141
- _style(_format_relative_age(r.get("updated_at")), "2", use_color),
142
- ]
149
+ base += usage_columns[:3]
150
+ if block_visible:
151
+ base.append(_style(block, "33" if block not in ("?", "-") else "2", use_color))
152
+ if credits_visible:
153
+ base.append(_style(credits, "33" if r.get("credits") is not None else "2", use_color))
154
+ base += [*[value for value, visible in zip(usage_columns[3:], reset_visible) if visible], _style(_format_relative_age(r.get("updated_at")), "2", use_color)]
143
155
  table_rows.append(base)
144
156
  # "Recommended", not "Priority": the `--priority` setting is one input to
145
157
  # this ranking among several, and labelling the whole recommendation with
@@ -23,7 +23,7 @@ from .status_view import _format_reset_time
23
23
 
24
24
  SCHEMA_NAME = "cdx.tray.snapshot"
25
25
  SCHEMA_MAJOR = 1
26
- SCHEMA_MINOR = 2
26
+ SCHEMA_MINOR = 3
27
27
 
28
28
  # Below this much remaining capacity a session cannot usefully take work. Shared
29
29
  # with the ranking's own usability threshold so the tray and `cdx next` do not
@@ -139,6 +139,7 @@ def menu_session(row, now):
139
139
  "freshness": freshness,
140
140
  "age_seconds": None if age is None else int(age),
141
141
  "updated_at": row.get("updated_at"),
142
+ "last_launched_at": row.get("last_launched_at"),
142
143
  }
143
144
 
144
145
 
@@ -223,13 +224,17 @@ def tooltip_for(worst):
223
224
  return "CDX · " + " · ".join(parts)
224
225
 
225
226
 
226
- def build_snapshot(rows, now, cdx_version, refreshable=True, plugins=None, terminal=None):
227
+ def build_snapshot(rows, now, cdx_version, refreshable=True, plugins=None, terminal=None, terminal_candidates=None, sort="capacity"):
227
228
  """The whole contract, from the rows `cdx status` already returns.
228
229
 
229
230
  `icon` carries no session name, account, or figure: it is what shows while
230
231
  the menu is closed, and `req_035` AC3 forbids leaking anything there.
231
232
  """
232
233
  sessions = _by_urgency([menu_session(row, now) for row in rows if _eligible(row)])
234
+ if sort == "reset":
235
+ sessions.sort(key=lambda row: (_reset_sort_key(row), row["name"]))
236
+ elif sort == "recent":
237
+ sessions.sort(key=lambda row: (_recent_sort_key(row), row["name"]))
233
238
  if not sessions:
234
239
  icon = {
235
240
  "state": ICON_UNKNOWN,
@@ -263,9 +268,23 @@ def build_snapshot(rows, now, cdx_version, refreshable=True, plugins=None, termi
263
268
  # other durable tray preference already lives in CDX and a second store
264
269
  # is a second thing to keep in step.
265
270
  "terminal": terminal,
271
+ "terminal_candidates": list(terminal_candidates or []),
272
+ "sort": sort if sort in ("capacity", "reset", "recent") else "capacity",
266
273
  }
267
274
 
268
275
 
276
+ def _reset_sort_key(row):
277
+ from .status_view import _parse_reset_timestamp
278
+ value = _parse_reset_timestamp(row.get("reset_at"))
279
+ return value if value is not None else float("inf")
280
+
281
+
282
+ def _recent_sort_key(row):
283
+ from .status_view import _parse_reset_timestamp
284
+ value = _parse_reset_timestamp(row.get("last_launched_at"))
285
+ return -value if value is not None else float("inf")
286
+
287
+
269
288
  def read_snapshot(payload):
270
289
  """Read a snapshot that may be newer than this reader.
271
290
 
@@ -20,6 +20,8 @@ from .logics_view import resolve_logics_manager
20
20
  from .tray_plugins import ADAPTER_TIMEOUT_SECONDS, CARD_TTL_SECONDS
21
21
 
22
22
  _PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
23
+ _MAX_GROUPS = 5
24
+ _MAX_ROWS_PER_GROUP = 2
23
25
 
24
26
 
25
27
  def logics_card(env=None, now=None, cache=None, runner=None, executable=None, directories=None):
@@ -96,20 +98,18 @@ def _status(env=None, runner=None, executable=None, directories=None):
96
98
  return payloads if roots else (payloads[0][1] if payloads else None)
97
99
 
98
100
 
99
- def _card_from_status(status):
101
+ def _card_from_status(status, root=None):
100
102
  if isinstance(status, list):
101
- cards = [(root, _card_from_status(payload), len(payload.get("blocked_docs") or [])) for root, payload in status]
102
- cards = [(root, card, blocked) for root, card, blocked in cards if card]
103
- if not cards:
103
+ groups = []
104
+ for root, payload in status:
105
+ card = _card_from_status(payload, root)
106
+ if card:
107
+ groups.append({"root": root, "label": _repository_label(root, [item[0] for item in status]), "rows": card["rows"][:_MAX_ROWS_PER_GROUP]})
108
+ groups = [group for group in groups if group["rows"]][:_MAX_GROUPS]
109
+ if not groups:
104
110
  return None
105
- root, card, _ = sorted(cards, key=lambda item: (-item[2], -len(item[1]["rows"]), item[0] or ""))[0]
106
- repository_count = len(cards)
107
- blocked_count = sum(item[2] for item in cards)
108
- card["summary"] = f"{repository_count} repositories · {blocked_count} blocked"
109
- prefix = os.path.basename(root or "")
110
- for row in card["rows"]:
111
- row["label"] = f"{prefix}: {row['label']}"
112
- return card
111
+ blocked_count = sum(len(payload.get("blocked_docs") or []) for _, payload in status if isinstance(payload, dict))
112
+ return {"title": "Logics", "summary": f"{len(groups)} repositories · {blocked_count} blocked", "rows": [], "groups": groups, "actions": ["logics.open", "logics.refresh"]}
113
113
  if not isinstance(status, dict):
114
114
  return None
115
115
  blocked = [doc for doc in (status.get("blocked_docs") or []) if isinstance(doc, dict)]
@@ -126,9 +126,9 @@ def _card_from_status(status):
126
126
  }
127
127
  rows = []
128
128
  if blocked:
129
- rows.append(_row(blocked[0], "blocked"))
129
+ rows.append(_row(blocked[0], "blocked", root))
130
130
  if active:
131
- rows.append(_row(_most_urgent(active), "next"))
131
+ rows.append(_row(_most_urgent(active), "next", root))
132
132
  return {
133
133
  "title": "Logics",
134
134
  "summary": f"{len(blocked)} blocked · {len(active)} in progress",
@@ -155,9 +155,22 @@ def _progress(task):
155
155
  return value if isinstance(value, (int, float)) else 0
156
156
 
157
157
 
158
- def _row(doc, kind):
158
+ def _row(doc, kind, root=None):
159
159
  ref = doc.get("ref")
160
160
  title = doc.get("title") or ref
161
161
  if not isinstance(ref, str) or not ref or not isinstance(title, str):
162
162
  return None
163
- return {"label": f"{kind}: {title}", "action": f"logics.focus:{ref}"}
163
+ row = {"label": f"{kind}: {title}", "action": f"logics.focus:{ref}"}
164
+ if root:
165
+ row["root"] = root
166
+ return row
167
+
168
+
169
+ def _repository_label(root, roots):
170
+ """A distinct local label without leaking the full local path."""
171
+ name = os.path.basename(root or "") or "repository"
172
+ collisions = [item for item in roots if os.path.basename(item or "") == name]
173
+ if len(collisions) < 2:
174
+ return name
175
+ parent = os.path.basename(os.path.dirname(root or ""))
176
+ return f"{parent}/{name}" if parent else name
@@ -10,12 +10,14 @@ different id. An id CDX does not recognise is refused, and refusal is the
10
10
  default branch rather than the exception — so an integration gaining a new
11
11
  capability is a change to this file, reviewed like any other.
12
12
  """
13
+ import os
14
+
13
15
  from .errors import CdxError
14
16
 
15
17
  ACTION_UNKNOWN = "tray_plugin_action_unknown"
16
18
 
17
19
 
18
- def perform_action(action, ctx):
20
+ def perform_action(action, ctx, root=None):
19
21
  """Run one action id. Never raises for a bad id: it reports."""
20
22
  if not isinstance(action, str) or "." not in action:
21
23
  return _unknown(action)
@@ -31,21 +33,25 @@ def perform_action(action, ctx):
31
33
  if verb == "open":
32
34
  return _open_viewer(ctx, None)
33
35
  if verb == "focus" and reference:
34
- return _open_viewer(ctx, reference)
36
+ return _open_viewer(ctx, reference, root)
35
37
  return _unknown(action)
36
38
 
37
39
 
38
- def _open_viewer(ctx, reference):
39
- from .logics_view import missing_logics_manager_failure, resolve_logics_manager, run_logics_viewer
40
+ def _open_viewer(ctx, reference, root=None):
41
+ from .logics_view import missing_logics_manager_failure, resolve_logics_manager, spawn_logics_viewer
40
42
 
41
43
  executable = resolve_logics_manager(ctx.get("env") or {})
42
44
  if not executable:
43
45
  failure = missing_logics_manager_failure()
44
46
  return {"ok": False, "code": failure["code"], "message": failure["message"]}
45
- extra = ["--focus", reference] if reference else []
47
+ if reference and not _valid_root(root):
48
+ return {"ok": False, "code": "logics_viewer_root_invalid", "message": "The focused Logics action has no valid repository context."}
49
+ # A tray click must coexist with any viewer already serving another repo.
50
+ # Port 0 is Logics' documented request for a free local port.
51
+ extra = ["--focus", reference, "--open", "--port", "0"] if reference else ["--fleet", "--open", "--port", "0"]
46
52
  try:
47
- run_logics_viewer(
48
- executable, ctx.get("cwd"), env=ctx.get("env"),
53
+ spawn_logics_viewer(
54
+ executable, root if reference else ctx.get("cwd"), env=ctx.get("env"),
49
55
  extra_args=extra, runner=ctx.get("spawn_detached_runner"),
50
56
  )
51
57
  except (OSError, CdxError) as error:
@@ -54,6 +60,15 @@ def _open_viewer(ctx, reference):
54
60
  return {"ok": True, "code": None, "message": f"Opened the Logics viewer{where}."}
55
61
 
56
62
 
63
+ def _valid_root(root):
64
+ return (
65
+ isinstance(root, str)
66
+ and os.path.isabs(root)
67
+ and "\x00" not in root
68
+ and os.path.exists(os.path.join(root, ".git"))
69
+ )
70
+
71
+
57
72
  def _unknown(action):
58
73
  return {
59
74
  "ok": False,
@@ -108,7 +108,11 @@ def bounded_card(raw, plugin):
108
108
  action = row.get("action")
109
109
  if not label or not _valid_action(action, plugin):
110
110
  continue
111
- rows.append({"label": label, "action": action})
111
+ root = row.get("root")
112
+ if root is not None:
113
+ if plugin != "logics" or not action.startswith("logics.focus:") or not _valid_root(root):
114
+ continue
115
+ rows.append({"label": label, "action": action, **({"root": root} if root else {})})
112
116
  actions = [a for a in (raw.get("actions") or []) if _valid_action(a, plugin)]
113
117
  return {
114
118
  "plugin": plugin,
@@ -128,6 +132,10 @@ def _valid_action(action, plugin):
128
132
  )
129
133
 
130
134
 
135
+ def _valid_root(root):
136
+ return isinstance(root, str) and os.path.isabs(root) and "\x00" not in root and len(root) <= 4096
137
+
138
+
131
139
  def collect_cards(base_dir, env=None, now=None, adapters=None, cache=None, directories=None):
132
140
  """Every enabled plugin's card, bounded, with failures dropped.
133
141
 
@@ -16,8 +16,12 @@ platform's own launcher resolves it.
16
16
  import json
17
17
  import os
18
18
  import re
19
+ import shutil
20
+ import subprocess
21
+ import sys
19
22
 
20
23
  STATE_VERSION = 1
24
+ SORT_VALUES = ("capacity", "reset", "recent")
21
25
  # An application name or a bundle identifier, and nothing that could be read as
22
26
  # a command. No slashes, so it cannot name a path; no quotes, semicolons,
23
27
  # ampersands, pipes, backticks or dollars, so it cannot carry a shell fragment;
@@ -42,35 +46,70 @@ def terminal_preference(base_dir):
42
46
  preference. A value that fails validation today must not be honoured just
43
47
  because it was written before the rule existed.
44
48
  """
45
- try:
46
- with open(state_path(base_dir), encoding="utf-8") as handle:
47
- state = json.load(handle)
48
- except (OSError, ValueError):
49
- return None
49
+ state = _state(base_dir)
50
50
  if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
51
51
  return None
52
52
  name = state.get("terminal")
53
53
  return name if isinstance(name, str) and _NAME.match(name) else None
54
54
 
55
55
 
56
+ def sort_preference(base_dir):
57
+ state = _state(base_dir)
58
+ value = state.get("sort")
59
+ return value if value in SORT_VALUES else "capacity"
60
+
61
+
56
62
  def valid_terminal(name):
57
63
  return isinstance(name, str) and bool(_NAME.match(name))
58
64
 
59
65
 
66
+ def terminal_candidates(platform=None, which=None, available=None):
67
+ """Known launchable applications on this host; no guessed executables."""
68
+ platform = platform or sys.platform
69
+ which = which or shutil.which
70
+ names = ["Terminal", "iTerm", "Ghostty", "WezTerm"] if platform == "darwin" else (["wt", "powershell", "pwsh", "cmd"] if platform.startswith("win") else ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"])
71
+ if platform == "darwin":
72
+ available = available or (lambda name: subprocess.run(["open", "-Ra", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0)
73
+ return [name for name in names if available(name)]
74
+ return [name for name in names if which(name)]
75
+
76
+
60
77
  def set_terminal(base_dir, name):
61
78
  """Record a preferred terminal. Raises ValueError on anything else."""
62
79
  if not valid_terminal(name):
63
80
  raise ValueError(REFUSAL)
64
- _write(base_dir, {"version": STATE_VERSION, "terminal": name})
81
+ state = _state(base_dir)
82
+ state["terminal"] = name
83
+ _write(base_dir, state)
65
84
  return name
66
85
 
67
86
 
68
87
  def clear_terminal(base_dir):
69
88
  """Return to the platform's default."""
70
- _write(base_dir, {"version": STATE_VERSION, "terminal": None})
89
+ state = _state(base_dir)
90
+ state["terminal"] = None
91
+ _write(base_dir, state)
71
92
  return None
72
93
 
73
94
 
95
+ def set_sort(base_dir, value):
96
+ if value not in SORT_VALUES:
97
+ raise ValueError("Tray sort must be capacity, reset, or recent.")
98
+ state = _state(base_dir)
99
+ state["sort"] = value
100
+ _write(base_dir, state)
101
+ return value
102
+
103
+
104
+ def _state(base_dir):
105
+ try:
106
+ with open(state_path(base_dir), encoding="utf-8") as handle:
107
+ state = json.load(handle)
108
+ except (OSError, ValueError):
109
+ state = {}
110
+ return state if isinstance(state, dict) and state.get("version") == STATE_VERSION else {"version": STATE_VERSION}
111
+
112
+
74
113
  def _write(base_dir, state):
75
114
  path = state_path(base_dir)
76
115
  os.makedirs(os.path.dirname(path), exist_ok=True)