@phnx-labs/agents-cli 1.20.33 → 1.20.35

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.
Files changed (159) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +32 -2
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +17 -12
  8. package/dist/commands/doctor.d.ts +11 -0
  9. package/dist/commands/doctor.js +108 -14
  10. package/dist/commands/events.d.ts +16 -0
  11. package/dist/commands/events.js +173 -0
  12. package/dist/commands/exec.js +6 -1
  13. package/dist/commands/hosts.js +28 -24
  14. package/dist/commands/import.js +1 -1
  15. package/dist/commands/inspect.d.ts +2 -0
  16. package/dist/commands/inspect.js +54 -7
  17. package/dist/commands/logs.d.ts +17 -0
  18. package/dist/commands/logs.js +139 -0
  19. package/dist/commands/models.d.ts +3 -0
  20. package/dist/commands/models.js +27 -8
  21. package/dist/commands/permissions.js +9 -2
  22. package/dist/commands/repo.d.ts +34 -0
  23. package/dist/commands/repo.js +243 -65
  24. package/dist/commands/resource-view.d.ts +20 -0
  25. package/dist/commands/resource-view.js +90 -28
  26. package/dist/commands/rules.js +1 -1
  27. package/dist/commands/secrets-migrate.js +23 -11
  28. package/dist/commands/secrets.d.ts +20 -0
  29. package/dist/commands/secrets.js +93 -17
  30. package/dist/commands/sessions-resume.d.ts +2 -0
  31. package/dist/commands/sessions-resume.js +209 -0
  32. package/dist/commands/sessions-tail.d.ts +10 -0
  33. package/dist/commands/sessions-tail.js +11 -0
  34. package/dist/commands/sessions.d.ts +73 -1
  35. package/dist/commands/sessions.js +273 -53
  36. package/dist/commands/status.d.ts +12 -0
  37. package/dist/commands/status.js +81 -0
  38. package/dist/commands/teams.js +70 -6
  39. package/dist/commands/versions.js +2 -1
  40. package/dist/commands/view.d.ts +42 -0
  41. package/dist/commands/view.js +213 -83
  42. package/dist/commands/wallet.d.ts +6 -0
  43. package/dist/commands/wallet.js +22 -5
  44. package/dist/index.js +61 -32
  45. package/dist/lib/acp/harnesses.d.ts +1 -1
  46. package/dist/lib/acp/harnesses.js +2 -2
  47. package/dist/lib/agents.d.ts +12 -0
  48. package/dist/lib/agents.js +145 -41
  49. package/dist/lib/browser/cdp.js +5 -1
  50. package/dist/lib/browser/chrome.js +20 -0
  51. package/dist/lib/browser/drivers/ssh.d.ts +27 -0
  52. package/dist/lib/browser/drivers/ssh.js +94 -19
  53. package/dist/lib/browser/ipc.d.ts +3 -0
  54. package/dist/lib/browser/ipc.js +13 -9
  55. package/dist/lib/browser/service.js +71 -15
  56. package/dist/lib/daemon.d.ts +35 -0
  57. package/dist/lib/daemon.js +33 -5
  58. package/dist/lib/devices/connect.d.ts +3 -2
  59. package/dist/lib/devices/connect.js +5 -3
  60. package/dist/lib/doctor-diff.js +29 -2
  61. package/dist/lib/drift-sync.d.ts +43 -0
  62. package/dist/lib/drift-sync.js +179 -0
  63. package/dist/lib/events.d.ts +9 -2
  64. package/dist/lib/events.js +70 -11
  65. package/dist/lib/exec.d.ts +15 -0
  66. package/dist/lib/exec.js +43 -11
  67. package/dist/lib/hooks.js +8 -2
  68. package/dist/lib/hosts/dispatch.js +6 -7
  69. package/dist/lib/hosts/logs.d.ts +16 -0
  70. package/dist/lib/hosts/logs.js +45 -0
  71. package/dist/lib/hosts/progress.d.ts +66 -0
  72. package/dist/lib/hosts/progress.js +125 -17
  73. package/dist/lib/hosts/ready.d.ts +23 -2
  74. package/dist/lib/hosts/ready.js +35 -13
  75. package/dist/lib/hosts/reconcile.d.ts +53 -0
  76. package/dist/lib/hosts/reconcile.js +81 -0
  77. package/dist/lib/hosts/tasks.d.ts +8 -0
  78. package/dist/lib/hosts/tasks.js +14 -0
  79. package/dist/lib/permissions.d.ts +4 -0
  80. package/dist/lib/permissions.js +35 -0
  81. package/dist/lib/picker.d.ts +24 -0
  82. package/dist/lib/picker.js +124 -0
  83. package/dist/lib/platform/winpath.d.ts +31 -2
  84. package/dist/lib/platform/winpath.js +133 -24
  85. package/dist/lib/plugin-marketplace.d.ts +30 -0
  86. package/dist/lib/plugin-marketplace.js +215 -2
  87. package/dist/lib/plugins.d.ts +5 -0
  88. package/dist/lib/plugins.js +45 -4
  89. package/dist/lib/pwsh.d.ts +11 -0
  90. package/dist/lib/pwsh.js +13 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/agent.d.ts +42 -1
  94. package/dist/lib/secrets/agent.js +89 -11
  95. package/dist/lib/secrets/bundles.js +40 -9
  96. package/dist/lib/secrets/filestore.js +31 -1
  97. package/dist/lib/secrets/index.d.ts +33 -1
  98. package/dist/lib/secrets/index.js +90 -9
  99. package/dist/lib/secrets/windows.d.ts +74 -0
  100. package/dist/lib/secrets/windows.js +440 -0
  101. package/dist/lib/session/active.d.ts +7 -0
  102. package/dist/lib/session/active.js +11 -3
  103. package/dist/lib/session/discover.js +94 -0
  104. package/dist/lib/session/parse.d.ts +6 -0
  105. package/dist/lib/session/parse.js +283 -2
  106. package/dist/lib/session/pid-registry.d.ts +21 -0
  107. package/dist/lib/session/pid-registry.js +80 -0
  108. package/dist/lib/session/remote-active.d.ts +26 -0
  109. package/dist/lib/session/remote-active.js +141 -0
  110. package/dist/lib/session/remote.js +2 -6
  111. package/dist/lib/session/render.js +1 -1
  112. package/dist/lib/session/sync/config.d.ts +8 -0
  113. package/dist/lib/session/sync/config.js +11 -2
  114. package/dist/lib/session/types.d.ts +1 -1
  115. package/dist/lib/session/types.js +1 -1
  116. package/dist/lib/shims.d.ts +30 -2
  117. package/dist/lib/shims.js +107 -50
  118. package/dist/lib/ssh-exec.d.ts +26 -3
  119. package/dist/lib/ssh-exec.js +45 -3
  120. package/dist/lib/ssh-tunnel.d.ts +24 -5
  121. package/dist/lib/ssh-tunnel.js +60 -62
  122. package/dist/lib/staleness/writers/hooks.js +1 -1
  123. package/dist/lib/startup/command-registry.d.ts +3 -0
  124. package/dist/lib/startup/command-registry.js +6 -0
  125. package/dist/lib/sync-status.d.ts +102 -0
  126. package/dist/lib/sync-status.js +135 -0
  127. package/dist/lib/teams/agents.d.ts +24 -0
  128. package/dist/lib/teams/agents.js +30 -1
  129. package/dist/lib/teams/registry.js +25 -9
  130. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  131. package/dist/lib/terminal/backends/ghostty.js +69 -0
  132. package/dist/lib/terminal/backends/index.d.ts +18 -0
  133. package/dist/lib/terminal/backends/index.js +29 -0
  134. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  135. package/dist/lib/terminal/backends/iterm.js +62 -0
  136. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  137. package/dist/lib/terminal/backends/tmux.js +23 -0
  138. package/dist/lib/terminal/engine.d.ts +39 -0
  139. package/dist/lib/terminal/engine.js +54 -0
  140. package/dist/lib/terminal/index.d.ts +14 -0
  141. package/dist/lib/terminal/index.js +5 -0
  142. package/dist/lib/terminal/policy.d.ts +11 -0
  143. package/dist/lib/terminal/policy.js +11 -0
  144. package/dist/lib/terminal/quote.d.ts +11 -0
  145. package/dist/lib/terminal/quote.js +13 -0
  146. package/dist/lib/terminal/shell.d.ts +6 -0
  147. package/dist/lib/terminal/shell.js +23 -0
  148. package/dist/lib/terminal/transport.d.ts +15 -0
  149. package/dist/lib/terminal/transport.js +40 -0
  150. package/dist/lib/terminal/types.d.ts +59 -0
  151. package/dist/lib/terminal/types.js +13 -0
  152. package/dist/lib/types.d.ts +27 -1
  153. package/dist/lib/usage.d.ts +30 -0
  154. package/dist/lib/usage.js +159 -2
  155. package/dist/lib/versions.d.ts +7 -3
  156. package/dist/lib/versions.js +10 -5
  157. package/dist/lib/whats-new.d.ts +9 -0
  158. package/dist/lib/whats-new.js +35 -0
  159. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.35
