@phnx-labs/agents-cli 1.20.34 → 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 (121) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -1
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/cost.js +17 -12
  6. package/dist/commands/doctor.d.ts +1 -0
  7. package/dist/commands/doctor.js +59 -14
  8. package/dist/commands/events.d.ts +16 -0
  9. package/dist/commands/events.js +173 -0
  10. package/dist/commands/exec.js +6 -1
  11. package/dist/commands/hosts.js +28 -24
  12. package/dist/commands/inspect.d.ts +2 -0
  13. package/dist/commands/inspect.js +54 -7
  14. package/dist/commands/logs.d.ts +17 -0
  15. package/dist/commands/logs.js +139 -0
  16. package/dist/commands/models.d.ts +3 -0
  17. package/dist/commands/models.js +27 -8
  18. package/dist/commands/permissions.js +9 -2
  19. package/dist/commands/repo.d.ts +34 -0
  20. package/dist/commands/repo.js +243 -65
  21. package/dist/commands/resource-view.d.ts +20 -0
  22. package/dist/commands/resource-view.js +90 -28
  23. package/dist/commands/secrets.js +40 -16
  24. package/dist/commands/sessions-resume.d.ts +2 -0
  25. package/dist/commands/sessions-resume.js +209 -0
  26. package/dist/commands/sessions-tail.d.ts +10 -0
  27. package/dist/commands/sessions-tail.js +11 -0
  28. package/dist/commands/sessions.d.ts +73 -1
  29. package/dist/commands/sessions.js +273 -53
  30. package/dist/commands/view.d.ts +3 -0
  31. package/dist/commands/view.js +19 -8
  32. package/dist/commands/wallet.d.ts +6 -0
  33. package/dist/commands/wallet.js +22 -5
  34. package/dist/index.js +58 -31
  35. package/dist/lib/agents.js +30 -9
  36. package/dist/lib/browser/cdp.js +5 -1
  37. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  38. package/dist/lib/browser/drivers/ssh.js +76 -16
  39. package/dist/lib/browser/ipc.d.ts +3 -0
  40. package/dist/lib/browser/ipc.js +13 -9
  41. package/dist/lib/browser/service.js +71 -15
  42. package/dist/lib/daemon.d.ts +35 -0
  43. package/dist/lib/daemon.js +33 -5
  44. package/dist/lib/devices/connect.d.ts +3 -2
  45. package/dist/lib/devices/connect.js +5 -3
  46. package/dist/lib/events.d.ts +9 -2
  47. package/dist/lib/events.js +70 -11
  48. package/dist/lib/exec.js +22 -0
  49. package/dist/lib/hooks.js +8 -2
  50. package/dist/lib/hosts/dispatch.js +6 -7
  51. package/dist/lib/hosts/logs.d.ts +16 -0
  52. package/dist/lib/hosts/logs.js +45 -0
  53. package/dist/lib/hosts/progress.d.ts +66 -0
  54. package/dist/lib/hosts/progress.js +125 -17
  55. package/dist/lib/hosts/ready.d.ts +23 -2
  56. package/dist/lib/hosts/ready.js +35 -13
  57. package/dist/lib/hosts/reconcile.d.ts +53 -0
  58. package/dist/lib/hosts/reconcile.js +81 -0
  59. package/dist/lib/hosts/tasks.d.ts +8 -0
  60. package/dist/lib/hosts/tasks.js +14 -0
  61. package/dist/lib/permissions.d.ts +4 -0
  62. package/dist/lib/permissions.js +35 -0
  63. package/dist/lib/picker.d.ts +24 -0
  64. package/dist/lib/picker.js +124 -0
  65. package/dist/lib/plugin-marketplace.d.ts +30 -0
  66. package/dist/lib/plugin-marketplace.js +215 -2
  67. package/dist/lib/plugins.d.ts +5 -0
  68. package/dist/lib/plugins.js +45 -4
  69. package/dist/lib/session/active.d.ts +7 -0
  70. package/dist/lib/session/active.js +11 -3
  71. package/dist/lib/session/discover.js +94 -0
  72. package/dist/lib/session/parse.d.ts +6 -0
  73. package/dist/lib/session/parse.js +283 -2
  74. package/dist/lib/session/pid-registry.d.ts +21 -0
  75. package/dist/lib/session/pid-registry.js +80 -0
  76. package/dist/lib/session/remote-active.d.ts +26 -0
  77. package/dist/lib/session/remote-active.js +141 -0
  78. package/dist/lib/session/remote.js +2 -6
  79. package/dist/lib/session/render.js +1 -1
  80. package/dist/lib/session/sync/config.d.ts +8 -0
  81. package/dist/lib/session/sync/config.js +11 -2
  82. package/dist/lib/session/types.d.ts +1 -1
  83. package/dist/lib/session/types.js +1 -1
  84. package/dist/lib/shims.d.ts +11 -3
  85. package/dist/lib/shims.js +54 -30
  86. package/dist/lib/ssh-exec.d.ts +26 -3
  87. package/dist/lib/ssh-exec.js +45 -3
  88. package/dist/lib/ssh-tunnel.d.ts +24 -5
  89. package/dist/lib/ssh-tunnel.js +60 -62
  90. package/dist/lib/staleness/writers/hooks.js +1 -1
  91. package/dist/lib/startup/command-registry.d.ts +2 -0
  92. package/dist/lib/startup/command-registry.js +4 -0
  93. package/dist/lib/teams/registry.js +25 -9
  94. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  95. package/dist/lib/terminal/backends/ghostty.js +69 -0
  96. package/dist/lib/terminal/backends/index.d.ts +18 -0
  97. package/dist/lib/terminal/backends/index.js +29 -0
  98. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  99. package/dist/lib/terminal/backends/iterm.js +62 -0
  100. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  101. package/dist/lib/terminal/backends/tmux.js +23 -0
  102. package/dist/lib/terminal/engine.d.ts +39 -0
  103. package/dist/lib/terminal/engine.js +54 -0
  104. package/dist/lib/terminal/index.d.ts +14 -0
  105. package/dist/lib/terminal/index.js +5 -0
  106. package/dist/lib/terminal/policy.d.ts +11 -0
  107. package/dist/lib/terminal/policy.js +11 -0
  108. package/dist/lib/terminal/quote.d.ts +11 -0
  109. package/dist/lib/terminal/quote.js +13 -0
  110. package/dist/lib/terminal/shell.d.ts +6 -0
  111. package/dist/lib/terminal/shell.js +23 -0
  112. package/dist/lib/terminal/transport.d.ts +15 -0
  113. package/dist/lib/terminal/transport.js +40 -0
  114. package/dist/lib/terminal/types.d.ts +59 -0
  115. package/dist/lib/terminal/types.js +13 -0
  116. package/dist/lib/types.d.ts +7 -0
  117. package/dist/lib/versions.d.ts +7 -3
  118. package/dist/lib/versions.js +10 -5
  119. package/dist/lib/whats-new.d.ts +9 -0
  120. package/dist/lib/whats-new.js +35 -0
  121. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
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
+
5
27
  ## 1.20.34
