cdx-manager 0.18.7 → 0.19.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
@@ -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.1-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.
@@ -114,8 +114,6 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
114
114
  - **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
115
115
  - **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period.
116
116
 
117
- ![cdx stats: runs, input and output tokens, total time and last use per session](docs/assets/stats.png)
118
-
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.
121
119
  - **Portable bundles.** `cdx export` / `cdx import` move sessions between machines, with passphrase-encrypted auth data when you ask for it.
@@ -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
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.18.7
1
+ 0.19.1
@@ -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.
@@ -268,6 +268,21 @@
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"
271
286
  }
272
287
  }
273
288
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.18.7",
3
+ "version": "0.19.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.18.7"
7
+ version = "0.19.1"
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,
@@ -66,14 +66,41 @@ def _launch_directory(session, ctx, explicit, json_flag):
66
66
  choices = recent + [cwd]
67
67
  if len(choices) == 1:
68
68
  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
69
+ ask = ctx["options"].get("input") or input
70
+ other_index = len(choices) + 1
71
+ while True:
72
+ ctx["out"](
73
+ "Choose launch directory:\n"
74
+ + "".join(f" {index + 1}. {path}\n" for index, path in enumerate(choices))
75
+ + f" {other_index}. Other (enter a path)\n"
76
+ )
77
+ try:
78
+ answer = ask(f"Directory [1-{other_index}] (default {len(choices)}): ").strip()
79
+ except KeyboardInterrupt:
80
+ raise CdxError("Launch cancelled.", exit_code=130) from None
81
+ if not answer:
82
+ return cwd
83
+ try:
84
+ choice = int(answer)
85
+ except ValueError:
86
+ raise CdxError("Launch cancelled: choose a listed directory.") from None
87
+ if choice == other_index:
88
+ while True:
89
+ try:
90
+ entered = ask("Directory path (blank to return): ").strip()
91
+ except KeyboardInterrupt:
92
+ raise CdxError("Launch cancelled.", exit_code=130) from None
93
+ if not entered:
94
+ break
95
+ directory = os.path.realpath(entered)
96
+ if os.path.isdir(directory):
97
+ return directory
98
+ ctx["out"](f"Invalid directory: {entered}\n")
99
+ continue
100
+ try:
101
+ return choices[choice - 1]
102
+ except IndexError:
103
+ raise CdxError("Launch cancelled: choose a listed directory.") from None
77
104
 
78
105
 
79
106
  def _format_resume_capability(capability, use_color=False):
@@ -128,19 +155,51 @@ def handle_resume(rest, ctx):
128
155
  raise CdxError(RESUME_USAGE)
129
156
  return handle_launch(args[0], ctx, resume=True, force_json=json_flag, directory=directory)
130
157
 
158
+
159
+ def _notification_provisioning_enabled(session, ctx, json_flag):
160
+ """Ask once before the first supported profile receives a hook."""
161
+ enabled = notifications_enabled(session)
162
+ if enabled or not supports_agent_alerts(session["provider"]):
163
+ return enabled
164
+ from ..tray_defaults import alerts_default, set_alerts_default
165
+
166
+ base_dir = ctx["service"]["base_dir"]
167
+ if alerts_default(base_dir):
168
+ return True
169
+ if not ctx["stdin_is_tty"] or json_flag:
170
+ return False
171
+ ask = ctx.get("options", {}).get("input") or input
172
+ try:
173
+ answer = ask("Allow CDX to install this provider's agent-alert hook? [y/N] ").strip().lower()
174
+ except EOFError:
175
+ return False
176
+ if answer not in ("y", "yes"):
177
+ return False
178
+ set_alerts_default(base_dir, True)
179
+ from ..tray_alerts import set_alerts
180
+ set_alerts(base_dir, False)
181
+ return True
182
+
183
+
131
184
  def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None):
132
185
  json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
133
186
  if force_json is not None:
134
187
  json_flag = force_json
135
188
  warnings = _update_notice_warnings(ctx)
136
189
  _warn_if_session_already_running(command, ctx)
137
- session = ctx["service"]["launch_session"](command)
190
+ session = ctx["service"]["get_session"](command)
191
+ if not session:
192
+ raise CdxError(f"Unknown session: {command}")
193
+ if session.get("enabled", True) is False:
194
+ raise CdxError(f"Session is disabled: {command}")
138
195
  capability = _resume_capability_for_session(session, ctx) if resume else None
139
196
  if capability and not capability["resumable"]:
140
197
  raise CdxError(
141
198
  f"Provider {session['provider']} does not support native resume through cdx."
142
199
  )
143
200
  cwd = _launch_directory(session, ctx, directory, json_flag)
201
+ session = ctx["service"]["launch_session"](command)
202
+ capability = _resume_capability_for_session(session, ctx) if resume else None
144
203
  if not json_flag and not ctx["stdin_is_tty"]:
145
204
  ctx["out"](f"Using directory: {cwd}\n")
146
205
  _ensure_session_authentication(
@@ -162,10 +221,11 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
162
221
  if not json_flag:
163
222
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
164
223
  _write_update_notice(ctx)
224
+ alerts_allowed = _notification_provisioning_enabled(session, ctx, json_flag)
165
225
  installed = provision(
166
226
  _get_auth_home(session),
167
227
  session["provider"],
168
- notifications_enabled(session),
228
+ alerts_allowed,
169
229
  ctx.get("env"),
170
230
  spawn_sync=ctx.get("spawn_sync"),
171
231
  base_dir=ctx["service"]["base_dir"],
@@ -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"]:
@@ -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
+ )
@@ -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)