@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
@@ -12,6 +12,7 @@ export { shellQuote };
12
12
  // the single ssh-tunnel helper. Calling it with no options preserves this
13
13
  // driver's original foreground, stderr-captured behavior exactly.
14
14
  import { startSSHTunnel } from '../../ssh-tunnel.js';
15
+ import { encodePwshBase64 } from '../../pwsh.js';
15
16
  export async function connectSSH(endpoint, profile) {
16
17
  const url = new URL(endpoint);
17
18
  if (url.protocol !== 'ssh:') {
@@ -47,12 +48,12 @@ export async function connectSSH(endpoint, profile) {
47
48
  `is already in use by ${occupant.command} (pid ${occupant.pid}). ` +
48
49
  `Either kill that process (\`kill ${occupant.pid}\`) or change the profile's port.`);
49
50
  }
50
- try {
51
- await ensureRemoteBrowser(user, host, profile.browser, remotePort, remoteOs, profile.binary);
52
- }
53
- catch {
54
- // Browser may already be running, continue
55
- }
51
+ // A non-zero remote exit (missing exe, auth failure, bad launch) now rejects
52
+ // with the captured stderr and propagates. An already-running browser exits 0
53
+ // (the launch backgrounds/WMI-spawns regardless), so this does not spuriously
54
+ // fail the reconnect path — only genuine launch failures reach the caller
55
+ // instead of being swallowed and mis-reported later as a tunnel timeout.
56
+ await ensureRemoteBrowser(user, host, profile.browser, remotePort, remoteOs, profile.binary);
56
57
  let tunnel;
57
58
  if (occupant) {
58
59
  // Reuse the existing tunnel rather than spawning a duplicate.
@@ -101,6 +102,11 @@ export async function connectSSH(endpoint, profile) {
101
102
  pid: tunnelPid,
102
103
  cleanup: () => {
103
104
  cdp.close();
105
+ // Kill the remote browser BEFORE tearing down the tunnel. It runs on a
106
+ // separate ssh connection (independent of the tunnel we're about to
107
+ // kill), so tunnel teardown can't cut it off. Fire-and-forget — cleanup
108
+ // stays synchronous, and killRemoteBrowser never rejects.
109
+ killRemoteBrowser(user, host, remoteOs, remotePort).catch(() => { });
104
110
  tunnel.kill();
105
111
  clearProfileRuntime(profile.name);
106
112
  },
@@ -159,8 +165,7 @@ function psSingleQuote(s) {
159
165
  * `powershell -Command "…"` is fragile the moment a path or URL is involved).
160
166
  */
161
167
  export function encodePowerShell(script) {
162
- const b64 = Buffer.from(script, 'utf16le').toString('base64');
163
- return `powershell -NoProfile -EncodedCommand ${b64}`;
168
+ return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(script)}`;
164
169
  }
165
170
  /**
166
171
  * The PowerShell that launches the browser on a Windows remote. Two hard
@@ -177,9 +182,9 @@ export function encodePowerShell(script) {
177
182
  * registry at runtime rather than relying on a bare `msedge` name.
178
183
  */
179
184
  export function buildWindowsLaunchScript(browserType, port, customBinary) {
180
- let exeExpr;
185
+ let exeStmts;
181
186
  if (customBinary) {
182
- exeExpr = psSingleQuote(customBinary);
187
+ exeStmts = [`$exe = ${psSingleQuote(customBinary)}`];
183
188
  }
184
189
  else if (browserType === 'custom') {
185
190
  throw new Error('browser: custom requires a binary path in the profile');
@@ -188,13 +193,24 @@ export function buildWindowsLaunchScript(browserType, port, customBinary) {
188
193
  const exeKey = WIN_BROWSER_APPPATH[browserType];
189
194
  if (!exeKey)
190
195
  throw new Error(`Unknown browser type for windows remote: ${browserType}`);
191
- exeExpr =
192
- `(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exeKey}').'(default)'`;
196
+ const appPath = `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exeKey}`;
197
+ // Per-user installs — the DEFAULT for Edge and Chrome — register App Paths
198
+ // under HKCU, not HKLM, so an HKLM-only lookup silently missed them and the
199
+ // launch failed. Resolve from HKLM first, then fall through to HKCU.
200
+ // Windows PowerShell 5.1 (what `powershell.exe` is) has no `??`, so the
201
+ // fallback is an explicit `if`. A missing exe `throw`s so the failure rides
202
+ // out as a non-zero ssh exit (surfaced by ensureRemoteBrowser) instead of a
203
+ // silent empty launch.
204
+ exeStmts = [
205
+ `$exe = (Get-ItemProperty 'HKLM:\\${appPath}' -EA SilentlyContinue).'(default)'`,
206
+ `if (-not $exe) { $exe = (Get-ItemProperty 'HKCU:\\${appPath}' -EA SilentlyContinue).'(default)' }`,
207
+ `if (-not $exe) { throw 'browser exe not found in HKLM/HKCU App Paths: ${exeKey}' }`,
208
+ ];
193
209
  }
194
210
  // Keep the `--remote-allow-origins=http://127.0.0.1:${port}` literal in
195
211
  // source — a test asserts CDP is never opened to `*`.
196
212
  return [
197
- `$exe = ${exeExpr}`,
213
+ ...exeStmts,
198
214
  `$cl = '"' + $exe + '" --remote-debugging-port=${port}` +
199
215
  ` --remote-allow-origins=http://127.0.0.1:${port}` +
200
216
  ` --disable-background-timer-throttling --user-data-dir="' + $env:TEMP + '\\agents-browser-${port}"'`,
@@ -242,21 +258,56 @@ export function buildKillCmd(remoteOs, port) {
242
258
  }
243
259
  return `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
244
260
  }
245
- async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
261
+ export async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
246
262
  const remoteCmd = buildLaunchCmd(remoteOs, browserType, port, customBinary);
247
263
  return new Promise((resolve, reject) => {
264
+ // Capture stderr so a real launch failure (missing exe, auth denied, bad
265
+ // CreateProcess) surfaces here as a clear error. Previously stdio was
266
+ // ignored and this resolved on close regardless of exit code, so failures
267
+ // were swallowed and only re-emerged 8s later as a generic
268
+ // "SSH tunnel failed to establish".
248
269
  const child = spawn('ssh', [
249
270
  `${user}@${host}`,
250
271
  '-o',
251
272
  'BatchMode=yes',
252
273
  remoteCmd,
253
- ], { stdio: 'ignore' });
254
- child.on('close', () => resolve());
255
- child.on('error', reject);
256
- setTimeout(() => {
274
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
275
+ let stderr = '';
276
+ child.stderr?.on('data', (d) => {
277
+ stderr += d.toString();
278
+ });
279
+ let settled = false;
280
+ const timer = setTimeout(() => {
281
+ if (settled)
282
+ return;
283
+ settled = true;
284
+ // ssh is still running the backgrounding launch command past our window.
285
+ // Killing the local ssh client does NOT reap the WMI/detached remote
286
+ // browser, so treat this as launched and let waitForPort confirm.
257
287
  child.kill();
258
288
  resolve();
259
289
  }, 2000);
290
+ child.on('close', (code) => {
291
+ if (settled)
292
+ return;
293
+ settled = true;
294
+ clearTimeout(timer);
295
+ if (code && code !== 0) {
296
+ const detail = stderr.trim();
297
+ reject(new Error(`Remote browser launch failed on ${host} (ssh exit ${code})` +
298
+ (detail ? `: ${detail}` : '')));
299
+ }
300
+ else {
301
+ resolve();
302
+ }
303
+ });
304
+ child.on('error', (err) => {
305
+ if (settled)
306
+ return;
307
+ settled = true;
308
+ clearTimeout(timer);
309
+ reject(err);
310
+ });
260
311
  });
261
312
  }
262
313
  export async function restartRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
@@ -266,6 +317,15 @@ export async function restartRemoteBrowser(user, host, browserType, port, remote
266
317
  await ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary);
267
318
  await sleep(1500);
268
319
  }
320
+ /**
321
+ * Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
322
+ * WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
323
+ * the ssh tunnel is torn down — killing only the tunnel left it running on
324
+ * win-mini after every `browser stop`. Best-effort: never rejects.
325
+ */
326
+ export function killRemoteBrowser(user, host, remoteOs, port) {
327
+ return runSSHCommand(user, host, buildKillCmd(remoteOs, port));
328
+ }
269
329
  function runSSHCommand(user, host, cmd) {
270
330
  return new Promise((resolve) => {
271
331
  const child = spawn('ssh', [`${user}@${host}`, '-o', 'BatchMode=yes', cmd], {
@@ -290,8 +350,23 @@ function sleep(ms) {
290
350
  *
291
351
  * Best-effort match against the ssh -L command line via `ps`. If we
292
352
  * can't read the cmd or the args don't look like ours, treat as not-ours.
353
+ *
354
+ * Windows has no `ps`, so the POSIX branch below always throws there and every
355
+ * reuse check returned false — the second invocation to the same host:port then
356
+ * failed "port in use". On win32 we instead reuse the netstat -ano + tasklist
357
+ * occupant lookup (getPortOccupant): the tunnel binds `remotePort` locally
358
+ * (localPort === remotePort in the caller), so the pid holding that port is our
359
+ * ssh.exe. tasklist only exposes the image name, not the full command line, so
360
+ * we can't match host/remotePort as tightly as the POSIX branch — an ssh
361
+ * process on our exact local port is our tunnel in practice.
293
362
  */
294
- function isOwnTunnel(pid, host, remotePort) {
363
+ export function isOwnTunnel(pid, host, remotePort) {
364
+ if (process.platform === 'win32') {
365
+ const occupant = getPortOccupant(remotePort);
366
+ if (!occupant || occupant.pid !== pid)
367
+ return false;
368
+ return occupant.command.toLowerCase().startsWith('ssh');
369
+ }
295
370
  try {
296
371
  const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
297
372
  encoding: 'utf-8',
@@ -8,6 +8,9 @@ export declare class BrowserDaemonNotRunningError extends Error {
8
8
  }
9
9
  export declare function formatBrowserDaemonNotRunningError(): string;
10
10
  export declare function getSocketPath(): string;
11
+ /** Is the daemon reachable? A real connect probe on every platform — a socket
12
+ * file existing on disk is not proof a daemon is listening on it. */
13
+ export declare function isDaemonReachable(): Promise<boolean>;
11
14
  export declare class BrowserIPCServer {
12
15
  private server;
13
16
  private service;
@@ -32,8 +32,12 @@ export function getSocketPath() {
32
32
  function getIpcEndpoint() {
33
33
  return ipcEndpoint(getSocketPath());
34
34
  }
35
- /** Can we open a connection to the daemon right now? Used on Windows where a
36
- * named pipe can't be probed with fs.existsSync. Resolves false on any error. */
35
+ /** Can we open a connection to the daemon right now? Resolves false on any
36
+ * error. This is the authoritative liveness check on every platform: a live
37
+ * daemon accepts the connection, while a stale POSIX socket file left behind by
38
+ * a crashed daemon (or one that "appears and is immediately destroyed", #556)
39
+ * rejects with ECONNREFUSED and is correctly reported as not reachable —
40
+ * something fs.existsSync can't distinguish. */
37
41
  function probeDaemon(endpoint, timeoutMs = 500) {
38
42
  return new Promise((resolve) => {
39
43
  const sock = net.createConnection(endpoint);
@@ -45,16 +49,16 @@ function probeDaemon(endpoint, timeoutMs = 500) {
45
49
  sock.on('error', () => { clearTimeout(timer); finish(false); });
46
50
  });
47
51
  }
48
- /** Is the daemon reachable? existsSync probe on POSIX, connect probe on Windows. */
49
- async function isDaemonReachable() {
50
- if (IS_WINDOWS)
51
- return probeDaemon(getIpcEndpoint());
52
- return fs.existsSync(getSocketPath());
52
+ /** Is the daemon reachable? A real connect probe on every platform a socket
53
+ * file existing on disk is not proof a daemon is listening on it. */
54
+ export async function isDaemonReachable() {
55
+ return probeDaemon(getIpcEndpoint());
53
56
  }
54
- async function waitForSocket(socketPath, timeoutMs) {
57
+ async function waitForSocket(_socketPath, timeoutMs) {
58
+ const endpoint = getIpcEndpoint();
55
59
  const deadline = Date.now() + timeoutMs;
56
60
  while (Date.now() < deadline) {
57
- if (IS_WINDOWS ? await probeDaemon(getIpcEndpoint()) : fs.existsSync(socketPath))
61
+ if (await probeDaemon(endpoint))
58
62
  return;
59
63
  await new Promise((resolve) => setTimeout(resolve, 100));
60
64
  }
@@ -412,12 +412,23 @@ export class BrowserService {
412
412
  return this.stop(taskName);
413
413
  }
414
414
  async stopProfile(profileName) {
415
- const conn = this.connections.get(profileName);
416
- if (conn) {
415
+ // Connections are keyed by the composite `<profile>@<endpoint>` (see start()),
416
+ // but callers pass the bare profile name (or, occasionally, an exact composite).
417
+ // A plain `connections.get(profileName)` therefore missed every real remote
418
+ // connection, so `cleanup()` never ran — leaving the SSH tunnel and, on
419
+ // Windows, the WMI-spawned browser orphaned on the remote host after every
420
+ // `browser stop --profile` (#559). Match the exact key AND every
421
+ // `<profileName>@<endpoint>` composite so all of a profile's live connections
422
+ // are torn down.
423
+ const keys = [...this.connections.keys()].filter((k) => k === profileName || k.startsWith(`${profileName}@`));
424
+ for (const key of keys) {
425
+ const conn = this.connections.get(key);
426
+ if (!conn)
427
+ continue;
417
428
  conn.cdp.close();
418
429
  killChrome(conn.pid);
419
430
  conn.cleanup?.();
420
- this.connections.delete(profileName);
431
+ this.connections.delete(key);
421
432
  }
422
433
  const runtimeDir = getProfileRuntimeDir(profileName);
423
434
  const pidFile = path.join(runtimeDir, 'pid');
@@ -693,14 +704,22 @@ export class BrowserService {
693
704
  const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
694
705
  // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
695
706
  const { spawn } = await import('child_process');
707
+ // CDP `Page.startScreencast` delivers frames at a VARIABLE cadence (paints
708
+ // are event-driven, not clocked). Feeding those into a fixed `-framerate`
709
+ // input made ffmpeg assume every frame was 1/fps apart, so a page that
710
+ // painted slower/faster than `fps` played back at the wrong speed. Instead
711
+ // stamp each frame with its wall-clock arrival time
712
+ // (`-use_wallclock_as_timestamps 1`) and keep those timings on output
713
+ // (`-vsync vfr`), so playback duration matches the real capture.
696
714
  const ffmpeg = spawn('ffmpeg', [
697
715
  '-loglevel', 'error',
698
716
  '-f', 'image2pipe',
699
- '-framerate', String(fps),
717
+ '-use_wallclock_as_timestamps', '1',
700
718
  '-i', '-',
701
719
  '-c:v', 'libvpx-vp9',
702
720
  '-b:v', '1M',
703
721
  '-pix_fmt', 'yuv420p',
722
+ '-vsync', 'vfr',
704
723
  '-y',
705
724
  outputPath,
706
725
  ], { stdio: ['pipe', 'ignore', 'pipe'] });
@@ -724,9 +743,16 @@ export class BrowserService {
724
743
  ffmpeg.once('error', onError);
725
744
  ffmpeg.once('spawn', onSpawn);
726
745
  });
727
- // Surface ffmpeg's own diagnostics (encoder error, etc.) in case stderr
728
- // arrives after spawn.
729
- ffmpeg.stderr?.on('data', () => { });
746
+ // Capture ffmpeg's own diagnostics (encoder error, bad codec, etc.) so a
747
+ // failing encode is DIAGNOSABLE at recordStop instead of being discarded.
748
+ // Cap the buffer so a chatty ffmpeg can't grow it unbounded.
749
+ let stderrBuf = '';
750
+ ffmpeg.stderr?.on('data', (d) => {
751
+ stderrBuf += d.toString();
752
+ if (stderrBuf.length > 64 * 1024)
753
+ stderrBuf = stderrBuf.slice(-64 * 1024);
754
+ });
755
+ const ffmpegStderr = () => stderrBuf;
730
756
  ffmpeg.on('error', () => { });
731
757
  // 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
732
758
  const everyNthFrame = Math.max(1, Math.round(30 / fps));
@@ -752,6 +778,7 @@ export class BrowserService {
752
778
  durationMs,
753
779
  maxBytes,
754
780
  ffmpeg,
781
+ ffmpegStderr,
755
782
  sessionId,
756
783
  conn,
757
784
  frameHandler,
@@ -795,27 +822,56 @@ export class BrowserService {
795
822
  catch {
796
823
  // session may already be gone
797
824
  }
798
- // Close ffmpeg stdin so it flushes the output file cleanly.
799
- await new Promise((resolve) => {
800
- rec.ffmpeg.on('exit', () => resolve());
825
+ // Close ffmpeg stdin so it flushes the output file cleanly, and observe how
826
+ // it exits. A non-zero exit means the encode failed — the recording must
827
+ // NOT be reported as success. If ffmpeg hangs, KILL it (don't just abandon
828
+ // the promise, which leaked the process) and treat the recording as failed.
829
+ const finalize = await new Promise((resolve) => {
830
+ let done = false;
831
+ const settle = (r) => {
832
+ if (done)
833
+ return;
834
+ done = true;
835
+ clearTimeout(timer);
836
+ resolve(r);
837
+ };
838
+ const timer = setTimeout(() => {
839
+ try {
840
+ rec.ffmpeg.kill('SIGKILL');
841
+ }
842
+ catch { /* already gone */ }
843
+ settle({ code: null, timedOut: true });
844
+ }, 5000);
845
+ rec.ffmpeg.once('exit', (code) => settle({ code, timedOut: false }));
801
846
  try {
802
847
  rec.ffmpeg.stdin?.end();
803
848
  }
804
849
  catch {
805
- resolve();
850
+ // stdin already closed; wait for exit or the hard timeout above.
806
851
  }
807
- setTimeout(resolve, 5000); // Hard timeout if ffmpeg hangs
808
852
  });
853
+ const durationMs = Date.now() - rec.startedAt;
854
+ // Drop the recording from the map before any throw, so a failed stop
855
+ // doesn't wedge the task in a permanent "already recording" state.
856
+ this.recordings.delete(taskId);
857
+ if (finalize.timedOut) {
858
+ throw new Error(`ffmpeg did not exit within 5s while finalizing the recording; killed it. ` +
859
+ `The recording at ${rec.outputPath} is incomplete.`);
860
+ }
861
+ if (finalize.code !== 0) {
862
+ const err = rec.ffmpegStderr().trim();
863
+ throw new Error(`ffmpeg exited abnormally (code ${finalize.code}) while finalizing the recording at ` +
864
+ `${rec.outputPath}; the file is likely corrupt or empty.` +
865
+ (err ? ` ffmpeg: ${err.slice(-800)}` : ''));
866
+ }
809
867
  let bytes = 0;
810
868
  try {
811
869
  const st = await fs.promises.stat(rec.outputPath);
812
870
  bytes = st.size;
813
871
  }
814
872
  catch {
815
- // ffmpeg may have failed; file missing
873
+ // ffmpeg exited 0 but the file is missing — still a failed recording.
816
874
  }
817
- const durationMs = Date.now() - rec.startedAt;
818
- this.recordings.delete(taskId);
819
875
  return { path: rec.outputPath, bytes, durationMs, reason };
820
876
  }
821
877
  async recordStatus(taskId) {
@@ -46,6 +46,40 @@ export declare function startDaemon(): {
46
46
  * value (e.g. inherited from launchd) is left untouched.
47
47
  */
48
48
  export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
49
+ /**
50
+ * Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
51
+ * exact form that works under a direct `daemon _run`.
52
+ *
53
+ * We spawn the Node runtime (`process.execPath`) with the CLI entry as an
54
+ * argument rather than executing the entry path directly. Executing the `.js`
55
+ * path relies on its shebang on POSIX, and on Windows CreateProcess can't run a
56
+ * `.js`/shim directly at all — it gets launched through a transient
57
+ * console-owning wrapper (cmd.exe / the npm shim). When that wrapper exits it
58
+ * closes its console, and the detached daemon sharing that console receives a
59
+ * console-close event that trips its shutdown handler — the daemon comes up,
60
+ * binds the browser IPC socket, then tears itself down ~36ms later (#556).
61
+ * Going through `process.execPath` means a real PE/binary is spawned with
62
+ * `detached: true` and no console, so nothing signals the daemon after launch.
63
+ *
64
+ * When the entry isn't a JS file (e.g. a native launcher resolved via
65
+ * `which agents`), run it directly — it owns its own runtime resolution.
66
+ */
67
+ export declare function getDaemonLaunch(agentsBin?: string): {
68
+ command: string;
69
+ args: string[];
70
+ };
71
+ interface StartDetachedOptions {
72
+ /** CLI entry to launch (defaults to the running binary). Injectable for tests. */
73
+ agentsBin?: string;
74
+ /** Log file the daemon's stdio is redirected to (defaults to the daemon log). */
75
+ logPath?: string;
76
+ /** Environment for the child (defaults to the OAuth-augmented detached env). */
77
+ env?: NodeJS.ProcessEnv;
78
+ }
79
+ export declare function startDetached(opts?: StartDetachedOptions): {
80
+ pid: number | null;
81
+ method: string;
82
+ };
49
83
  /** Stop the daemon, unloading it from launchd/systemd if applicable. */
50
84
  export declare function stopDaemon(): boolean;
51
85
  /** Get current daemon status including running state, PID, and enabled job count. */
@@ -59,3 +93,4 @@ export declare function getDaemonStatus(): {
59
93
  export declare function readDaemonLog(lines?: number): string;
60
94
  /** Send SIGHUP to the daemon to trigger a job reload. */
61
95
  export declare function signalDaemonReload(): boolean;
96
+ export {};
@@ -538,14 +538,42 @@ export function buildDetachedDaemonEnv(baseEnv = process.env) {
538
538
  }
539
539
  return env;
540
540
  }
541
- function startDetached() {
542
- const agentsBin = getAgentsBinPath();
543
- const logPath = getLogPath();
541
+ /**
542
+ * Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
543
+ * exact form that works under a direct `daemon _run`.
544
+ *
545
+ * We spawn the Node runtime (`process.execPath`) with the CLI entry as an
546
+ * argument rather than executing the entry path directly. Executing the `.js`
547
+ * path relies on its shebang on POSIX, and on Windows CreateProcess can't run a
548
+ * `.js`/shim directly at all — it gets launched through a transient
549
+ * console-owning wrapper (cmd.exe / the npm shim). When that wrapper exits it
550
+ * closes its console, and the detached daemon sharing that console receives a
551
+ * console-close event that trips its shutdown handler — the daemon comes up,
552
+ * binds the browser IPC socket, then tears itself down ~36ms later (#556).
553
+ * Going through `process.execPath` means a real PE/binary is spawned with
554
+ * `detached: true` and no console, so nothing signals the daemon after launch.
555
+ *
556
+ * When the entry isn't a JS file (e.g. a native launcher resolved via
557
+ * `which agents`), run it directly — it owns its own runtime resolution.
558
+ */
559
+ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
560
+ if (/\.(c|m)?js$/.test(agentsBin)) {
561
+ return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
562
+ }
563
+ return { command: agentsBin, args: ['daemon', '_run'] };
564
+ }
565
+ export function startDetached(opts = {}) {
566
+ const agentsBin = opts.agentsBin ?? getAgentsBinPath();
567
+ const logPath = opts.logPath ?? getLogPath();
544
568
  const logFd = fs.openSync(logPath, 'a');
545
- const child = spawn(agentsBin, ['daemon', '_run'], {
569
+ const { command, args } = getDaemonLaunch(agentsBin);
570
+ const child = spawn(command, args, {
546
571
  stdio: ['ignore', logFd, logFd],
547
572
  detached: true,
548
- env: buildDetachedDaemonEnv(),
573
+ // No console window on Windows — a hidden, consoleless process can't be
574
+ // torn down by a console-close event when its launcher exits (#556).
575
+ windowsHide: true,
576
+ env: opts.env ?? buildDetachedDaemonEnv(),
549
577
  });
550
578
  child.unref();
551
579
  fs.closeSync(logFd);
@@ -11,8 +11,9 @@ export declare function sshTargetFor(device: DeviceProfile): string;
11
11
  /**
12
12
  * Wrap a remote command for the device's shell. Windows devices speak
13
13
  * PowerShell, so a bare command is run through `powershell -NoProfile
14
- * -Command`; POSIX devices get the command verbatim (the remote login shell
15
- * parses it). Returns undefined when no command was given (interactive login).
14
+ * -EncodedCommand`; POSIX devices get the command verbatim (the remote login
15
+ * shell parses it). Returns undefined when no command was given (interactive
16
+ * login).
16
17
  */
17
18
  export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
18
19
  /**
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
+ import { encodePwshBase64 } from '../pwsh.js';
19
20
  import { getCacheDir } from '../state.js';
20
21
  import { hostNameFor } from './ssh-config.js';
21
22
  /** Env var the askpass shim reads to know which bundle holds the password. */
@@ -38,15 +39,16 @@ export function sshTargetFor(device) {
38
39
  /**
39
40
  * Wrap a remote command for the device's shell. Windows devices speak
40
41
  * PowerShell, so a bare command is run through `powershell -NoProfile
41
- * -Command`; POSIX devices get the command verbatim (the remote login shell
42
- * parses it). Returns undefined when no command was given (interactive login).
42
+ * -EncodedCommand`; POSIX devices get the command verbatim (the remote login
43
+ * shell parses it). Returns undefined when no command was given (interactive
44
+ * login).
43
45
  */
44
46
  export function wrapRemoteCommand(device, cmd) {
45
47
  if (cmd.length === 0)
46
48
  return undefined;
47
49
  const joined = cmd.join(' ');
48
50
  if (device.shell === 'powershell') {
49
- return `powershell -NoProfile -Command ${shellQuote(joined)}`;
51
+ return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(joined)}`;
50
52
  }
51
53
  return joined;
52
54
  }
@@ -29,6 +29,8 @@ import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace
29
29
  import { markdownToToml } from './convert.js';
30
30
  import { resolveImports, supportsRulesImports } from './rules/compile.js';
31
31
  import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
32
+ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
33
+ import { supports } from './capabilities.js';
32
34
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
33
35
  import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
34
36
  const RULES_DOC_FILENAME = 'README.md';
@@ -87,6 +89,15 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
87
89
  const isToml = agentConfig.format === 'toml';
88
90
  const ext = isToml ? '.toml' : '.md';
89
91
  const homeDir = getVersionCommandsDir(agent, version);
92
+ // Command-as-skill agents (kimi, codex>=0.117, grok) install every command as a
93
+ // SKILL wrapper at <agentDir>/skills/<cmd>/SKILL.md, not a native command file.
94
+ // The compare must follow that or every command false-reports as drifted.
95
+ const asSkill = shouldInstallCommandAsSkill(agent, version);
96
+ // Agents that hold commands neither natively nor as skills (e.g. goose) must not
97
+ // report source commands as "missing" — they structurally can't take them.
98
+ if (!asSkill && !supports(agent, 'commands', version).ok)
99
+ return [];
100
+ const agentDir = path.join(getVersionHomePath(agent, version), agentConfigDirName(agent));
90
101
  const installed = new Set(listCommandsInVersionHome(agent, version));
91
102
  const layerBases = buildLayerBases(cwd, 'commands', { excludeProject });
92
103
  const sourceByName = new Map();
@@ -113,11 +124,24 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
113
124
  const seen = new Set();
114
125
  for (const [name, src] of sourceByName) {
115
126
  seen.add(name);
116
- const homePath = path.join(homeDir, `${name}${ext}`);
117
127
  if (!installed.has(name)) {
118
128
  rows.push({ kind: 'commands', name, status: 'missing', source: src.layer, sourcePath: src.path });
119
129
  continue;
120
130
  }
131
+ if (asSkill) {
132
+ // Compare against the installed command-skill wrapper, not a native path.
133
+ const matches = commandSkillMatches(agentDir, name, src.path);
134
+ rows.push({
135
+ kind: 'commands',
136
+ name,
137
+ status: matches ? 'ok' : 'diff',
138
+ source: src.layer,
139
+ sourcePath: src.path,
140
+ homePath: path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md'),
141
+ });
142
+ continue;
143
+ }
144
+ const homePath = path.join(homeDir, `${name}${ext}`);
121
145
  const installedContent = readSafe(homePath);
122
146
  const sourceContent = readSafe(src.path);
123
147
  if (installedContent == null || sourceContent == null) {
@@ -138,7 +162,10 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
138
162
  for (const name of installed) {
139
163
  if (seen.has(name))
140
164
  continue;
141
- rows.push({ kind: 'commands', name, status: 'extra', homePath: path.join(homeDir, `${name}${ext}`) });
165
+ const extraHome = asSkill
166
+ ? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
167
+ : path.join(homeDir, `${name}${ext}`);
168
+ rows.push({ kind: 'commands', name, status: 'extra', homePath: extraHome });
142
169
  }
143
170
  return rows.sort((a, b) => a.name.localeCompare(b.name));
144
171
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Interactive drift-sync flow — the single "we detected drift, want to fix it?"
3
+ * action, shared by `agents status`, `agents doctor`, and the menu-bar "NEEDS
4
+ * SYNC" row.
5
+ *
6
+ * It composes existing pieces, re-implementing nothing:
7
+ * - computeSyncStatus() — the unified detection engine (sync-status.ts)
8
+ * - pullRepo() — fast-forward the `.system` repo (git.ts)
9
+ * - promptAgentVersionSelection() — the "which agent types / versions?" picker
10
+ * - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
11
+ *
12
+ * Combined flow (one confirmation): if `.system` is behind AND resources drifted,
13
+ * a single "Sync all detected" both pulls `.system` and reconciles the chosen
14
+ * version homes. The security posture is preserved — the `.system` pull only ever
15
+ * happens on an explicit user choice here, never silently (see auto-pull-worker.ts
16
+ * for why system auto-pull is off by default).
17
+ */
18
+ import { type VersionHealResult } from './heal.js';
19
+ import { type UnifiedSyncStatus } from './sync-status.js';
20
+ export interface DriftSyncOptions {
21
+ cwd?: string;
22
+ /** Reconcile everything detected with no prompts — the "kick it" path, also the
23
+ * non-TTY / menu-bar-launched-with-flag behavior. Pulls `.system` if behind. */
24
+ yes?: boolean;
25
+ /** Pre-computed status, to avoid a second scan when the caller already has one. */
26
+ status?: UnifiedSyncStatus;
27
+ /** Skip the drift summary — set by callers that already printed their own. */
28
+ quiet?: boolean;
29
+ }
30
+ export interface DriftSyncResult {
31
+ systemBehindBefore: number;
32
+ systemPulled: boolean;
33
+ healed: VersionHealResult[];
34
+ /** True when the user declined at the prompt (nothing was changed). */
35
+ cancelled: boolean;
36
+ /** True when there was no drift to act on in the first place. */
37
+ nothingToDo: boolean;
38
+ }
39
+ /**
40
+ * The unified "drift detected — sync now?" flow. Returns a structured result so
41
+ * callers (menu-bar, doctor) can report without re-scanning.
42
+ */
43
+ export declare function promptDriftSync(opts?: DriftSyncOptions): Promise<DriftSyncResult>;