6
28
 
7
29
  **Test suite runs remotely on a crabbox VM (#525, #540)**
package/README.md CHANGED
@@ -240,7 +240,9 @@ agents hosts add gpu-box
240
240
  agents hosts check gpu-box # reachable? which agents-cli version?
241
241
 
242
242
  # Run there instead of locally
243
- agents run claude --host gpu-box "profile this build"
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
244
246
  agents view claude --host gpu-box # inspect the remote install
245
247
  agents sync --host gpu-box # make the remote machine current
246
248
 
@@ -252,6 +254,8 @@ agents ssh mac-mini # hardened SSH: fails fast if offline,
252
254
 
253
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).
254
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
+
255
259
  ---
256
260
 
257
261
  ## Teams
@@ -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;
@@ -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,6 +22,7 @@
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[];
25
26
  /**
26
27
  * Windows-only advisory lines. When the effective PowerShell execution policy
27
28
  * blocks unsigned local `.ps1` scripts (`Restricted`/`AllSigned`), the generated
@@ -13,6 +13,7 @@ import { listCliStatus } from '../lib/cli-resources.js';
13
13
  import { setHelpSections } from '../lib/help.js';
14
14
  import { heal, healChangedAnything } from '../lib/heal.js';
15
15
  import { blocksLocalScripts, getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
16
+ import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
16
17
  import * as fs from 'fs';
17
18
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
18
19
  // ─── overview mode (no target) ────────────────────────────────────────────────
@@ -29,7 +30,6 @@ function divergenceLines(report) {
29
30
  else if (p.status === 'diff')
30
31
  lines.push(`plugin ${p.name} — ${p.detail ?? 'mirror drifted'}`);
31
32
  }
32
- const counts = [];
33
33
  for (const kind of ['commands', 'skills', 'hooks', 'rules', 'mcp', 'permissions', 'subagents']) {
34
34
  const rows = report.kinds[kind];
35
35
  const miss = rows.filter((r) => r.status === 'missing').length;
@@ -40,12 +40,44 @@ function divergenceLines(report) {
40
40
  if (dif)
41
41
  bits.push(`${dif} drifted`);
42
42
  if (bits.length)
43
- counts.push(`${kind} ${bits.join('/')}`);
43
+ lines.push(`${kind.padEnd(11)} ${bits.join(' · ')}`);
44
44
  }
45
- if (counts.length)
46
- lines.push(counts.join(' · '));
47
45
  return lines;
48
46
  }
47
+ function collapseWhitespace(s) {
48
+ return s.replace(/\s+/g, ' ').trim();
49
+ }
50
+ export function wrapLine(prefix, text, width = terminalWidth()) {
51
+ const words = collapseWhitespace(text).split(' ').filter(Boolean);
52
+ if (words.length === 0)
53
+ return [prefix.trimEnd()];
54
+ const continuation = ' '.repeat(stringWidth(prefix));
55
+ const lines = [];
56
+ let linePrefix = prefix;
57
+ let line = prefix;
58
+ let hasWord = false;
59
+ for (const word of words) {
60
+ const room = Math.max(1, width - stringWidth(linePrefix));
61
+ const piece = stringWidth(word) > room ? truncateToWidth(word, room) : word;
62
+ const candidate = hasWord ? `${line} ${piece}` : `${line}${piece}`;
63
+ if (hasWord && stringWidth(candidate) > width) {
64
+ lines.push(line);
65
+ linePrefix = continuation;
66
+ line = continuation + piece;
67
+ hasWord = true;
68
+ }
69
+ else {
70
+ line = candidate;
71
+ hasWord = true;
72
+ }
73
+ }
74
+ lines.push(line);
75
+ return lines;
76
+ }
77
+ function printWrappedLine(prefix, text) {
78
+ for (const line of wrapLine(prefix, text))
79
+ console.log(chalk.gray(line));
80
+ }
49
81
  function checkSyncStatus(cwd) {
50
82
  const rows = [];
51
83
  // Every installed version, not just the default — a stale NON-default version
@@ -130,7 +162,7 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
130
162
  }
131
163
  }
132
164
  if (hidden.length > 0) {
133
- console.log(chalk.gray(` +${hidden.length} more supported (${hidden.join(', ')}) — \`agents add <name>\` to manage`));
165
+ printWrappedLine(' ', `+${hidden.length} more supported (${hidden.join(', ')}) — \`agents add <name>\` to manage`);
134
166
  }
135
167
  console.log();
136
168
  console.log(chalk.bold('Sync status (installed versions)'));
@@ -162,7 +194,7 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
162
194
  // Version homes are reconciled only by management commands, so point at one
163
195
  // rather than promising an auto-sync that never happens.
164
196
  if (anyOutOfSync) {
165
- console.log(chalk.gray(' Reconcile with `agents doctor <agent>@<version> --fix` or `agents sync <agent>@<version>` (not applied on launch).'));
197
+ printWrappedLine(' ', 'Reconcile with `agents doctor <agent>@<version> --fix` or `agents sync <agent>@<version>` (not applied on launch).');
166
198
  }
167
199
  }
168
200
  console.log();
@@ -199,10 +231,17 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
199
231
  const label = manifest.name.padEnd(nameWidth);
200
232
  const src = chalk.gray(`[${manifest.source}]`);
201
233
  if (installed) {
202
- console.log(` ${chalk.green('ready')} ${label} ${src} ${chalk.gray(manifest.description || '')}`);
234
+ const prefix = ` ${chalk.green('ready')} ${label} ${src}`;
235
+ const desc = manifest.description
236
+ ? ` ${truncateToWidth(collapseWhitespace(manifest.description), Math.max(1, terminalWidth() - stringWidth(prefix) - 2))}`
237
+ : '';
238
+ console.log(prefix + chalk.gray(desc));
203
239
  }
204
240
  else {
205
- console.log(` ${chalk.red('miss ')} ${label} ${src} ${chalk.gray(`not installed — run \`agents cli install ${manifest.name}\``)}`);
241
+ const prefix = ` ${chalk.red('miss ')} ${label} ${src}`;
242
+ const msg = `not installed — run \`agents cli install ${manifest.name}\``;
243
+ const budget = Math.max(1, terminalWidth() - stringWidth(prefix) - 2);
244
+ console.log(prefix + chalk.gray(` ${truncateToWidth(msg, budget)}`));
206
245
  }
207
246
  }
208
247
  }
@@ -357,8 +396,12 @@ function renderKindSection(kind, rows, layers, options) {
357
396
  }
358
397
  for (const r of visible) {
359
398
  const src = sourceLabel(r, layers);
360
- const detail = r.detail ? chalk.gray(` ${r.detail}`) : '';
361
- console.log(` ${statusLabel(r.status)} ${r.name.padEnd(28)} ${src}${detail}`);
399
+ const name = padToWidth(truncateToWidth(r.name, 28), 28);
400
+ const prefix = ` ${statusLabel(r.status)} ${name} ${src}`;
401
+ const detail = r.detail
402
+ ? chalk.gray(` ${truncateToWidth(collapseWhitespace(r.detail), Math.max(1, terminalWidth() - stringWidth(prefix) - 2))}`)
403
+ : '';
404
+ console.log(prefix + detail);
362
405
  if (options.showDiff && r.status === 'diff' && r.sourcePath && r.homePath) {
363
406
  const expected = readExpectedForDiff(kind, r);
364
407
  const actual = safeRead(r.homePath);
@@ -395,8 +438,10 @@ function readExpectedForDiff(kind, row) {
395
438
  function renderTargetText(report, options) {
396
439
  const label = `${AGENT_NAMES[report.agent] || report.agent}@${report.version}`;
397
440
  console.log(chalk.bold(label));
398
- console.log(chalk.gray(` home: ${report.home}`));
399
- console.log(chalk.gray(` cwd: ${report.cwd}`));
441
+ const homePrefix = ' home: ';
442
+ const cwdPrefix = ' cwd: ';
443
+ console.log(chalk.gray(homePrefix + truncateToWidth(report.home, Math.max(1, terminalWidth() - stringWidth(homePrefix)))));
444
+ console.log(chalk.gray(cwdPrefix + truncateToWidth(report.cwd, Math.max(1, terminalWidth() - stringWidth(cwdPrefix)))));
400
445
  const layerStr = [
401
446
  report.layers.project ? `project=${report.layers.project}` : null,
402
447
  `user=${report.layers.user}`,
@@ -405,7 +450,7 @@ function renderTargetText(report, options) {
405
450
  ? `extras=[${report.layers.extras.map((e) => e.alias).join(',')}]`
406
451
  : null,
407
452
  ].filter(Boolean).join(' ');
408
- console.log(chalk.gray(` layers: ${layerStr}`));
453
+ printWrappedLine(' layers: ', layerStr);
409
454
  // Staleness manifest verdict — single-line summary from the staleness
410
455
  // library, sitting alongside the detailed per-resource diff below.
411
456
  const manifest = loadManifest(report.agent, report.version);
@@ -446,7 +491,7 @@ function renderTargetText(report, options) {
446
491
  }
447
492
  else {
448
493
  console.log(` Verdict: ${verdictParts.join(', ')}.`);
449
- console.log(chalk.gray(` Run \`agents doctor ${report.agent}@${report.version} --fix\` to heal, or \`agents prune cleanup\` to drop extras.`));
494
+ printWrappedLine(' ', `Run \`agents doctor ${report.agent}@${report.version} --fix\` to heal, or \`agents prune cleanup\` to drop extras.`);
450
495
  }
451
496
  }
452
497
  // ─── fix / heal mode ───────────────────────────────────────────────────────────
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `agents events` — read the structured audit/event log.
3
+ *
4
+ * The event log (`~/.agents/.cache/logs/events-YYYY-MM-DD.jsonl`) records every
5
+ * `agents <module> <cmd>` invocation plus richer typed events (secrets access,
6
+ * version installs, ...), each stamped with who ran it and from where (OS user,
7
+ * local vs SSH, remote client IP). This command reads it back — the audit trail
8
+ * for "who accessed a secret / created a team / started an agent, and from which
9
+ * host?".
10
+ *
11
+ * Filter by `--module` (top-level group, e.g. teams), `--command` (path prefix,
12
+ * e.g. "teams create"), `--event` (typed event), `--agent`, and `--since`.
13
+ * `--follow` tails today's log live.
14
+ */
15
+ import type { Command } from 'commander';
16
+ export declare function registerEventsCommand(program: Command): void;