6
+
7
+ **CI: build node-pty's native binary on macOS/Windows so the release matrix is green cross-platform**
8
+
9
+ - The cross-platform matrix (`ci.yml`, runs only on `release/**` + `v*`) installed deps with `bun install --ignore-scripts`, so `pty.node` from `@homebridge/node-pty-prebuilt-multiarch` was never fetched/built. That package ships prebuilt binaries only for Linux; macOS/Windows obtain `pty.node` via its own install script (prebuild-install download, else a node-gyp compile). With that script skipped the native module was absent, so the daemon-liveness integration test added in #568 — which spawns the real daemon (it loads node-pty) and asserts the browser IPC socket stays up — crashed on macOS/Windows while passing on Linux, and had been red on every release since. The matrix runs only on release branches, so it never surfaced on normal PRs (bun does not run that install script even without `--ignore-scripts` in bun 1.3.x). CI now runs a dedicated step that invokes the package's own install script (`npm run install`), which prefers a prebuilt download and falls back to a node-gyp compile, so it self-heals across platforms and node ABIs. Production (`npm install`) already built the native module, so end users were unaffected. A second macOS/Windows-only failure in the same #568 daemon-liveness test was also fixed: the test rooted its fake `HOME` under `os.tmpdir()`, which on macOS is the long `/var/folders/…/T/…`, pushing the daemon's AF_UNIX socket path to ~116 bytes — past macOS's 104-byte `sun_path` limit — so `bind()` failed with `EADDRINUSE`. The test now roots `HOME` at a short base on POSIX (Windows uses length-unlimited named pipes); real users with a normal `HOME` were never affected. Source: `.github/workflows/ci.yml`, `src/lib/daemon.test.ts`.
10
+
11
+ **`agents logs`: a top-level, unified run-log viewer (#575)**
12
+
13
+ - Viewing a dispatched run's output used to be nested and undiscoverable — only `agents hosts logs <id>` and `agents daemon logs` existed, and `agents hosts` wasn't even in `--help`. `agents logs [id]` is now a discoverable top-level command that resolves a run across **two substrates** — host-dispatch task stdout (`agents run --host`) and the local session index — and shows or (`-f`) follows it. `[id]`/`--session` load directly (host task tried first, then session); with no id, `--host`/`--agent`/`--version` filter a merged candidate list (one match shows, several open a fuzzy picker, non-TTY prints the list). Additive: `agents hosts logs` and `agents sessions tail` are unchanged and share the same helpers. Source: `src/commands/logs.ts`, `src/lib/hosts/logs.ts`.
14
+
15
+ **Host-follow log tailer: no self-corruption on localhost, byte-accurate offsets (#586, #589)**
16
+
17
+ - Following a run dispatched to **localhost** tripled the on-disk log and triple-printed the output, because the local mirror file and the remote log were the same file and the tailer appended its own reads back into it; it now detects that aliasing by file identity (`dev:ino`) and echoes only. Separately, the offset tracker advanced by a re-encoded string length, so a multibyte UTF-8 char split at a poll boundary drifted the offset and corrupted the stream on non-ASCII output; the tail is now byte-exact (raw `Buffer` via `sshExecRaw`). Source: `src/lib/hosts/progress.ts`, `src/lib/ssh-exec.ts`.
18
+
19
+ **`agents upgrade`: the "What's new" changelog is now a compact heading list (#562)**
20
+
21
+ - The post-upgrade changelog dumped every heading *and* every verbose sub-bullet for each version in the range — a screenful across a multi-version jump. It now prints one bullet per feature/fix heading and links to the full CHANGELOG for the details. The parser was extracted to a pure, unit-tested `renderWhatsNew` so it can be exercised without the CLI's import-time side effects. Source: `src/lib/whats-new.ts`, `src/index.ts`.
22
+
23
+ **`agents sessions --active`: a per-pid registry de-collapses co-located agents (#546)**
24
+
25
+ - On a host with no terminal extension (bare SSH/tmux — e.g. any Linux box), `--active` could only map a discovered agent process to a session by guessing the newest `.jsonl` in its cwd, so several agents in the same repo collapsed onto one session row (observed live: a single id listed 28 times), and `/restore` couldn't tell them apart. `agents run` now records each launch to `~/.agents/.cache/terminals/by-pid/<pid>.json` (`{agent, cwd, tmuxPane, sessionId, startedAtMs}`) — the headless equivalent of the terminal extension's `live-terminals.json` — so `--active` and `/restore` attribute each co-located agent correctly. Source: `src/lib/session/pid-registry.ts`, `src/lib/session/active.ts`, `src/lib/exec.ts`.
26
+
27
+ ## 1.20.34
28
+
29
+ **Test suite runs remotely on a crabbox VM (#525, #540)**
30
+
31
+ - `scripts/release.sh`'s test gate now runs `bun install && bun run build && bun run test` on a leased crabbox VM via `scripts/sandbox.sh` instead of freezing the local machine, matching CI's Build→Test order (crabbox's sync honors `.gitignore`, so the gitignored `dist/` is built on the box). A new `bun run test:remote` offloads the suite the same way for local dev. Publishing still happens locally — only the signed macOS keychain helper can be produced and notarized here, and crabbox boxes are Linux. Source: `scripts/sandbox.sh`, `scripts/release.sh`, `package.json`.
32
+ - `scripts/sandbox.sh` box acquisition is now robust: secrets load via `agents secrets export --plaintext` (the bare form now hard-errors), a missing `.crabbox.yaml` no longer aborts the script under `set -e`, and the agents-cli/claude install is gated to PR mode so test-mode runs match GitHub CI. Box selection gates on `crabbox status … ready=true` — skipping failed-bootstrap duds (which still report `status=running`) and warming a fresh box if none are ready — keyed on the stable `profile` label rather than an ephemeral slug. A dedicated `agents-cli` crabbox profile (`.crabbox.yaml`) isolates this repo's warm pool. Source: `scripts/sandbox.sh`, `.crabbox.yaml`.
33
+
5
34
  ## 1.20.31
6
35
 
7
36
  **`agents sessions <id>`: a catch-up digest for switching between many agents (#502)**
package/README.md CHANGED
@@ -230,6 +230,34 @@ Profile YAML has no secrets -- safe to `agents repo push` to a shared repo. `age
230
230
 
231
231
  ---
232
232
 
233
+ ## Run on your own machines
234
+
235
+ Dispatch any read-only or config command -- and `agents run` itself -- to another machine over SSH. No daemon.
236
+
237
+ ```bash
238
+ # Enroll a machine (from ~/.ssh/config, or inline with user@address)
239
+ agents hosts add gpu-box
240
+ agents hosts check gpu-box # reachable? which agents-cli version?
241
+
242
+ # Run there instead of locally
243
+ agents run claude --host gpu-box "profile this build" # follows live by default
244
+ agents logs --host gpu-box # pick a dispatched run and view its log
245
+ agents logs <id> -f # re-attach to a running one and follow
246
+ agents view claude --host gpu-box # inspect the remote install
247
+ agents sync --host gpu-box # make the remote machine current
248
+
249
+ # Your Tailscale fleet, auto-discovered
250
+ agents devices sync # ingest `tailscale status`
251
+ agents ssh mac-mini # hardened SSH: fails fast if offline,
252
+ # PowerShell on Windows, password-from-Keychain
253
+ ```
254
+
255
+ **Hosts** (`agents hosts`) are git-synced dispatch targets in `agents.yaml`; **devices** (`agents devices`) are your Tailscale machines in a local registry. Both ride SSH. See [docs/00-concepts.md](docs/00-concepts.md#devices--hosts).
256
+
257
+ Every `--host` command rides one multiplexed SSH engine, tuned for driving a fleet from a small laptop: the first call to a machine opens a control socket and every later call reuses it (no repeat TCP+auth handshake), connections carry keepalive so a dropped link dies in ~45 s instead of zombying, and following a remote run polls in a single round-trip per cycle. Measured against a Tailscale-relayed host: repeated calls **~6–7× faster**, dispatch readiness **~2×**, and the follow loop **~21× faster with 50% fewer local ssh spawns**. Design: [docs/09-ssh-transport.md](docs/09-ssh-transport.md) · reproduce: `node scripts/bench-ssh.mjs <host>`.
258
+
259
+ ---
260
+
233
261
  ## Teams
234
262
 
235
263
  ```bash
@@ -646,11 +674,13 @@ By default, secrets sync via iCloud Keychain to your other Macs. With `--no-iclo
646
674
 
647
675
  Which DotAgents resources each agent CLI can load. Source of truth: [src/lib/agents.ts](src/lib/agents.ts) (`capabilities`); gates use `supports(agent, cap, version)` from [src/lib/capabilities.ts](src/lib/capabilities.ts). Full matrix also in [docs/00-concepts.md](docs/00-concepts.md).
648
676
 
677
+ > **† Gemini CLI is deprecated.** Google retired it for free, Pro, and Ultra tiers on **June 18, 2026** (announced at Google I/O 2026); the `gemini` command no longer serves requests on those tiers. agents-cli still manages existing installs, but warns on `agents add gemini` and `agents teams add … gemini`. New setups should use **Antigravity CLI** (`antigravity`), Google's official successor — see [the transition notice](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/).
678
+
649
679
  | Agent | Versions | Hooks | MCP | Permissions | Skills | Commands | Plugins | Subagents | Rules | Workflows |
650
680
  |-------|----------|-------|-----|-------------|--------|----------|---------|-----------|-------|-----------|
651
681
  | Claude Code | yes | yes | yes | yes | yes | yes | yes | yes | `CLAUDE.md` | yes |
652
682
  | Codex CLI | yes | >= 0.116.0 | yes | no | yes | < 0.117.0 · skills ($name, >= 0.117) | >= 0.128.0 | no | `AGENTS.md` | no |
653
- | Gemini CLI | yes | >= 0.26.0 | yes | no | yes | yes (.toml) | no | no | `GEMINI.md` | no |
683
+ | Gemini CLI | yes | >= 0.26.0 | yes | no | yes | yes (.toml) | no | no | `GEMINI.md` | no |
654
684
  | Antigravity | yes | yes | yes | yes | yes | yes | yes | no | `AGENTS.md` | no |
655
685
  | Grok Build | yes | yes | yes | yes | yes | skills ($name) | yes | no | `AGENTS.md` | no |
656
686
  | OpenClaw | yes | yes | yes | no | yes | gateway | yes | yes | `workspace/AGENTS.md` | no |
@@ -672,7 +702,7 @@ Which DotAgents resources each agent CLI can load. Source of truth: [src/lib/age
672
702
  |-------|----------|-------|---------------|
673
703
  | Claude Code | yes | yes | yes |
674
704
  | Codex CLI | yes | yes | yes |
675
- | Gemini CLI | yes | yes | yes |
705
+ | Gemini CLI | yes | yes | yes |
676
706
  | Cursor | -- | yes | -- |
677
707
  | OpenCode | -- | yes | -- |
678
708
  | Grok Build | -- | yes | yes |
@@ -55,4 +55,19 @@ export declare function buildWaitParams(opts: {
55
55
  };
56
56
  export declare function withClient<T>(fn: (client: ComputerClient) => Promise<T>): Promise<T>;
57
57
  export declare function unwrap(r: RPCResponse): Record<string, unknown>;
58
+ export declare function resolveTargetPidDecision(client: ComputerClient, opts: {
59
+ pid?: number;
60
+ bundle?: string;
61
+ }, gate?: {
62
+ verb?: string;
63
+ env?: NodeJS.ProcessEnv;
64
+ nowMs?: number;
65
+ }): Promise<{
66
+ ok: true;
67
+ pid: number;
68
+ source: 'pid' | 'list_apps' | 'session_admission';
69
+ } | {
70
+ ok: false;
71
+ error: string;
72
+ }>;
58
73
  export declare function registerActionCommands(program: Command): void;
@@ -6,7 +6,88 @@
6
6
  // The daemon already implements every method; this file is the thin, typed
7
7
  // CLI skin over it plus a shared target resolver so callers stay in bundle-id
8
8
  // space and never hand-manage pids.
9
- import { openComputerClient, describeTransport, } from '../lib/computer-rpc.js';
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { openComputerClient, describeTransport, resolvePolicyPath, } from '../lib/computer-rpc.js';
12
+ import { COMPUTER_INPUT_GATED_VERBS, formatComputerPermissionGrantHint, } from '../lib/permissions.js';
13
+ function isComputerInputVerb(verb) {
14
+ return typeof verb === 'string' && COMPUTER_INPUT_GATED_VERBS.includes(verb);
15
+ }
16
+ function computerSessionId(env = process.env) {
17
+ return env.CODEX_THREAD_ID
18
+ || env.CLAUDE_CODE_SESSION_ID
19
+ || env.CLAUDE_SESSION_ID
20
+ || env.AGENTS_SESSION_ID
21
+ || env.AGENTS_RUN_ID
22
+ || null;
23
+ }
24
+ function admissionCachePath(env = process.env) {
25
+ if (env.AGENTS_COMPUTER_ADMISSION_CACHE)
26
+ return env.AGENTS_COMPUTER_ADMISSION_CACHE;
27
+ return path.join(path.dirname(resolvePolicyPath()), 'computer-target-admissions.json');
28
+ }
29
+ function targetSelector(opts) {
30
+ return opts.bundle ? `bundle:${opts.bundle}` : 'frontmost';
31
+ }
32
+ function readAdmissionCache(filePath) {
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
35
+ return Array.isArray(parsed.admissions) ? parsed.admissions.filter(isAdmission) : [];
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ }
41
+ function isAdmission(v) {
42
+ if (!v || typeof v !== 'object')
43
+ return false;
44
+ const r = v;
45
+ return r.sessionId !== undefined
46
+ && typeof r.sessionId === 'string'
47
+ && typeof r.selector === 'string'
48
+ && r.gateClass === 'input'
49
+ && typeof r.pid === 'number'
50
+ && typeof r.bundle_id === 'string'
51
+ && typeof r.name === 'string'
52
+ && typeof r.admittedAtMs === 'number'
53
+ && isComputerInputVerb(r.admittedByVerb);
54
+ }
55
+ function rememberAdmission(app, opts) {
56
+ const sessionId = computerSessionId(opts.env);
57
+ if (!sessionId)
58
+ return;
59
+ const filePath = admissionCachePath(opts.env);
60
+ const admissions = readAdmissionCache(filePath);
61
+ const next = {
62
+ sessionId,
63
+ selector: opts.selector,
64
+ gateClass: 'input',
65
+ pid: app.pid,
66
+ bundle_id: app.bundle_id,
67
+ name: app.name,
68
+ admittedAtMs: opts.nowMs ?? Date.now(),
69
+ admittedByVerb: opts.verb,
70
+ };
71
+ const filtered = admissions.filter((a) => !(a.sessionId === sessionId && a.selector === opts.selector && a.gateClass === 'input'));
72
+ filtered.push(next);
73
+ try {
74
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
75
+ fs.writeFileSync(filePath, JSON.stringify({ admissions: filtered }, null, 2), { mode: 0o600 });
76
+ }
77
+ catch {
78
+ // Cache persistence is best-effort; the daemon remains the final gate.
79
+ }
80
+ }
81
+ function findAdmission(opts) {
82
+ const sessionId = computerSessionId(opts.env);
83
+ if (!sessionId)
84
+ return null;
85
+ const admissions = readAdmissionCache(admissionCachePath(opts.env));
86
+ const matches = admissions
87
+ .filter((a) => a.sessionId === sessionId && a.selector === opts.selector && a.gateClass === 'input')
88
+ .sort((a, b) => b.admittedAtMs - a.admittedAtMs);
89
+ return matches[0] ?? null;
90
+ }
10
91
  // Pure target picker — exercised by unit tests. Precedence: explicit --pid,
11
92
  // then --bundle, then the frontmost active allow-listed app (the same default
12
93
  // `screenshot` uses). Kept side-effect-free so the resolution rules are
@@ -24,7 +105,7 @@ export function pickTarget(list, opts) {
24
105
  if (!app) {
25
106
  return {
26
107
  ok: false,
27
- error: `bundle not in allow list (or not running): ${opts.bundle}\nadd Computer(${opts.bundle}) to a permissions group, then \`agents computer reload\``,
108
+ error: `bundle not in allow list (or not running): ${opts.bundle}\n${formatComputerPermissionGrantHint(opts.bundle)}`,
28
109
  };
29
110
  }
30
111
  return { ok: true, app };
@@ -33,7 +114,7 @@ export function pickTarget(list, opts) {
33
114
  if (!active) {
34
115
  return {
35
116
  ok: false,
36
- error: 'no active app found in allow list\nadd Computer(<bundle-id>) to a permissions group, then `agents computer reload`',
117
+ error: `no active app found in allow list\n${formatComputerPermissionGrantHint()}`,
37
118
  };
38
119
  }
39
120
  return { ok: true, app: active };
@@ -141,20 +222,37 @@ export function unwrap(r) {
141
222
  }
142
223
  return r.result ?? {};
143
224
  }
144
- // Resolve the target pid via list_apps + pickTarget, printing a precise error
145
- // and exiting when no target matches.
146
- async function resolveTargetPid(client, opts) {
225
+ export async function resolveTargetPidDecision(client, opts, gate) {
147
226
  // A directly-supplied pid skips the list_apps roundtrip — the daemon gates.
148
227
  if (opts.pid != null)
149
- return opts.pid;
228
+ return { ok: true, pid: opts.pid, source: 'pid' };
150
229
  const apps = unwrap(await client.call('list_apps'));
151
230
  const list = apps.apps || [];
152
231
  const picked = pickTarget(list, opts);
232
+ const verb = gate?.verb;
233
+ const selector = targetSelector(opts);
234
+ if (picked.ok && isComputerInputVerb(verb)) {
235
+ rememberAdmission(picked.app, { selector, verb, env: gate?.env, nowMs: gate?.nowMs });
236
+ }
153
237
  if (!picked.ok) {
154
- console.error(picked.error);
238
+ if (isComputerInputVerb(verb)) {
239
+ const admitted = findAdmission({ selector, env: gate?.env });
240
+ if (admitted)
241
+ return { ok: true, pid: admitted.pid, source: 'session_admission' };
242
+ }
243
+ return { ok: false, error: picked.error };
244
+ }
245
+ return { ok: true, pid: picked.app.pid, source: 'list_apps' };
246
+ }
247
+ // Resolve the target pid via list_apps + pickTarget, printing a precise error
248
+ // and exiting when no target matches.
249
+ async function resolveTargetPid(client, opts, gate) {
250
+ const resolved = await resolveTargetPidDecision(client, opts, gate);
251
+ if (!resolved.ok) {
252
+ console.error(resolved.error);
155
253
  process.exit(1);
156
254
  }
157
- return picked.app.pid;
255
+ return resolved.pid;
158
256
  }
159
257
  // --raise flag: app-level focus_window before the main action so coordinate
160
258
  // clicks and keystrokes land on a visible, key window.
@@ -210,7 +308,7 @@ export function registerActionCommands(program) {
210
308
  .option('--depth <n>', 'Max tree depth', (v) => parseInt(v, 10))
211
309
  .option('--json', 'Emit compact JSON (default: pretty)')).action(async (opts) => {
212
310
  await withClient(async (client) => {
213
- const pid = await resolveTargetPid(client, opts);
311
+ const pid = await resolveTargetPid(client, opts, { verb: 'describe' });
214
312
  const params = { pid };
215
313
  if (opts.depth != null)
216
314
  params.max_depth = opts.depth;
@@ -228,7 +326,7 @@ export function registerActionCommands(program) {
228
326
  .option('--raise', 'Bring the target app to the front first')
229
327
  .option('--json', 'Emit JSON'))).action(async (opts) => {
230
328
  await withClient(async (client) => {
231
- const pid = await resolveTargetPid(client, opts);
329
+ const pid = await resolveTargetPid(client, opts, { verb: 'click' });
232
330
  const spec = buildElementOrCoords(opts);
233
331
  if (!spec.ok) {
234
332
  console.error(spec.error);
@@ -250,7 +348,7 @@ export function registerActionCommands(program) {
250
348
  .description('Right-click (context menu) an element or coordinate')
251
349
  .option('--json', 'Emit JSON'))).action(async (opts) => {
252
350
  await withClient(async (client) => {
253
- const pid = await resolveTargetPid(client, opts);
351
+ const pid = await resolveTargetPid(client, opts, { verb: 'right-click' });
254
352
  const spec = buildElementOrCoords(opts);
255
353
  if (!spec.ok) {
256
354
  console.error(spec.error);
@@ -269,7 +367,7 @@ export function registerActionCommands(program) {
269
367
  .option('--allow-secure-field', 'Permit typing into a password field')
270
368
  .option('--json', 'Emit JSON'))).action(async (opts) => {
271
369
  await withClient(async (client) => {
272
- const pid = await resolveTargetPid(client, opts);
370
+ const pid = await resolveTargetPid(client, opts, { verb: 'type' });
273
371
  const spec = buildElementOrCoords(opts);
274
372
  if (!spec.ok) {
275
373
  console.error(spec.error);
@@ -295,7 +393,7 @@ export function registerActionCommands(program) {
295
393
  .option('--char-delay <ms>', 'Inter-character delay in ms (default 4; raise for lossy keyboard relays like VM guests, e.g. 25). Clamped to [1, 250].', (v) => parseInt(v, 10))
296
394
  .option('--json', 'Emit JSON')).action(async (opts) => {
297
395
  await withClient(async (client) => {
298
- const pid = await resolveTargetPid(client, opts);
396
+ const pid = await resolveTargetPid(client, opts, { verb: 'type-text' });
299
397
  await raiseIfRequested(client, pid, opts.raise);
300
398
  const params = { pid, text: opts.text };
301
399
  if (opts.commit)
@@ -319,7 +417,7 @@ export function registerActionCommands(program) {
319
417
  .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
320
418
  .option('--json', 'Emit JSON')).action(async (opts) => {
321
419
  await withClient(async (client) => {
322
- const pid = await resolveTargetPid(client, opts);
420
+ const pid = await resolveTargetPid(client, opts, { verb: 'key' });
323
421
  await raiseIfRequested(client, pid, opts.raise);
324
422
  const params = { pid, keys: opts.keys };
325
423
  if (opts.requireFrontmost)
@@ -350,7 +448,7 @@ export function registerActionCommands(program) {
350
448
  process.exit(1);
351
449
  }
352
450
  await withClient(async (client) => {
353
- const pid = await resolveTargetPid(client, opts);
451
+ const pid = await resolveTargetPid(client, opts, { verb: 'drag' });
354
452
  await raiseIfRequested(client, pid, opts.raise);
355
453
  const params = {
356
454
  pid,
@@ -373,7 +471,7 @@ export function registerActionCommands(program) {
373
471
  .option('--raise', 'Bring the target app to the front first')
374
472
  .option('--json', 'Emit JSON'))).action(async (opts) => {
375
473
  await withClient(async (client) => {
376
- const pid = await resolveTargetPid(client, opts);
474
+ const pid = await resolveTargetPid(client, opts, { verb: 'scroll' });
377
475
  await raiseIfRequested(client, pid, opts.raise);
378
476
  const params = { pid };
379
477
  if (opts.id)
@@ -398,7 +496,7 @@ export function registerActionCommands(program) {
398
496
  .requiredOption('--action <name>', 'AX action name')
399
497
  .option('--json', 'Emit JSON')).action(async (opts) => {
400
498
  await withClient(async (client) => {
401
- const pid = await resolveTargetPid(client, opts);
499
+ const pid = await resolveTargetPid(client, opts, { verb: 'ax-action' });
402
500
  const res = unwrap(await client.call('ax_action', { pid, element_id: opts.id, action: opts.action }));
403
501
  emit(res, Boolean(opts.json), () => `performed ${opts.action}`);
404
502
  });
@@ -410,7 +508,7 @@ export function registerActionCommands(program) {
410
508
  .requiredOption('--id <@eN>', 'Element id from `describe`')
411
509
  .option('--json', 'Emit JSON')).action(async (opts) => {
412
510
  await withClient(async (client) => {
413
- const pid = await resolveTargetPid(client, opts);
511
+ const pid = await resolveTargetPid(client, opts, { verb: 'focus' });
414
512
  const res = unwrap(await client.call('set_focus', { pid, element_id: opts.id }));
415
513
  emit(res, Boolean(opts.json), () => `focused ${opts.id}`);
416
514
  });
@@ -426,7 +524,7 @@ export function registerActionCommands(program) {
426
524
  .option('--title <s>', 'Raise the window whose title contains this string')
427
525
  .option('--json', 'Emit JSON')).action(async (opts) => {
428
526
  await withClient(async (client) => {
429
- const pid = await resolveTargetPid(client, opts);
527
+ const pid = await resolveTargetPid(client, opts, { verb: 'raise' });
430
528
  const res = unwrap(await client.call('focus_window', { pid, ...buildRaiseParams(opts) }));
431
529
  emit(res, Boolean(opts.json), () => {
432
530
  const scope = res.raised_window ? `window ${res.title ?? res.window_id ?? ''}`.trim() : 'app';
@@ -455,7 +553,7 @@ export function registerActionCommands(program) {
455
553
  const params = { ...spec.params };
456
554
  // duration-only waits don't need a target pid
457
555
  if (params.duration_ms == null)
458
- params.pid = await resolveTargetPid(client, opts);
556
+ params.pid = await resolveTargetPid(client, opts, { verb: 'wait' });
459
557
  const res = unwrap(await client.call('wait', params));
460
558
  emit(res, Boolean(opts.json), () => res.satisfied ? `satisfied (${res.waited_ms}ms)` : `timed out (${res.waited_ms}ms)`);
461
559
  });
@@ -468,7 +566,7 @@ export function registerActionCommands(program) {
468
566
  .option('--max-chars <n>', 'Cap the extracted text length', (v) => parseInt(v, 10))
469
567
  .option('--json', 'Emit JSON')).action(async (opts) => {
470
568
  await withClient(async (client) => {
471
- const pid = await resolveTargetPid(client, opts);
569
+ const pid = await resolveTargetPid(client, opts, { verb: 'get-text' });
472
570
  const params = { pid };
473
571
  if (opts.id)
474
572
  params.element_id = opts.id;
@@ -12,6 +12,29 @@ export declare function shouldBlockOffPlatform(opts: {
12
12
  tcpConfigured: boolean;
13
13
  host?: string;
14
14
  }): boolean;
15
+ /**
16
+ * Sniff the image format from the leading magic bytes: PNG starts with the
17
+ * 8-byte signature `89 50 4E 47` ("\x89PNG"), JPEG with `FF D8 FF`. Returns the
18
+ * canonical file extension, or null for anything else.
19
+ */
20
+ export declare function detectImageFormat(buf: Buffer): '.png' | '.jpg' | null;
21
+ /**
22
+ * Make the screenshot filename honest about its bytes. The two helper backends
23
+ * encode DIFFERENT formats and neither re-encodes to match the requested name:
24
+ * the macOS helper (ScreenCaptureKit) returns JPEG
25
+ * (packages/computer-helper/Sources/ComputerHelper/Screenshot.swift:207,212),
26
+ * the Windows helper returns PNG
27
+ * (packages/computer-helper-win/Screenshot.cs:33). So a fixed default extension
28
+ * cannot be correct for both — the only honest path is to sniff the real format
29
+ * and swap the extension to match. Pure so it's unit-testable.
30
+ *
31
+ * Returns the path to write to (caller's path with its extension corrected) and
32
+ * whether a correction was made. Unknown formats pass through unchanged.
33
+ */
34
+ export declare function reconcileScreenshotExt(outPath: string, buf: Buffer): {
35
+ path: string;
36
+ corrected: boolean;
37
+ };
15
38
  export declare function registerComputerCommand(program: Command): void;
16
39
  export declare function registerComputerSubcommands(program: Command): void;
17
40
  export { resolveHelperExec as resolveHelperPath };
@@ -33,6 +33,42 @@ export function shouldBlockOffPlatform(opts) {
33
33
  return false; // remote path resolves its own endpoint
34
34
  return true;
35
35
  }
36
+ /**
37
+ * Sniff the image format from the leading magic bytes: PNG starts with the
38
+ * 8-byte signature `89 50 4E 47` ("\x89PNG"), JPEG with `FF D8 FF`. Returns the
39
+ * canonical file extension, or null for anything else.
40
+ */
41
+ export function detectImageFormat(buf) {
42
+ if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
43
+ return '.png';
44
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
45
+ return '.jpg';
46
+ return null;
47
+ }
48
+ /**
49
+ * Make the screenshot filename honest about its bytes. The two helper backends
50
+ * encode DIFFERENT formats and neither re-encodes to match the requested name:
51
+ * the macOS helper (ScreenCaptureKit) returns JPEG
52
+ * (packages/computer-helper/Sources/ComputerHelper/Screenshot.swift:207,212),
53
+ * the Windows helper returns PNG
54
+ * (packages/computer-helper-win/Screenshot.cs:33). So a fixed default extension
55
+ * cannot be correct for both — the only honest path is to sniff the real format
56
+ * and swap the extension to match. Pure so it's unit-testable.
57
+ *
58
+ * Returns the path to write to (caller's path with its extension corrected) and
59
+ * whether a correction was made. Unknown formats pass through unchanged.
60
+ */
61
+ export function reconcileScreenshotExt(outPath, buf) {
62
+ const actual = detectImageFormat(buf);
63
+ if (!actual)
64
+ return { path: outPath, corrected: false };
65
+ const cur = path.extname(outPath).toLowerCase();
66
+ const alreadyMatches = cur === actual || (actual === '.jpg' && (cur === '.jpg' || cur === '.jpeg'));
67
+ if (alreadyMatches)
68
+ return { path: outPath, corrected: false };
69
+ const base = outPath.slice(0, outPath.length - path.extname(outPath).length);
70
+ return { path: base + actual, corrected: true };
71
+ }
36
72
  export function registerComputerCommand(program) {
37
73
  const computer = program
38
74
  .command('computer')
@@ -128,8 +164,8 @@ function registerScreenshotCommand(program) {
128
164
  .option('--list', 'List the app\'s windows (id/title/layer/bounds) instead of capturing — reveals modals/popups')
129
165
  .option('--window-id <n>', 'Capture a specific window by id (from --list)', (v) => parseInt(v, 10))
130
166
  .option('--display', 'Capture the whole display the app is on (composites stacked modals)')
131
- .option('--out <path>', 'Output JPEG path', './computer-screenshot.jpg')
132
- .option('--quality <n>', 'JPEG quality 1-100', (v) => parseInt(v, 10), 85)
167
+ .option('--out <path>', 'Output image path — extension auto-corrected to the encoded format (JPEG on macOS, PNG on a Windows --host)', './computer-screenshot.jpg')
168
+ .option('--quality <n>', 'JPEG quality 1-100 (macOS capture only; the Windows helper encodes lossless PNG and ignores this)', (v) => parseInt(v, 10), 85)
133
169
  .option('--json', 'Emit JSON (metadata for captures; window list for --list)')
134
170
  .action(async (opts) => {
135
171
  const quality = Math.max(1, Math.min(100, opts.quality || 85));
@@ -176,7 +212,10 @@ function registerScreenshotCommand(program) {
176
212
  process.exit(1);
177
213
  }
178
214
  const buf = Buffer.from(b64, 'base64');
179
- const outPath = path.resolve(opts.out);
215
+ // Sniff the real format and correct the extension so the filename never
216
+ // lies about its bytes (macOS -> JPEG, Windows helper -> PNG).
217
+ const requested = path.resolve(opts.out);
218
+ const { path: outPath, corrected } = reconcileScreenshotExt(requested, buf);
180
219
  fs.writeFileSync(outPath, buf);
181
220
  if (opts.json) {
182
221
  // Drop the heavy base64 from the metadata echo; report where it went.
@@ -184,6 +223,9 @@ function registerScreenshotCommand(program) {
184
223
  console.log(JSON.stringify(meta, null, 2));
185
224
  }
186
225
  else {
226
+ if (corrected) {
227
+ console.log(`note: bytes are ${path.extname(outPath).slice(1).toUpperCase()}; corrected extension from ${path.basename(requested)}`);
228
+ }
187
229
  const origin = res.origin || [];
188
230
  const originStr = origin.length === 2 ? `, origin [${origin.join(',')}], scale ${res.scale ?? '?'}` : '';
189
231
  console.log(`saved: ${outPath} (${res.width ?? '?'}x${res.height ?? '?'}, ${buf.byteLength} bytes${originStr})`);
@@ -4,6 +4,7 @@ import { discoverSessions, parseTimeFilter } from '../lib/session/discover.js';
4
4
  import { queryUsageRollup, topSessionsByCost, } from '../lib/session/db.js';
5
5
  import { formatUsd, PRICING_VERSION } from '../lib/pricing/index.js';
6
6
  import { formatDuration } from '../lib/session/render.js';
7
+ import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
7
8
  export function registerCostCommand(program) {
8
9
  addHostOption(program.command('cost'))
9
10
  .description('Roll up $ cost and duration across local agent sessions')
@@ -84,27 +85,36 @@ async function costAction(options) {
84
85
  // Top sessions by cost.
85
86
  if (top.length > 0) {
86
87
  out.push(chalk.bold('Top sessions by cost'));
88
+ const cols = terminalWidth();
89
+ const showProject = cols >= 100;
87
90
  const costW = Math.max(...top.map(t => formatUsd(t.costUsd).length), 4);
88
91
  for (const t of top) {
89
92
  const cost = formatUsd(t.costUsd).padStart(costW);
90
93
  const dur = t.durationMs > 0 ? formatDuration(t.durationMs) : '—';
91
- const label = t.meta.label || t.meta.topic || '(untitled)';
92
- const proj = t.meta.project ? chalk.gray(` ${t.meta.project}`) : '';
93
- out.push(` ${chalk.green(cost)} ${chalk.gray(t.meta.shortId)} ${chalk.cyan(t.meta.agent.padEnd(7))} ${truncate(label, 48)}` +
94
- proj +
95
- chalk.gray(` ${dur}`));
94
+ const label = (t.meta.label || t.meta.topic || '(untitled)').replace(/\s+/g, ' ').trim();
95
+ const proj = showProject && t.meta.project ? chalk.gray(` ${t.meta.project}`) : '';
96
+ const prefix = ` ${chalk.green(cost)} ${chalk.gray(t.meta.shortId)} ${chalk.cyan(t.meta.agent.padEnd(7))} `;
97
+ const suffix = proj + chalk.gray(` ${dur}`);
98
+ const topicW = Math.max(12, cols - stringWidth(prefix) - stringWidth(suffix));
99
+ out.push(prefix + truncateToWidth(label, topicW) + suffix);
96
100
  }
97
101
  out.push('');
98
102
  }
99
103
  // Per-agent / per-project / per-day breakdown.
100
104
  const groupLabel = groupBy === 'agent' ? 'agent' : groupBy === 'project' ? 'project' : 'day';
101
105
  out.push(chalk.bold(`By ${groupLabel}`));
102
- const keyW = Math.max(...breakdown.map(r => r.key.length), groupLabel.length);
106
+ const cols = terminalWidth();
103
107
  const costW2 = Math.max(...breakdown.map(r => formatUsd(r.costUsd).length), 4);
108
+ const countW = Math.max(...breakdown.map(r => String(r.sessionCount).length), 1);
109
+ const sessionW = Math.max(...breakdown.map(r => `${String(r.sessionCount).padStart(countW)} session${r.sessionCount !== 1 ? 's' : ''}`.length));
110
+ const durationW = Math.max(...breakdown.map(r => r.durationMs > 0 ? stringWidth(formatDuration(r.durationMs)) : 1), 1);
111
+ const fixedW = 2 + 2 + costW2 + 2 + sessionW + 2 + durationW;
112
+ const keyW = Math.max(8, Math.min(Math.max(...breakdown.map(r => stringWidth(r.key)), groupLabel.length), cols - fixedW));
104
113
  for (const r of breakdown) {
105
114
  const cost = formatUsd(r.costUsd).padStart(costW2);
106
115
  const dur = r.durationMs > 0 ? formatDuration(r.durationMs) : '—';
107
- out.push(` ${r.key.padEnd(keyW)} ${chalk.green(cost)} ${chalk.gray(`${r.sessionCount} session${r.sessionCount !== 1 ? 's' : ''}`)} ${chalk.gray(dur)}`);
116
+ const sessions = `${String(r.sessionCount).padStart(countW)} session${r.sessionCount !== 1 ? 's' : ''}`;
117
+ out.push(` ${padToWidth(truncateToWidth(r.key, keyW), keyW)} ${chalk.green(cost)} ${chalk.gray(padToWidth(sessions, sessionW))} ${chalk.gray(padToWidth(dur, durationW))}`);
108
118
  }
109
119
  console.log(out.join('\n'));
110
120
  }
@@ -132,8 +142,3 @@ function renderDailyHistogram(daily) {
132
142
  }
133
143
  return lines.join('\n');
134
144
  }
135
- /** Truncate a string to n chars with an ellipsis. */
136
- function truncate(s, n) {
137
- const oneLine = s.replace(/\s+/g, ' ').trim();
138
- return oneLine.length > n ? oneLine.slice(0, n - 1) + '…' : oneLine;
139
- }
@@ -22,4 +22,15 @@
22
22
  * apply pending sync.
23
23
  */
24
24
  import type { Command } from 'commander';
25
+ export declare function wrapLine(prefix: string, text: string, width?: number): string[];
26
+ /**
27
+ * Windows-only advisory lines. When the effective PowerShell execution policy
28
+ * blocks unsigned local `.ps1` scripts (`Restricted`/`AllSigned`), the generated
29
+ * `agents.ps1` launcher fails in PowerShell even when it is on PATH. Surface the
30
+ * remediation; the `.cmd` companion still works, so this is a warning, not an
31
+ * error, and doctor never auto-changes the policy. Pure — returns `[]` on
32
+ * non-Windows or a permissive policy, so it is testable without invoking
33
+ * PowerShell.
34
+ */
35
+ export declare function execPolicyWarningLines(platform: NodeJS.Platform, policy: string | null): string[];
25
36
  export declare function registerDoctorCommand(program: Command): void;