@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/dist/index.js CHANGED
@@ -51,9 +51,11 @@ if (IS_DEV_BUILD) {
51
51
  // module on each invocation (which loaded the whole ~50-module tree before the
52
52
  // first byte of output), the registry maps a command name to a thunk that
53
53
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
54
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadHosts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
54
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadSsh, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
55
55
  import { applyGlobalHelpConventions } from './lib/help.js';
56
+ import { renderWhatsNew } from './lib/whats-new.js';
56
57
  import { IS_WINDOWS } from './lib/platform/index.js';
58
+ import { emit, redactArgs } from './lib/events.js';
57
59
  // Transparent shim delegate: the generated Windows `.cmd` shims invoke
58
60
  // `agents __shim <agent>[@version] <raw args>`. Intercept here, before commander
59
61
  // parses anything, so the agent's own flags (`--help`, `--version`, etc.) pass
@@ -75,6 +77,56 @@ program
75
77
  .version(VERSION)
76
78
  .helpOption('-h, --help', 'Show help')
77
79
  .addHelpCommand(false);
80
+ // ─── Audit backbone ────────────────────────────────────────────────────────────
81
+ // One choke point logs every `agents <module> <cmd>` invocation to the structured
82
+ // event log — so team create/disband, agent run, secrets access, and everything
83
+ // else is captured generically (with SSH/remote-user attribution added in emit()),
84
+ // no per-command wiring. `agents events` reads it back. Attached to the root
85
+ // program, so it's inherited by every subcommand regardless of lazy registration.
86
+ /** Command path from the acting command up to (but excluding) the `agents` root. */
87
+ function auditCommandPath(cmd) {
88
+ const parts = [];
89
+ let c = cmd;
90
+ while (c && c.name() && c.name() !== 'agents') {
91
+ parts.unshift(c.name());
92
+ c = c.parent;
93
+ }
94
+ return parts;
95
+ }
96
+ const auditStarts = new WeakMap();
97
+ program.hook('preAction', (_thisCommand, actionCommand) => {
98
+ try {
99
+ const parts = auditCommandPath(actionCommand);
100
+ if (parts.length === 0)
101
+ return;
102
+ auditStarts.set(actionCommand, Date.now());
103
+ emit('command.start', {
104
+ module: parts[0],
105
+ command: parts.join(' '),
106
+ args: redactArgs(actionCommand.args),
107
+ cwd: process.cwd(),
108
+ });
109
+ }
110
+ catch {
111
+ // Audit logging must never break command dispatch.
112
+ }
113
+ });
114
+ program.hook('postAction', (_thisCommand, actionCommand) => {
115
+ try {
116
+ const parts = auditCommandPath(actionCommand);
117
+ if (parts.length === 0)
118
+ return;
119
+ const started = auditStarts.get(actionCommand);
120
+ emit('command.end', {
121
+ module: parts[0],
122
+ command: parts.join(' '),
123
+ ...(started !== undefined ? { durationMs: Date.now() - started } : {}),
124
+ });
125
+ }
126
+ catch {
127
+ // Best-effort completion record; the start line is the durable audit fact.
128
+ }
129
+ });
78
130
  // Custom help for the main program only
79
131
  const originalHelpInformation = program.helpInformation.bind(program);
80
132
  program.helpInformation = function () {
@@ -121,6 +173,7 @@ Run and dispatch:
121
173
  teams Coordinate multiple agents on shared work
122
174
  routines Run agents on a cron schedule (scheduler auto-starts)
123
175
  sessions Browse, search, and replay past runs (live-search in TTY; grouped by workspace)
176
+ logs [id] Show a run's log — host-dispatch task or session; -f to follow
124
177
  browser Automate a browser — navigate, click, screenshot, console, network
125
178
  pty Drive interactive terminal programs (REPLs, TUIs) via a persistent PTY session
126
179
 
@@ -175,41 +228,13 @@ async function showWhatsNew(fromVersion, toVersion) {
175
228
  const response = await fetch(`https://unpkg.com/@phnx-labs/agents-cli@${toVersion}/CHANGELOG.md`);
176
229
  if (!response.ok)
177
230
  return;
178
- const changelog = await response.text();
179
- const lines = changelog.split('\n');
180
- const relevantChanges = [];
181
- let inRelevantSection = false;
182
- let currentVersion = '';
183
- for (const line of lines) {
184
- const versionMatch = line.match(/^## (\d+\.\d+\.\d+)/);
185
- if (versionMatch) {
186
- currentVersion = versionMatch[1];
187
- // Only the range the user actually moved through: (fromVersion, toVersion].
188
- // Bounding the top end matters when upgrading to a specific older
189
- // version, and guards against a changelog that lists unreleased entries.
190
- const inRange = compareVersions(currentVersion, fromVersion) > 0 &&
191
- compareVersions(currentVersion, toVersion) <= 0;
192
- inRelevantSection = inRange;
193
- if (inRelevantSection) {
194
- relevantChanges.push('');
195
- relevantChanges.push(chalk.bold(`v${currentVersion}`));
196
- }
197
- continue;
198
- }
199
- if (inRelevantSection && line.trim()) {
200
- if (line.startsWith('**') && line.endsWith('**')) {
201
- relevantChanges.push(chalk.cyan(line.replace(/\*\*/g, '')));
202
- }
203
- else if (line.startsWith('- ')) {
204
- relevantChanges.push(chalk.gray(` ${line}`));
205
- }
206
- }
207
- }
231
+ const relevantChanges = renderWhatsNew(await response.text(), fromVersion, toVersion);
208
232
  if (relevantChanges.length > 0) {
209
233
  console.log(chalk.bold("\nWhat's new:\n"));
210
234
  for (const line of relevantChanges) {
211
235
  console.log(line);
212
236
  }
237
+ console.log(chalk.gray('\nFull notes: https://github.com/phnx-labs/agents-cli/blob/main/CHANGELOG.md'));
213
238
  console.log();
214
239
  }
215
240
  }
@@ -785,6 +810,8 @@ async function registerAllEagerCommands() {
785
810
  await reg(loadBrowser);
786
811
  await reg(loadComputer);
787
812
  await reg(loadHosts);
813
+ await reg(loadLogs);
814
+ await reg(loadEvents);
788
815
  await reg(loadSsh);
789
816
  registerJobsCronAliasCommand(program, 'jobs');
790
817
  registerJobsCronAliasCommand(program, 'cron');
@@ -327,11 +327,16 @@ export const AGENTS = {
327
327
  commandsSubdir: 'commands',
328
328
  skillsDir: path.join(HOME, '.copilot', 'skills'),
329
329
  hooksDir: 'hooks',
330
+ // Copilot reads a plugin's manifest from the plugin ROOT (plugin.json),
331
+ // not `.claude-plugin/plugin.json`. Mirror it there. Verified against the
332
+ // GitHub Copilot CLI (1.0.56): `copilot plugin install` produces an
333
+ // installed plugin dir whose manifest sits at the root.
334
+ pluginManifestDir: '.',
330
335
  instructionsFile: 'AGENTS.md',
331
336
  format: 'markdown',
332
337
  variableSyntax: '$ARGUMENTS',
333
338
  supportsHooks: false,
334
- capabilities: { hooks: false, mcp: true, allowlist: false, skills: true, commands: true, plugins: false, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, modes: ['plan', 'edit', 'auto', 'skip'] },
339
+ capabilities: { hooks: false, mcp: true, allowlist: false, skills: true, commands: true, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, modes: ['plan', 'edit', 'auto', 'skip'] },
335
340
  },
336
341
  amp: {
337
342
  id: 'amp',
@@ -491,11 +496,26 @@ export const AGENTS = {
491
496
  // Install: `curl -fsSL https://app.factory.ai/cli | sh` (no npm package).
492
497
  // Binary is NOT in node_modules/.bin — the shim resolves the fixed install
493
498
  // path ~/.local/bin/droid directly (see the droid branch in shims.ts).
494
- // Config: `~/.factory/` (settings.json, mcp.json, droids/, commands/).
495
- // Memory: native AGENTS.md. Subagents = custom droids (top-level .md files
496
- // in ~/.factory/droids/). Config isolation rides the ~/.factory symlink
497
- // switch (no FACTORY_HOME env var exists). Headless: `droid exec "<prompt>"`
498
- // with --auto low|medium|high, -o stream-json, -m <model>, -r <effort>.
499
+ // Config: `~/.factory/` (settings.json, mcp.json, droids/, commands/, hooks/,
500
+ // plugins/). Memory: native AGENTS.md. Subagents = custom droids (top-level
501
+ // .md files in ~/.factory/droids/). Config isolation rides the ~/.factory
502
+ // symlink switch (no FACTORY_HOME env var exists). Headless:
503
+ // `droid exec "<prompt>"` with --auto low|medium|high, -o stream-json,
504
+ // -m <model>, -r <effort>.
505
+ //
506
+ // Hooks: Claude-shaped. settings.json carries a top-level `hooks` object keyed
507
+ // by event (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart,
508
+ // SessionEnd, Stop, SubagentStop, Notification, PreCompact), each an array of
509
+ // `{ matcher?, hooks: [{ type: "command", command, timeout? }] }` matcher
510
+ // groups — verified against the droid binary's zod schema. So the Claude
511
+ // registrar is reused verbatim, just targeting `.factory/settings.json`.
512
+ //
513
+ // Plugins: native `droid plugin` command group + ~/.factory/plugins/ with the
514
+ // same marketplace layout Claude uses (known_marketplaces.json +
515
+ // marketplaces/<name>/plugins/<plugin>/). Droid's plugin manifest dir is
516
+ // `.factory-plugin/` (it also reads `.claude-plugin/` for compatibility) — set
517
+ // pluginManifestDir so syncPluginToVersion mirrors the manifest into it, the
518
+ // same pattern codex uses with `.codex-plugin`.
499
519
  droid: {
500
520
  id: 'droid',
501
521
  name: 'Droid',
@@ -509,20 +529,21 @@ export const AGENTS = {
509
529
  commandsSubdir: 'commands',
510
530
  skillsDir: '', // no skills concept
511
531
  hooksDir: 'hooks',
532
+ pluginManifestDir: '.factory-plugin',
512
533
  instructionsFile: 'AGENTS.md',
513
534
  format: 'markdown',
514
535
  variableSyntax: '$ARGUMENTS',
515
- supportsHooks: false,
536
+ supportsHooks: true,
516
537
  // Factory Droid Computers (cloud VMs) reached via `droid computer ssh` +
517
538
  // remote headless `droid exec`.
518
539
  cloudProvider: 'factory',
519
540
  capabilities: {
520
- hooks: false,
541
+ hooks: true,
521
542
  mcp: true,
522
543
  allowlist: false,
523
544
  skills: false,
524
545
  commands: true,
525
- plugins: false,
546
+ plugins: true,
526
547
  subagents: true,
527
548
  rules: { file: 'AGENTS.md' },
528
549
  workflows: false,
@@ -219,7 +219,11 @@ export function verifyBrowserIdentity(reported, expected, port, host = 'localhos
219
219
  // to a Comet instance doesn't trip a false "identity mismatch".
220
220
  comet: ['comet', 'chrome'],
221
221
  brave: ['brave', 'brave-browser', 'chrome'],
222
- edge: ['edge', 'microsoft-edge', 'msedge', 'chrome'],
222
+ // Windows/Chromium Edge reports its /json/version "Browser" field as
223
+ // "Edg/<version>" (the `Edg` token, not `Edge`), which normalizeBrowserName
224
+ // reduces to "edg". Accept it so attaching to a real Edge doesn't trip a
225
+ // false identity mismatch (the profile enum only allows "edge").
226
+ edge: ['edge', 'edg', 'microsoft-edge', 'msedge', 'chrome'],
223
227
  };
224
228
  const accepted = matches[expected] || [expected];
225
229
  if (accepted.includes(reported))
@@ -53,7 +53,15 @@ export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string,
53
53
  * (Get-NetTCPConnection → Stop-Process).
54
54
  */
55
55
  export declare function buildKillCmd(remoteOs: RemoteOs, port: number): string;
56
+ export declare function ensureRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
56
57
  export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
58
+ /**
59
+ * Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
60
+ * WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
61
+ * the ssh tunnel is torn down — killing only the tunnel left it running on
62
+ * win-mini after every `browser stop`. Best-effort: never rejects.
63
+ */
64
+ export declare function killRemoteBrowser(user: string, host: string, remoteOs: RemoteOs, port: number): Promise<void>;
57
65
  /**
58
66
  * Identify whether a pid listening on our target local port is an SSH
59
67
  * tunnel WE would have spawned for `host:remotePort`. Used so that two
@@ -48,12 +48,12 @@ export async function connectSSH(endpoint, profile) {
48
48
  `is already in use by ${occupant.command} (pid ${occupant.pid}). ` +
49
49
  `Either kill that process (\`kill ${occupant.pid}\`) or change the profile's port.`);
50
50
  }
51
- try {
52
- await ensureRemoteBrowser(user, host, profile.browser, remotePort, remoteOs, profile.binary);
53
- }
54
- catch {
55
- // Browser may already be running, continue
56
- }
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);
57
57
  let tunnel;
58
58
  if (occupant) {
59
59
  // Reuse the existing tunnel rather than spawning a duplicate.
@@ -102,6 +102,11 @@ export async function connectSSH(endpoint, profile) {
102
102
  pid: tunnelPid,
103
103
  cleanup: () => {
104
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(() => { });
105
110
  tunnel.kill();
106
111
  clearProfileRuntime(profile.name);
107
112
  },
@@ -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], {
@@ -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 {};