cdx-manager 0.18.6 → 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 +18 -10
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_18_7.md +5 -0
- package/changelogs/CHANGELOGS_0_19_0.md +14 -0
- package/changelogs/CHANGELOGS_0_19_1.md +7 -0
- package/checksums/release-archives.json +26 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +6 -9
- package/src/cli.py +1 -0
- package/src/cli_args.py +1 -1
- package/src/commands/launch.py +71 -11
- package/src/commands/settings.py +1 -0
- package/src/commands/tray.py +38 -3
- package/src/logics_view.py +10 -0
- package/src/provider_runtime.py +18 -3
- package/src/status_view.py +23 -11
- package/src/tray_contract.py +21 -2
- package/src/tray_logics.py +29 -16
- package/src/tray_plugin_actions.py +22 -7
- package/src/tray_plugins.py +9 -1
- package/src/tray_terminal.py +46 -7
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
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.**
|
|
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
|
-

|
|
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
|
-
|
|
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
|
|
@@ -466,6 +464,8 @@ Interactive launches and resumes set the terminal window title to `session — f
|
|
|
466
464
|
|
|
467
465
|
The title is only written when stdout is a terminal. Nothing is emitted for `--json` launches, redirected or piped output, `cdx run` and other headless runs, login flows, or Ollama sessions. Session names and directories are stripped of escape and control characters before the title is emitted, and cdx does not restore the previous title when the session ends.
|
|
468
466
|
|
|
467
|
+
Claude Code writes its own animated title while an action runs, and it redraws far more often than cdx re-asserts the shared title. So interactive Claude launches and resumes started by cdx set the Claude-specific `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` in the provider environment, which stops Claude from setting and clearing the title and leaves `session — folder` stable while Claude is busy. Claude's in-TUI activity indicator is unaffected, the value is forced so an ambient setting in your shell cannot hand the title back, and headless `cdx run`, Claude authentication flows, and every non-Claude provider keep their previous environment.
|
|
468
|
+
|
|
469
469
|
### Launch History
|
|
470
470
|
|
|
471
471
|
Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including success/failure, duration, cwd, launch settings, and transcript path.
|
|
@@ -538,21 +538,22 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
538
538
|
| `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
|
|
539
539
|
| `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
|
|
540
540
|
| `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known headless token usage by session |
|
|
541
|
-
| `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 |
|
|
542
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 |
|
|
543
543
|
| `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
|
|
544
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 |
|
|
545
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 |
|
|
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.
|
|
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 |
|
|
547
547
|
| `cdx tray uninstall [--json]` | Remove exactly what `install` recorded, and nothing else |
|
|
548
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 |
|
|
549
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 |
|
|
550
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 |
|
|
551
551
|
| `cdx tray ack <id>... [--json]` | Mark events as shown, idempotently |
|
|
552
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 |
|
|
553
|
-
| `cdx tray terminal [status\|set <app>\|clear] [--json]` | Choose which terminal a tray session row opens
|
|
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. |
|
|
554
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 |
|
|
555
|
-
| `cdx tray alerts [on\|off\|status] [--json]` | Silence agent alerts, or let them through again
|
|
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 |
|
|
556
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 |
|
|
557
558
|
| `cdx --help` | Show usage |
|
|
558
559
|
| `cdx --version` | Show version |
|
|
@@ -594,7 +595,9 @@ The companion is a native binary distributed as a release asset, not part of the
|
|
|
594
595
|
|
|
595
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.
|
|
596
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.
|
|
597
|
-
- **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.
|
|
598
601
|
|
|
599
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.
|
|
600
603
|
|
|
@@ -605,6 +608,7 @@ The companion is a native binary distributed as a release asset, not part of the
|
|
|
605
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.
|
|
606
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.
|
|
607
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.
|
|
608
612
|
|
|
609
613
|
## JSON Output
|
|
610
614
|
|
|
@@ -1047,6 +1051,10 @@ Without `--yes`, an interactive confirmation is required. `cdx` sends an idempot
|
|
|
1047
1051
|
|
|
1048
1052
|
Contribution guidelines are available in [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
|
1049
1053
|
|
|
1054
|
+
Working on the tray companion? [`docs/tray-local-testing.md`](docs/tray-local-testing.md)
|
|
1055
|
+
covers the local loops, from rendering the menu to stdout to running a real icon
|
|
1056
|
+
in the menu bar.
|
|
1057
|
+
|
|
1050
1058
|
---
|
|
1051
1059
|
|
|
1052
1060
|
## License
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.19.1
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# CDX Manager 0.18.7
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
- Interactive Claude sessions keep the cdx `session — folder` terminal title while Claude is running an action, instead of losing it to Claude's own animated title. Headless runs, Claude authentication flows, and other providers are unchanged.
|
|
@@ -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.
|
|
@@ -257,6 +257,32 @@
|
|
|
257
257
|
"x86_64-pc-windows-msvc": "31a1743b0b6ec420a85c1679e93fcbd32c982a36e080a59d563276204682f152",
|
|
258
258
|
"x86_64-unknown-linux-musl": "49fba26d3deeab1e46af542528a5c71262e6c4d82898fcf989d7222a09a11d59"
|
|
259
259
|
}
|
|
260
|
+
},
|
|
261
|
+
"v0.18.6": {
|
|
262
|
+
"github_tarball_sha256": "31111eda2c31399b3fea14d80d10c0a2d10f15aab93a77a60939c86c564971ca",
|
|
263
|
+
"github_zip_sha256": "a3d78348416b3494645fa9f0467144c7e76b40d8862be959d125eb3d13805b15",
|
|
264
|
+
"tray_assets": {
|
|
265
|
+
"aarch64-apple-darwin": "aeb9267f72c3ee7426672d2e1fe98c0e39e0df795bbad6832299d4176c21022e",
|
|
266
|
+
"aarch64-unknown-linux-musl": "846969612d9a0e82f5f6cacc05aedc33153c1c325b8af95f63c8a2a5c2217261",
|
|
267
|
+
"x86_64-apple-darwin": "da5b4d3994fff9e616401e5a4cd01d68d216ddbeb6d41d96d67bbc789ef85118",
|
|
268
|
+
"x86_64-pc-windows-msvc": "296f760a7e10b7ef05936157b9de2b9d3b5e13f7ea2f57b7616705352ea97bd2",
|
|
269
|
+
"x86_64-unknown-linux-musl": "b360ea3ec14ab3aad0ccffcf477dee4bdadf6c1d4f150ef1cd13c7d293ae6829"
|
|
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"
|
|
260
286
|
}
|
|
261
287
|
}
|
|
262
288
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/agent_notify.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/commands/launch.py
CHANGED
|
@@ -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["
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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"]["
|
|
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
|
-
|
|
228
|
+
alerts_allowed,
|
|
169
229
|
ctx.get("env"),
|
|
170
230
|
spawn_sync=ctx.get("spawn_sync"),
|
|
171
231
|
base_dir=ctx["service"]["base_dir"],
|
package/src/commands/settings.py
CHANGED
|
@@ -58,6 +58,7 @@ def _notification_setting_notice(settings, sessions, action):
|
|
|
58
58
|
notices.append(f"Agent alerts updated for {names} — this provider does not support notification hooks.")
|
|
59
59
|
return "\n".join(notices)
|
|
60
60
|
if "notify_preview" in settings:
|
|
61
|
+
names = _format_bulk_launch_summary(sessions)
|
|
61
62
|
if action == "set" and settings["notify_preview"]:
|
|
62
63
|
return f"Response previews enabled for {names} — completion text may be visible on the lock screen."
|
|
63
64
|
return f"Response previews disabled for {names}."
|
package/src/commands/tray.py
CHANGED
|
@@ -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/logics_view.py
CHANGED
|
@@ -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
|
+
)
|
package/src/provider_runtime.py
CHANGED
|
@@ -108,6 +108,9 @@ def headless_permission_disables_network(provider, permission):
|
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
REDACTED_PROMPT_ARG = "[prompt redacted]"
|
|
111
|
+
# Documented Claude Code switch that stops it from setting and clearing the
|
|
112
|
+
# terminal title, leaving cdx's shared title keeper as the only writer.
|
|
113
|
+
CLAUDE_DISABLE_TERMINAL_TITLE_ENV = "CLAUDE_CODE_DISABLE_TERMINAL_TITLE"
|
|
111
114
|
CLAUDE_CLI_MODEL_ALIASES = {
|
|
112
115
|
"claude-sonnet": "sonnet",
|
|
113
116
|
"claude-opus": "opus",
|
|
@@ -148,11 +151,23 @@ def _anthropic_credentials_path(auth_home):
|
|
|
148
151
|
return os.path.join(auth_home, "credentials", f"{_anthropic_profile_name()}.json")
|
|
149
152
|
|
|
150
153
|
|
|
151
|
-
def _claude_env(base_env, auth_home):
|
|
154
|
+
def _claude_env(base_env, auth_home, own_terminal_title=False):
|
|
155
|
+
"""Build the Claude provider environment for one cdx spawn.
|
|
156
|
+
|
|
157
|
+
`own_terminal_title` is set by the interactive launch/resume paths, where
|
|
158
|
+
cdx's title keeper owns the `session — folder` title. Claude Code redraws an
|
|
159
|
+
animated busy title far more often than the keeper re-emits, so we tell it
|
|
160
|
+
to leave the title alone; its in-TUI activity indicator is unaffected. The
|
|
161
|
+
value is forced rather than defaulted so an ambient `0` in the operator's
|
|
162
|
+
shell cannot hand the title back to Claude. Headless and auth spawns do not
|
|
163
|
+
own a title and keep their previous environment.
|
|
164
|
+
"""
|
|
152
165
|
env = {**base_env, **_home_env_overrides(auth_home)}
|
|
153
166
|
env.pop("CLAUDE_CONFIG_DIR", None)
|
|
154
167
|
env.pop("CODEX_HOME", None)
|
|
155
168
|
env.setdefault("ANTHROPIC_PROFILE", _anthropic_profile_name())
|
|
169
|
+
if own_terminal_title:
|
|
170
|
+
env[CLAUDE_DISABLE_TERMINAL_TITLE_ENV] = "1"
|
|
156
171
|
return env
|
|
157
172
|
|
|
158
173
|
|
|
@@ -547,7 +562,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
547
562
|
if initial_prompt:
|
|
548
563
|
args.append(initial_prompt)
|
|
549
564
|
auth_home = _get_auth_home(session)
|
|
550
|
-
claude_env = _claude_env(env, auth_home)
|
|
565
|
+
claude_env = _claude_env(env, auth_home, own_terminal_title=True)
|
|
551
566
|
oauth_token = _read_claude_launch_oauth_token(auth_home)
|
|
552
567
|
if oauth_token:
|
|
553
568
|
claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
|
@@ -693,7 +708,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
693
708
|
if resume_prompt:
|
|
694
709
|
args.append(resume_prompt)
|
|
695
710
|
auth_home = _get_auth_home(session)
|
|
696
|
-
claude_env = _claude_env(env, auth_home)
|
|
711
|
+
claude_env = _claude_env(env, auth_home, own_terminal_title=True)
|
|
697
712
|
oauth_token = _read_claude_launch_oauth_token(auth_home)
|
|
698
713
|
if oauth_token:
|
|
699
714
|
claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
package/src/status_view.py
CHANGED
|
@@ -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"
|
|
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"
|
|
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
|
-
|
|
139
|
-
_style(
|
|
140
|
-
|
|
141
|
-
_style(
|
|
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
|
package/src/tray_contract.py
CHANGED
|
@@ -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 =
|
|
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
|
|
package/src/tray_logics.py
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/tray_plugins.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/tray_terminal.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|