cdx-manager 0.18.7 → 0.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_19_0.md +14 -0
- package/changelogs/CHANGELOGS_0_19_1.md +7 -0
- package/changelogs/CHANGELOGS_0_19_2.md +11 -0
- package/changelogs/CHANGELOGS_0_19_3.md +6 -0
- package/checksums/release-archives.json +30 -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 +87 -12
- package/src/commands/status.py +5 -2
- package/src/commands/tray.py +38 -3
- package/src/fs_utils.py +9 -0
- package/src/interactive_usage.py +124 -0
- package/src/logics_view.py +10 -0
- package/src/run_registry.py +22 -14
- package/src/run_usage.py +8 -1
- 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.
|
|
@@ -112,9 +112,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
|
|
|
112
112
|
### Maintenance
|
|
113
113
|
|
|
114
114
|
- **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
|
|
115
|
-
- **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period.
|
|
116
|
-
|
|
117
|
-

|
|
115
|
+
- **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period. Interactive Codex and Claude launches also record provider-native token totals when their local session transcript is available.
|
|
118
116
|
|
|
119
117
|
- **Disk usage and cleanup.** `cdx disk` reports `CDX_HOME` usage, `cdx disk profiles --candidates` identifies reclaimable profile caches/logs with evidence, and `cdx clean profiles ...` applies explicit cleanup actions.
|
|
120
118
|
- **Health and repair.** `cdx doctor` inspects dependencies, permissions, and orphan profiles; `cdx repair` plans or applies safe fixes.
|
|
@@ -357,7 +355,7 @@ desktop notification naming the session and the repository:
|
|
|
357
355
|
✓ codex-a cdx-manager · turn complete
|
|
358
356
|
```
|
|
359
357
|
|
|
360
|
-
|
|
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.
|
|
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
|
|
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
|
|
556
|
+
| `cdx tray alerts [on\|off\|status] [--json]` | Silence agent alerts, or let them through again. These commands work with or without a running tray: with one, muted events remain in its menu; without one, delivery falls back directly to the desktop notification channel. In both cases `off` suppresses banners and `on` restores delivery for the next event, including hooks already running with an older launch environment. Distinct from provider-hook consent, which controls configuration writes |
|
|
558
557
|
| `cdx tray doctor [--json]` | Report companion, executable, version, running instance, autostart, and the two capabilities that fail silently — whether this desktop can draw a tray icon at all, and on Windows whether the Start Menu shortcut toasts depend on exists. Reads only; it never repairs |
|
|
559
558
|
| `cdx --help` | Show usage |
|
|
560
559
|
| `cdx --version` | Show version |
|
|
@@ -596,7 +595,9 @@ The companion is a native binary distributed as a release asset, not part of the
|
|
|
596
595
|
|
|
597
596
|
- **Where the checksum comes from.** Releases older than the one you are running are vouched for by `checksums/release-archives.json`, committed to the repository before those assets existed. Your own release is a special case: its tray assets are built after the tag, so no package built at tag time can carry their checksums. For that one version CDX reads the ledger the release publishes beside the assets — the publisher's own assertion over the same channel, which catches a truncated download or a wrong-architecture asset but does not defend against someone who can rewrite the release itself.
|
|
598
597
|
- **The checksum is the vouching, not Apple or Microsoft.** The macOS bundle is signed with a *self-signed* certificate. That signature exists because Apple Silicon refuses to execute unsigned arm64 code at all — it is not a claim that Apple reviewed anything. What actually vouches for the asset is the SHA-256 published in `checksums/release-archives.json`, verified before a single byte is unpacked. An asset with no published checksum is refused rather than installed hopefully.
|
|
599
|
-
- **Tray integrations are cards, not plugins.** `cdx tray plugin enable logics` adds one section to the menu: a blocked/ready summary and at most two rows — the first blocked document, because it is what stops work, and the highest-priority task in progress, because it is what to do instead. Clicking a row opens the Logics viewer focused on it.
|
|
598
|
+
- **Tray integrations are cards, not plugins.** `cdx tray plugin enable logics` adds one section to the menu: a blocked/ready summary and at most two rows — the first blocked document, because it is what stops work, and the highest-priority task in progress, because it is what to do instead. Clicking a row opens the Logics viewer focused on it. Tray openings ask Logics for a free local port, so an existing viewer is never displaced.
|
|
599
|
+
|
|
600
|
+
- **Alert return path.** When an alert originated in iTerm2, the macOS tray row and native CDX notification focus that exact iTerm session. macOS may ask once for CDX to control iTerm; declining leaves the alert visible but does not focus a different window. Script Editor fallback banners and Windows/Linux notifications remain delivery-only.
|
|
600
601
|
|
|
601
602
|
There is deliberately no marketplace, no discovery, no installation from a URL, and no plugin auto-update. An adapter is a function in this repository, named in a registry CDX owns, so nothing unknown can run. Adapters produce data and never behaviour: a card is a summary, rows, and action ids from a fixed vocabulary, and the tray hands an id back to CDX rather than running anything itself. Adding a capability is a change to CDX, reviewed like any other.
|
|
602
603
|
|
|
@@ -607,6 +608,7 @@ The companion is a native binary distributed as a release asset, not part of the
|
|
|
607
608
|
- **Uninstall removes only what install recorded**, from a record CDX wrote itself. A damaged record reads as absent rather than being guessed at, because that record drives deletion.
|
|
608
609
|
- **Linux carries no C library.** The Linux asset is statically linked against musl, so there is no glibc version to match and no `gtk`, `libxdo`, or `appindicator` package to install. Tray support is resolved at runtime by looking for the `org.kde.StatusNotifierWatcher` D-Bus name — never by matching a distribution or desktop name. A session without it is told so, and nothing is registered to start at login.
|
|
609
610
|
- **Version drift between CDX and the companion is normal**, since pip upgrades one and `cdx tray install` the other. Whichever is older keeps serving every field it understands and shows one update hint.
|
|
611
|
+
- **About stays actionable.** The last tray action before Quit is `About (v<installed CDX version>)`; it opens the project’s GitHub page with the platform’s normal browser launcher.
|
|
610
612
|
|
|
611
613
|
## JSON Output
|
|
612
614
|
|
|
@@ -706,7 +708,7 @@ cdx run \
|
|
|
706
708
|
|
|
707
709
|
The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
|
|
708
710
|
|
|
709
|
-
Known headless token usage is persisted in launch history and can be aggregated later:
|
|
711
|
+
Known headless and completed interactive token usage is persisted in launch history and can be aggregated later. Interactive records are best-effort local data: Codex reads its latest cumulative rollout snapshot and Claude reads its assistant usage records. Cached input is retained separately from the provider's total, so it is never counted twice.
|
|
710
712
|
|
|
711
713
|
```bash
|
|
712
714
|
cdx stats --since 7d --json
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.19.3
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# CDX Manager 0.19.0
|
|
2
|
+
|
|
3
|
+
## Features
|
|
4
|
+
|
|
5
|
+
- Tray alerts can return to their originating iTerm session on macOS, including from a native notification banner.
|
|
6
|
+
- The tray can group enabled Logics actions by repository and opens its viewer on a free local port.
|
|
7
|
+
- The tray offers supported terminal choices and persists its session sort order.
|
|
8
|
+
- Interactive launches ask for an optional agent-alert hook and support a validated manually entered launch directory.
|
|
9
|
+
|
|
10
|
+
## Fixes
|
|
11
|
+
|
|
12
|
+
- Tray session rows now show only meaningful quota reset columns and retain their selected terminal behavior across macOS, Windows, and WSL.
|
|
13
|
+
- Existing provider hooks read the current alert mute setting, so `cdx tray alerts on|off` takes effect without a relaunch.
|
|
14
|
+
- Local tray development helpers now use the checkout reliably on Windows and WSL.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# CDX Manager 0.19.1
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
- Restored Linux tray companion builds and terminal fallback handling.
|
|
6
|
+
- Removed the unused GTK dependency tree from the tray companion, resolving a Dependabot security alert.
|
|
7
|
+
- Removed the outdated `cdx stats` image from the README.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# CDX Manager 0.19.2
|
|
2
|
+
|
|
3
|
+
## Features
|
|
4
|
+
|
|
5
|
+
- Record Codex and Claude interactive CLI token usage, including cached input tokens, in existing run history and status statistics.
|
|
6
|
+
|
|
7
|
+
## Fixes
|
|
8
|
+
|
|
9
|
+
- Make detached-run registry persistence durable and lock acquisition bounded on POSIX and Windows.
|
|
10
|
+
- Bound interactive transcript discovery and parsing while preserving fail-open behavior.
|
|
11
|
+
- Verify tray tests, macOS CI coverage, and installable npm/wheel package artifacts before release.
|
|
@@ -268,6 +268,36 @@
|
|
|
268
268
|
"x86_64-pc-windows-msvc": "296f760a7e10b7ef05936157b9de2b9d3b5e13f7ea2f57b7616705352ea97bd2",
|
|
269
269
|
"x86_64-unknown-linux-musl": "b360ea3ec14ab3aad0ccffcf477dee4bdadf6c1d4f150ef1cd13c7d293ae6829"
|
|
270
270
|
}
|
|
271
|
+
},
|
|
272
|
+
"v0.18.7": {
|
|
273
|
+
"github_tarball_sha256": "913d43fc57f29b5dae486d1be2e30a48aa9abdd37cce8bd7219e23b49c0b4045",
|
|
274
|
+
"github_zip_sha256": "f24a4cd9943372b7e7ee67fc112da7873fd832438b88236aa10428f029bb20e5",
|
|
275
|
+
"tray_assets": {
|
|
276
|
+
"aarch64-apple-darwin": "a8d5b8c6f474594a609f7172ef9a307698f588ca907a6649b1229e8a2e71d99f",
|
|
277
|
+
"aarch64-unknown-linux-musl": "1d26111f25cc19986fdc4641c6f27875f20f55c19c700e6dc8edfdb6aab78ccc",
|
|
278
|
+
"x86_64-apple-darwin": "1e79a847ba780e9818173a0ced2172c4e37a5fc2121003679603900662e41609",
|
|
279
|
+
"x86_64-pc-windows-msvc": "dc6b9b951526837a8adfe58cf56683fa6072a18488a47c093bc0ba492b588ba7",
|
|
280
|
+
"x86_64-unknown-linux-musl": "4affb9acf4b6ce1384af883fa9943cca7d76bb049d5562aa8d5498d51e7204a2"
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"v0.19.0": {
|
|
284
|
+
"github_tarball_sha256": "d9816c0e4a2313bc10d8d9b527795b3027798607af49c5ef820f491d167176b4",
|
|
285
|
+
"github_zip_sha256": "656090f68694695fd947b0e73fc8c1ef66785054fd04054f8659b03997daedfb"
|
|
286
|
+
},
|
|
287
|
+
"v0.19.1": {
|
|
288
|
+
"github_tarball_sha256": "351b8840a1dc073a91d198c2d565d9666f9ea7a8d545bac6a01e31ae41aec3ca",
|
|
289
|
+
"github_zip_sha256": "7e74a60fdf20b66faec25d11a8b8780fe4c1fad4a62b24f2395ea7d3667549ae",
|
|
290
|
+
"tray_assets": {
|
|
291
|
+
"aarch64-apple-darwin": "fd07386f7d7d8528927980929a260f3816b050a8d985186a18dae0ce8563af56",
|
|
292
|
+
"aarch64-unknown-linux-musl": "ca963298f96bb125892472cbcb125513f11941cb29258ad2b1ea3b9fb441ff74",
|
|
293
|
+
"x86_64-apple-darwin": "b22601c1c203a17f143ce83e19c5a4c54c546ea52f927f2ad8042ce5aa57a16c",
|
|
294
|
+
"x86_64-pc-windows-msvc": "7d5b01ab05a83c5e5a18f515a7010b799c19b3b6b6374ed39d8fa386ea66892b",
|
|
295
|
+
"x86_64-unknown-linux-musl": "2b318774e1ddccda5e070475042c395c6c90dda3ec89a6cf6498ba11dbdc04e8"
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"v0.19.2": {
|
|
299
|
+
"github_tarball_sha256": "d644666c727b2a29fa022700da1c23483173a2110606b994d200f72feb959c3b",
|
|
300
|
+
"github_zip_sha256": "bca42637ddab4502e2dd6d9d63239eca322d953f8d910d8ada209fcfdd2f98d4"
|
|
271
301
|
}
|
|
272
302
|
}
|
|
273
303
|
}
|
package/package.json
CHANGED
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,
|
|
@@ -25,6 +25,7 @@ from ..cli_render import _dim, _info, _success, _warn
|
|
|
25
25
|
from ..config import PROVIDER_CODEX
|
|
26
26
|
from ..context_store import install_context_for_session, write_context
|
|
27
27
|
from ..errors import CdxError
|
|
28
|
+
from ..interactive_usage import extract_interactive_usage
|
|
28
29
|
from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
|
|
29
30
|
|
|
30
31
|
|
|
@@ -66,14 +67,41 @@ def _launch_directory(session, ctx, explicit, json_flag):
|
|
|
66
67
|
choices = recent + [cwd]
|
|
67
68
|
if len(choices) == 1:
|
|
68
69
|
return cwd
|
|
69
|
-
ctx["
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
ask = ctx["options"].get("input") or input
|
|
71
|
+
other_index = len(choices) + 1
|
|
72
|
+
while True:
|
|
73
|
+
ctx["out"](
|
|
74
|
+
"Choose launch directory:\n"
|
|
75
|
+
+ "".join(f" {index + 1}. {path}\n" for index, path in enumerate(choices))
|
|
76
|
+
+ f" {other_index}. Other (enter a path)\n"
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
answer = ask(f"Directory [1-{other_index}] (default {len(choices)}): ").strip()
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
raise CdxError("Launch cancelled.", exit_code=130) from None
|
|
82
|
+
if not answer:
|
|
83
|
+
return cwd
|
|
84
|
+
try:
|
|
85
|
+
choice = int(answer)
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise CdxError("Launch cancelled: choose a listed directory.") from None
|
|
88
|
+
if choice == other_index:
|
|
89
|
+
while True:
|
|
90
|
+
try:
|
|
91
|
+
entered = ask("Directory path (blank to return): ").strip()
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
raise CdxError("Launch cancelled.", exit_code=130) from None
|
|
94
|
+
if not entered:
|
|
95
|
+
break
|
|
96
|
+
directory = os.path.realpath(entered)
|
|
97
|
+
if os.path.isdir(directory):
|
|
98
|
+
return directory
|
|
99
|
+
ctx["out"](f"Invalid directory: {entered}\n")
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
return choices[choice - 1]
|
|
103
|
+
except IndexError:
|
|
104
|
+
raise CdxError("Launch cancelled: choose a listed directory.") from None
|
|
77
105
|
|
|
78
106
|
|
|
79
107
|
def _format_resume_capability(capability, use_color=False):
|
|
@@ -128,19 +156,51 @@ def handle_resume(rest, ctx):
|
|
|
128
156
|
raise CdxError(RESUME_USAGE)
|
|
129
157
|
return handle_launch(args[0], ctx, resume=True, force_json=json_flag, directory=directory)
|
|
130
158
|
|
|
159
|
+
|
|
160
|
+
def _notification_provisioning_enabled(session, ctx, json_flag):
|
|
161
|
+
"""Ask once before the first supported profile receives a hook."""
|
|
162
|
+
enabled = notifications_enabled(session)
|
|
163
|
+
if enabled or not supports_agent_alerts(session["provider"]):
|
|
164
|
+
return enabled
|
|
165
|
+
from ..tray_defaults import alerts_default, set_alerts_default
|
|
166
|
+
|
|
167
|
+
base_dir = ctx["service"]["base_dir"]
|
|
168
|
+
if alerts_default(base_dir):
|
|
169
|
+
return True
|
|
170
|
+
if not ctx["stdin_is_tty"] or json_flag:
|
|
171
|
+
return False
|
|
172
|
+
ask = ctx.get("options", {}).get("input") or input
|
|
173
|
+
try:
|
|
174
|
+
answer = ask("Allow CDX to install this provider's agent-alert hook? [y/N] ").strip().lower()
|
|
175
|
+
except EOFError:
|
|
176
|
+
return False
|
|
177
|
+
if answer not in ("y", "yes"):
|
|
178
|
+
return False
|
|
179
|
+
set_alerts_default(base_dir, True)
|
|
180
|
+
from ..tray_alerts import set_alerts
|
|
181
|
+
set_alerts(base_dir, False)
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
|
|
131
185
|
def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None):
|
|
132
186
|
json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
|
|
133
187
|
if force_json is not None:
|
|
134
188
|
json_flag = force_json
|
|
135
189
|
warnings = _update_notice_warnings(ctx)
|
|
136
190
|
_warn_if_session_already_running(command, ctx)
|
|
137
|
-
session = ctx["service"]["
|
|
191
|
+
session = ctx["service"]["get_session"](command)
|
|
192
|
+
if not session:
|
|
193
|
+
raise CdxError(f"Unknown session: {command}")
|
|
194
|
+
if session.get("enabled", True) is False:
|
|
195
|
+
raise CdxError(f"Session is disabled: {command}")
|
|
138
196
|
capability = _resume_capability_for_session(session, ctx) if resume else None
|
|
139
197
|
if capability and not capability["resumable"]:
|
|
140
198
|
raise CdxError(
|
|
141
199
|
f"Provider {session['provider']} does not support native resume through cdx."
|
|
142
200
|
)
|
|
143
201
|
cwd = _launch_directory(session, ctx, directory, json_flag)
|
|
202
|
+
session = ctx["service"]["launch_session"](command)
|
|
203
|
+
capability = _resume_capability_for_session(session, ctx) if resume else None
|
|
144
204
|
if not json_flag and not ctx["stdin_is_tty"]:
|
|
145
205
|
ctx["out"](f"Using directory: {cwd}\n")
|
|
146
206
|
_ensure_session_authentication(
|
|
@@ -162,10 +222,11 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
162
222
|
if not json_flag:
|
|
163
223
|
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
164
224
|
_write_update_notice(ctx)
|
|
225
|
+
alerts_allowed = _notification_provisioning_enabled(session, ctx, json_flag)
|
|
165
226
|
installed = provision(
|
|
166
227
|
_get_auth_home(session),
|
|
167
228
|
session["provider"],
|
|
168
|
-
|
|
229
|
+
alerts_allowed,
|
|
169
230
|
ctx.get("env"),
|
|
170
231
|
spawn_sync=ctx.get("spawn_sync"),
|
|
171
232
|
base_dir=ctx["service"]["base_dir"],
|
|
@@ -204,6 +265,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
204
265
|
)
|
|
205
266
|
except CdxError as error:
|
|
206
267
|
run_info = getattr(error, "run_info", {}) or {}
|
|
268
|
+
_attach_interactive_usage(session, run_info)
|
|
207
269
|
if runtime_run_id:
|
|
208
270
|
ctx["service"]["finish_session_runtime"](
|
|
209
271
|
session["name"],
|
|
@@ -224,7 +286,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
224
286
|
"action": "resume" if resume else "launch",
|
|
225
287
|
"cwd": cwd,
|
|
226
288
|
"exit_code": 0,
|
|
227
|
-
**run_info,
|
|
289
|
+
**_attach_interactive_usage(session, run_info),
|
|
228
290
|
})
|
|
229
291
|
if json_flag:
|
|
230
292
|
extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
|
|
@@ -233,6 +295,19 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
233
295
|
_write_json(ctx, _json_success("resume" if resume else "launch", message, warnings=warnings, **extra))
|
|
234
296
|
return 0
|
|
235
297
|
|
|
298
|
+
|
|
299
|
+
def _attach_interactive_usage(session, run_info):
|
|
300
|
+
"""Attach best-effort local provider usage without affecting launch outcome."""
|
|
301
|
+
run_info = dict(run_info or {})
|
|
302
|
+
usage, provider_transcript = extract_interactive_usage(
|
|
303
|
+
session.get("provider"), _get_auth_home(session), run_info.get("started_at")
|
|
304
|
+
)
|
|
305
|
+
if usage:
|
|
306
|
+
run_info["usage"] = usage
|
|
307
|
+
if provider_transcript:
|
|
308
|
+
run_info["provider_transcript_path"] = provider_transcript
|
|
309
|
+
return run_info
|
|
310
|
+
|
|
236
311
|
def handle_handoff(rest, ctx):
|
|
237
312
|
json_flag, args = _parse_json_flag(rest)
|
|
238
313
|
if len(args) not in (1, 2):
|
package/src/commands/status.py
CHANGED
|
@@ -253,6 +253,7 @@ def _summarize_stats(entries):
|
|
|
253
253
|
"duration_ms": 0,
|
|
254
254
|
"usage_runs": 0,
|
|
255
255
|
"input_tokens": 0,
|
|
256
|
+
"cached_input_tokens": 0,
|
|
256
257
|
"output_tokens": 0,
|
|
257
258
|
"reasoning_tokens": 0,
|
|
258
259
|
"total_tokens": 0,
|
|
@@ -270,7 +271,7 @@ def _summarize_stats(entries):
|
|
|
270
271
|
usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
|
|
271
272
|
parsed_usage = {
|
|
272
273
|
key: _token_value(usage, key)
|
|
273
|
-
for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
274
|
+
for key in ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
274
275
|
}
|
|
275
276
|
if any(value is not None for value in parsed_usage.values()):
|
|
276
277
|
row["usage_runs"] += 1
|
|
@@ -295,6 +296,7 @@ def _stats_totals(rows):
|
|
|
295
296
|
"duration_ms": sum(row["duration_ms"] for row in rows),
|
|
296
297
|
"usage_runs": sum(row["usage_runs"] for row in rows),
|
|
297
298
|
"input_tokens": sum(row["input_tokens"] for row in rows),
|
|
299
|
+
"cached_input_tokens": sum(row["cached_input_tokens"] for row in rows),
|
|
298
300
|
"output_tokens": sum(row["output_tokens"] for row in rows),
|
|
299
301
|
"reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
|
|
300
302
|
"total_tokens": sum(row["total_tokens"] for row in rows),
|
|
@@ -389,7 +391,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
389
391
|
if not rows:
|
|
390
392
|
return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
|
|
391
393
|
table = [[_style(value, "1", use_color) for value in [
|
|
392
|
-
"SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"
|
|
394
|
+
"SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "TIME", "LAST"
|
|
393
395
|
]]]
|
|
394
396
|
for row in rows:
|
|
395
397
|
session_name = row["session_name"]
|
|
@@ -400,6 +402,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
400
402
|
_style(str(row["launches"]), "1", use_color),
|
|
401
403
|
_style(str(row["usage_runs"]), "32" if row["usage_runs"] else "2", use_color),
|
|
402
404
|
_style(_format_token_count(row["input_tokens"]), "96" if row["input_tokens"] else "2", use_color),
|
|
405
|
+
_style(_format_token_count(row["cached_input_tokens"]), "96" if row["cached_input_tokens"] else "2", use_color),
|
|
403
406
|
_style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
|
|
404
407
|
_style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
|
|
405
408
|
_style(_format_token_count(row["total_tokens"]), "1;96" if row["total_tokens"] else "2", use_color),
|
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/fs_utils.py
CHANGED
|
@@ -28,6 +28,15 @@ def atomic_write(path, data, mode=None):
|
|
|
28
28
|
if mode is not None and os.name != "nt":
|
|
29
29
|
os.chmod(temp_path, mode)
|
|
30
30
|
os.replace(temp_path, path)
|
|
31
|
+
if os.name != "nt":
|
|
32
|
+
try:
|
|
33
|
+
directory_fd = os.open(directory, os.O_RDONLY)
|
|
34
|
+
try:
|
|
35
|
+
os.fsync(directory_fd)
|
|
36
|
+
finally:
|
|
37
|
+
os.close(directory_fd)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
31
40
|
except Exception:
|
|
32
41
|
try:
|
|
33
42
|
os.unlink(temp_path)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Best-effort usage readers for interactive provider sessions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024
|
|
8
|
+
MAX_TRANSCRIPT_CANDIDATES = 1000
|
|
9
|
+
MAX_TRANSCRIPT_SCAN_SECONDS = 1
|
|
10
|
+
|
|
11
|
+
def extract_interactive_usage(provider, auth_home, started_at=None):
|
|
12
|
+
"""Return the newest provider-native usage after an interactive session.
|
|
13
|
+
|
|
14
|
+
Provider transcripts are not stable public APIs. A missing or changed
|
|
15
|
+
record is therefore ordinary absence, never a launch failure.
|
|
16
|
+
"""
|
|
17
|
+
path = _latest_transcript(provider, auth_home, started_at)
|
|
18
|
+
if not path:
|
|
19
|
+
return None, None
|
|
20
|
+
try:
|
|
21
|
+
if os.path.getsize(path) > MAX_TRANSCRIPT_BYTES:
|
|
22
|
+
return None, path
|
|
23
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
24
|
+
return (_codex_usage(handle) if provider == "codex" else _claude_usage(handle)), path
|
|
25
|
+
except OSError:
|
|
26
|
+
return None, path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _latest_transcript(provider, auth_home, started_at):
|
|
30
|
+
if not auth_home:
|
|
31
|
+
return None
|
|
32
|
+
root = os.path.join(auth_home, "sessions") if provider == "codex" else os.path.join(auth_home, ".claude", "projects")
|
|
33
|
+
if not os.path.isdir(root):
|
|
34
|
+
return None
|
|
35
|
+
cutoff = _timestamp(started_at)
|
|
36
|
+
candidates = []
|
|
37
|
+
deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
|
|
38
|
+
for directory, subdirs, names in os.walk(root):
|
|
39
|
+
if time.monotonic() >= deadline:
|
|
40
|
+
return max(candidates, default=(None, None))[1]
|
|
41
|
+
if provider == "claude":
|
|
42
|
+
subdirs[:] = [name for name in subdirs if name != "subagents"]
|
|
43
|
+
for name in names:
|
|
44
|
+
if not name.endswith(".jsonl"):
|
|
45
|
+
continue
|
|
46
|
+
path = os.path.join(directory, name)
|
|
47
|
+
try:
|
|
48
|
+
modified = os.path.getmtime(path)
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
if cutoff is None or modified >= cutoff:
|
|
52
|
+
candidates.append((modified, path))
|
|
53
|
+
if len(candidates) > MAX_TRANSCRIPT_CANDIDATES:
|
|
54
|
+
return max(candidates)[1]
|
|
55
|
+
return max(candidates, default=(None, None))[1]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _timestamp(value):
|
|
59
|
+
if not value:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
from datetime import datetime
|
|
63
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _codex_usage(handle):
|
|
69
|
+
latest = None
|
|
70
|
+
for line in handle:
|
|
71
|
+
try:
|
|
72
|
+
record = json.loads(line)
|
|
73
|
+
payload = record.get("payload") or {}
|
|
74
|
+
usage = ((payload.get("info") or {}).get("total_token_usage") or {}) if payload.get("type") == "token_count" else {}
|
|
75
|
+
candidate = _normalize(usage, cached_key="cached_input_tokens", reasoning_key="reasoning_output_tokens")
|
|
76
|
+
if candidate:
|
|
77
|
+
latest = candidate
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
continue
|
|
80
|
+
return latest
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _claude_usage(handle):
|
|
84
|
+
totals = {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0}
|
|
85
|
+
seen = set()
|
|
86
|
+
for line in handle:
|
|
87
|
+
try:
|
|
88
|
+
record = json.loads(line)
|
|
89
|
+
message = record.get("message") or {}
|
|
90
|
+
usage = message.get("usage") or {}
|
|
91
|
+
identifier = record.get("uuid")
|
|
92
|
+
if record.get("type") != "assistant" or not usage or (identifier and identifier in seen):
|
|
93
|
+
continue
|
|
94
|
+
if identifier:
|
|
95
|
+
seen.add(identifier)
|
|
96
|
+
totals["input_tokens"] += _number(usage.get("input_tokens"))
|
|
97
|
+
totals["cached_input_tokens"] += _number(usage.get("cache_creation_input_tokens")) + _number(usage.get("cache_read_input_tokens"))
|
|
98
|
+
totals["output_tokens"] += _number(usage.get("output_tokens"))
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
continue
|
|
101
|
+
if not seen and not any(totals.values()):
|
|
102
|
+
return None
|
|
103
|
+
totals["reasoning_tokens"] = None
|
|
104
|
+
totals["total_tokens"] = totals["input_tokens"] + totals["output_tokens"]
|
|
105
|
+
return totals
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _normalize(usage, cached_key, reasoning_key):
|
|
109
|
+
if not isinstance(usage, dict) or not usage:
|
|
110
|
+
return None
|
|
111
|
+
return {
|
|
112
|
+
"input_tokens": _number(usage.get("input_tokens")),
|
|
113
|
+
"cached_input_tokens": _number(usage.get(cached_key)),
|
|
114
|
+
"output_tokens": _number(usage.get("output_tokens")),
|
|
115
|
+
"reasoning_tokens": _number(usage.get(reasoning_key)) or None,
|
|
116
|
+
"total_tokens": _number(usage.get("total_tokens")) or None,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _number(value):
|
|
121
|
+
try:
|
|
122
|
+
return max(0, int(value or 0))
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
return 0
|
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/run_registry.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import sys
|
|
4
|
-
import tempfile
|
|
5
4
|
import time
|
|
6
5
|
from contextlib import contextmanager
|
|
7
6
|
from datetime import datetime, timezone
|
|
8
7
|
|
|
9
8
|
from .errors import CdxError
|
|
9
|
+
from .fs_utils import atomic_write
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def utc_now_iso():
|
|
@@ -53,6 +53,25 @@ def _acquire_windows_lock(handle, timeout_seconds):
|
|
|
53
53
|
time.sleep(_LOCK_RETRY_SECONDS)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _acquire_posix_lock(handle, timeout_seconds):
|
|
57
|
+
"""Acquire a POSIX advisory lock within the same bounded wait as Windows."""
|
|
58
|
+
import fcntl
|
|
59
|
+
|
|
60
|
+
deadline = time.monotonic() + timeout_seconds
|
|
61
|
+
while True:
|
|
62
|
+
try:
|
|
63
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
64
|
+
return
|
|
65
|
+
except OSError:
|
|
66
|
+
if time.monotonic() >= deadline:
|
|
67
|
+
raise CdxError(
|
|
68
|
+
"Timed out waiting for the run registry lock after "
|
|
69
|
+
f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
|
|
70
|
+
75,
|
|
71
|
+
) from None
|
|
72
|
+
time.sleep(_LOCK_RETRY_SECONDS)
|
|
73
|
+
|
|
74
|
+
|
|
56
75
|
@contextmanager
|
|
57
76
|
def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
|
|
58
77
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
@@ -67,7 +86,7 @@ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
|
|
|
67
86
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
68
87
|
else:
|
|
69
88
|
import fcntl
|
|
70
|
-
|
|
89
|
+
_acquire_posix_lock(handle, timeout_seconds)
|
|
71
90
|
try:
|
|
72
91
|
yield
|
|
73
92
|
finally:
|
|
@@ -89,18 +108,7 @@ def _read_registry(path):
|
|
|
89
108
|
|
|
90
109
|
|
|
91
110
|
def _write_registry(path, data):
|
|
92
|
-
|
|
93
|
-
fd, temp_path = tempfile.mkstemp(prefix=".runs-", suffix=".json", dir=os.path.dirname(path))
|
|
94
|
-
try:
|
|
95
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
96
|
-
json.dump(data, handle, indent=2, sort_keys=True)
|
|
97
|
-
handle.write("\n")
|
|
98
|
-
os.replace(temp_path, path)
|
|
99
|
-
finally:
|
|
100
|
-
try:
|
|
101
|
-
os.unlink(temp_path)
|
|
102
|
-
except FileNotFoundError:
|
|
103
|
-
pass
|
|
111
|
+
atomic_write(path, json.dumps(data, indent=2, sort_keys=True) + "\n", mode=0o600)
|
|
104
112
|
|
|
105
113
|
|
|
106
114
|
def _is_pid_alive(pid):
|
package/src/run_usage.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
|
-
USAGE_KEYS = ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
3
|
+
USAGE_KEYS = ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
4
4
|
SUPPORTED_PROVIDERS = {"claude", "codex"}
|
|
5
5
|
|
|
6
6
|
|
|
@@ -89,6 +89,12 @@ def _usage_from_dict(value):
|
|
|
89
89
|
usage.get("cache_creation_input_tokens"),
|
|
90
90
|
usage.get("cache_read_input_tokens"),
|
|
91
91
|
)
|
|
92
|
+
cached_input_tokens = _first_int(usage.get("cached_input_tokens"))
|
|
93
|
+
if cached_input_tokens is None:
|
|
94
|
+
cached_input_tokens = _int_value(
|
|
95
|
+
usage.get("cache_creation_input_tokens"),
|
|
96
|
+
usage.get("cache_read_input_tokens"),
|
|
97
|
+
)
|
|
92
98
|
output_tokens = _int_value(usage.get("output_tokens"), usage.get("completion_tokens"))
|
|
93
99
|
reasoning_tokens = _int_value(
|
|
94
100
|
usage.get("reasoning_tokens"),
|
|
@@ -102,6 +108,7 @@ def _usage_from_dict(value):
|
|
|
102
108
|
|
|
103
109
|
return {
|
|
104
110
|
"input_tokens": input_tokens,
|
|
111
|
+
"cached_input_tokens": cached_input_tokens,
|
|
105
112
|
"output_tokens": output_tokens,
|
|
106
113
|
"reasoning_tokens": reasoning_tokens,
|
|
107
114
|
"total_tokens": total_tokens,
|
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)
|