@phnx-labs/agents-cli 1.22.117 → 1.22.118

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 (248) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/browser.d.ts +52 -70
  4. package/dist/commands/browser.js +192 -3562
  5. package/dist/commands/daemon.d.ts +39 -2
  6. package/dist/commands/daemon.js +72 -73
  7. package/dist/commands/events.js +1 -1
  8. package/dist/commands/exec.js +53 -1
  9. package/dist/commands/repo.js +13 -49
  10. package/dist/commands/route.js +5 -5
  11. package/dist/commands/routines.js +39 -12
  12. package/dist/commands/sessions-render.d.ts +1 -1
  13. package/dist/commands/sessions-render.js +0 -1
  14. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  15. package/dist/commands/sessions.test-fixture.js +0 -37
  16. package/dist/commands/setup-browser.d.ts +17 -12
  17. package/dist/commands/setup-browser.js +52 -87
  18. package/dist/commands/setup-preferences.d.ts +0 -17
  19. package/dist/commands/setup-preferences.js +18 -49
  20. package/dist/commands/setup.js +9 -9
  21. package/dist/commands/ssh.js +10 -1
  22. package/dist/commands/sync.js +6 -44
  23. package/dist/commands/teams.js +0 -1
  24. package/dist/commands/update.d.ts +19 -0
  25. package/dist/commands/update.js +34 -6
  26. package/dist/commands/view.d.ts +12 -1
  27. package/dist/commands/view.js +47 -2
  28. package/dist/index.js +107 -26
  29. package/dist/lib/account-capabilities.js +0 -2
  30. package/dist/lib/account-catalog.d.ts +38 -0
  31. package/dist/lib/account-catalog.js +110 -26
  32. package/dist/lib/account-provider-registry.js +1 -1
  33. package/dist/lib/account-registry.d.ts +1 -1
  34. package/dist/lib/account-registry.js +36 -2
  35. package/dist/lib/accounting/rotate.js +22 -8
  36. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  37. package/dist/lib/accounting/usage-ingest.js +115 -45
  38. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  39. package/dist/lib/accounting/usage-sync.js +227 -66
  40. package/dist/lib/accounting/usage.d.ts +16 -3
  41. package/dist/lib/accounting/usage.js +26 -8
  42. package/dist/lib/accounts/slots.d.ts +8 -0
  43. package/dist/lib/accounts/slots.js +17 -0
  44. package/dist/lib/add-dir.js +0 -1
  45. package/dist/lib/agent-cli-commands.js +0 -1
  46. package/dist/lib/agent-spec/agents.d.ts +6 -7
  47. package/dist/lib/agent-spec/agents.js +6 -60
  48. package/dist/lib/auth-health.d.ts +69 -1
  49. package/dist/lib/auth-health.js +133 -4
  50. package/dist/lib/browser/context.d.ts +82 -0
  51. package/dist/lib/browser/context.js +88 -0
  52. package/dist/lib/browser/paths.d.ts +25 -0
  53. package/dist/lib/browser/paths.js +51 -0
  54. package/dist/lib/browser/record.d.ts +38 -0
  55. package/dist/lib/browser/record.js +57 -0
  56. package/dist/lib/browser/sessions-list.js +1 -2
  57. package/dist/lib/browser-client.d.ts +158 -0
  58. package/dist/lib/browser-client.js +234 -0
  59. package/dist/lib/channels/owner-sink.d.ts +1 -18
  60. package/dist/lib/channels/owner-sink.js +29 -82
  61. package/dist/lib/channels/providers/rush.d.ts +7 -7
  62. package/dist/lib/channels/providers/rush.js +127 -57
  63. package/dist/lib/claude-statusline.d.ts +1 -1
  64. package/dist/lib/claude-statusline.js +15 -3
  65. package/dist/lib/commands.js +1 -1
  66. package/dist/lib/crabbox/lease.js +1 -1
  67. package/dist/lib/crabbox/runtimes.js +1 -3
  68. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  69. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  70. package/dist/lib/daemon/auth-sync-service.js +37 -43
  71. package/dist/lib/daemon/daemon.d.ts +25 -14
  72. package/dist/lib/daemon/daemon.js +92 -154
  73. package/dist/lib/daemon/runner.d.ts +2 -2
  74. package/dist/lib/daemon/runner.js +32 -9
  75. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  76. package/dist/lib/daemon/self-update-service.js +40 -36
  77. package/dist/lib/daemon/service.d.ts +12 -8
  78. package/dist/lib/daemon/supervisor.d.ts +65 -54
  79. package/dist/lib/daemon/supervisor.js +136 -171
  80. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  81. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  82. package/dist/lib/daemon/usage-sync-service.js +46 -78
  83. package/dist/lib/daemon-health.d.ts +22 -5
  84. package/dist/lib/daemon-health.js +47 -3
  85. package/dist/lib/daemon-services.d.ts +1 -1
  86. package/dist/lib/daemon-services.js +0 -10
  87. package/dist/lib/daemon-ticks.js +4 -2
  88. package/dist/lib/devices/doctor-findings.js +8 -11
  89. package/dist/lib/devices/harness-inventory.js +14 -5
  90. package/dist/lib/exec-account-home.js +5 -0
  91. package/dist/lib/exec.js +0 -11
  92. package/dist/lib/feed/events.js +0 -1
  93. package/dist/lib/feed/tool-activity.js +5 -2
  94. package/dist/lib/fleet-shared-state.d.ts +45 -0
  95. package/dist/lib/fleet-shared-state.js +78 -9
  96. package/dist/lib/fs-atomic.js +25 -0
  97. package/dist/lib/harness-auth-capabilities.js +0 -1
  98. package/dist/lib/helper-versions.js +1 -1
  99. package/dist/lib/hooks/cache.d.ts +7 -0
  100. package/dist/lib/hooks/cache.js +27 -2
  101. package/dist/lib/hooks/install.js +7 -1
  102. package/dist/lib/hosts/ready.d.ts +38 -8
  103. package/dist/lib/hosts/ready.js +62 -14
  104. package/dist/lib/installations/active-check.d.ts +27 -0
  105. package/dist/lib/installations/active-check.js +61 -0
  106. package/dist/lib/installations/migrate.js +6 -3
  107. package/dist/lib/installations/shims.js +9 -7
  108. package/dist/lib/installations/update.js +21 -15
  109. package/dist/lib/installations/versions.js +12 -1
  110. package/dist/lib/manifest.js +1 -1
  111. package/dist/lib/models.js +0 -2
  112. package/dist/lib/open-url.js +28 -54
  113. package/dist/lib/profiles.js +0 -2
  114. package/dist/lib/refresh.js +1 -1
  115. package/dist/lib/routine-readiness.d.ts +35 -0
  116. package/dist/lib/routine-readiness.js +74 -16
  117. package/dist/lib/rules/compile.js +6 -0
  118. package/dist/lib/sandbox.js +0 -2
  119. package/dist/lib/secrets-policy.d.ts +89 -11
  120. package/dist/lib/secrets-policy.js +175 -28
  121. package/dist/lib/session/db.d.ts +1 -1
  122. package/dist/lib/session/db.js +65 -18
  123. package/dist/lib/session/discover.js +1 -283
  124. package/dist/lib/session/mirror.d.ts +4 -3
  125. package/dist/lib/session/mirror.js +4 -3
  126. package/dist/lib/session/parse.d.ts +0 -2
  127. package/dist/lib/session/parse.js +1 -138
  128. package/dist/lib/session/throughput.d.ts +1 -3
  129. package/dist/lib/session/throughput.js +0 -20
  130. package/dist/lib/session/types.d.ts +2 -2
  131. package/dist/lib/session/types.js +2 -2
  132. package/dist/lib/sessions-client.d.ts +60 -1
  133. package/dist/lib/sessions-client.js +227 -4
  134. package/dist/lib/setup-tool-status.js +5 -10
  135. package/dist/lib/signin-badge.d.ts +3 -2
  136. package/dist/lib/signin-badge.js +6 -3
  137. package/dist/lib/smart-launch.js +4 -1
  138. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  139. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  140. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  141. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  142. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  143. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  144. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  145. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  146. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  147. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  148. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  149. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  150. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  151. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  152. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  153. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  154. package/dist/lib/staleness/writers/sources.js +5 -0
  155. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  156. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  157. package/dist/lib/state.d.ts +6 -4
  158. package/dist/lib/state.js +7 -17
  159. package/dist/lib/teams/parsers.d.ts +1 -1
  160. package/dist/lib/teams/parsers.js +1 -136
  161. package/dist/lib/teams/placement-probe.js +50 -15
  162. package/dist/lib/teams/scheduler.d.ts +7 -0
  163. package/dist/lib/types.d.ts +10 -1
  164. package/dist/lib/types.js +1 -1
  165. package/dist/lib/view-types.d.ts +32 -0
  166. package/dist/lib/watchdog/read.js +0 -1
  167. package/dist/lib/watchdog/rotate.js +1 -2
  168. package/dist/lib/watchdog/runner.js +2 -2
  169. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  170. package/dist/lib/watchdog/watchdogTail.js +0 -31
  171. package/package.json +1 -1
  172. package/scripts/postinstall.js +8 -8
  173. package/dist/browser.d.ts +0 -2
  174. package/dist/browser.js +0 -17
  175. package/dist/commands/browser-picker.d.ts +0 -18
  176. package/dist/commands/browser-picker.js +0 -97
  177. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  178. package/dist/lib/browser/arc-discovery.js +0 -197
  179. package/dist/lib/browser/arc-dom.d.ts +0 -14
  180. package/dist/lib/browser/arc-dom.js +0 -121
  181. package/dist/lib/browser/caller-identity.d.ts +0 -40
  182. package/dist/lib/browser/caller-identity.js +0 -175
  183. package/dist/lib/browser/cdp.d.ts +0 -51
  184. package/dist/lib/browser/cdp.js +0 -254
  185. package/dist/lib/browser/chrome.d.ts +0 -141
  186. package/dist/lib/browser/chrome.js +0 -704
  187. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  188. package/dist/lib/browser/chromium-discovery.js +0 -96
  189. package/dist/lib/browser/devices.d.ts +0 -34
  190. package/dist/lib/browser/devices.js +0 -61
  191. package/dist/lib/browser/domain-skills.d.ts +0 -71
  192. package/dist/lib/browser/domain-skills.js +0 -195
  193. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  194. package/dist/lib/browser/drivers/arc.js +0 -276
  195. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  196. package/dist/lib/browser/drivers/firefox.js +0 -377
  197. package/dist/lib/browser/drivers/local.d.ts +0 -55
  198. package/dist/lib/browser/drivers/local.js +0 -266
  199. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  200. package/dist/lib/browser/drivers/ssh.js +0 -467
  201. package/dist/lib/browser/editor.d.ts +0 -3
  202. package/dist/lib/browser/editor.js +0 -50
  203. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  204. package/dist/lib/browser/ffmpeg.js +0 -184
  205. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  206. package/dist/lib/browser/firefox-discovery.js +0 -162
  207. package/dist/lib/browser/har.d.ts +0 -85
  208. package/dist/lib/browser/har.js +0 -77
  209. package/dist/lib/browser/hygiene.d.ts +0 -97
  210. package/dist/lib/browser/hygiene.js +0 -153
  211. package/dist/lib/browser/index.d.ts +0 -5
  212. package/dist/lib/browser/index.js +0 -5
  213. package/dist/lib/browser/input.d.ts +0 -7
  214. package/dist/lib/browser/input.js +0 -92
  215. package/dist/lib/browser/ipc.d.ts +0 -204
  216. package/dist/lib/browser/ipc.js +0 -1453
  217. package/dist/lib/browser/login-detection.d.ts +0 -87
  218. package/dist/lib/browser/login-detection.js +0 -274
  219. package/dist/lib/browser/profiles.d.ts +0 -422
  220. package/dist/lib/browser/profiles.js +0 -1157
  221. package/dist/lib/browser/refs.d.ts +0 -89
  222. package/dist/lib/browser/refs.js +0 -191
  223. package/dist/lib/browser/registry.d.ts +0 -70
  224. package/dist/lib/browser/registry.js +0 -252
  225. package/dist/lib/browser/remote-control.d.ts +0 -59
  226. package/dist/lib/browser/remote-control.js +0 -85
  227. package/dist/lib/browser/resolve-target.d.ts +0 -66
  228. package/dist/lib/browser/resolve-target.js +0 -264
  229. package/dist/lib/browser/runtime-state.d.ts +0 -291
  230. package/dist/lib/browser/runtime-state.js +0 -584
  231. package/dist/lib/browser/secret-ref.d.ts +0 -10
  232. package/dist/lib/browser/secret-ref.js +0 -14
  233. package/dist/lib/browser/service.d.ts +0 -812
  234. package/dist/lib/browser/service.js +0 -4693
  235. package/dist/lib/browser/stream.d.ts +0 -17
  236. package/dist/lib/browser/stream.js +0 -72
  237. package/dist/lib/browser/task-index.d.ts +0 -78
  238. package/dist/lib/browser/task-index.js +0 -199
  239. package/dist/lib/browser/types.d.ts +0 -647
  240. package/dist/lib/browser/types.js +0 -61
  241. package/dist/lib/browser/upload.d.ts +0 -29
  242. package/dist/lib/browser/upload.js +0 -298
  243. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  244. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  245. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  246. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  247. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  248. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -1,3605 +1,235 @@
1
- import { Option } from 'commander';
2
- import chalk from 'chalk';
3
- import { spawnSync } from 'node:child_process';
4
- import * as fs from 'fs';
5
- import * as path from 'path';
6
- import { listProfiles, getProfile, createProfile, deleteProfile, getConfiguredDefaultProfileName, resolveProfileRef, resolveProfileRefForStart, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, formatProfilesTable, editProfile, renameProfile, assertRegistrableProfileName, isProfileLaunchableHere, isAttachOnlyProfile, resolveProfileDataDir, normalizeDataDir, persistDiscoveredProfile, } from '../lib/browser/profiles.js';
7
- import { declaringDevices, migrateCentralBrowserProfiles, profileKind } from '../lib/browser/registry.js';
8
- import { resolveActor } from '../lib/actor.js';
9
- import { loginsForProfile, profilesLoggedInto, serviceForUrl, loginsWithAccountsForProfile, accountsForProfile, credKeysForService, AUTH_SIGNATURES, } from '../lib/browser/login-detection.js';
10
- import { parseSecretRef } from '../lib/browser/secret-ref.js';
11
- import { readAndResolveBundleEnv, bundleExists, readBundle } from '../lib/secrets-client.js';
12
- import { findBrowserPath, getPortOccupant, getProcessUserDataDir, isLauncherScript, listInstalledBrowsers } from '../lib/browser/chrome.js';
13
- import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, buildProfilePrunePlan, pruneProfiles, PRUNE_REASON_TEXT, identityLoopbackMismatch, } from '../lib/browser/runtime-state.js';
14
- import { DEFAULT_VIEWPORT, parseWindowSize, parseWindowPosition } from '../lib/browser/devices.js';
15
- import { runBrowserSessionsCommand } from './browser-sessions-picker.js';
16
- import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
17
- import { parseTargetFilter } from '../lib/browser/service.js';
18
- import { BrowserServiceNotRunningError, formatBrowserServiceNotRunningError, sendIPCRequest as sendRawIPCRequest, stopBrowserService, } from '../lib/browser/ipc.js';
19
- import { bindTask, getTaskBinding, honorScreenshotOutput, isTerminalBrowserVerb, REJECT_DEVICE_MESSAGE, resolveTaskRoute, unbindTask, unbindTasksForProfile, updateTaskBinding, } from '../lib/browser/task-index.js';
20
- import { callerIdentityEnv, resolveCallerIdentity } from '../lib/browser/caller-identity.js';
21
- import { isSelfHost } from '../lib/devices/self-host.js';
22
- import { resolveHost } from '../lib/hosts/registry.js';
23
- import { sshTargetFor } from '../lib/hosts/types.js';
24
- import { withActorEnv } from '../lib/hosts/dispatch.js';
25
- import { streamAgentsOnHost, passthroughSshOptions, } from '../lib/hosts/passthrough.js';
26
- import { buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, stripRoutingFlags, } from '../lib/hosts/remote-cmd.js';
27
- import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
28
- import { sshExec, SSH_OPTS, sshStreamWithArgs } from '../lib/ssh-exec.js';
29
- import { getBrowserRuntimeDir } from '../lib/state.js';
30
- import { buildSshInvocation, writeAskpassShim } from '../lib/devices/connect.js';
31
- import { resolveDeviceProfile } from '../lib/devices/resolve-profile.js';
32
- import { resolveDeviceTarget } from '../lib/devices/resolve-target.js';
33
- import { flagValue } from '../lib/hosts/routing-flag.js';
34
- import { browserTaskPicker } from './browser-picker.js';
35
- import { assertRemoteControlAllowed, isFleetRemoteInvocation } from '../lib/browser/remote-control.js';
36
- import { getConfigValue, setConfigValue, unsetConfigValue } from '../lib/device-config.js';
37
- import { isInteractiveTerminal } from './utils.js';
38
- import { registerCommandGroups, setHelpSections } from '../lib/help.js';
39
- import { buildHar } from '../lib/browser/har.js';
40
- import { getCliVersion } from '../lib/version.js';
41
- import { runBrowserIPCStream } from '../lib/browser/stream.js';
42
- import { machineId, normalizeHost } from '../lib/machine-id.js';
43
- import { isArcRunning } from '../lib/browser/drivers/arc.js';
44
- import { resolveBrowserTarget } from '../lib/browser/resolve-target.js';
45
- /**
46
- * Task name inferred from the local task→device index when `--task` was
47
- * omitted and this session owns exactly one open task. Set by the page-verb
48
- * preAction hook; consumed by {@link resolveTaskName}.
49
- */
50
- let inferredTaskName;
51
1
  /**
52
- * Resolve which browser task a command targets. Order:
53
- * 1. `--task <name>` flag (explicit per-command override)
54
- * 2. `$AGENTS_BROWSER_TASK` (optional shell default)
55
- * 3. the local task-index match for this session (exactly one)
56
- * 4. `undefined` — the daemon resolves from the caller's identity
2
+ * `agents browser` — the consumer surface over the standalone `browser` CLI
3
+ * (@phnx-labs/browser-cli, PHNX-4101).
57
4
  *
58
- * `--device` on a page verb is rejected here so commander-parsed flags cannot
59
- * silently re-route a later verb. Bind the device at `start`.
5
+ * WHAT THIS FILE IS NOW. Every verb below forwards its arguments verbatim to the
6
+ * standalone engine and propagates its exit code. agents-cli contributes what the
7
+ * engine cannot know, carried on the fd-3 context (`lib/browser/context.ts`):
60
8
  *
61
- * `undefined` is valid: page verbs create a task when none resolves, and
62
- * done/stop report "nothing to close". Agents no longer need to type a handle
63
- * in the common case.
64
- */
65
- function resolveTaskName(opts) {
66
- if (opts.device) {
67
- const route = resolveTaskRoute({ device: opts.device });
68
- console.error(route.kind === 'reject-device' ? route.message : REJECT_DEVICE_MESSAGE);
69
- process.exit(1);
70
- }
71
- if (opts.task)
72
- return opts.task;
73
- const fromEnv = process.env.AGENTS_BROWSER_TASK;
74
- if (fromEnv)
75
- return fromEnv;
76
- return inferredTaskName;
77
- }
78
- // `-t` is taken by `--tab` on most commands, so `--task` is long-form only.
79
- // The daemon resolves from caller identity when omitted.
80
- const TASK_OPTION_FLAG = '--task <name>';
81
- const TASK_OPTION_DESC = 'Task name (defaults to $AGENTS_BROWSER_TASK, else the caller\'s live task)';
82
- const DEVICE_ON_PAGE_VERB_FLAG = '--device <name>';
83
- const DEVICE_ON_PAGE_VERB_DESC = 'Not valid here — bind the device at `agents browser start --device`';
84
- const TASK_ROUTED_COMMANDS = new Set([
85
- 'stream',
86
- 'done',
87
- 'stop',
88
- 'navigate',
89
- 'goto',
90
- 'tab',
91
- 'tabs',
92
- 'screenshot',
93
- 'pdf',
94
- 'evaluate',
95
- 'refs',
96
- 'click',
97
- 'type',
98
- 'press',
99
- 'hover',
100
- 'scroll',
101
- 'upload',
102
- 'set',
103
- 'console',
104
- 'errors',
105
- 'requests',
106
- 'logs',
107
- 'responsebody',
108
- 'wait',
109
- 'download',
110
- 'record',
111
- 'waitdownload',
112
- ]);
113
- function commandPath(actionCommand) {
114
- const parts = [];
115
- let current = actionCommand;
116
- while (current) {
117
- const name = current.name();
118
- if (name === 'agents' || name === 'browser')
119
- break;
120
- parts.unshift(name);
121
- current = current.parent ?? null;
122
- }
123
- return parts;
124
- }
125
- function callerSessionId() {
126
- return process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID;
127
- }
128
- function callerLaunchId() {
129
- return process.env.AGENT_LAUNCH_ID;
130
- }
131
- function browserForwardedArgv() {
132
- const raw = process.argv.slice(2);
133
- const withCommand = raw[0] === 'browser' ? raw : ['browser', ...raw];
134
- return stripRoutingFlags(withCommand, HOST_ROUTING_SPECS);
135
- }
136
- async function dispatchBrowserToDevice(device, forwardedArgs, mode) {
137
- const host = await resolveHost(device);
138
- if (!host) {
139
- throw new Error(`Unknown device "${device}". Next: agents devices list`);
140
- }
141
- const target = sshTargetFor(host);
142
- const remoteOs = resolveRemoteOsSync(host.name);
143
- // Forward the caller's identity so the HUB resolves the same session/launch id
144
- // the worker would stamp locally. Without it the forwarded verb lands blank on
145
- // the hub and its no-identity task bucket collides with unrelated tasks — the
146
- // surface-parity gap the run --device path already closes (caller-identity.ts).
147
- const identityEnv = callerIdentityEnv(resolveCallerIdentity());
148
- const env = withActorEnv({ ...identityEnv, AGENTS_FLEET_REMOTE: '1' });
149
- const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, undefined, remoteOs, env);
150
- if (mode === 'stream') {
151
- const code = streamAgentsOnHost(host, forwardedArgs, {
152
- interactive: !!process.stdout.isTTY && !process.argv.includes('--no-tty'),
153
- extraEnv: { ...identityEnv, AGENTS_FLEET_REMOTE: '1' },
154
- remoteOs,
155
- target,
156
- });
157
- return { code, stdout: '', stderr: '' };
158
- }
159
- const sshOpts = passthroughSshOptions(host, false);
160
- const result = sshExec(target, remoteCmd, {
161
- multiplex: sshOpts.multiplex,
162
- extraSshArgs: sshOpts.extraSshArgs,
163
- });
164
- return {
165
- code: result.code ?? 1,
166
- stdout: result.stdout,
167
- stderr: result.stderr,
168
- };
169
- }
170
- export function remoteStartTaskName(stdout, explicit) {
171
- if (explicit)
172
- return explicit;
173
- const trimmed = stdout.trim();
174
- if (!trimmed)
175
- return undefined;
176
- try {
177
- const decoded = JSON.parse(trimmed);
178
- if (typeof decoded.task === 'string' && decoded.task.length > 0)
179
- return decoded.task;
180
- }
181
- catch {
182
- // Human output is one task name on the first non-empty line.
183
- }
184
- return trimmed.split('\n').map((line) => line.trim()).find(Boolean);
185
- }
186
- /**
187
- * Fetch an absolute remote file into a private local path, bounded.
9
+ * 1. `--device <name>` resolved against the fleet — the device registry, ssh
10
+ * identity and platform — so the engine drives the right remote box without
11
+ * a fleet registry of its own (it matches the alias against `context.target`,
12
+ * then `~/.ssh/config`);
13
+ * 2. whether THIS machine consents to being driven by a peer
14
+ * (`browser.remote-control`); the engine enforces the resolved flag;
15
+ * 3. the acting actor and agent session, so an action lands in the right
16
+ * session history.
17
+ *
18
+ * The engine streams back the actions it performed on fd 4, and agents-cli — which
19
+ * owns `sessions.db` — records each into the durable `browser_sessions` row that
20
+ * `agents browser sessions` and `agents sessions --browser` read
21
+ * (`lib/browser/record.ts`).
188
22
  *
189
- * Bounded for real, not by a pre-flight `stat`: the byte budget is enforced on the
190
- * stream as it arrives (`sshExecRawStream` + `cat`), so a file that grows between
191
- * a size check and the copy — or a peer that lies about the size — still cannot
192
- * spend more than the budget. `scp` could not do this; it would transfer
193
- * everything before this process got a chance to refuse.
23
+ * THE REMOTE PATH IS THE ENGINE's. `--device <name>` is resolved to an ssh target
24
+ * here and forwarded verbatim (the engine binds the device once at `start` and
25
+ * runs page verbs against the task's bound device). agents-cli opens no tunnel and
26
+ * publishes no endpoint: the engine owns the CDP/BiDi/Arc drivers, the IPC service,
27
+ * the chrome-data store and the profile declarations, keeping every on-disk path
28
+ * the in-repo subsystem used (integration contract §4).
194
29
  *
195
- * The local file is written 0600 inside a 0700 directory, because a capture can
196
- * show anything that was on the peer's screen and a world-readable temp path
197
- * would publish it to every local user.
30
+ * WHY VERB FLAGS ARE NOT REDECLARED HERE. Each passthrough verb takes everything
31
+ * as opaque operands via `allowUnknownOption`. Mirroring the engine's flags would
32
+ * create a second, silently drifting copy of its surface: a flag added upstream
33
+ * would be rejected here as unknown until someone noticed. The engine also owns
34
+ * per-verb `--help` for the same reason. What agents-cli keeps is the verb CATALOG
35
+ * — names, one-line descriptions, help groups — because that is what makes the
36
+ * surface discoverable from `agents browser --help`.
37
+ *
38
+ * `sessions` is the one verb that never reaches the engine: it reads agents-cli's
39
+ * own capture/session history (`browser-sessions-picker.ts`).
198
40
  */
41
+ import { registerCommandGroups, setHelpSections } from '../lib/help.js';
42
+ import { buildBrowserContext } from '../lib/browser/context.js';
43
+ import { recordBrowserAction } from '../lib/browser/record.js';
44
+ import { isBrowserClientError, resolveBrowserBin, runBrowser, } from '../lib/browser-client.js';
45
+ import { runBrowserSessionsCommand } from './browser-sessions-picker.js';
46
+ // Help groups — mirror the standalone `browser --help` so the mental model
47
+ // carries over, and mirror `agents computer` where the two surfaces overlap.
48
+ const BROWSER_HELP_GROUPS = [
49
+ { title: 'Session lifecycle', names: ['use', 'start', 'done', 'status', 'prune'] },
50
+ { title: 'Fast action loop', names: ['stream'] },
51
+ { title: 'Drive the page', names: ['navigate', 'tabs', 'screenshot', 'evaluate', 'click', 'type', 'press', 'wait'] },
52
+ { title: 'Capture evidence', names: ['console', 'errors', 'requests', 'responsebody', 'record', 'pdf', 'logs'] },
53
+ { title: 'History and discovery', names: ['sessions', 'history', 'refs'] },
54
+ { title: 'Other', names: ['profiles', 'remote-control', 'stop', 'show', 'tab', 'ps', 'tasks', 'hover', 'scroll', 'upload', 'set', 'devices', 'download', 'waitdownload'] },
55
+ ];
199
56
  /**
200
- * The command that streams one file's RAW BYTES to stdout on the device's shell.
57
+ * The verb catalog. Descriptions are the consumer's (they appear in
58
+ * `agents browser --help`); flags are the engine's.
59
+ *
60
+ * This list is the contract with the engine: a verb the engine drops should fail
61
+ * loud here rather than silently vanish, and `browser.test.ts` pins the names so a
62
+ * drift shows up as a failing test. `sessions` is deliberately NOT in the list —
63
+ * it is agents-cli's own reader, registered separately.
201
64
  *
202
- * Platform-correct, not one command with a portable-looking name. On Windows
203
- * `cat` is an alias for `Get-Content`, which is a TEXT reader: it decodes the
204
- * file, splits it into lines, and re-encodes on output — so a screenshot arrives
205
- * corrupted rather than merely reordered. The .NET path opens a `FileStream` and
206
- * copies it into the raw stdout handle, bypassing PowerShell's object/text
207
- * pipeline entirely, which is the only way binary survives.
65
+ * `start` is the ONE verb that resolves `--device`; the engine binds the device
66
+ * there and rejects `--device` on the page verbs itself.
208
67
  */
209
- export function remoteReadFileArgv(remotePath, shell) {
210
- if (shell === 'powershell') {
211
- // Single-quoted inside the script with pwsh's own doubling, because this whole
212
- // script is ONE argv token to the outer powershell.
213
- const literal = remotePath.replace(/'/g, "''");
214
- return ['powershell', '-NoProfile', '-Command', [
215
- '$ErrorActionPreference=' + "'Stop';",
216
- `$in=[System.IO.File]::OpenRead('${literal}');`,
217
- 'try{$out=[System.Console]::OpenStandardOutput();$in.CopyTo($out);$out.Flush()}finally{$in.Dispose()}',
218
- ].join(' ')];
219
- }
220
- // `--` so a path that begins with a dash is a path, not an option.
221
- return ['cat', '--', remotePath];
222
- }
68
+ export const BROWSER_PASSTHROUGH_VERBS = [
69
+ { name: 'use', description: 'Pick the profile `agents browser start` uses when no --profile is passed' },
70
+ { name: 'start', description: 'Start a browser task — --profile/--url/--record/--title, and --device <name> to bind a remote box' },
71
+ { name: 'done', description: 'Complete a task and close its tabs (resolves from caller identity when --task is omitted)' },
72
+ { name: 'status', description: 'Show browser service state and running browser tasks' },
73
+ { name: 'prune', description: 'Close tabs for abandoned tasks and mark them done — the reaper the daemon runs, on demand' },
74
+ { name: 'stream', description: 'Keep one process + IPC socket open; read NDJSON requests from stdin, write NDJSON responses' },
75
+ { name: 'navigate', description: 'Navigate the current tab to a URL (creates a task and tab when none exist)' },
76
+ { name: 'tabs', description: 'List tabs open for the current task; --all shows every tab in the profile browser' },
77
+ { name: 'screenshot', description: 'Take a screenshot — auto-saved per task; --output only to pick a specific path' },
78
+ { name: 'evaluate', description: 'Evaluate JavaScript in the current tab' },
79
+ { name: 'click', description: 'Click an element by ref, or raw coordinates with --at X,Y' },
80
+ { name: 'type', description: 'Type text into an element by ref' },
81
+ { name: 'press', description: 'Press a key (Enter, Tab, Escape, …)' },
82
+ { name: 'wait', description: 'Wait for a condition' },
83
+ { name: 'console', description: 'Read console logs from a tab' },
84
+ { name: 'errors', description: 'Read page errors from a tab' },
85
+ { name: 'requests', description: 'Read captured network requests; --format har emits a HAR 1.2 document' },
86
+ { name: 'responsebody', description: 'Wait for and read a response body by URL pattern' },
87
+ { name: 'record', description: 'Record a video of the page (record start / record stop)' },
88
+ { name: 'pdf', description: 'Export the current tab as PDF via CDP — auto-saved under sessions/<task>/ when omitted' },
89
+ { name: 'logs', description: 'Read merged rush-app + rush-cli logs for a task' },
90
+ { name: 'history', description: 'Show recent browser task history' },
91
+ { name: 'refs', description: 'Get DOM refs for interactive elements' },
92
+ { name: 'profiles', description: 'Manage browser profiles (create / list / edit / rename / show / remove / use / …)' },
93
+ { name: 'remote-control', description: 'Allow or deny other fleet machines driving THIS machine\'s browser (on/off; default off)' },
94
+ { name: 'stop', description: 'Stop a task and close its tabs; --profile detaches the profile; --service stops the IPC service' },
95
+ { name: 'show', description: 'Open a URL for a human to read (goes to browser.viewer; binds no task)' },
96
+ { name: 'tab', description: 'Manage tabs (tab add / tab focus <id> / tab close [id])' },
97
+ { name: 'ps', description: 'List every browser/electron/tunnel process agents has tracked — works without the daemon' },
98
+ { name: 'tasks', description: 'List all browser tasks' },
99
+ { name: 'hover', description: 'Hover over an element by ref' },
100
+ { name: 'scroll', description: 'Scroll the page by a pixel amount (negatives scroll up/left)' },
101
+ { name: 'upload', description: 'Upload file(s) — hidden inputs, drag-drop targets, and OS chooser interception' },
102
+ { name: 'set', description: 'Set browser emulation options (set viewport / set device / set devices)' },
103
+ { name: 'devices', description: 'List available device emulation presets' },
104
+ { name: 'download', description: 'Set the download directory for a task' },
105
+ { name: 'waitdownload', description: 'Wait for a download to complete' },
106
+ ];
223
107
  /**
224
- * Largest capture `browser show --device` will pull. A capture is a screenshot,
225
- * a PDF or a short recording; anything past this is not something a viewer should
226
- * stream over ssh, and the cap is what makes the transfer BOUNDED rather than
227
- * "however big the peer's file happens to be".
108
+ * Peek `--device <name>` (or `--device=name`) out of a raw argv without consuming
109
+ * it — the flag is forwarded to the engine verbatim, and agents-cli only reads it
110
+ * to resolve the fleet target for the fd-3 context. Pure, so it is unit-testable.
228
111
  */
229
- export const REMOTE_VIEW_MAX_BYTES = 64 * 1024 * 1024;
230
- /** Write a whole buffer, looping until it is all out. */
231
- function writeFully(handle, chunk) {
232
- let offset = 0;
233
- while (offset < chunk.length) {
234
- // `writeSync` may write FEWER bytes than asked (a signal, a pipe boundary),
235
- // and ignoring the return value silently truncated the file mid-capture.
236
- const wrote = fs.writeSync(handle, chunk, offset, chunk.length - offset);
237
- if (wrote <= 0)
238
- throw new Error('local write made no progress');
239
- offset += wrote;
112
+ export function peekDevice(argv) {
113
+ for (let i = 0; i < argv.length; i++) {
114
+ const arg = argv[i];
115
+ if (arg === '--device')
116
+ return argv[i + 1];
117
+ if (arg.startsWith('--device='))
118
+ return arg.slice('--device='.length);
240
119
  }
120
+ return undefined;
241
121
  }
242
122
  /**
243
- * Fetch an absolute remote file into a private local path, bounded.
244
- *
245
- * Auth is the CANONICAL device path, not a hand-rolled ssh: `buildSshInvocation`
246
- * supplies the askpass shim for a password-auth box, `-i`/`IdentitiesOnly` for an
247
- * explicit identity file, and the managed known-hosts pinning. Building the ssh
248
- * args here instead meant a password-auth device could not be reached at all and
249
- * an explicit `identityFile` was silently ignored.
123
+ * Forward one invocation to the engine and propagate its exit code.
250
124
  *
251
- * Bounded for real, not by a pre-flight `stat`: the byte budget is enforced on the
252
- * stream as it arrives, so a file that grows between a size check and the copy — or
253
- * a peer that misreports its size — still cannot spend more than the budget.
254
- *
255
- * The local file is written 0600 inside a 0700 directory, because a capture can
256
- * show anything that was on the peer's screen and a world-readable temp path
257
- * would publish it to every local user.
125
+ * A missing standalone is the one failure agents-cli reports itself, because it
126
+ * is the one the engine cannot: it prints the install line and exits 1. There is
127
+ * no fallback engine to reach for — that is the point of the extraction.
258
128
  */
259
- export async function fetchRemoteFileForViewing(device, remotePath, localPath, opts = {}) {
260
- const posixAbsolute = path.posix.isAbsolute(remotePath);
261
- const windowsAbsolute = /^[A-Za-z]:[\\/]/.test(remotePath) || /^\\\\/.test(remotePath);
262
- if (!posixAbsolute && !windowsAbsolute) {
263
- throw new Error(`Remote path must be absolute: got "${remotePath}".\n`
264
- + 'This machine cannot resolve the peer\'s working directory, so a relative path\n'
265
- + 'would name a different file there than you meant here.');
266
- }
267
- // `resolveDeviceTarget` is the SAME resolver `agents ssh` uses, so it yields the
268
- // full device profile — platform, shell and auth — that `buildSshInvocation`
269
- // needs. `resolveHost` returns a hosts-registry `Host`, which carries none of
270
- // that: with it, a password-auth device could not be reached and an explicit
271
- // identityFile was silently dropped.
272
- const resolved = await resolveDeviceTarget(device);
273
- if (!resolved)
274
- throw new Error(`Unknown device "${device}". Next: agents devices list`);
275
- const profile = resolveDeviceProfile(resolved);
276
- const maxBytes = opts.maxBytes ?? REMOTE_VIEW_MAX_BYTES;
277
- // A POSIX device cannot read a Windows path and vice versa; saying so here beats
278
- // a confusing shell error from the peer.
279
- if (profile.shell === 'powershell' && posixAbsolute && !windowsAbsolute) {
280
- throw new Error(`"${remotePath}" is a POSIX path but ${device} runs PowerShell. Pass the Windows path (e.g. C:\\Users\\…).`);
281
- }
282
- if (profile.shell !== 'powershell' && windowsAbsolute) {
283
- throw new Error(`"${remotePath}" is a Windows path but ${device} is a POSIX host. Pass the absolute POSIX path.`);
284
- }
285
- const resolvedLocal = path.resolve(localPath);
286
- const dir = path.dirname(resolvedLocal);
287
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
288
- fs.chmodSync(dir, 0o700);
289
- const handle = fs.openSync(resolvedLocal, 'w', 0o600);
290
- let written = 0;
291
- let overflow = false;
292
- const controller = new AbortController();
129
+ async function forwardToBrowser(opts) {
293
130
  try {
294
- // `argv: true` is what makes a path containing a space or a quote survive —
295
- // the same exact-token delivery `agents ssh --argv` exposes.
296
- const { args, env } = buildSshInvocation(profile, remoteReadFileArgv(remotePath, profile.shell), writeAskpassShim(), {}, { argv: true });
297
- const result = await sshStreamWithArgs({
298
- args,
299
- env,
300
- timeoutMs: opts.timeoutMs ?? 120_000,
301
- signal: controller.signal,
302
- onStdout: (chunk) => {
303
- if (overflow)
304
- return;
305
- if (written + chunk.length > maxBytes) {
306
- overflow = true;
307
- controller.abort();
308
- return;
309
- }
310
- writeFully(handle, chunk);
311
- written += chunk.length;
312
- },
313
- });
314
- if (overflow) {
315
- throw new Error(`Refusing to pull ${device}:${remotePath}: larger than the ${maxBytes}-byte view budget.`);
316
- }
317
- if (result.timedOut)
318
- throw new Error(`Timed out pulling ${device}:${remotePath}.`);
319
- if (result.code !== 0) {
320
- const detail = result.stderr.toString('utf8').trim();
321
- throw new Error(`Failed to read ${device}:${remotePath}${detail ? `: ${detail}` : ` (ssh exited ${result.code})`}`);
322
- }
323
- if (written === 0)
324
- throw new Error(`${device}:${remotePath} is empty or was not readable.`);
131
+ resolveBrowserBin();
325
132
  }
326
- catch (error) {
327
- // Never leave a partial file behind for a viewer to open as if it were whole.
328
- try {
329
- fs.unlinkSync(resolvedLocal);
133
+ catch (err) {
134
+ if (isBrowserClientError(err)) {
135
+ console.error(err.message);
136
+ return { exitCode: 1, stdout: '' };
330
137
  }
331
- catch { /* nothing to clean */ }
332
- throw error;
333
- }
334
- finally {
335
- fs.closeSync(handle);
138
+ throw err;
336
139
  }
140
+ const context = await buildBrowserContext({ device: opts.device });
141
+ return runBrowser({
142
+ argv: opts.argv,
143
+ context,
144
+ capture: opts.capture,
145
+ onEvent: (event) => recordBrowserAction(event, { device: opts.device }),
146
+ });
337
147
  }
338
- async function pullRemoteFile(device, remotePath, localPath) {
339
- const host = await resolveHost(device);
340
- if (!host) {
341
- throw new Error(`Unknown device "${device}". Next: agents devices list`);
342
- }
343
- const target = sshTargetFor(host);
344
- fs.mkdirSync(path.dirname(path.resolve(localPath)), { recursive: true });
345
- const sshOpts = passthroughSshOptions(host, false);
346
- const copied = spawnSync('scp', [...sshOpts.extraSshArgs, ...SSH_OPTS, `${target}:${remotePath}`, path.resolve(localPath)], { encoding: 'utf8' });
347
- if (copied.status !== 0) {
348
- throw new Error(`Failed to copy screenshot from ${device}:${remotePath}: ${(copied.stderr || copied.stdout || '').trim()}`);
349
- }
350
- }
351
- function syncTaskIndex(request, response) {
352
- if (!response.ok)
353
- return;
354
- if (request.action === 'stop' && request.profile && !request.task) {
355
- unbindTasksForProfile(request.profile);
356
- return;
357
- }
358
- if (request.action === 'gc') {
359
- if (!request.dryRun) {
360
- for (const closed of response.reaped?.closed ?? []) {
361
- unbindTask(closed.task);
362
- }
363
- }
364
- return;
365
- }
366
- const name = response.task ?? request.task ?? request.taskName;
367
- if (!name)
368
- return;
369
- if (request.action === 'done' || (request.action === 'stop' && request.task)) {
370
- unbindTask(name);
371
- return;
372
- }
373
- if (request.action === 'start') {
374
- const existing = getTaskBinding(name);
375
- if (!existing) {
376
- bindTask(name, {
377
- device: machineId(),
378
- profile: request.profile,
379
- url: request.url,
380
- sessionId: request.sessionId ?? callerSessionId(),
381
- launchId: request.launchId ?? callerLaunchId(),
382
- createdAt: Date.now(),
383
- });
384
- }
385
- else if (request.url) {
386
- updateTaskBinding(name, { url: request.url });
387
- }
388
- return;
389
- }
390
- if (request.url) {
391
- const existing = getTaskBinding(name);
392
- if (existing) {
393
- updateTaskBinding(name, { url: request.url });
394
- }
395
- else {
396
- bindTask(name, {
397
- device: machineId(),
398
- profile: request.profile,
399
- url: request.url,
400
- sessionId: request.sessionId ?? callerSessionId(),
401
- launchId: request.launchId ?? callerLaunchId(),
402
- createdAt: Date.now(),
403
- });
404
- }
405
- return;
406
- }
407
- if (!getTaskBinding(name)) {
408
- bindTask(name, {
409
- device: machineId(),
410
- profile: request.profile,
411
- sessionId: request.sessionId ?? callerSessionId(),
412
- launchId: request.launchId ?? callerLaunchId(),
413
- createdAt: Date.now(),
414
- });
415
- }
416
- }
417
- async function sendIPCRequest(request, opts) {
418
- const response = await sendRawIPCRequest(request, opts);
419
- syncTaskIndex(request, response);
420
- return response;
421
- }
422
- function assertDeviceDeclaresProfile(device, profileName) {
423
- const declared = declaringDevices(profileName);
424
- if (declared.includes(device))
425
- return;
426
- const where = declared.length > 0
427
- ? `Declared on: ${declared.join(', ')}.`
428
- : 'No device declares that profile.';
429
- throw new Error(`Device "${device}" does not declare browser profile "${profileName}". ${where}\n` +
430
- `Next: run \`agents browser profiles add ${profileName} --browser <b>\` on ${device}.`);
148
+ /** Forward, then exit with the engine's status so shells and agents see the truth. */
149
+ async function forwardAndExit(opts) {
150
+ const { exitCode } = await forwardToBrowser(opts);
151
+ if (exitCode !== 0)
152
+ process.exit(exitCode);
431
153
  }
432
154
  /**
433
- * The fleet browser hub this box drives by default (`browser.device`), or
434
- * undefined to drive locally. It is one fleet-synced value in the central
435
- * agents.yaml, so every box reads the same hub name — but the hub itself resolves
436
- * to a self-host, and a fleet-dispatched invocation is already ON its target, so
437
- * both return undefined. That self short-circuit is what lets a single synced
438
- * value be safe: only the boxes that are NOT the hub forward to it, and the hub
439
- * never forwards to itself.
155
+ * Register every plain verb as an opaque forwarder.
156
+ *
157
+ * `allowUnknownOption` is what makes this thin: commander stops trying to parse
158
+ * flags it does not own and hands them through in `cmd.args`, so the engine's flag
159
+ * surface (and its `tab`/`set`/`record`/`profiles` subverbs) can grow without a
160
+ * matching edit here. `start` resolves `--device` from the raw args for the fd-3
161
+ * target; every other verb forwards it (and lets the engine reject a stray one).
440
162
  */
441
- export function defaultBrowserHub() {
442
- if (isFleetRemoteInvocation())
443
- return undefined;
444
- const hub = getConfigValue('browser.device').value;
445
- if (!hub || isSelfHost(hub))
446
- return undefined;
447
- return hub;
163
+ function registerPassthroughVerbs(program) {
164
+ for (const verb of BROWSER_PASSTHROUGH_VERBS) {
165
+ program
166
+ .command(verb.name)
167
+ .description(verb.description)
168
+ .allowUnknownOption(true)
169
+ .allowExcessArguments(true)
170
+ .helpOption(false)
171
+ .action(async (_opts, cmd) => {
172
+ const argv = [verb.name, ...cmd.args];
173
+ const device = verb.name === 'start' ? peekDevice(cmd.args) : undefined;
174
+ await forwardAndExit({ argv, device });
175
+ });
176
+ }
177
+ }
178
+ // sessions — task-first history over the on-disk captures + `browser_sessions`
179
+ // rows (RUSH-2407), the browser counterpart of `agents computer sessions`. It
180
+ // reads agents-cli's own store, so it never reaches the engine. `agents sessions
181
+ // --browser` (sessions.ts) routes to the same runBrowserSessionsCommand.
182
+ function registerSessionsCommand(program) {
183
+ program
184
+ .command('sessions')
185
+ .description('Browse a profile\'s captured screenshots, PDFs, recordings, and downloads, grouped by task')
186
+ .option('--profile <name>', 'Only this profile (default: all profiles with captures)')
187
+ .option('--open [selector]', "Open a capture in the OS default app: 'latest' or a filename")
188
+ .option('--json', 'Emit machine-readable JSON')
189
+ .option('--no-interactive', 'Print the flat listing instead of opening the interactive task browser')
190
+ .action(async (opts) => {
191
+ await runBrowserSessionsCommand({ profile: opts.profile, open: opts.open, json: opts.json, interactive: opts.interactive });
192
+ });
448
193
  }
449
- // Help groups — surfaces the actual mental model an agent follows
450
- // ("open a session / drive the page / capture evidence / rare extras")
451
- // instead of an alphabetical dump. Everything not listed falls into a
452
- // trailing "Other commands" section automatically.
453
- const BROWSER_HELP_GROUPS = [
454
- { title: 'Session lifecycle', names: ['use', 'start', 'done', 'status', 'prune'] },
455
- { title: 'Fast action loop', names: ['stream'] },
456
- {
457
- title: 'Drive the page',
458
- names: ['navigate', 'tabs', 'screenshot', 'evaluate', 'click', 'type', 'press', 'wait'],
459
- },
460
- {
461
- title: 'Capture evidence',
462
- names: ['console', 'errors', 'requests', 'responsebody', 'record', 'pdf', 'logs'],
463
- },
464
- { title: 'History and discovery', names: ['sessions', 'history', 'refs'] },
465
- ];
466
194
  export function registerBrowserCommand(program) {
467
195
  const browser = program
468
196
  .command('browser')
469
- .description('Launch and drive browser profiles via the Chrome DevTools Protocol. Power-tool for the `browser` skill.');
197
+ .description('Drive a real browser (Chrome/Brave/Edge/Firefox/Arc) over CDP/BiDi — navigate, screenshot, click, capture; --device to drive a remote box');
198
+ registerPassthroughVerbs(browser);
199
+ registerSessionsCommand(browser);
200
+ registerCommandGroups(browser, BROWSER_HELP_GROUPS);
470
201
  setHelpSections(browser, {
471
202
  examples: `
472
- # List configured browser profiles
473
- agents browser profiles list
474
-
475
- # Create a Chrome profile pointed at a CDP endpoint
476
- agents browser profiles create work --browser chrome --endpoint http://localhost:9222
477
-
478
- # Start a session on this machine's default browser (set it once with: agents browser use)
479
- agents browser start
203
+ # One-time: install the engine, then pick a profile
204
+ npm i -g @phnx-labs/browser-cli
205
+ agents browser profiles create work --browser chromium
206
+ agents browser use work
480
207
 
481
- # Or pin to a specific profile
208
+ # Start a task, drive it, capture, close
482
209
  agents browser start --profile work
483
-
484
- # Drive the page (no start / --task needed — identity resolves the task)
485
210
  agents browser navigate https://example.com
486
- agents browser screenshot
487
-
488
- # Every tab open in the profile browser, yours next to the task's (OWNER column)
489
- agents browser tabs --all
490
-
491
- # Keep one process and browser-service socket warm for repeated actions
492
- agents browser stream --task "$AGENTS_BROWSER_TASK"
493
-
494
- # Bind a device at start; later verbs resolve it from the task
495
- agents browser start --task post --device zion --url https://x.com/
496
- agents browser type --task post --ref @e3 "hello"
497
- agents browser screenshot --task post
498
-
499
- # Browse a task-heavy profile's captures: one row per task, not per file
500
- agents browser sessions
501
-
502
- # Allow / deny other fleet machines driving THIS machine's browser
503
- agents browser remote-control on
504
-
505
- # End the session when done
211
+ agents browser screenshot -o /tmp/shot.png
506
212
  agents browser done
507
213
 
508
- # Close tabs the daemon's own reaper would have caught on its next 5-min tick
509
- agents browser prune --dry-run
510
-
511
- # Recover browser IPC without interrupting routines, usage, or secrets:
512
- # stop only the browser service, then start it clean
513
- agents browser stop --service
514
- agents browser start
515
- `,
516
- notes: `
517
- Most agent workflows should use the 'browser' skill instead of raw subcommands.
518
- The skill wraps profile selection, snapshotting, and tunneling.
519
-
520
- Browser support: Chromium-family over the Chrome DevTools Protocol (Chrome,
521
- Comet, Chromium, Brave, Edge), Arc natively (one profile per Space), and
522
- Firefox over WebDriver BiDi (one profile per profiles.ini entry; Firefox 129+
523
- dropped CDP). Safari is not supported. On Windows, Edge is the default
524
- because it's preinstalled. On macOS and Linux, Chrome is preferred when
525
- installed; otherwise the first Chromium-family binary on disk wins.
526
- `,
527
- });
528
- registerBrowserUseCommand(browser);
529
- registerProfilesCommands(browser);
530
- registerTaskCommands(browser);
531
- registerCommandGroups(browser, BROWSER_HELP_GROUPS);
532
- }
533
- export function registerBrowserSubcommands(program) {
534
- registerBrowserUseCommand(program);
535
- registerProfilesCommands(program);
536
- registerTaskCommands(program);
537
- registerCommandGroups(program, BROWSER_HELP_GROUPS);
538
- }
539
- export function buildBrowserUseChoices(profiles, installed, current) {
540
- const choices = profiles.map((profile) => ({
541
- name: `${profile.name}${profile.name === current ? ' (current)' : ''}`,
542
- value: { name: profile.name },
543
- }));
544
- const known = new Set(profiles.map((profile) => profile.name));
545
- for (const browser of installed) {
546
- const name = `${browser.browserType}-local`;
547
- if (!known.has(name)) {
548
- choices.push({ name: `${name} (installed ${browser.browserType})`, value: { name, installed: browser } });
549
- }
550
- }
551
- return choices;
552
- }
553
- export async function runBrowserUse(name, opts, interactive = isInteractiveTerminal()) {
554
- if (opts.unset || name === 'auto') {
555
- unsetConfigValue('browser.profile');
556
- console.log('Default browser profile cleared. Pick one with `agents browser use <name>` (or `agents setup`); until then `agents browser start` has no default to launch here.');
557
- return true;
558
- }
559
- let selectedName = name;
560
- if (!selectedName) {
561
- const current = getConfiguredDefaultProfileName();
562
- if (!interactive) {
563
- console.log(current
564
- ? `Default browser profile (this machine): ${current}`
565
- : 'Default browser profile (this machine): none — pick one with `agents browser use <name>`');
566
- const hub = getConfigValue('browser.device').value;
567
- if (hub) {
568
- console.log(isSelfHost(hub)
569
- ? `Browser hub (browser.device): ${hub} — this machine, so drives locally`
570
- : `Browser hub (browser.device): ${hub} — bare \`agents browser start\` drives ${hub}'s browser`);
571
- }
572
- console.log('Usage: agents browser use <name> (or --unset)');
573
- console.log('Fleet hub: agents config set browser.device <device> (drive that box\'s browser from every machine)');
574
- return true;
575
- }
576
- const choices = buildBrowserUseChoices(await listProfiles(), listInstalledBrowsers(), current);
577
- if (choices.length === 0) {
578
- console.error('No configured profiles or supported installed browsers found.');
579
- console.error('Create one with: agents browser profiles create <name> --browser chrome');
580
- return false;
581
- }
582
- const { select } = await import('@inquirer/prompts');
583
- const selected = await select({ message: 'Select default browser profile:', choices });
584
- selectedName = selected.name;
585
- if (selected.installed) {
586
- const freePort = await findFreeProfilePort();
587
- await createProfile({
588
- name: selected.name,
589
- description: `Seeded ${selected.installed.browserType} profile`,
590
- browser: selected.installed.browserType,
591
- binary: selected.installed.binary,
592
- endpoints: [`cdp://127.0.0.1:${freePort}`],
593
- viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
594
- });
595
- }
596
- }
597
- const target = await getProfile(selectedName);
598
- if (!target) {
599
- console.error(`Profile "${selectedName}" not found.`);
600
- const all = await listProfiles();
601
- if (all.length > 0)
602
- console.error(`Available profiles: ${all.map((profile) => profile.name).join(', ')}`);
603
- return false;
604
- }
605
- await persistDiscoveredProfile(selectedName);
606
- setConfigValue('browser.profile', selectedName);
607
- const endpoint = Object.values(getEndpointPresets(target))[0]?.target ?? '';
608
- console.log(`Default browser profile (this machine) is now "${selectedName}" (${target.browser}${endpoint ? `, ${endpoint}` : ''}).`);
609
- console.log('Bare `agents browser start` and `--profile default` will use it.');
610
- return true;
611
- }
612
- function configureBrowserUseCommand(command, deprecated = false) {
613
- command
614
- .description('Pick the profile `agents browser start` uses when no --profile is passed. No name opens a picker on a TTY or prints the current default headlessly.')
615
- .option('--unset', 'Clear the configured default (leaves this machine with no default until you pick one)')
616
- .action(async (name, opts) => {
617
- if (deprecated) {
618
- console.warn(chalk.yellow('Deprecation: `agents browser profiles set-default` is replaced by `agents browser use`.'));
619
- }
620
- if (!await runBrowserUse(name, opts))
621
- process.exitCode = 1;
622
- });
623
- }
624
- function registerBrowserUseCommand(browser) {
625
- configureBrowserUseCommand(browser.command('use [name]'));
626
- }
627
- /**
628
- * Whether a profile's secrets bundle holds login creds for `service`
629
- * (`<PREFIX>_USERNAME` + `<PREFIX>_PASSWORD`). Reads bundle METADATA only (key
630
- * names, never values) so it doesn't decrypt. Returns the bundle name when both
631
- * keys exist, '-' when not, or '(unknown)' if the metadata read would prompt or
632
- * fails — the view must never block on Touch ID.
633
- */
634
- async function credAvailability(bundleName, service) {
635
- if (!bundleName)
636
- return '-';
637
- const keys = credKeysForService(service);
638
- if (!keys)
639
- return '-';
640
- try {
641
- if (!(await bundleExists(bundleName)))
642
- return '-';
643
- const present = new Set(Object.keys((await readBundle(bundleName)).vars));
644
- return present.has(keys.user) && present.has(keys.pass) ? bundleName : '-';
645
- }
646
- catch {
647
- return '(unknown)';
648
- }
649
- }
650
- function registerProfilesCommands(browser) {
651
- const profiles = browser
652
- .command('profiles')
653
- .description('Manage browser profiles');
654
- profiles
655
- .command('list')
656
- .alias('ls')
657
- .description('List all browser profiles and the devices declaring each one (WHERE)')
658
- .option('--json', 'Output machine-readable JSON (includes devices + kind)')
659
- .action(async (opts) => {
660
- const allProfiles = await listProfiles();
661
- const configuredDefault = getConfiguredDefaultProfileName();
662
- if (opts.json) {
663
- console.log(JSON.stringify(allProfiles.map((profile) => ({
664
- ...profile,
665
- devices: profile.devices,
666
- kind: profile.arc || profile.firefox ? 'identity' : profileKind(profile.name),
667
- isConfiguredDefault: profile.name === configuredDefault,
668
- })), null, 2));
669
- return;
670
- }
671
- if (allProfiles.length === 0) {
672
- console.log('No browser profiles configured.');
673
- console.log('Create one with: agents browser profiles create <name> --browser chrome');
674
- return;
675
- }
676
- // Rendering lives in lib/browser/profiles.ts so the column widths and the
677
- // default-marking rules are unit-tested rather than eyeballed (RUSH-2710).
678
- for (const line of formatProfilesTable(allProfiles, configuredDefault))
679
- console.log(line);
680
- });
681
- profiles
682
- .command('seed')
683
- .description('Create a machine-local profile for each installed browser (named <browser>-local), so you can pick or use one instead of hand-crafting each. Idempotent — existing profiles are left untouched.')
684
- .action(async () => {
685
- const installed = listInstalledBrowsers();
686
- if (installed.length === 0) {
687
- console.error('No supported browser found to seed a profile for.');
688
- process.exit(1);
689
- }
690
- for (const { browserType, binary } of installed) {
691
- const name = `${browserType}-local`;
692
- if (await getProfile(name)) {
693
- console.log(`= ${name} (already exists)`);
694
- continue;
695
- }
696
- const freePort = await findFreeProfilePort();
697
- await createProfile({
698
- name,
699
- description: `Seeded ${browserType} profile`,
700
- browser: browserType,
701
- binary,
702
- endpoints: [`cdp://127.0.0.1:${freePort}`],
703
- viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
704
- });
705
- console.log(`+ ${name} (${browserType})`);
706
- }
707
- console.log('Pick your default with: agents browser use <name>');
708
- });
709
- profiles
710
- .command('claim [name]')
711
- .description('Move leftover central browser: profiles into this device\'s declaration file. Only profiles this machine can host are claimed; the rest stay central. Run on the machine that actually has the browser.')
712
- .option('--json', 'Output machine-readable JSON')
713
- .action(async (name, opts) => {
714
- const canHostHere = (config) => isProfileLaunchableHere({
715
- name: '_',
716
- browser: config.browser,
717
- binary: config.binary,
718
- endpoints: config.endpoints,
719
- });
720
- let result;
721
- try {
722
- result = migrateCentralBrowserProfiles(canHostHere, name);
723
- }
724
- catch (err) {
725
- console.error(err instanceof Error ? err.message : String(err));
726
- process.exit(1);
727
- }
728
- if (opts.json) {
729
- console.log(JSON.stringify({ ...result, device: machineId() }, null, 2));
730
- return;
731
- }
732
- if (result.claimed.length === 0 && result.skipped.length === 0) {
733
- console.log('No leftover central browser profiles to claim.');
734
- return;
735
- }
736
- for (const claimed of result.claimed) {
737
- console.log(`Claimed ${claimed} on ${machineId()}`);
738
- }
739
- for (const skipped of result.skipped) {
740
- console.log(`Skipped ${skipped}: this machine cannot host it (browser/binary isn't installed here). ` +
741
- `Run \`agents browser profiles claim ${skipped}\` on the machine that has that browser.`);
742
- }
743
- if (result.claimed.length === 0) {
744
- console.log('No leftover central profiles this machine can host.');
745
- }
746
- });
747
- configureBrowserUseCommand(profiles.command('use [name]'));
748
- configureBrowserUseCommand(profiles.command('set-default [name]', { hidden: true }), true);
749
- profiles
750
- .command('logins')
751
- .description('Show which login-gated services each profile has a live session for, the account signed in, and whether login creds are available in the profile\'s secrets bundle (reads cookie/username presence only, never decrypts).')
752
- .action(async () => {
753
- const allProfiles = await listProfiles();
754
- if (allProfiles.length === 0) {
755
- console.log('No browser profiles configured.');
756
- return;
757
- }
758
- console.log('PROFILE'.padEnd(20) + 'SERVICE'.padEnd(12) + 'ACCOUNT'.padEnd(32) + 'CREDS');
759
- console.log('-'.repeat(80));
760
- for (const p of allProfiles) {
761
- const rows = await loginsWithAccountsForProfile(p.name);
762
- if (rows.length === 0) {
763
- console.log(p.name.padEnd(20) + '(none detected)');
764
- continue;
765
- }
766
- let first = true;
767
- for (const r of rows) {
768
- const name = first ? p.name : '';
769
- console.log(name.padEnd(20) +
770
- r.service.padEnd(12) +
771
- (r.username ?? '(unknown)').padEnd(32) +
772
- (await credAvailability(p.secrets, r.service)));
773
- first = false;
774
- }
775
- }
776
- });
777
- const VALID_BROWSERS = ['chrome', 'comet', 'chromium', 'brave', 'edge', 'arc', 'custom'];
778
- profiles
779
- .command('create <name>')
780
- .alias('add')
781
- .description('Create a new browser profile on this device')
782
- .requiredOption('-b, --browser <type>', `Browser type: ${VALID_BROWSERS.join(', ')}`)
783
- .option('-e, --endpoint <url>', 'CDP endpoint URL (repeatable; auto-assigned if omitted)', collect, [])
784
- .option('-s, --secrets <bundle>', 'Secrets bundle to inject')
785
- .option('-d, --description <text>', 'Profile description')
786
- .option('--headless', 'Run in headless mode')
787
- .option('--window <WxH>', `Window size in CSS pixels (default: ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}, MacBook Pro 14")`)
788
- .option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
789
- .option('--binary <path>', 'Absolute path to the browser/app binary (required with --browser custom)')
790
- .option('--electron', 'Treat this profile as an Electron desktop app: never call Target.createTarget; bind to the visible window using --target-filter or the skip-invisible heuristic')
791
- .option('--target-filter <expr>', 'Pick the existing CDP page target to drive (Electron apps, and Arc — which reuses an open tab / Space rather than creating one). Format: url:<substring> or title:<substring>')
792
- .option('--attach-only', 'Never spawn a rival window: attach to a browser you already started with remote debugging, else fail loud with a relaunch hint (PHNX-3967). The model for a canonical signed-in Comet; Arc is always attach-only. Pairs with a durable --user-data-dir.')
793
- .option('--user-data-dir <path>', "Absolute durable --user-data-dir for this profile's browser. Default for attach-only: ~/.agents/.history/browser-profiles/<name>/chrome-data (outside .cache, so a one-time sign-in survives relaunch).")
794
- .action(async (name, opts) => {
795
- try {
796
- assertRegistrableProfileName(name);
797
- }
798
- catch (err) {
799
- console.error(err instanceof Error ? err.message : String(err));
800
- process.exit(1);
801
- }
802
- if (!VALID_BROWSERS.includes(opts.browser)) {
803
- console.error(`Invalid browser type. Must be one of: ${VALID_BROWSERS.join(', ')}`);
804
- process.exit(1);
805
- }
806
- if (opts.browser === 'custom' && !opts.binary) {
807
- console.error('--browser custom requires --binary <path>');
808
- process.exit(1);
809
- }
810
- if (opts.targetFilter) {
811
- // Route through the same parser the runtime uses so the CLI gate matches
812
- // the runtime contract — `url:` (empty value) and `url: foo` (leading
813
- // whitespace) both pass a naive `kind` check but produce a silent
814
- // heuristic fallback at runtime.
815
- const parsed = parseTargetFilter(String(opts.targetFilter));
816
- if (!parsed) {
817
- console.error('--target-filter must be url:<substring> or title:<substring> (non-empty value, no leading whitespace)');
818
- process.exit(1);
819
- }
820
- // The filter picks WHICH existing page target to drive without ever
821
- // calling Target.createTarget — the two profiles that do that are
822
- // Electron apps and Arc (which crashes on tab creation, so it drives an
823
- // existing tab / Space, PHNX-2399). Any other browser opens its own tab
824
- // and never consults the filter, so requiring it there would be a lie.
825
- if (!opts.electron && opts.browser !== 'arc') {
826
- console.error('--target-filter requires --electron or --browser arc (the filter is only consulted on profiles that reuse an existing tab)');
827
- process.exit(1);
828
- }
829
- }
830
- // Auto-assign a free port if no endpoint was provided
831
- let endpoints = opts.endpoint;
832
- if (endpoints.length === 0) {
833
- const freePort = await findFreeProfilePort();
834
- endpoints = [`cdp://127.0.0.1:${freePort}`];
835
- }
836
- // Viewport is mandatory — default to MacBook Pro 14" (1512x982) if
837
- // --window is not provided. See lib/browser/devices.ts DEFAULT_VIEWPORT.
838
- let viewport = {
839
- width: DEFAULT_VIEWPORT.width,
840
- height: DEFAULT_VIEWPORT.height,
841
- };
842
- if (opts.window) {
843
- const size = parseWindowSize(String(opts.window));
844
- if (!size) {
845
- console.error(`--window must be WxH, e.g. ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
846
- process.exit(1);
847
- }
848
- viewport.width = size.width;
849
- viewport.height = size.height;
850
- }
851
- if (opts.position) {
852
- const pos = parseWindowPosition(String(opts.position));
853
- if (!pos) {
854
- console.error('--position must be X,Y, e.g. 80,80');
855
- process.exit(1);
856
- }
857
- viewport.x = pos.x;
858
- viewport.y = pos.y;
859
- }
860
- if (opts.userDataDir && !path.isAbsolute(String(opts.userDataDir))) {
861
- console.error('--user-data-dir must be an absolute path');
862
- process.exit(1);
863
- }
864
- const profile = {
865
- name,
866
- description: opts.description,
867
- browser: opts.browser,
868
- binary: opts.binary,
869
- electron: opts.electron || undefined,
870
- targetFilter: opts.targetFilter,
871
- endpoints,
872
- launchPolicy: opts.attachOnly ? 'attach-only' : undefined,
873
- userDataDir: opts.userDataDir ? String(opts.userDataDir) : undefined,
874
- secrets: opts.secrets,
875
- chrome: opts.headless ? { headless: true } : undefined,
876
- viewport,
877
- };
878
- await createProfile(profile);
879
- const port = extractConfiguredPort(profile);
880
- console.log(port !== undefined
881
- ? `Added "${name}" on ${machineId()} (port ${port}).`
882
- : `Added "${name}" on ${machineId()}.`);
883
- // Attach-only profiles never launch a browser — tell the user the one-time
884
- // relaunch that makes their canonical instance attachable, pinned to the
885
- // durable data dir so the sign-in persists (PHNX-3967).
886
- if (isAttachOnlyProfile(profile) && profile.browser !== 'arc' && port !== undefined) {
887
- const app = profile.browser === 'comet' ? 'Comet' : profile.browser;
888
- console.log(`attach-only: agents will not spawn ${app}. Start the canonical one once with:\n` +
889
- ` open -a ${app} --args --remote-debugging-port=${port} --user-data-dir=${resolveProfileDataDir(profile)}\n` +
890
- `Sign in in that window; the data dir is durable so the login survives quit+relaunch.`);
891
- }
892
- // Warn (don't fail) if the declared secrets bundle doesn't exist yet — it
893
- // may be created later, but a typo should surface now.
894
- if (opts.secrets && !(await bundleExists(opts.secrets))) {
895
- console.error(`warning: secrets bundle "${opts.secrets}" does not exist yet. Create it with: agents secrets create ${opts.secrets}`);
896
- }
897
- });
898
- profiles
899
- .command('edit <name>')
900
- .description('Edit an existing profile in place (stays in the store it already lives in)')
901
- // Declared only so the action can explain WHY the browser type is not
902
- // editable. Without it commander rejects `-b` as an unknown option before
903
- // the action runs, and the delete-and-recreate guidance never reaches anyone.
904
- .option('-b, --browser <type>', 'Not editable — see the error for the delete-and-recreate path')
905
- .option('-d, --description <text>', "Profile description (pass '' to clear)")
906
- .option('-e, --endpoint <url>', 'Replace the endpoint list (repeatable)', collect, [])
907
- .option('-s, --secrets <bundle>', 'Secrets bundle to inject')
908
- .option('--headless', 'Run in headless mode')
909
- .option('--no-headless', 'Run headed')
910
- .option('--window <WxH>', 'Window size in CSS pixels')
911
- .option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
912
- .option('--binary <path>', 'Absolute path to the browser/app binary')
913
- .option('--electron', 'Treat this profile as an Electron desktop app')
914
- .option('--no-electron', 'Stop treating it as an Electron app')
915
- .option('--target-filter <expr>', "url:<substring> or title:<substring>; consulted on Electron and Arc profiles (pass '' to clear)")
916
- .option('--attach-only', 'Make this profile attach-only (never spawn a rival window) — PHNX-3967')
917
- .option('--launch', 'Undo attach-only: let agents launch the browser when nothing is serving CDP on the port')
918
- .option('--user-data-dir <path>', "Absolute durable --user-data-dir (pass '' to clear back to the default durable dir)")
919
- .option('--json', 'Output machine-readable JSON')
920
- .action(async (name, opts) => {
921
- // The browser type and the name are identity, not settings: both key the
922
- // on-disk runtime dir and any live `<name>@<endpoint>` connection, so
923
- // changing either orphans the cached browser data (and its logins).
924
- if (opts.browser) {
925
- console.error('browser type is not editable — it keys the on-disk profile cache. ' +
926
- `Remove and recreate instead: agents browser profiles remove ${name} && agents browser profiles create ${name} -b <type>`);
927
- process.exit(1);
928
- }
929
- if (opts.targetFilter) {
930
- const parsed = parseTargetFilter(String(opts.targetFilter));
931
- if (!parsed) {
932
- console.error('--target-filter must be url:<substring> or title:<substring> (non-empty value, no leading whitespace)');
933
- process.exit(1);
934
- }
935
- }
936
- if (opts.attachOnly && opts.launch) {
937
- console.error('Pass either --attach-only or --launch, not both.');
938
- process.exit(1);
939
- }
940
- if (opts.userDataDir && !path.isAbsolute(String(opts.userDataDir))) {
941
- console.error('--user-data-dir must be an absolute path');
942
- process.exit(1);
943
- }
944
- const patch = {};
945
- if (opts.description !== undefined)
946
- patch.description = opts.description || undefined;
947
- if (opts.endpoint && opts.endpoint.length > 0)
948
- patch.endpoints = opts.endpoint;
949
- if (opts.secrets !== undefined)
950
- patch.secrets = opts.secrets;
951
- if (opts.binary !== undefined)
952
- patch.binary = opts.binary;
953
- if (opts.targetFilter !== undefined)
954
- patch.targetFilter = opts.targetFilter || undefined;
955
- // launchPolicy toggle: --attach-only sets it, --launch clears it (back to
956
- // the default launch behavior). commander leaves each undefined when unset.
957
- if (opts.attachOnly)
958
- patch.launchPolicy = 'attach-only';
959
- else if (opts.launch)
960
- patch.launchPolicy = undefined;
961
- // '' clears back to the default durable dir; any other value pins it.
962
- if (opts.userDataDir !== undefined)
963
- patch.userDataDir = opts.userDataDir || undefined;
964
- // commander maps --electron/--no-electron and --headless/--no-headless onto
965
- // one boolean each, and leaves it undefined when neither was passed.
966
- if (opts.electron !== undefined)
967
- patch.electron = opts.electron || undefined;
968
- if (opts.headless !== undefined || opts.window || opts.position) {
969
- const current = await getProfile(name);
970
- if (!current) {
971
- console.error(`Profile "${name}" does not exist`);
972
- process.exit(1);
973
- }
974
- if (opts.headless !== undefined) {
975
- const chrome = { ...current.chrome };
976
- if (opts.headless)
977
- chrome.headless = true;
978
- else
979
- delete chrome.headless;
980
- // `{ headless: undefined }` is truthy, so profileToConfig would persist
981
- // a bare `chrome: {}` into the YAML and the change-detector would report
982
- // a phantom edit on an already-headed profile. Drop the key entirely.
983
- patch.chrome = Object.keys(chrome).length > 0 ? chrome : undefined;
984
- }
985
- if (opts.window || opts.position) {
986
- const viewport = { ...(current.viewport ?? { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height }) };
987
- if (opts.window) {
988
- const size = parseWindowSize(String(opts.window));
989
- if (!size) {
990
- console.error('--window must be WxH, e.g. 1512x982');
991
- process.exit(1);
992
- }
993
- viewport.width = size.width;
994
- viewport.height = size.height;
995
- }
996
- if (opts.position) {
997
- const pos = parseWindowPosition(String(opts.position));
998
- if (!pos) {
999
- console.error('--position must be X,Y, e.g. 80,80');
1000
- process.exit(1);
1001
- }
1002
- viewport.x = pos.x;
1003
- viewport.y = pos.y;
1004
- }
1005
- patch.viewport = viewport;
1006
- }
1007
- }
1008
- if (Object.keys(patch).length === 0) {
1009
- console.error('Nothing to edit. Pass at least one field, e.g. -d "<description>".');
1010
- process.exit(1);
1011
- }
1012
- let result;
1013
- try {
1014
- result = await editProfile(name, patch);
1015
- }
1016
- catch (err) {
1017
- console.error(err instanceof Error ? err.message : String(err));
1018
- process.exit(1);
1019
- }
1020
- if (opts.json) {
1021
- console.log(JSON.stringify({ ...result.profile, devices: result.devices, changed: result.changed }, null, 2));
1022
- }
1023
- else if (result.changed.length === 0) {
1024
- console.log(`No change: ${name} already had those values.`);
1025
- }
1026
- else {
1027
- console.log(`Updated ${name} on ${machineId()}: ${result.changed.join(', ')}`);
1028
- }
1029
- if (patch.secrets && !(await bundleExists(patch.secrets))) {
1030
- console.error(`warning: secrets bundle "${patch.secrets}" does not exist yet. Create it with: agents secrets create ${patch.secrets}`);
1031
- }
1032
- });
1033
- profiles
1034
- .command('rename <from> <to>')
1035
- .description("Rename a profile, moving its browser data with it (logins survive)")
1036
- .option('--json', 'Output machine-readable JSON')
1037
- .action(async (from, to, opts) => {
1038
- let res;
1039
- try {
1040
- res = await renameProfile(from, to);
1041
- }
1042
- catch (err) {
1043
- console.error(err instanceof Error ? err.message : String(err));
1044
- process.exit(1);
1045
- }
1046
- if (opts.json) {
1047
- console.log(JSON.stringify({ from, to, ...res }, null, 2));
1048
- return;
1049
- }
1050
- console.log(`Renamed ${from} -> ${to} on ${machineId()}`);
1051
- if (res.movedDirs.length > 0) {
1052
- console.log(` moved ${res.movedDirs.length} browser data dir${res.movedDirs.length === 1 ? '' : 's'} — logins preserved`);
1053
- }
1054
- if (res.repointedDefault) {
1055
- console.log(` browser.profile now points at ${to}`);
1056
- }
1057
- if (res.repointedViewer) {
1058
- console.log(` browser.viewer now points at ${to}`);
1059
- }
1060
- if (res.stalePins.length > 0) {
1061
- const devices = [...new Set(res.stalePins.map((p) => p.device))];
1062
- const one = devices.length === 1;
1063
- console.error(`warning: ${devices.join(', ')} still ${one ? 'pins' : 'pin'} "${from}" in ` +
1064
- `${one ? 'its' : 'their'} own device config. Fix with:`);
1065
- for (const pin of res.stalePins) {
1066
- console.error(` agents config set ${pin.key} ${to} --device ${pin.device}`);
1067
- }
1068
- }
1069
- });
1070
- profiles
1071
- .command('show <name>')
1072
- .description('Show profile details')
1073
- .option('--json', 'Output machine-readable JSON')
1074
- .action(async (name, opts) => {
1075
- const profile = await getProfile(name);
1076
- if (!profile) {
1077
- if (opts.json) {
1078
- console.log(JSON.stringify({ ok: false, error: `Profile "${name}" not found` }));
1079
- }
1080
- else {
1081
- console.error(`Profile "${name}" not found`);
1082
- }
1083
- process.exit(1);
1084
- }
1085
- if (opts.json) {
1086
- console.log(JSON.stringify(profile, null, 2));
1087
- return;
1088
- }
1089
- console.log(`Name: ${profile.name}`);
1090
- console.log(`Browser: ${profile.browser}`);
1091
- if (getConfiguredDefaultProfileName() === profile.name) {
1092
- console.log('Default: yes (this machine — used by `agents browser start`)');
1093
- }
1094
- if (profile.binary)
1095
- console.log(`Binary: ${profile.binary}`);
1096
- if (profile.electron)
1097
- console.log(`Electron: true`);
1098
- if (isAttachOnlyProfile(profile)) {
1099
- const why = profile.browser === 'arc' ? ' (Arc is always attach-only)' : '';
1100
- console.log(`Launch policy: attach-only${why} — agents attach, never spawn a rival window`);
1101
- if (profile.browser !== 'arc') {
1102
- console.log(`User-data-dir: ${resolveProfileDataDir(profile)} (durable)`);
1103
- }
1104
- }
1105
- if (profile.targetFilter)
1106
- console.log(`Target filter: ${profile.targetFilter}`);
1107
- if (profile.description)
1108
- console.log(`Description: ${profile.description}`);
1109
- if (profile.arc) {
1110
- console.log(`Arc Space: ${profile.arc.spaceTitle} (${profile.arc.spaceId})`);
1111
- console.log(`Arc profile: ${profile.arc.profileName} (${profile.arc.profileId})`);
1112
- }
1113
- if (profile.firefox) {
1114
- console.log(`Firefox profile: ${profile.firefox.profileName}${profile.firefox.isDefault ? ' (default)' : ''}`);
1115
- if (profile.userDataDir)
1116
- console.log(`Profile directory: ${profile.userDataDir}`);
1117
- }
1118
- const presets = getEndpointPresets(profile);
1119
- const defaultName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
1120
- ? profile.defaultEndpoint
1121
- : Object.keys(presets)[0];
1122
- console.log('Endpoints:');
1123
- for (const [presetName, preset] of Object.entries(presets)) {
1124
- const marker = presetName === defaultName ? ' (default)' : '';
1125
- const isLegacy = presetName.startsWith('endpoint-');
1126
- console.log(` - ${isLegacy ? preset.target : `${presetName}: ${preset.target}`}${marker}`);
1127
- if (preset.binary)
1128
- console.log(` binary: ${preset.binary}`);
1129
- if (preset.targetFilter)
1130
- console.log(` targetFilter: ${preset.targetFilter}`);
1131
- }
1132
- if (profile.viewport) {
1133
- const v = profile.viewport;
1134
- const pos = v.x !== undefined && v.y !== undefined ? ` @ ${v.x},${v.y}` : '';
1135
- console.log(`Viewport: ${v.width}×${v.height}${pos}`);
1136
- }
1137
- if (profile.secrets)
1138
- console.log(`Secrets: ${profile.secrets}`);
1139
- if (profile.chrome?.headless)
1140
- console.log(`Headless: true`);
1141
- // Login state per known service: live session + account identity + whether
1142
- // login creds are declared in the profile's secrets bundle.
1143
- if (profile.arc || profile.firefox)
1144
- return;
1145
- const active = await loginsForProfile(profile.name);
1146
- const accounts = await accountsForProfile(profile.name);
1147
- const lines = [];
1148
- for (const service of Object.keys(AUTH_SIGNATURES)) {
1149
- const isActive = active.includes(service);
1150
- const creds = await credAvailability(profile.secrets, service);
1151
- if (!isActive && creds === '-')
1152
- continue; // nothing interesting to show
1153
- const status = isActive
1154
- ? `logged in${accounts[service] ? ` as ${accounts[service]}` : ''}`
1155
- : 'logged out';
1156
- const credNote = creds !== '-' ? ` creds: ${creds}` : '';
1157
- lines.push(` ${service.padEnd(10)} ${status}${credNote}`);
1158
- }
1159
- if (lines.length > 0) {
1160
- console.log('Logins:');
1161
- for (const l of lines)
1162
- console.log(l);
1163
- }
1164
- });
1165
- profiles
1166
- .command('remove <name>')
1167
- .alias('delete')
1168
- .description('Remove the agents-cli profile alias and cached runtime dirs (never deletes native Arc data)')
1169
- .option('--keep-cache', "Leave ~/.agents/.cache/browser/<name>* dirs in place (don't wipe chrome-data)")
1170
- .action(async (name, opts) => {
1171
- await deleteProfile(name);
1172
- // The composite naming change introduced multiple cache dirs per
1173
- // profile (`<name>`, `<name>@endpoint-0`, …). Sweep them all unless
1174
- // the user explicitly wants the chrome-data preserved (e.g. for
1175
- // re-import into a freshly-created profile of the same name).
1176
- let removed = 0;
1177
- if (!opts.keepCache) {
1178
- const cacheDirs = listProfileCacheDirs(name);
1179
- removed = cacheDirs.length;
1180
- for (const dir of cacheDirs) {
1181
- // `removeProfileCache` operates by profile-name; for the
1182
- // composite dirs we already have the absolute path. Use rmSync
1183
- // directly so we don't depend on naming round-trips.
1184
- try {
1185
- fs.rmSync(dir, { recursive: true, force: true });
1186
- }
1187
- catch { /* ignore */ }
1188
- }
1189
- // The canonical wipe also covers the legacy dir if present.
1190
- removeProfileCache(name);
1191
- }
1192
- console.log(`Deleted profile: ${name}` +
1193
- (removed > 0 ? ` (and ${removed} cache dir${removed === 1 ? '' : 's'})` : ''));
1194
- if (getConfiguredDefaultProfileName() === name) {
1195
- console.error(`warning: "${name}" was this machine's default browser profile; ` +
1196
- `\`agents browser start\` has no default here until you run: agents browser use <name>`);
1197
- }
1198
- });
1199
- profiles
1200
- .command('prune')
1201
- .description('Remove dead profiles this device declares: browser not installed here, or never started')
1202
- .option('-n, --dry-run', 'Print what would be removed and exit without changing anything')
1203
- .option('--json', 'Output machine-readable JSON')
1204
- .action(async (opts) => {
1205
- const plan = await buildProfilePrunePlan();
1206
- if (!opts.dryRun)
1207
- await pruneProfiles(plan);
1208
- if (opts.json) {
1209
- console.log(JSON.stringify({ dryRun: !!opts.dryRun, ...plan }, null, 2));
1210
- return;
1211
- }
1212
- // A misfiled profile is never a prune candidate — the entry belongs to the
1213
- // device that declared it, not to this one — so without this it would only
1214
- // ever surface in --json. It is the one kept-reason a user has to act on.
1215
- const misfiled = plan.kept.filter((k) => k.misfiled);
1216
- const reportMisfiled = () => {
1217
- if (misfiled.length === 0)
1218
- return;
1219
- console.log('');
1220
- console.log(`${misfiled.length} profile${misfiled.length === 1 ? '' : 's'} ${misfiled.length === 1 ? 'is' : 'are'} misfiled — ` +
1221
- `declared on another device but bound to a loopback port, so the name resolves to ` +
1222
- `this machine's own browser rather than the declaring device's:`);
1223
- for (const k of misfiled) {
1224
- console.log(` ${k.name} — ${k.why}`);
1225
- }
1226
- console.log('Re-declare each on the machine that owns the browser. Nothing was moved for you.');
1227
- console.log('(Prune never deletes these — the declaring device owns the entry.)');
1228
- };
1229
- if (plan.candidates.length === 0) {
1230
- console.log('Nothing to prune — every profile is in use, healthy, or protected.');
1231
- if (plan.kept.length > 0) {
1232
- for (const k of plan.kept)
1233
- console.log(` kept ${k.name} (${k.scope}) — ${k.why}`);
1234
- }
1235
- reportMisfiled();
1236
- return;
1237
- }
1238
- const verb = opts.dryRun ? 'Would remove' : 'Removed';
1239
- console.log(`${verb} ${plan.candidates.length} profile${plan.candidates.length === 1 ? '' : 's'}:`);
1240
- for (const c of plan.candidates) {
1241
- const cache = c.cacheDirs.length > 0
1242
- ? ` + ${c.cacheDirs.length} cache dir${c.cacheDirs.length === 1 ? '' : 's'}`
1243
- : '';
1244
- console.log(` ${c.name} (${c.scope}) — ${PRUNE_REASON_TEXT[c.reason]}${cache}`);
1245
- }
1246
- reportMisfiled();
1247
- if (opts.dryRun) {
1248
- console.log('');
1249
- console.log('Nothing was changed. Re-run without --dry-run to apply.');
1250
- }
1251
- });
1252
- setHelpSections(profiles, {
1253
- examples: `
1254
- # WHERE is the devices whose own file declares the name
1255
- agents browser profiles list
1256
-
1257
- # Create one on this device
1258
- agents browser profiles add work --browser chrome
1259
- agents browser profiles create work --browser chrome
1260
-
1261
- # Claim leftover central profiles on the machine that hosts the browser
1262
- agents browser profiles claim
1263
- agents browser profiles claim comet-local
1264
-
1265
- # Clean up dead ones this device declares: check first, then apply
1266
- agents browser profiles prune --dry-run
1267
- agents browser profiles prune
1268
-
1269
- # Make one the machine's default for a bare \`agents browser start\`
1270
- agents browser use work
214
+ # A remote box over the fleet (device bound at start)
215
+ agents browser start --device box --profile work
216
+ agents browser navigate https://example.com
217
+ agents browser done
1271
218
  `,
1272
219
  notes: `
1273
- A device declares its own browsers in its own \`devices/<machine>/agents.yaml\`.
1274
- A name declared by exactly one device is identity-bearing; a name declared
1275
- by several is fungible. Leftover central \`browser:\` entries are claimed
1276
- with \`agents browser profiles claim\` on the machine that hosts the browser.
1277
-
1278
- In \`list\`, the \`*\` marker means "this machine's configured default"
1279
- (\`agents browser start\` with no --profile). A profile a setup wizard pins
1280
- is named \`auto-chrome\`; \`default\` is only an ALIAS for whichever profile
1281
- this machine is configured to use, resolved the same way by every command.
220
+ The engine is the standalone \`browser\` CLI (npm i -g @phnx-labs/browser-cli);
221
+ agents-cli supplies --device fleet resolution, remote-control consent, and the
222
+ session/feed history. Per-verb flags are the engine's — \`agents browser
223
+ screenshot --help\` asks it directly.
1282
224
 
1283
- \`prune\` only considers profiles this device declares. It never removes a
1284
- profile that is in use, the configured default, or the \`auto-chrome\`
1285
- profile a setup wizard created.
225
+ \`--device\` is bound once at \`start\`; page verbs run against the task's bound
226
+ device. \`--device local\` forces this machine.
1286
227
 
1287
- On a Mac with Arc, every Arc Space is listed as a profile (arc-gmail,
1288
- arc-work, ...) discovered read-only from Arc's own metadata — a Space
1289
- already carries its Arc profile's logins, so it IS the browser profile.
1290
- Agents attach to the running Arc through Apple Events and never launch or
1291
- relaunch it. Comet's own profiles are listed the same way (comet-work, ...),
1292
- pinned to Comet's real user-data dir, so agents and you share one Comet
1293
- window per profile: agents attach when it is running with remote debugging,
1294
- launch it on your store when it is not running, and fail loud with the
1295
- relaunch command when it runs without a port. The daemon publishes every
1296
- discovered profile into this device's declaration, so other fleet boxes list
1297
- it with WHERE=<this device> and route to it. Point agents at Comet when a workflow needs screenshots,
1298
- downloads, network capture, or trusted input:
1299
- agents browser profiles create agents-comet --browser comet --attach-only
1300
- A CDP \`--attach-only\` profile attaches to a browser you
1301
- already started with remote debugging and fails loud with the relaunch
1302
- command otherwise, pinned to a durable --user-data-dir so the sign-in
1303
- survives quit+relaunch. A foreign browser squatting the canonical port is
1304
- rejected, not driven — \`agents browser profiles doctor <name>\` flags it.
1305
- `,
1306
- });
1307
- profiles
1308
- .command('doctor <name>')
1309
- .description('Diagnose a browser profile: where it is declared, binary, port, user-data-dir, onboarding state')
1310
- .action(async (name) => {
1311
- const profile = await getProfile(name);
1312
- if (!profile) {
1313
- console.error(`Profile "${name}" not found`);
1314
- process.exit(1);
1315
- }
1316
- if ((profile.arc || profile.firefox) && !isFleetRemoteInvocation()) {
1317
- const routed = resolveBrowserTarget(profile.name);
1318
- if (!routed.local && routed.commandDispatch) {
1319
- const result = await dispatchBrowserToDevice(routed.device, ['browser', 'profiles', 'doctor', name], 'capture');
1320
- process.stdout.write(result.stdout);
1321
- process.stderr.write(result.stderr);
1322
- if (result.code !== 0)
1323
- process.exit(result.code);
1324
- return;
1325
- }
1326
- }
1327
- const checks = [];
1328
- // 0. Declaration topology. An identity-bearing profile declared by a
1329
- // different device cannot be diagnosed through a loopback CDP endpoint
1330
- // on this one — that is the original comet-local bug. Checked first
1331
- // because it invalidates every local check.
1332
- const misfiled = identityLoopbackMismatch(profile);
1333
- const kind = profileKind(profile.name);
1334
- const where = profile.devices.length > 0 ? profile.devices.join(', ') : 'no device';
1335
- checks.push(misfiled.misfiled
1336
- ? { label: 'where', ok: false, detail: misfiled.why }
1337
- : {
1338
- label: 'where',
1339
- ok: true,
1340
- detail: kind
1341
- ? `${kind}, declared on ${where}`
1342
- : `declared on ${where}`,
1343
- });
1344
- if (misfiled.misfiled) {
1345
- for (const c of checks) {
1346
- const marker = c.ok ? 'OK ' : 'FAIL';
1347
- console.log(`${marker} ${c.label.padEnd(15)} ${c.detail}`);
1348
- }
1349
- process.exit(1);
1350
- }
1351
- if (profile.arc) {
1352
- checks.push({
1353
- label: 'space',
1354
- ok: true,
1355
- detail: `${profile.arc.spaceTitle} (${profile.arc.spaceId})`,
1356
- });
1357
- checks.push({
1358
- label: 'arc-profile',
1359
- ok: true,
1360
- detail: `${profile.arc.profileName} (${profile.arc.profileId})`,
1361
- });
1362
- const running = await isArcRunning();
1363
- checks.push({
1364
- label: 'Arc process',
1365
- ok: running,
1366
- detail: running ? 'running; native automation attaches without launching or restarting Arc' : 'not running — start Arc normally',
1367
- });
1368
- const allOk = checks.every((check) => check.ok);
1369
- for (const check of checks) {
1370
- console.log(`${check.ok ? 'OK ' : 'FAIL'} ${check.label.padEnd(15)} ${check.detail}`);
1371
- }
1372
- if (!allOk)
1373
- process.exit(1);
1374
- return;
1375
- }
1376
- // 1. Binary exists for declared browser type, and is a real executable we
1377
- // can drive — not a distro launcher script. findBrowserPath already
1378
- // unwraps the known Chromium wrappers to their ELF; if it still hands
1379
- // back a shebang script we couldn't resolve, `start` would fail with
1380
- // `CDP connection closed` (the wrapper re-execs the browser as a child,
1381
- // breaking the --remote-debugging-pipe transport — issue #229). Flag it
1382
- // here instead of letting launch fail opaquely.
1383
- try {
1384
- const binPath = findBrowserPath(profile.browser, profile.binary);
1385
- if (isLauncherScript(binPath)) {
1386
- checks.push({
1387
- label: 'binary',
1388
- ok: false,
1389
- detail: `${binPath} is a launcher script, not the browser executable — ` +
1390
- `agents browser drives the browser over --remote-debugging-pipe and ` +
1391
- `can't attach to a wrapper that re-execs it. Point the profile at the ` +
1392
- `real binary (\`--binary /path/to/browser\`) or reinstall the standard package.`,
1393
- });
1394
- }
1395
- else {
1396
- checks.push({ label: 'binary', ok: true, detail: binPath });
1397
- }
1398
- }
1399
- catch (err) {
1400
- checks.push({
1401
- label: 'binary',
1402
- ok: false,
1403
- detail: err instanceof Error ? err.message : String(err),
1404
- });
1405
- }
1406
- // 2. Configured port. For local cdp:// we check the local port. For
1407
- // ssh:// the port lives on a remote host — doctor's previous
1408
- // behavior was to lsof the LOCAL port number, which was both
1409
- // misleading and arbitrary (after the SSH-binds-locally change
1410
- // the local port now matches the remote, so a positive answer
1411
- // is plausible; but doctor still shouldn't report on remote
1412
- // state without an --remote-probe explicitly).
1413
- const port = extractConfiguredPort(profile);
1414
- let attachingToExistingBrowser = false;
1415
- const firstEndpointTarget = (() => {
1416
- const presets = getEndpointPresets(profile);
1417
- const first = Object.keys(presets)[0];
1418
- return first ? presets[first].target : undefined;
1419
- })();
1420
- const isSshEndpoint = firstEndpointTarget?.startsWith('ssh://') ?? false;
1421
- if (port === undefined) {
1422
- checks.push({ label: 'port', ok: true, detail: 'no port in endpoint' });
1423
- }
1424
- else if (isSshEndpoint) {
1425
- checks.push({
1426
- label: 'port',
1427
- ok: true,
1428
- detail: `${port} (remote on ${firstEndpointTarget}) — skipping local check`,
1429
- });
1430
- }
1431
- else {
1432
- const occupant = getPortOccupant(port);
1433
- if (!occupant) {
1434
- // A free port doesn't mean "ready to launch here": for a local
1435
- // profile we self-launch over an internal --remote-debugging-pipe and
1436
- // never bind this port. The port is consulted only to attach to a
1437
- // browser someone already started on it. Say so, so a green doctor
1438
- // can't be read as "the port is what launch depends on" (#229).
1439
- checks.push({
1440
- label: 'port',
1441
- ok: true,
1442
- detail: `${port} free — will self-launch over an internal pipe (port used only to attach to an already-running browser)`,
1443
- });
1444
- }
1445
- else {
1446
- try {
1447
- const { browser } = await discoverBrowserWsUrl(port, 'localhost', profile.name);
1448
- verifyBrowserIdentity(browser, profile.browser, port);
1449
- // Port-squat check (PHNX-3967): for an attach-only profile, a browser
1450
- // of the right FAMILY on the canonical port is not enough — confirm
1451
- // its --user-data-dir is this profile's durable dir. A logged-out
1452
- // /tmp Comet answering CDP here would otherwise pass as ready and get
1453
- // driven as if it were the credentialed browser.
1454
- const expectedDir = resolveProfileDataDir(profile);
1455
- const ownershipChecked = isAttachOnlyProfile(profile) && profile.browser !== 'arc';
1456
- const runningDir = ownershipChecked ? getProcessUserDataDir(occupant.pid) : null;
1457
- if (runningDir && normalizeDataDir(runningDir) !== normalizeDataDir(expectedDir)) {
1458
- checks.push({
1459
- label: 'port',
1460
- ok: false,
1461
- detail: `${port} serving ${browser} (pid ${occupant.pid}) but from a FOREIGN ` +
1462
- `user-data-dir ${runningDir} (expected ${expectedDir}). This is a ` +
1463
- `port-squatter, not this profile's browser — agents will refuse to drive ` +
1464
- `it. Close it (\`kill ${occupant.pid}\`) and relaunch the canonical browser ` +
1465
- `with --user-data-dir=${expectedDir}.`,
1466
- });
1467
- }
1468
- else if (ownershipChecked && !runningDir) {
1469
- // Attach-only, an occupant is serving CDP, but its --user-data-dir
1470
- // couldn't be read — ownership is UNVERIFIED. Surface it rather than
1471
- // report a silent green, matching the runtime guard which refuses to
1472
- // attach to an unverifiable instance (PHNX-3967).
1473
- checks.push({
1474
- label: 'port',
1475
- ok: false,
1476
- detail: `${port} serving ${browser} (pid ${occupant.pid}) but its --user-data-dir ` +
1477
- `could not be read, so ownership can't be confirmed. agents will refuse to ` +
1478
- `attach to an unverified instance. Relaunch the canonical browser with ` +
1479
- `--user-data-dir=${expectedDir} so the attach-only guard can verify it.`,
1480
- });
1481
- }
1482
- else {
1483
- checks.push({
1484
- label: 'port',
1485
- ok: true,
1486
- detail: `${port} serving ${browser} (pid ${occupant.pid})`,
1487
- });
1488
- attachingToExistingBrowser = true;
1489
- }
1490
- }
1491
- catch (err) {
1492
- const msg = err instanceof Error ? err.message : String(err);
1493
- checks.push({
1494
- label: 'port',
1495
- ok: false,
1496
- detail: `${port} taken by ${occupant.command} (pid ${occupant.pid}) — ${msg}`,
1497
- });
1498
- }
1499
- }
1500
- }
1501
- // 3. User-data-dir exists and is writable. An attach-only profile's login
1502
- // lives in the DURABLE dir (outside .cache) the canonical browser is
1503
- // launched with; a launch-policy profile uses the managed cache dir.
1504
- const userDataDir = isAttachOnlyProfile(profile) && profile.browser !== 'arc'
1505
- ? resolveProfileDataDir(profile)
1506
- : path.join(getProfileRuntimeDir(name), 'chrome-data');
1507
- try {
1508
- if (!fs.existsSync(userDataDir)) {
1509
- checks.push({
1510
- label: 'user-data-dir',
1511
- ok: true,
1512
- detail: `will be created at ${userDataDir}`,
1513
- });
1514
- }
1515
- else {
1516
- fs.accessSync(userDataDir, fs.constants.W_OK);
1517
- checks.push({ label: 'user-data-dir', ok: true, detail: userDataDir });
1518
- }
1519
- }
1520
- catch (err) {
1521
- checks.push({
1522
- label: 'user-data-dir',
1523
- ok: false,
1524
- detail: `${userDataDir} not writable: ${err instanceof Error ? err.message : err}`,
1525
- });
1526
- }
1527
- // 4. Onboarding heuristic — only meaningful when WE will launch the
1528
- // browser. When the configured port is already serving a debuggable
1529
- // browser, that browser owns its own user-data-dir and the priming
1530
- // status of our managed dir is irrelevant.
1531
- if (attachingToExistingBrowser) {
1532
- checks.push({
1533
- label: 'onboarding',
1534
- ok: true,
1535
- detail: 'n/a (attaching to existing browser)',
1536
- });
1537
- }
1538
- else {
1539
- const localStatePath = path.join(userDataDir, 'Local State');
1540
- if (fs.existsSync(localStatePath)) {
1541
- const size = fs.statSync(localStatePath).size;
1542
- if (size > 0) {
1543
- checks.push({ label: 'onboarding', ok: true, detail: 'Local State present' });
1544
- }
1545
- else {
1546
- checks.push({
1547
- label: 'onboarding',
1548
- ok: false,
1549
- detail: 'Local State is empty — run `agents browser start --profile ' +
1550
- name +
1551
- '` and finish any first-run screens before automating',
1552
- });
1553
- }
1554
- }
1555
- else {
1556
- checks.push({
1557
- label: 'onboarding',
1558
- ok: false,
1559
- detail: 'Not initialized yet — run `agents browser start --profile ' +
1560
- name +
1561
- '` and finish any first-run screens before automating',
1562
- });
1563
- }
1564
- }
1565
- const allOk = checks.every((c) => c.ok);
1566
- for (const c of checks) {
1567
- const marker = c.ok ? 'OK ' : 'FAIL';
1568
- console.log(`${marker} ${c.label.padEnd(15)} ${c.detail}`);
1569
- }
1570
- if (!allOk)
1571
- process.exit(1);
1572
- });
1573
- }
1574
- function registerTaskCommands(browser) {
1575
- browser.hook('preAction', async (_thisCommand, actionCommand) => {
1576
- inferredTaskName = undefined;
1577
- if (isFleetRemoteInvocation())
1578
- return;
1579
- const pathNames = commandPath(actionCommand);
1580
- const top = pathNames[0];
1581
- if (!top || !TASK_ROUTED_COMMANDS.has(top))
1582
- return;
1583
- if (pathNames.length === 1 && top === 'start')
1584
- return;
1585
- const opts = actionCommand.opts();
1586
- const deviceFlag = opts.device ?? flagValue(process.argv.slice(2), 'device', 'D');
1587
- const taskFlag = opts.task ?? flagValue(process.argv.slice(2), 'task');
1588
- // Reject --device on every later verb, including `stop --profile`.
1589
- if (deviceFlag) {
1590
- console.error(REJECT_DEVICE_MESSAGE);
1591
- process.exit(1);
1592
- }
1593
- if (top === 'stop' && opts.profile && !taskFlag)
1594
- return;
1595
- // A native Arc endpoint cannot be represented by an SSH-forwarded socket.
1596
- // For a cold profile-scoped page verb, create the task on the declaring
1597
- // owner first and bind that owner locally; the ordinary task router below
1598
- // then forwards this complete verb and every subsequent verb to that host.
1599
- if (!taskFlag && opts.profile) {
1600
- try {
1601
- const profileName = (await resolveProfileRef(opts.profile)) ?? opts.profile;
1602
- const target = resolveBrowserTarget(profileName);
1603
- if (!target.local && target.commandDispatch) {
1604
- const started = await dispatchBrowserToDevice(target.device, ['browser', 'start', '--profile', profileName, '--json'], 'capture');
1605
- if (started.code !== 0) {
1606
- process.stdout.write(started.stdout);
1607
- process.stderr.write(started.stderr);
1608
- process.exit(started.code);
1609
- }
1610
- const task = remoteStartTaskName(started.stdout);
1611
- if (!task)
1612
- throw new Error(`Remote start on ${target.device} produced no task name.`);
1613
- bindTask(task, {
1614
- device: target.device,
1615
- profile: profileName,
1616
- sessionId: callerSessionId(),
1617
- launchId: callerLaunchId(),
1618
- createdAt: Date.now(),
1619
- });
1620
- inferredTaskName = task;
1621
- }
1622
- }
1623
- catch (err) {
1624
- console.error(err instanceof Error ? err.message : String(err));
1625
- process.exit(1);
1626
- }
1627
- }
1628
- const route = resolveTaskRoute({
1629
- task: inferredTaskName ?? taskFlag ?? process.env.AGENTS_BROWSER_TASK,
1630
- sessionId: callerSessionId(),
1631
- launchId: callerLaunchId(),
1632
- hub: defaultBrowserHub(),
1633
- });
1634
- if (route.kind !== 'proceed') {
1635
- console.error(route.message);
1636
- process.exit(1);
1637
- }
1638
- inferredTaskName = route.task;
1639
- // A cold page verb (no bound task) whose route is the fleet hub still
1640
- // forwards: `route.task` is undefined but `route.device` is a peer, so the
1641
- // `--task` push below is skipped and the hub's daemon creates the caller's
1642
- // OWN task (keyed to the identity `dispatchBrowserToDevice` now forwards).
1643
- // Only a self-host route runs locally.
1644
- if (isSelfHost(route.device))
1645
- return;
1646
- // ...but a cold CLOSE verb (done/stop) must NOT forward: with no task it
1647
- // would target a browsing context this session never opened. It stays local
1648
- // (its historic no-task behaviour) unless an explicit --task/local binding
1649
- // routes it — a taskless `done` must never blindly stop a hub task.
1650
- if (!route.task && isTerminalBrowserVerb(top))
1651
- return;
1652
- const forwarded = browserForwardedArgv();
1653
- if (route.task && !forwarded.includes('--task')) {
1654
- forwarded.push('--task', route.task);
1655
- }
1656
- const outputPath = top === 'screenshot'
1657
- ? actionCommand.opts().output ??
1658
- flagValue(process.argv.slice(2), 'output', 'o')
1659
- : undefined;
1660
- if (top === 'screenshot' && outputPath) {
1661
- const withoutOutput = stripRoutingFlags(forwarded, [
1662
- { long: 'output', short: 'o', takesValue: true },
1663
- ]);
1664
- const captured = await dispatchBrowserToDevice(route.device, withoutOutput, 'capture');
1665
- if (captured.code !== 0) {
1666
- process.stderr.write(captured.stderr);
1667
- process.exit(captured.code);
1668
- }
1669
- const remotePath = captured.stdout
1670
- .split('\n')
1671
- .map((line) => line.trim())
1672
- .find((line) => line.length > 0);
1673
- if (!remotePath) {
1674
- console.error(`Remote screenshot on ${route.device} produced no path.`);
1675
- process.exit(1);
1676
- }
1677
- try {
1678
- await pullRemoteFile(route.device, remotePath, outputPath);
1679
- }
1680
- catch (err) {
1681
- console.error(err instanceof Error ? err.message : String(err));
1682
- process.exit(1);
1683
- }
1684
- const saved = path.resolve(outputPath);
1685
- console.log(saved);
1686
- process.exit(0);
1687
- }
1688
- const result = await dispatchBrowserToDevice(route.device, forwarded, 'stream');
1689
- if (result.code === 0 && (top === 'done' || top === 'stop') && route.task) {
1690
- unbindTask(route.task);
1691
- }
1692
- process.exit(result.code);
1693
- });
1694
- browser
1695
- .command('remote-control [state]')
1696
- .description("Allow or deny other fleet machines driving THIS machine's browser over `browser --device` or `agents ssh …` (`agents browser` / `ag browser` / standalone `browser`). " +
1697
- '`on`/`off` to set (device-local, never synced); no argument prints the current value. Default off.')
1698
- .option('--json', 'Output as JSON')
1699
- .action((state, opts) => {
1700
- const KEY = 'browser.remote-control';
1701
- if (state === undefined) {
1702
- const cur = getConfigValue(KEY).value === true;
1703
- if (opts.json) {
1704
- console.log(JSON.stringify({ remoteControl: cur }));
1705
- return;
1706
- }
1707
- console.log(`Remote browser control (this machine): ${cur ? 'on' : 'off'}`);
1708
- if (!cur)
1709
- console.log('Enable with: agents browser remote-control on');
1710
- return;
1711
- }
1712
- const norm = state.toLowerCase();
1713
- const onWords = ['on', 'true', 'yes', 'allow', 'enable'];
1714
- const offWords = ['off', 'false', 'no', 'deny', 'disable'];
1715
- if (!onWords.includes(norm) && !offWords.includes(norm)) {
1716
- console.error(`Expected "on" or "off", got "${state}".`);
1717
- process.exit(1);
1718
- }
1719
- const value = onWords.includes(norm);
1720
- setConfigValue(KEY, value);
1721
- if (opts.json) {
1722
- console.log(JSON.stringify({ remoteControl: value }));
1723
- return;
1724
- }
1725
- console.log(`Remote browser control (this machine) is now ${value ? 'on' : 'off'}.`);
1726
- console.log(value
1727
- ? 'Other fleet machines can now drive this browser via `browser --device <this-device>`.'
1728
- : 'Cross-machine `browser --device` drives to this machine are refused.');
1729
- });
1730
- const stream = browser
1731
- .command('stream')
1732
- .description('Keep one process and daemon IPC socket open; read NDJSON requests from stdin and write NDJSON responses')
1733
- .option(TASK_OPTION_FLAG, 'Default task for requests that omit `task` (defaults to $AGENTS_BROWSER_TASK)')
1734
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1735
- .action(async (opts) => {
1736
- await runBrowserIPCStream({
1737
- input: process.stdin,
1738
- output: process.stdout,
1739
- task: opts.task ?? process.env.AGENTS_BROWSER_TASK,
1740
- actor: resolveActor().id,
1741
- launchId: process.env.AGENT_LAUNCH_ID,
1742
- sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
1743
- });
1744
- });
1745
- setHelpSections(stream, {
1746
- examples: `
1747
- # Batch two warm actions through one process and one browser-service connection
1748
- printf '%s\\n' \\
1749
- '{"action":"screenshot","path":"/tmp/page.jpg"}' \\
1750
- '{"action":"click","atX":320,"atY":540}' \\
1751
- | agents browser stream --task "$AGENTS_BROWSER_TASK"
228
+ Another fleet machine may drive this browser only after \`agents browser
229
+ remote-control on\` here (device-local, never synced; default off).
1752
230
 
1753
- # Keep the command open and send one JSON object per line from a long-lived shell
1754
- agents browser stream --task "$AGENTS_BROWSER_TASK"
1755
- `,
1756
- notes: `
1757
- stdout is protocol-only: one compact JSON response for each non-empty input line.
1758
- Malformed JSON returns an error response without closing the stream.
1759
- The first start response becomes the default task for later lines in the same stream.
231
+ \`agents browser sessions\` (and \`agents sessions --browser\`) reads the capture
232
+ and task history agents-cli records — it never leaves this CLI.
1760
233
  `,
1761
234
  });
1762
- browser
1763
- .command('start')
1764
- .description('Start a browser task. Pass --profile <name>; omit to use your configured default (set it with `agents browser use <name>` or `agents setup`). Page verbs (navigate/screenshot/…) create a task implicitly when none exists — start is for --profile/--url/--record/--title.')
1765
- .option('-p, --profile <name>', 'Browser profile to use (omit to use this machine\'s configured default; set it with `agents browser use <name>`)')
1766
- .option(TASK_OPTION_FLAG, 'Task name (auto-generated short id if omitted)')
1767
- .option('--title <label>', 'Human label shown in `browser status` (defaults to first navigated host)')
1768
- .option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
1769
- .option('-u, --url <url>', 'Open URL in first tab')
1770
- .option('--device <name>', 'Device that hosts this task (defaults to the browser.device hub when set; use `local` to force this machine). Later verbs resolve it from --task; not valid on page verbs')
1771
- .option('--fresh', 'Always open a new tab, skipping the reclaim of a tab an abandoned task is holding on that URL')
1772
- .option('--no-skills', 'Skip auto-discovery of site-specific SKILL.md from ~/.agents/skills/browser/domain-skills/')
1773
- .option('--record', 'Start recording right after the tab opens (shorthand for `agents browser record start` as a follow-up)')
1774
- .option('--fps <n>', 'Recording frames per second (with --record; 1–30, default 5)', (v) => parseInt(v, 10))
1775
- .option('--duration <sec>', 'Recording duration cap in seconds (with --record; default 60)', (v) => parseInt(v, 10))
1776
- .option('--max-mb <mb>', 'Recording size cap in MB (with --record; default 25)', (v) => parseInt(v, 10))
1777
- .option('--json', 'Output machine-readable JSON (task handle + tabId + reused/message)')
1778
- .action(async (opts) => {
1779
- // Fast-fail copy of the consent gate, so a refused start never resolves or
1780
- // auto-creates a profile. The AUTHORITATIVE gate is in the daemon
1781
- // (BrowserService.start / resolveOrCreateTask, via
1782
- // assertRemoteControlAllowedForRequest) — it has to be, because the page
1783
- // verbs create a browser implicitly and never reach this command.
1784
- try {
1785
- assertRemoteControlAllowed();
1786
- }
1787
- catch (err) {
1788
- console.error(err instanceof Error ? err.message : String(err));
1789
- process.exit(1);
1790
- }
1791
- // Thin-client hub (PHNX-2010): when this box has a fleet browser hub
1792
- // (`browser.device`) and the caller gave no explicit --device, forward the
1793
- // WHOLE start there — bare OR --profile — and skip the local profile/browser
1794
- // resolution below. The hub owns its profiles, browser, and consent, so this
1795
- // box needs no local browser and need not carry the named profile; forwarding
1796
- // the local pre-checks here would reject a profile that lives only on the hub.
1797
- // Later page verbs follow the task to the hub via the task→device index bound
1798
- // here. `--device local`/`--device self` (below) force a local run instead;
1799
- // `defaultBrowserHub()` is undefined on the hub itself and on a fleet-remote
1800
- // re-exec, so neither forwards to itself.
1801
- if (!opts.device) {
1802
- const hub = defaultBrowserHub();
1803
- if (hub) {
1804
- // No local profile validation: the hub is the authority on its own
1805
- // profiles (including fleet-scoped ones this box can't see in
1806
- // `declaringDevices`), so a bad `--profile` is caught by the hub's own
1807
- // start and streamed back with its exit code, rather than second-guessed
1808
- // here against a registry that doesn't carry the hub's profiles.
1809
- try {
1810
- const result = await dispatchBrowserToDevice(hub, browserForwardedArgv(), 'capture');
1811
- process.stdout.write(result.stdout);
1812
- process.stderr.write(result.stderr);
1813
- if (result.code !== 0)
1814
- process.exit(result.code);
1815
- const taskName = remoteStartTaskName(result.stdout, opts.task);
1816
- if (!taskName) {
1817
- console.error(`Remote start on ${hub} produced no task name.`);
1818
- process.exit(1);
1819
- }
1820
- bindTask(taskName, {
1821
- device: hub,
1822
- // Only what the caller named; the hub's own default (bare start) is
1823
- // opaque here. Name-based `done`/`stop --task` and the post-stop
1824
- // unbind GC these regardless.
1825
- profile: opts.profile,
1826
- url: opts.url,
1827
- sessionId: callerSessionId(),
1828
- launchId: callerLaunchId(),
1829
- createdAt: Date.now(),
1830
- });
1831
- }
1832
- catch (err) {
1833
- console.error(err instanceof Error ? err.message : String(err));
1834
- process.exit(1);
1835
- }
1836
- return;
1837
- }
1838
- }
1839
- // Resolve an EXPLICIT --device BEFORE any local profile/browser
1840
- // resolution (PHNX-3289). The task lives on the TARGET device, so its
1841
- // browser binary and profile must resolve THERE. Resolving locally first
1842
- // made a browserless box fail with a misleading "No supported browser
1843
- // found" (bare start) or "Profile <x> not found" (a profile that lives
1844
- // only on the target) before the start ever routed to --device. This
1845
- // mirrors the hub path above: the target is the authority on its own
1846
- // profiles, so forward the whole start and let it validate. `local`/`self`
1847
- // force a local run; a fleet-remote re-exec is already ON the target and
1848
- // falls through to the local launch below.
1849
- const deviceName = opts.device === 'local' || opts.device === 'self' ? undefined : opts.device;
1850
- if (deviceName) {
1851
- const lowered = deviceName.toLowerCase();
1852
- if (lowered === 'all' || lowered === 'auto') {
1853
- console.error(`--device ${deviceName} is not valid on \`agents browser start\`: a task lives on one device.\n` +
1854
- `Next: agents browser start --task <name> --device <device>`);
1855
- process.exit(1);
1856
- }
1857
- if (!isSelfHost(deviceName) && !isFleetRemoteInvocation()) {
1858
- // Resolve the profile NAME (never a local browser auto-pick) so a
1859
- // named profile can still be validated against the target's
1860
- // declarations before we round-trip. A bare start carries no name to
1861
- // validate — the target picks its own default — so it forwards
1862
- // straight through, which is exactly what unblocks a browserless box.
1863
- // Only resolve when the caller actually named a profile: passing an
1864
- // undefined ref would let `resolveProfileRef` fall back to THIS box's
1865
- // configured/auto-detected default (matching the ~4 other call sites
1866
- // in this file), and validating that local name against the target
1867
- // re-introduces the exact false failure this reorder fixes.
1868
- const forwardProfile = opts.profile ? await resolveProfileRef(opts.profile) : undefined;
1869
- if (forwardProfile) {
1870
- try {
1871
- assertDeviceDeclaresProfile(deviceName, forwardProfile);
1872
- }
1873
- catch (err) {
1874
- console.error(err instanceof Error ? err.message : String(err));
1875
- process.exit(1);
1876
- }
1877
- }
1878
- try {
1879
- const result = await dispatchBrowserToDevice(deviceName, browserForwardedArgv(), 'capture');
1880
- process.stdout.write(result.stdout);
1881
- process.stderr.write(result.stderr);
1882
- if (result.code !== 0)
1883
- process.exit(result.code);
1884
- const taskName = remoteStartTaskName(result.stdout, opts.task);
1885
- if (!taskName) {
1886
- console.error(`Remote start on ${deviceName} produced no task name.`);
1887
- process.exit(1);
1888
- }
1889
- bindTask(taskName, {
1890
- device: deviceName,
1891
- // Only the caller-named profile; the target's own default (bare
1892
- // start) is opaque here, exactly as in the hub path.
1893
- profile: forwardProfile ?? opts.profile,
1894
- url: opts.url,
1895
- sessionId: callerSessionId(),
1896
- launchId: callerLaunchId(),
1897
- createdAt: Date.now(),
1898
- });
1899
- if (!result.stderr.includes(`started on ${deviceName}`)) {
1900
- console.error(`Task "${taskName}" started on ${deviceName}.`);
1901
- }
1902
- }
1903
- catch (err) {
1904
- console.error(err instanceof Error ? err.message : String(err));
1905
- process.exit(1);
1906
- }
1907
- return;
1908
- }
1909
- }
1910
- // One resolution order for every command (RUSH-2709): `--profile default`
1911
- // means the same profile here as it does in stop / status / navigate.
1912
- // `start` is the one command that LAUNCHES, so its implicit path goes
1913
- // through ensureDefaultBrowserProfile, which additionally verifies the
1914
- // resolved default can launch on THIS machine and, when nothing launchable
1915
- // resolves, throws an actionable "pick a browser in `agents setup`" error
1916
- // (PHNX-3296 — it no longer silently mints an auto-chrome). A filter-only
1917
- // command must not warn about the config, so that check lives only here.
1918
- let profileName;
1919
- try {
1920
- profileName = await resolveProfileRefForStart(opts.profile);
1921
- }
1922
- catch (err) {
1923
- console.error(err instanceof Error ? err.message : String(err));
1924
- process.exit(1);
1925
- }
1926
- // Pre-check the profile locally so we fail fast with a helpful error
1927
- // instead of round-tripping a generic "Profile not found" through the daemon.
1928
- const profile = await getProfile(profileName);
1929
- if (!profile) {
1930
- console.error(`Profile "${profileName}" not found.`);
1931
- const all = await listProfiles();
1932
- if (all.length > 0) {
1933
- console.error(`Available profiles: ${all.map((p) => p.name).join(', ')}`);
1934
- }
1935
- console.error(`Create one with: agents browser profiles create ${profileName} --browser <chrome|comet|chromium|brave|edge|arc|custom>`);
1936
- process.exit(1);
1937
- }
1938
- // Discovery is read-only for list/show/doctor. Starting is the explicit
1939
- // adoption point: persist only the agents-cli alias/native identity, never
1940
- // mutate Arc's own profile or Space data.
1941
- await persistDiscoveredProfile(profileName);
1942
- // A native endpoint is not CDP and cannot be tunnelled. Re-exec the
1943
- // complete command on the declaring owner, then bind its returned task
1944
- // locally so every later verb follows the same owner from task-index.
1945
- if (!isFleetRemoteInvocation()) {
1946
- let routed;
1947
- try {
1948
- routed = resolveBrowserTarget(profileName, { endpointName: opts.endpoint });
1949
- }
1950
- catch (err) {
1951
- console.error(err instanceof Error ? err.message : String(err));
1952
- process.exit(1);
1953
- }
1954
- if (!routed.local && routed.commandDispatch) {
1955
- const result = await dispatchBrowserToDevice(routed.device, browserForwardedArgv(), 'capture');
1956
- process.stdout.write(result.stdout);
1957
- process.stderr.write(result.stderr);
1958
- if (result.code !== 0)
1959
- process.exit(result.code);
1960
- const taskName = remoteStartTaskName(result.stdout, opts.task);
1961
- if (!taskName) {
1962
- console.error(`Remote start on ${routed.device} produced no task name.`);
1963
- process.exit(1);
1964
- }
1965
- bindTask(taskName, {
1966
- device: routed.device,
1967
- profile: profileName,
1968
- url: opts.url,
1969
- sessionId: callerSessionId(),
1970
- launchId: callerLaunchId(),
1971
- createdAt: Date.now(),
1972
- });
1973
- return;
1974
- }
1975
- }
1976
- // Pre-check the endpoint name too — same fail-fast rationale.
1977
- if (opts.endpoint) {
1978
- const presets = getEndpointPresets(profile);
1979
- if (!presets[opts.endpoint]) {
1980
- console.error(`Endpoint "${opts.endpoint}" not found on profile "${profileName}". ` +
1981
- `Available: ${Object.keys(presets).join(', ')}`);
1982
- process.exit(1);
1983
- }
1984
- }
1985
- // Only the LOCAL launch reaches here — an explicit remote --device already
1986
- // forwarded and returned above. A residual `deviceName` means an explicit
1987
- // self target (or a fleet-remote re-exec on the target); its profile
1988
- // resolved locally, so verify this box actually declares it (the
1989
- // `profiles claim` hint lives in `assertDeviceDeclaresProfile`).
1990
- if (deviceName) {
1991
- try {
1992
- assertDeviceDeclaresProfile(deviceName, profileName);
1993
- }
1994
- catch (err) {
1995
- console.error(err instanceof Error ? err.message : String(err));
1996
- process.exit(1);
1997
- }
1998
- }
1999
- // Grounded login guardrail: if the opening URL is a known login-gated
2000
- // service and the resolved profile has no session for it, say so on stderr
2001
- // and name a profile that IS logged in. Reads the profile's real cookie DB
2002
- // (presence only) — never blocks or slows start; any failure is silent.
2003
- if (opts.url) {
2004
- try {
2005
- const service = serviceForUrl(opts.url);
2006
- if (service) {
2007
- const here = await loginsForProfile(profileName);
2008
- if (!here.includes(service)) {
2009
- const elsewhere = (await profilesLoggedInto(service)).filter((p) => p !== profileName);
2010
- const hint = elsewhere.length
2011
- ? `logged in elsewhere: ${elsewhere.join(', ')}. try: --profile ${elsewhere[0]}`
2012
- : `no profile on this machine has a ${service} session.`;
2013
- console.error(`warning: profile "${profileName}" (${profile.browser}) has no ${service} session. ${hint}`);
2014
- }
2015
- }
2016
- }
2017
- catch { /* login detection is best-effort; never block start */ }
2018
- }
2019
- // Identity is stamped inside sendIPCRequest; no need to pass it here.
2020
- const response = await sendIPCRequest({
2021
- action: 'start',
2022
- profile: profileName,
2023
- taskName: opts.task,
2024
- url: opts.url,
2025
- endpoint: opts.endpoint,
2026
- skipDomainSkill: opts.skills === false,
2027
- fresh: opts.fresh === true,
2028
- title: opts.title,
2029
- });
2030
- if (!response.ok) {
2031
- console.error(response.error);
2032
- process.exit(1);
2033
- }
2034
- const boundDevice = deviceName && isSelfHost(deviceName) ? deviceName : machineId();
2035
- if (response.task) {
2036
- bindTask(response.task, {
2037
- device: boundDevice,
2038
- profile: profileName,
2039
- url: opts.url,
2040
- sessionId: callerSessionId(),
2041
- launchId: callerLaunchId(),
2042
- createdAt: Date.now(),
2043
- });
2044
- }
2045
- // --json: machine-readable, still task-handle-first for callers that parse it.
2046
- if (opts.json) {
2047
- console.log(JSON.stringify({
2048
- ok: true,
2049
- task: response.task,
2050
- tabId: response.tabId,
2051
- device: boundDevice,
2052
- profile: profileName,
2053
- // `reused` is task-level; `created`/`refreshed` describe the page op.
2054
- reused: response.reused === true,
2055
- created: response.created,
2056
- refreshed: response.refreshed,
2057
- message: response.message,
2058
- }, null, 2));
2059
- return;
2060
- }
2061
- // stdout: just the resolved name, one line, no decoration. Lets callers do:
2062
- // export AGENTS_BROWSER_TASK=$(agents browser start --profile work)
2063
- console.log(response.task);
2064
- // stderr: human-friendly commentary so a TTY user still sees what happened.
2065
- // Shell substitution captures stdout only, so $(...) stays clean. A same-name
2066
- // retry reused the TASK — key on `reused`, not the page-level `refreshed`
2067
- // (a no-URL retry reuses the task without reloading anything).
2068
- const startVerb = response.reused ? 'reused' : 'started';
2069
- console.error(`Task "${response.task}" ${startVerb} on ${boundDevice} (profile: ${profileName}).`);
2070
- if (response.message)
2071
- console.error(response.message);
2072
- if (opts.url && response.tabId) {
2073
- console.error(`Tab ${response.tabId}`);
2074
- }
2075
- console.error('Tip: page verbs resolve this task from --task; --device is only valid on start.');
2076
- console.error('Try: agents browser screenshot | agents browser console --level error');
2077
- // Surface the matched domain-skill (if any) so an agent driving the
2078
- // task picks up site-specific selectors and gotchas before it starts
2079
- // clicking. Header is recognizable so an agent parsing the stream can
2080
- // extract the skill content; suffix repeats the skill name for greps.
2081
- if (response.skill) {
2082
- console.error('');
2083
- console.error(`--- domain-skill: ${response.skill.name} (${response.skill.hostname}) ---`);
2084
- console.error(response.skill.content);
2085
- console.error(`--- end domain-skill: ${response.skill.name} ---`);
2086
- }
2087
- // --record convenience: fire record-start right after the tab opens so
2088
- // the user gets a single-command capture flow. Failures here are
2089
- // reported but don't fail the start — the task is already running.
2090
- if (opts.record) {
2091
- const recordResponse = await sendIPCRequest({
2092
- action: 'record-start',
2093
- task: response.task,
2094
- tabId: response.tabId,
2095
- fps: opts.fps,
2096
- duration: opts.duration,
2097
- maxMb: opts.maxMb,
2098
- });
2099
- if (!recordResponse.ok) {
2100
- console.error(`Recording failed to start: ${recordResponse.error}`);
2101
- }
2102
- else {
2103
- console.error(`Recording at ${recordResponse.fps} fps (cap ${recordResponse.durationCapSec}s / ${recordResponse.maxMb} MB) -> ${recordResponse.path}`);
2104
- console.error('Stop with: agents browser record stop');
2105
- }
2106
- }
2107
- });
2108
- browser
2109
- .command('done')
2110
- .description('Complete a task and close its tabs (resolves from caller identity when --task is omitted)')
2111
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2112
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2113
- .action(async (opts) => {
2114
- const task = resolveTaskName(opts);
2115
- const response = await sendIPCRequest({
2116
- action: 'done',
2117
- task,
2118
- });
2119
- if (!response.ok) {
2120
- console.error(response.error);
2121
- process.exit(1);
2122
- }
2123
- if (response.message === 'nothing to close' || !response.task) {
2124
- console.log('nothing to close');
2125
- return;
2126
- }
2127
- console.log(`Completed task: ${response.task}`);
2128
- });
2129
- browser
2130
- .command('stop')
2131
- .description('Stop a browser task and close its tabs; with --profile, detach the whole profile; with --service, stop only browser IPC while the shared daemon stays up')
2132
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2133
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2134
- .option('-p, --profile <name>', 'Detach the whole profile (incl. composite "name@endpoint") instead of stopping a single task')
2135
- .option('--service', 'Stop only the browser-ipc service and clear a stale/wedged socket; the shared daemon and its other services stay up')
2136
- .addOption(new Option('--daemon').hideHelp())
2137
- .action(async (opts) => {
2138
- if (opts.service || opts.daemon) {
2139
- if (opts.profile || opts.task) {
2140
- console.error('--service stops browser IPC; do not combine it with --profile or --task.');
2141
- process.exit(1);
2142
- }
2143
- if (opts.daemon) {
2144
- console.error('`--daemon` now means the browser service only; use `--service`. The shared daemon will not be stopped.');
2145
- }
2146
- try {
2147
- const result = await stopBrowserService();
2148
- const parts = [];
2149
- parts.push(result.wasRunning ? 'Stopped browser service' : 'Browser service was not running');
2150
- if (result.socketCleared)
2151
- parts.push('cleared stale socket');
2152
- parts.push(result.daemonRunning ? 'shared daemon is still running' : 'shared daemon was already stopped');
2153
- console.log(`${parts.join('; ')}. Browser IPC starts again on the next browser action that requires it.`);
2154
- }
2155
- catch (err) {
2156
- console.error(err instanceof Error ? err.message : String(err));
2157
- process.exit(1);
2158
- }
2159
- return;
2160
- }
2161
- if (opts.profile) {
2162
- const profile = (await resolveProfileRef(opts.profile)) ?? opts.profile;
2163
- const response = await sendIPCRequest({
2164
- action: 'stop',
2165
- profile,
2166
- });
2167
- if (!response.ok) {
2168
- console.error(response.error);
2169
- process.exit(1);
2170
- }
2171
- console.log(`Stopped profile: ${profile}`);
2172
- return;
2173
- }
2174
- const task = resolveTaskName(opts);
2175
- const response = await sendIPCRequest({
2176
- action: 'stop',
2177
- task,
2178
- });
2179
- if (!response.ok) {
2180
- console.error(response.error);
2181
- process.exit(1);
2182
- }
2183
- if (response.message === 'nothing to close' || !response.task) {
2184
- console.log('nothing to close');
2185
- return;
2186
- }
2187
- console.log(`Stopped task: ${response.task}`);
2188
- });
2189
- browser
2190
- .command('prune')
2191
- .alias('gc')
2192
- .description('Close tabs for abandoned tasks — owning agent session exited, or idle past the window — and mark them done. ' +
2193
- 'The same reaper the daemon already runs every 5 minutes; use this to run it now.')
2194
- .option('--dry-run', 'List what would be closed without closing anything')
2195
- .option('--idle-minutes <n>', 'Override the idle window in minutes (default: this device\'s browser.task-idle-minutes, or 30). ' +
2196
- '0 disables idle-only closing for this run; session-dead tasks are still closed.', (v) => parseInt(v, 10))
2197
- .option('--json', 'Output machine-readable JSON')
2198
- .action(async (opts) => {
2199
- const response = await sendIPCRequest({
2200
- action: 'gc',
2201
- dryRun: opts.dryRun,
2202
- idleMinutes: opts.idleMinutes,
2203
- });
2204
- if (!response.ok) {
2205
- if (opts.json) {
2206
- console.log(JSON.stringify({ ok: false, error: response.error }));
2207
- }
2208
- else {
2209
- console.error(response.error);
2210
- }
2211
- process.exit(1);
2212
- }
2213
- const reaped = response.reaped ?? { closed: [], skipped: 0 };
2214
- if (opts.json) {
2215
- console.log(JSON.stringify(reaped, null, 2));
2216
- return;
2217
- }
2218
- if (reaped.closed.length === 0) {
2219
- console.log(`No abandoned tasks${opts.dryRun ? ' (dry run)' : ''}; ${reaped.skipped} task(s) still active.`);
2220
- return;
2221
- }
2222
- const verb = opts.dryRun ? 'Would close' : 'Closed';
2223
- console.log(`${verb} ${reaped.closed.length} task(s), left ${reaped.skipped} alone:`);
2224
- for (const c of reaped.closed) {
2225
- console.log(` ${c.task} (${c.profile}, ${c.reason})`);
2226
- }
2227
- });
2228
- browser
2229
- .command('show <url>')
2230
- .description('Open a URL or local file for a human to read: goes to browser.viewer (default: browser.profile), and binds no task')
2231
- .option('--os-browser', 'Use the OS default handler instead of the configured viewer')
2232
- // `--device` here names WHERE THE FILE IS, not where to open it. `show` is the
2233
- // one browser verb narrowly exempt from the task-routing refusal
2234
- // (`REJECT_DEVICE_MESSAGE`) because it binds no task and has nothing to route:
2235
- // it is a local viewer, so the flag can only mean "the source lives there".
2236
- // Every other verb still refuses `--device` — a task is bound at `start`.
2237
- .option('--device <name>', 'fetch the file from this device, then view it locally (absolute path only)')
2238
- .option('--json', 'Output machine-readable JSON')
2239
- .action(async (url, opts) => {
2240
- // The entry point external tools need. `navigate` binds a task, which the
2241
- // abandoned-task reaper closes when the calling session ends — wrong for a
2242
- // page a person is reading. This does not.
2243
- const { showUrl, showFile } = await import('../lib/open-url.js');
2244
- // `+` not `*` on the scheme body: `[a-z][a-z0-9+.-]*:` matches a Windows
2245
- // drive letter (`C:\Users\me\plan.html`), so a real path was treated as a
2246
- // URL. A scheme is at least two characters.
2247
- const isLocalFile = !/^[a-z][a-z0-9+.-]+:/i.test(url);
2248
- let localTarget = isLocalFile ? path.resolve(url) : undefined;
2249
- let fetched;
2250
- if (opts.device) {
2251
- // A URL is already reachable from here, so `--device` with one is a
2252
- // contradiction rather than a no-op: it says the source is remote while
2253
- // naming something that is not a file on that peer.
2254
- if (!isLocalFile) {
2255
- console.error(`--device names the device holding a FILE, so it cannot be combined with a URL.\n Drop --device to open ${url} here, or pass an absolute path on ${opts.device}.`);
2256
- process.exit(1);
2257
- }
2258
- // The peer's path, verbatim — NOT path.resolve()'d, which would rewrite it
2259
- // against this machine's cwd and name a different file there.
2260
- const remotePath = url;
2261
- const dir = path.join(getBrowserRuntimeDir(), 'remote-views');
2262
- fetched = path.join(dir, `${normalizeHost(opts.device)}-${Date.now()}-${path.basename(remotePath) || 'capture'}`);
2263
- try {
2264
- await fetchRemoteFileForViewing(opts.device, remotePath, fetched);
2265
- }
2266
- catch (error) {
2267
- console.error(error.message);
2268
- process.exit(1);
2269
- }
2270
- localTarget = fetched;
2271
- }
2272
- const outcome = localTarget
2273
- ? await showFile(localTarget, { osBrowser: opts.osBrowser })
2274
- : await showUrl(url, { osBrowser: opts.osBrowser });
2275
- // The fetched copy is what a viewer actually opened, so it is what the
2276
- // caller is told about — pointing at the peer's path would name a file that
2277
- // is not on this machine.
2278
- const shown = fetched ?? url;
2279
- if (opts.json) {
2280
- console.log(JSON.stringify({ ...outcome, ...(fetched ? { source: `${opts.device}:${url}`, local: fetched } : {}) }, null, 2));
2281
- }
2282
- else if (outcome.via === 'none') {
2283
- console.error(`Could not open a browser — open this yourself:\n ${shown}`);
2284
- }
2285
- else if (outcome.via === 'profile') {
2286
- console.log(`Shown in ${outcome.profile}: ${shown}`);
2287
- }
2288
- else {
2289
- console.log(`Opened in the OS default browser: ${shown}`);
2290
- }
2291
- if (outcome.via === 'none')
2292
- process.exit(1);
2293
- });
2294
- browser
2295
- .command('navigate [url]')
2296
- .alias('goto')
2297
- .description('Navigate current tab to URL (creates a task and tab when none exist)')
2298
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2299
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2300
- .option('-u, --url <url>', 'URL to navigate to (or pass it positionally)')
2301
- .option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
2302
- .option('--json', 'Output machine-readable JSON')
2303
- .action(async (urlPos, opts) => {
2304
- const url = urlPos || opts.url;
2305
- if (!url) {
2306
- console.error('URL required. Usage: agents browser navigate <url>');
2307
- process.exit(1);
2308
- }
2309
- const task = resolveTaskName(opts);
2310
- const response = await sendIPCRequest({
2311
- action: 'navigate',
2312
- task,
2313
- url,
2314
- profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
2315
- });
2316
- if (!response.ok) {
2317
- if (opts.json)
2318
- console.log(JSON.stringify({ ok: false, error: response.error }));
2319
- else
2320
- console.error(response.error);
2321
- process.exit(1);
2322
- }
2323
- if (opts.json) {
2324
- console.log(JSON.stringify({
2325
- ok: true,
2326
- task: response.task,
2327
- tabId: response.tabId,
2328
- url,
2329
- created: response.created ?? false,
2330
- refreshed: response.refreshed ?? false,
2331
- message: response.message,
2332
- }, null, 2));
2333
- return;
2334
- }
2335
- if (response.task) {
2336
- console.error(`task ${response.task}`);
2337
- }
2338
- // Honest human output: a same-task reopen reloaded the SAME tab rather than
2339
- // navigating a fresh one (PHNX-2399). Any picked-device / reopen note rides
2340
- // stderr so stdout stays the one-line result.
2341
- if (response.message)
2342
- console.error(response.message);
2343
- const verb = response.refreshed ? 'Refreshed' : 'Navigated';
2344
- console.log(`${verb} ${response.tabId} to ${url}`);
2345
- });
2346
- // Tab subcommand group
2347
- const tab = browser.command('tab').description('Manage tabs');
2348
- tab
2349
- .command('add')
2350
- .description('Open URL in new tab (becomes current)')
2351
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2352
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2353
- .requiredOption('--url <url>', 'URL to open in the new tab')
2354
- .option('-p, --profile <name>', 'Browser profile')
2355
- .option('--json', 'Output machine-readable JSON')
2356
- .action(async (opts) => {
2357
- const task = resolveTaskName(opts);
2358
- const response = await sendIPCRequest({
2359
- action: 'tab-add',
2360
- task,
2361
- url: opts.url,
2362
- profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
2363
- });
2364
- if (!response.ok) {
2365
- if (opts.json)
2366
- console.log(JSON.stringify({ ok: false, error: response.error }));
2367
- else
2368
- console.error(response.error);
2369
- process.exit(1);
2370
- }
2371
- if (opts.json) {
2372
- console.log(JSON.stringify({
2373
- ok: true,
2374
- task: response.task,
2375
- tabId: response.tabId,
2376
- url: opts.url,
2377
- created: response.created ?? false,
2378
- refreshed: response.refreshed ?? false,
2379
- message: response.message,
2380
- }, null, 2));
2381
- return;
2382
- }
2383
- // A reopen of a URL already in an owned tab refreshes that tab in place
2384
- // rather than opening a duplicate (PHNX-2399).
2385
- if (response.refreshed) {
2386
- if (response.message)
2387
- console.error(response.message);
2388
- console.log(`Refreshed tab ${response.tabId}: ${opts.url}`);
2389
- }
2390
- else {
2391
- console.log(`Opened tab ${response.tabId}: ${opts.url}`);
2392
- }
2393
- });
2394
- tab
2395
- .command('focus <tabId>')
2396
- .description('Switch to tab (by ID, prefix, or URL substring)')
2397
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2398
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2399
- .action(async (tabId, opts) => {
2400
- const task = resolveTaskName(opts);
2401
- const response = await sendIPCRequest({
2402
- action: 'tab-focus',
2403
- task,
2404
- tabId,
2405
- });
2406
- if (!response.ok) {
2407
- console.error(response.error);
2408
- process.exit(1);
2409
- }
2410
- console.log(`Focused tab ${response.tabId}`);
2411
- });
2412
- tab
2413
- .command('close [tabId]')
2414
- .description('Close tab(s) — omit tabId to close all')
2415
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2416
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2417
- .action(async (tabId, opts) => {
2418
- const task = resolveTaskName(opts);
2419
- const response = await sendIPCRequest({
2420
- action: 'tab-close',
2421
- task,
2422
- tabId,
2423
- });
2424
- if (!response.ok) {
2425
- console.error(response.error);
2426
- process.exit(1);
2427
- }
2428
- console.log(tabId ? `Closed tab ${tabId}` : `Closed all tabs for ${task}`);
2429
- });
2430
- browser
2431
- .command('tabs')
2432
- .description('List tabs open for the current task; --all shows every tab open in the profile browser, yours included')
2433
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2434
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2435
- .option('--all', "Every tab open in the profile browser, not only this task's: OWNER names the owning task, or \"you\" for a tab no task owns")
2436
- .option('--json', 'Output machine-readable JSON')
2437
- .action(async (opts) => {
2438
- const task = resolveTaskName(opts);
2439
- const response = await sendIPCRequest({
2440
- action: 'tab-list',
2441
- task,
2442
- ...(opts.all ? { all: true } : {}),
2443
- });
2444
- if (!response.ok) {
2445
- if (opts.json) {
2446
- console.log(JSON.stringify({ ok: false, error: response.error }));
2447
- }
2448
- else {
2449
- console.error(response.error);
2450
- }
2451
- process.exit(1);
2452
- }
2453
- if (opts.json) {
2454
- console.log(JSON.stringify(response.tabs ?? [], null, 2));
2455
- return;
2456
- }
2457
- if (!response.tabs || response.tabs.length === 0) {
2458
- console.log('No tabs open');
2459
- return;
2460
- }
2461
- if (opts.all) {
2462
- console.log('TAB'.padEnd(12) + 'OWNER'.padEnd(18) + 'URL');
2463
- console.log('-'.repeat(88));
2464
- for (const t of response.tabs) {
2465
- const owner = t.task ?? 'you';
2466
- const current = t.current ? ' *' : '';
2467
- console.log(t.id.slice(0, 11).padEnd(12) + owner.slice(0, 17).padEnd(18) + t.url.slice(0, 55) + current);
2468
- }
2469
- return;
2470
- }
2471
- console.log('TAB'.padEnd(12) + 'URL');
2472
- console.log('-'.repeat(70));
2473
- for (const t of response.tabs) {
2474
- const current = t.current ? ' *' : '';
2475
- console.log(t.id.padEnd(12) + t.url.slice(0, 55) + current);
2476
- }
2477
- });
2478
- browser
2479
- .command('screenshot')
2480
- .description('Take a screenshot — auto-saved per task; --output only needed when you want a specific path')
2481
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2482
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2483
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2484
- .option('-o, --output <path>', 'Specific output path (otherwise auto-saved under sessions/<task>/)')
2485
- .option('-q, --quality <mode>', 'compressed (JPEG, capped at ~100 KB — default) or raw (PNG, pixel-faithful)', 'compressed')
2486
- .action(async (opts) => {
2487
- const task = resolveTaskName(opts);
2488
- if (opts.quality !== 'compressed' && opts.quality !== 'raw') {
2489
- console.error('--quality must be "compressed" or "raw"');
2490
- process.exit(1);
2491
- }
2492
- const response = await sendIPCRequest({
2493
- action: 'screenshot',
2494
- task,
2495
- tabId: opts.tab,
2496
- path: opts.output,
2497
- quality: opts.quality,
2498
- });
2499
- if (!response.ok) {
2500
- console.error(response.error);
2501
- process.exit(1);
2502
- }
2503
- // RUSH-3086: the daemon sandboxes writes to the runtime dir and silently
2504
- // ignores `-o` outside it. Honor the requested path here.
2505
- const savedPath = response.path
2506
- ? honorScreenshotOutput(opts.output, response.path)
2507
- : response.path;
2508
- // stdout: just the path, so `P=$(agents browser screenshot)` works.
2509
- console.log(savedPath);
2510
- // stderr: human commentary with size + dimensions, so an agent can
2511
- // see at a glance what was captured without `ls -l && file` round-trips.
2512
- const size = humanizeBytes(response.bytes);
2513
- const dims = response.width && response.height ? `${response.width}×${response.height}` : 'unknown size';
2514
- console.error(`Saved screenshot to ${savedPath} (${size}, ${dims})`);
2515
- // When auto-saving (no --output), surface the directory once so the
2516
- // agent doesn't have to dirname() the path or guess where files land.
2517
- if (!opts.output && savedPath) {
2518
- const dir = path.dirname(savedPath);
2519
- console.error(`Tip: auto-saving to ${dir}. Pass --output <path> to choose a path.`);
2520
- }
2521
- });
2522
- browser
2523
- .command('pdf [output]')
2524
- .description('Export the current tab as PDF via CDP Page.printToPDF — auto-saved under sessions/<task>/ when [output] is omitted')
2525
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2526
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2527
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2528
- .action(async (output, opts) => {
2529
- const task = resolveTaskName(opts);
2530
- const response = await sendIPCRequest({
2531
- action: 'pdf',
2532
- task,
2533
- tabId: opts.tab,
2534
- path: output,
2535
- });
2536
- if (!response.ok) {
2537
- console.error(response.error);
2538
- process.exit(1);
2539
- }
2540
- console.log(response.path);
2541
- const size = humanizeBytes(response.bytes);
2542
- console.error(`Saved PDF to ${response.path} (${size})`);
2543
- if (!output && response.path) {
2544
- const dir = path.dirname(response.path);
2545
- console.error(`Tip: auto-saving to ${dir}. Pass a path argument to choose one.`);
2546
- }
2547
- });
2548
- browser
2549
- .command('evaluate [expression]')
2550
- .alias('eval')
2551
- .description('Evaluate JavaScript in current tab')
2552
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2553
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2554
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2555
- .option('-e, --expression <js>', 'JavaScript expression to evaluate (or pass it positionally)')
2556
- .option('-f, --file <path>', 'Path to a .js file whose contents will be evaluated')
2557
- .action(async (exprPos, opts) => {
2558
- const task = resolveTaskName(opts);
2559
- const flagExpr = opts.expression || exprPos;
2560
- if (flagExpr && opts.file) {
2561
- console.error('Pass exactly one of an expression or --file');
2562
- process.exit(1);
2563
- }
2564
- let expression;
2565
- if (opts.file) {
2566
- try {
2567
- expression = fs.readFileSync(opts.file, 'utf8');
2568
- }
2569
- catch (err) {
2570
- console.error(`Cannot read --file ${opts.file}: ${err.message}`);
2571
- process.exit(1);
2572
- }
2573
- }
2574
- else if (flagExpr) {
2575
- expression = flagExpr;
2576
- }
2577
- else {
2578
- console.error('Pass an expression positionally, -e <js>, or --file <path>');
2579
- process.exit(1);
2580
- }
2581
- const response = await sendIPCRequest({
2582
- action: 'evaluate',
2583
- task,
2584
- tabId: opts.tab,
2585
- expr: expression,
2586
- });
2587
- if (!response.ok) {
2588
- console.error(response.error);
2589
- process.exit(1);
2590
- }
2591
- console.log(JSON.stringify(response.result, null, 2));
2592
- });
2593
- browser
2594
- .command('ps')
2595
- .description('List every browser/electron/tunnel process agents has tracked (alive or stale) — works without the daemon')
2596
- .option('--json', 'Output machine-readable JSON')
2597
- .action((opts) => {
2598
- const snapshots = listAllProfileSnapshots();
2599
- // Cross-check against what's actually listening locally so we can
2600
- // surface "port claimed by us but nothing is listening" (= leaked
2601
- // cache file) and "port listening but not in our records" (= someone
2602
- // else owns it; a new profile pointing here would collide).
2603
- const portOwners = new Map();
2604
- const conflicts = [];
2605
- for (const s of snapshots) {
2606
- const port = s.meta?.port;
2607
- if (!port)
2608
- continue;
2609
- const occupant = getPortOccupant(port);
2610
- if (!occupant) {
2611
- if (s.pidAlive || s.tunnelAlive) {
2612
- conflicts.push(`${s.name}: port ${port} marked active but nothing is listening`);
2613
- }
2614
- continue;
2615
- }
2616
- const ourPid = s.meta?.tunnelPid && s.meta.kind === 'tunnel'
2617
- ? s.meta.tunnelPid
2618
- : s.meta?.pid;
2619
- if (ourPid && occupant.pid !== ourPid) {
2620
- conflicts.push(`${s.name}: port ${port} listened on by ${occupant.command} (pid ${occupant.pid}) but our record says pid ${ourPid}`);
2621
- }
2622
- portOwners.set(port, occupant);
2623
- }
2624
- if (opts.json) {
2625
- console.log(JSON.stringify({ snapshots, conflicts }, null, 2));
2626
- return;
2627
- }
2628
- if (snapshots.length === 0) {
2629
- console.log('No tracked browser state. Run `agents browser start --profile <name>` to spawn one.');
2630
- return;
2631
- }
2632
- console.log('PROFILE KIND PID TUNNEL PORT ALIVE TASKS OWNER');
2633
- console.log('-----------------------------------------------------------------------------------------------');
2634
- for (const s of snapshots) {
2635
- const kind = s.meta?.kind ?? '-';
2636
- const pid = s.meta?.pid ?? '-';
2637
- const tunnelPid = s.meta?.tunnelPid ?? '-';
2638
- const port = s.meta?.port ?? '-';
2639
- const alive = aliveLabel(s);
2640
- const owner = s.meta?.daemonPid
2641
- ? `daemon${s.daemonAlive ? '' : '(dead)'}=${s.meta.daemonPid}`
2642
- : '-';
2643
- console.log(`${s.name.padEnd(40)} ${String(kind).padEnd(9)} ${String(pid).padEnd(6)} ${String(tunnelPid).padEnd(7)} ${String(port).padEnd(6)} ${alive.padEnd(6)} ${String(s.taskCount).padEnd(6)} ${owner}`);
2644
- }
2645
- if (conflicts.length > 0) {
2646
- console.log('');
2647
- console.log('Conflicts / leaks detected:');
2648
- for (const c of conflicts)
2649
- console.log(` - ${c}`);
2650
- console.log('');
2651
- console.log('Run `agents browser stop --profile <name>` to clean up a specific profile, or restart the daemon to trigger the orphan reaper.');
2652
- }
2653
- });
2654
- function aliveLabel(s) {
2655
- const k = s.meta?.kind;
2656
- if (k === 'tunnel')
2657
- return s.tunnelAlive ? 'yes' : 'stale';
2658
- return s.pidAlive ? 'yes' : 'stale';
2659
- }
2660
- browser
2661
- .command('status')
2662
- .description('Show browser service state and running browser tasks')
2663
- .option('-p, --profile <name>', 'Filter by profile')
2664
- .option('--json', 'Output machine-readable JSON')
2665
- .action(async (opts) => {
2666
- let response;
2667
- try {
2668
- response = await sendIPCRequest({
2669
- action: 'status',
2670
- profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
2671
- }, { autoStartDaemon: false });
2672
- }
2673
- catch (err) {
2674
- if (err instanceof BrowserServiceNotRunningError) {
2675
- const message = formatBrowserServiceNotRunningError();
2676
- if (opts.json) {
2677
- console.log(JSON.stringify({ ok: false, service: { id: 'browser-ipc', state: 'stopped' }, error: message }));
2678
- }
2679
- else {
2680
- console.error(message);
2681
- }
2682
- process.exit(1);
2683
- }
2684
- throw err;
2685
- }
2686
- if (!response.ok) {
2687
- if (opts.json) {
2688
- console.log(JSON.stringify({ ok: false, error: response.error }));
2689
- }
2690
- else {
2691
- console.error(response.error);
2692
- }
2693
- process.exit(1);
2694
- }
2695
- if (opts.json) {
2696
- console.log(JSON.stringify(response.profiles ?? [], null, 2));
2697
- return;
2698
- }
2699
- console.log(chalk.gray('Browser service: running (shared daemon unchanged)'));
2700
- // Build flat list of tasks with profile context
2701
- const allTasks = [];
2702
- for (const profile of response.profiles || []) {
2703
- for (const task of profile.tasks) {
2704
- allTasks.push({ task, profile });
2705
- }
2706
- }
2707
- if (allTasks.length === 0) {
2708
- // Show recent history instead
2709
- const historyResponse = await sendIPCRequest({ action: 'history', limit: 5 });
2710
- if (historyResponse.ok && historyResponse.history && historyResponse.history.length > 0) {
2711
- console.log('No active tasks. Recent history:\n');
2712
- console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
2713
- console.log('-'.repeat(75));
2714
- for (const h of historyResponse.history) {
2715
- const domains = h.domains?.slice(0, 2).join(', ') || '-';
2716
- const duration = formatDuration(h.endedAt - h.createdAt);
2717
- const ended = formatAge(h.endedAt);
2718
- console.log(h.profile.padEnd(15) +
2719
- h.name.padEnd(18) +
2720
- domains.slice(0, 20).padEnd(22) +
2721
- duration.padEnd(10) +
2722
- ended);
2723
- }
2724
- console.log('\nRun `browser history` for more.');
2725
- }
2726
- else {
2727
- console.log('No browser tasks running');
2728
- }
2729
- return;
2730
- }
2731
- // Interactive picker for TTY, plain output otherwise
2732
- if (isInteractiveTerminal()) {
2733
- const picked = await browserTaskPicker({
2734
- message: 'Browser tasks:',
2735
- tasks: allTasks,
2736
- });
2737
- if (picked) {
2738
- // Show tab list for the selected task
2739
- const tabResponse = await sendIPCRequest({
2740
- action: 'tab-list',
2741
- task: picked.task.task.name,
2742
- });
2743
- if (tabResponse.ok && tabResponse.tabs) {
2744
- console.log(`\nTabs for ${picked.task.task.name}:`);
2745
- for (const tab of tabResponse.tabs) {
2746
- console.log(` ${tab.id} ${tab.url}`);
2747
- }
2748
- }
2749
- }
2750
- }
2751
- else {
2752
- // Non-interactive: simple table output
2753
- for (const profile of response.profiles || []) {
2754
- const portLabel = profile.configuredPort && profile.configuredPort !== profile.port
2755
- ? `port ${profile.port} (configured ${profile.configuredPort})`
2756
- : `port ${profile.port}`;
2757
- // pid 0 means the daemon attached to a browser we didn't launch — no
2758
- // tracked pid. Render it as "attached" rather than the literal 0.
2759
- const pidLabel = profile.pid ? `pid ${profile.pid}` : 'attached';
2760
- // `profile.name` is the BARE profile; the endpoint is its own field,
2761
- // so the row reads `comet-local (endpoint: endpoint-0, port …)`
2762
- // instead of the raw `comet-local@endpoint-0` key (RUSH-2709).
2763
- const endpointLabel = profile.endpoint ? `endpoint: ${profile.endpoint}, ` : '';
2764
- console.log(`\n${profile.name} (${endpointLabel}${portLabel}, ${pidLabel})`);
2765
- // A profile whose live state could not be read is still listed, and
2766
- // says so — it is not silently dropped and not shown as healthy.
2767
- if (profile.unavailable) {
2768
- console.log(` Unavailable: ${profile.unavailable}`);
2769
- }
2770
- if (profile.tasks.length === 0) {
2771
- console.log(' No active tasks');
2772
- }
2773
- else {
2774
- console.log(' ' +
2775
- 'ID'.padEnd(12) +
2776
- 'LABEL'.padEnd(20) +
2777
- 'TABS'.padEnd(6) +
2778
- 'DOMAINS'.padEnd(22) +
2779
- 'CREATED');
2780
- for (const task of profile.tasks) {
2781
- const age = formatAge(task.createdAt);
2782
- const id = (task.name || task.id).slice(0, 10);
2783
- const label = (task.label || task.name || task.id).slice(0, 18);
2784
- const domains = task.domains?.slice(0, 2).join(', ') || '-';
2785
- console.log(' ' +
2786
- id.padEnd(12) +
2787
- label.padEnd(20) +
2788
- String(task.tabCount).padEnd(6) +
2789
- domains.slice(0, 20).padEnd(22) +
2790
- age);
2791
- // The recorded tab count above is what the task owns on disk; this
2792
- // says the live tabs behind it could not be read, so the count is
2793
- // not mistaken for verified-live tabs.
2794
- if (task.unavailable) {
2795
- console.log(' '.padEnd(14) + `↳ tabs unreadable: ${task.unavailable}`);
2796
- }
2797
- }
2798
- }
2799
- }
2800
- }
2801
- });
2802
- browser
2803
- .command('tasks')
2804
- .description('List all browser tasks')
2805
- .option('-p, --profile <name>', 'Filter by profile')
2806
- .option('--json', 'Output machine-readable JSON')
2807
- .action(async (opts) => {
2808
- const response = await sendIPCRequest({
2809
- action: 'status',
2810
- profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
2811
- });
2812
- if (!response.ok) {
2813
- if (opts.json) {
2814
- console.log(JSON.stringify({ ok: false, error: response.error }));
2815
- }
2816
- else {
2817
- console.error(response.error);
2818
- }
2819
- process.exit(1);
2820
- }
2821
- const allTasks = [];
2822
- for (const profile of response.profiles || []) {
2823
- for (const task of profile.tasks) {
2824
- allTasks.push({
2825
- profile: profile.name,
2826
- name: task.name || task.id,
2827
- tabs: task.tabCount,
2828
- domains: task.domains || [],
2829
- created: task.createdAt,
2830
- });
2831
- }
2832
- }
2833
- if (opts.json) {
2834
- console.log(JSON.stringify(allTasks, null, 2));
2835
- return;
2836
- }
2837
- if (allTasks.length === 0) {
2838
- // Show recent history instead
2839
- const historyResponse = await sendIPCRequest({ action: 'history', limit: 5 });
2840
- if (historyResponse.ok && historyResponse.history && historyResponse.history.length > 0) {
2841
- console.log('No active tasks. Recent history:\n');
2842
- console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
2843
- console.log('-'.repeat(75));
2844
- for (const h of historyResponse.history) {
2845
- const domains = h.domains?.slice(0, 2).join(', ') || '-';
2846
- const duration = formatDuration(h.endedAt - h.createdAt);
2847
- const ended = formatAge(h.endedAt);
2848
- console.log(h.profile.padEnd(15) +
2849
- h.name.padEnd(18) +
2850
- domains.slice(0, 20).padEnd(22) +
2851
- duration.padEnd(10) +
2852
- ended);
2853
- }
2854
- }
2855
- else {
2856
- console.log('No active tasks');
2857
- }
2858
- return;
2859
- }
2860
- console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'TABS'.padEnd(6) + 'DOMAINS'.padEnd(22) + 'CREATED');
2861
- console.log('-'.repeat(70));
2862
- for (const t of allTasks) {
2863
- const domains = t.domains.slice(0, 2).join(', ') || '-';
2864
- console.log(t.profile.padEnd(15) +
2865
- t.name.padEnd(18) +
2866
- String(t.tabs).padEnd(6) +
2867
- domains.slice(0, 20).padEnd(22) +
2868
- formatAge(t.created));
2869
- }
2870
- });
2871
- browser
2872
- .command('history')
2873
- .description('Show recent browser task history')
2874
- .option('-l, --limit <n>', 'Number of entries (default 10)', '10')
2875
- .option('--json', 'Output machine-readable JSON')
2876
- .action(async (opts) => {
2877
- const response = await sendIPCRequest({
2878
- action: 'history',
2879
- limit: parseInt(opts.limit, 10),
2880
- });
2881
- if (!response.ok) {
2882
- if (opts.json) {
2883
- console.log(JSON.stringify({ ok: false, error: response.error }));
2884
- }
2885
- else {
2886
- console.error(response.error);
2887
- }
2888
- process.exit(1);
2889
- }
2890
- if (opts.json) {
2891
- console.log(JSON.stringify(response.history ?? [], null, 2));
2892
- return;
2893
- }
2894
- if (!response.history || response.history.length === 0) {
2895
- console.log('No browser task history');
2896
- return;
2897
- }
2898
- console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
2899
- console.log('-'.repeat(75));
2900
- for (const h of response.history) {
2901
- const domains = h.domains?.slice(0, 2).join(', ') || '-';
2902
- const duration = formatDuration(h.endedAt - h.createdAt);
2903
- const ended = formatAge(h.endedAt);
2904
- console.log(h.profile.padEnd(15) +
2905
- h.name.padEnd(18) +
2906
- domains.slice(0, 20).padEnd(22) +
2907
- duration.padEnd(10) +
2908
- ended);
2909
- }
2910
- });
2911
- browser
2912
- .command('refs')
2913
- .description('Get DOM refs for interactive elements')
2914
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2915
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2916
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2917
- .option('--all', 'Include non-interactive elements')
2918
- .option('-l, --limit <n>', 'Max elements (default 500)', '500')
2919
- .option('--json', 'Output machine-readable JSON')
2920
- .action(async (opts) => {
2921
- const task = resolveTaskName(opts);
2922
- const response = await sendIPCRequest({
2923
- action: 'refs',
2924
- task,
2925
- tabId: opts.tab,
2926
- interactive: !opts.all,
2927
- limit: parseInt(opts.limit, 10),
2928
- });
2929
- if (!response.ok) {
2930
- if (opts.json) {
2931
- console.log(JSON.stringify({ ok: false, error: response.error }));
2932
- }
2933
- else {
2934
- console.error(response.error);
2935
- }
2936
- process.exit(1);
2937
- }
2938
- // Warn (on stderr, so it never corrupts --json stdout) when the a11y tree
2939
- // exposed nothing to click — the caller should fall back to a screenshot
2940
- // plus coordinate click.
2941
- const count = response.nodes?.length ?? 0;
2942
- if (count === 0) {
2943
- const scope = opts.all ? 'elements' : 'interactive elements';
2944
- console.error(`No ${scope} found. The page may render its UI on a canvas or in a custom widget ` +
2945
- `the accessibility tree doesn't expose. Take a screenshot ` +
2946
- `(\`browser screenshot\`) and click by position with \`browser click --at X,Y\`.`);
2947
- }
2948
- if (opts.json) {
2949
- console.log(JSON.stringify(response.nodes ?? [], null, 2));
2950
- return;
2951
- }
2952
- console.log(response.refs);
2953
- });
2954
- browser
2955
- .command('click [ref]')
2956
- .description('Click an element by ref, or raw coordinates with --at X,Y')
2957
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2958
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2959
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2960
- .option('--at <x,y>', 'Click viewport coordinates (e.g. --at 320,540), bypassing ref resolution')
2961
- .action(async (ref, opts) => {
2962
- const task = resolveTaskName(opts);
2963
- let requestExtra;
2964
- if (opts.at !== undefined) {
2965
- const parts = String(opts.at).split(',').map((s) => Number(s.trim()));
2966
- if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n))) {
2967
- console.error(`Invalid --at value "${opts.at}". Expected X,Y (e.g. --at 320,540).`);
2968
- process.exit(1);
2969
- }
2970
- requestExtra = { atX: parts[0], atY: parts[1] };
2971
- }
2972
- else if (ref !== undefined) {
2973
- const parsed = parseInt(ref, 10);
2974
- if (!Number.isFinite(parsed)) {
2975
- console.error(`Invalid ref "${ref}". Pass an integer ref or use --at X,Y.`);
2976
- process.exit(1);
2977
- }
2978
- requestExtra = { ref: parsed };
2979
- }
2980
- else {
2981
- console.error('Provide a ref (e.g. `click 5`) or coordinates (`click --at X,Y`).');
2982
- process.exit(1);
2983
- return;
2984
- }
2985
- const response = await sendIPCRequest({
2986
- action: 'click',
2987
- task,
2988
- tabId: opts.tab,
2989
- ...requestExtra,
2990
- });
2991
- if (!response.ok) {
2992
- console.error(response.error);
2993
- process.exit(1);
2994
- }
2995
- console.log(response.message ? `Clicked (${response.message})` : 'Clicked');
2996
- });
2997
- browser
2998
- .command('type <ref>')
2999
- .description('Type text into an element by ref')
3000
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3001
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3002
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3003
- .option('--text <text>', 'Text to type (use quotes for spaces/special chars)')
3004
- .option('--secret <ref>', 'Resolve <bundle>/<KEY> from `agents secrets` and type the value. The secret is resolved in-process and never printed to stdout or the transcript. Alternative to --text.')
3005
- .option('--clear', 'Clear editor content before typing')
3006
- .action(async (ref, opts) => {
3007
- const task = resolveTaskName(opts);
3008
- const refNum = parseInt(ref, 10);
3009
- if (!Number.isFinite(refNum)) {
3010
- console.error(`<ref> must be an integer, got: ${ref}`);
3011
- process.exit(1);
3012
- }
3013
- // Resolve the text to type: literal --text, or a --secret <bundle>/<KEY>
3014
- // reference resolved in-process so the value never crosses stdout/transcript.
3015
- let text = opts.text;
3016
- if (opts.secret) {
3017
- if (opts.text !== undefined) {
3018
- console.error('Pass either --text or --secret, not both.');
3019
- process.exit(1);
3020
- }
3021
- const parsed = parseSecretRef(opts.secret);
3022
- if (!parsed) {
3023
- console.error(`--secret must be <bundle>/<KEY>, got: ${opts.secret}`);
3024
- process.exit(1);
3025
- }
3026
- if (!(await bundleExists(parsed.bundle))) {
3027
- console.error(`Secrets bundle "${parsed.bundle}" not found.`);
3028
- process.exit(1);
3029
- }
3030
- try {
3031
- const { env } = await readAndResolveBundleEnv(parsed.bundle, { caller: 'browser type', keys: [parsed.key], keyMode: 'storage', agentOnly: true });
3032
- if (!(parsed.key in env)) {
3033
- console.error(`Key "${parsed.key}" not in bundle "${parsed.bundle}".`);
3034
- process.exit(1);
3035
- }
3036
- text = env[parsed.key];
3037
- }
3038
- catch (err) {
3039
- console.error(err.message);
3040
- process.exit(1);
3041
- }
3042
- }
3043
- if (text === undefined) {
3044
- console.error('Provide --text <text> or --secret <bundle>/<KEY>.');
3045
- process.exit(1);
3046
- }
3047
- const response = await sendIPCRequest({
3048
- action: 'type',
3049
- task,
3050
- tabId: opts.tab,
3051
- ref: refNum,
3052
- text,
3053
- clear: opts.clear,
3054
- });
3055
- if (!response.ok) {
3056
- console.error(response.error);
3057
- process.exit(1);
3058
- }
3059
- // Never echo the typed text — with --secret it would leak the credential.
3060
- console.log('Typed');
3061
- });
3062
- browser
3063
- .command('press <key>')
3064
- .description('Press a key (Enter, Tab, Escape, etc)')
3065
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3066
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3067
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3068
- .action(async (key, opts) => {
3069
- const task = resolveTaskName(opts);
3070
- const response = await sendIPCRequest({
3071
- action: 'press',
3072
- task,
3073
- tabId: opts.tab,
3074
- key,
3075
- });
3076
- if (!response.ok) {
3077
- console.error(response.error);
3078
- process.exit(1);
3079
- }
3080
- console.log('Pressed');
3081
- });
3082
- browser
3083
- .command('hover <ref>')
3084
- .description('Hover over an element by ref')
3085
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3086
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3087
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3088
- .action(async (ref, opts) => {
3089
- const task = resolveTaskName(opts);
3090
- const response = await sendIPCRequest({
3091
- action: 'hover',
3092
- task,
3093
- tabId: opts.tab,
3094
- ref: parseInt(ref, 10),
3095
- });
3096
- if (!response.ok) {
3097
- console.error(response.error);
3098
- process.exit(1);
3099
- }
3100
- console.log('Hovered');
3101
- });
3102
- browser
3103
- .command('scroll')
3104
- .description('Scroll the page by pixel amount (negatives scroll up/left)')
3105
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3106
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3107
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3108
- .option('--dx <n>', 'Horizontal pixels (negative = left)', (v) => parseInt(v, 10), 0)
3109
- .option('--dy <n>', 'Vertical pixels (negative = up)', (v) => parseInt(v, 10), 0)
3110
- .option('-x, --at-x <x>', 'X coordinate to dispatch scroll from (default 0)', parseInt)
3111
- .option('-y, --at-y <y>', 'Y coordinate to dispatch scroll from (default 0)', parseInt)
3112
- .action(async (opts) => {
3113
- const task = resolveTaskName(opts);
3114
- if (!Number.isFinite(opts.dx) || !Number.isFinite(opts.dy)) {
3115
- console.error('--dx and --dy must be integers');
3116
- process.exit(1);
3117
- }
3118
- if (opts.dx === 0 && opts.dy === 0) {
3119
- console.error('Pass --dx and/or --dy (at least one must be non-zero)');
3120
- process.exit(1);
3121
- }
3122
- const response = await sendIPCRequest({
3123
- action: 'scroll',
3124
- task,
3125
- tabId: opts.tab,
3126
- scrollX: opts.dx,
3127
- scrollY: opts.dy,
3128
- scrollAtX: opts.atX,
3129
- scrollAtY: opts.atY,
3130
- });
3131
- if (!response.ok) {
3132
- console.error(response.error);
3133
- process.exit(1);
3134
- }
3135
- console.log('Scrolled');
3136
- });
3137
- browser
3138
- .command('upload')
3139
- .description('Upload file(s) — supports hidden file inputs, drag-drop targets, and OS chooser interception')
3140
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3141
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3142
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3143
- .option('-r, --ref <n>', 'Ref of the upload target element (file input or drop zone)', (v) => parseInt(v, 10))
3144
- .option('--trigger <n>', 'Ref of a button that opens the OS file chooser (Pattern C)', (v) => parseInt(v, 10))
3145
- .option('-f, --file <path...>', 'Absolute path(s) to file(s) to upload (repeatable)')
3146
- .option('--drop', 'Force drag-drop pattern even if ref is an <input type=file>')
3147
- .option('--input', 'Force file-input pattern (DOM.setFileInputFiles)')
3148
- .option('--timeout <ms>', 'Timeout for chooser interception (Pattern C)', (v) => parseInt(v, 10))
3149
- .action(async (opts) => {
3150
- const task = resolveTaskName(opts);
3151
- const files = opts.file ?? [];
3152
- if (files.length === 0) {
3153
- console.error('--file <path> is required (repeat for multiple files)');
3154
- process.exit(1);
3155
- }
3156
- if (opts.ref === undefined && opts.trigger === undefined) {
3157
- console.error('--ref <n> or --trigger <n> is required');
3158
- process.exit(1);
3159
- }
3160
- if (opts.drop && opts.input) {
3161
- console.error('--drop and --input are mutually exclusive');
3162
- process.exit(1);
3163
- }
3164
- let mode = 'auto';
3165
- if (opts.trigger !== undefined)
3166
- mode = 'chooser';
3167
- else if (opts.drop)
3168
- mode = 'drop';
3169
- else if (opts.input)
3170
- mode = 'input';
3171
- const response = await sendIPCRequest({
3172
- action: 'upload',
3173
- task,
3174
- tabId: opts.tab,
3175
- ref: opts.ref,
3176
- trigger: opts.trigger,
3177
- files,
3178
- uploadMode: mode,
3179
- timeout: opts.timeout,
3180
- });
3181
- if (!response.ok) {
3182
- console.error(response.error);
3183
- process.exit(1);
3184
- }
3185
- console.log(`Uploaded ${files.length} file${files.length === 1 ? '' : 's'} (${response.uploadMode})`);
3186
- });
3187
- // ─── Viewport & Device ───────────────────────────────────────────────────────
3188
- const setCmd = browser.command('set').description('Set browser emulation options');
3189
- setCmd
3190
- .command('viewport <width> <height>')
3191
- .description('Set viewport size')
3192
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3193
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3194
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3195
- .option('-m, --mobile', 'Enable mobile emulation')
3196
- .option('-s, --scale <factor>', 'Device scale factor', parseFloat)
3197
- .action(async (width, height, opts) => {
3198
- const task = resolveTaskName(opts);
3199
- const response = await sendIPCRequest({
3200
- action: 'set-viewport',
3201
- task,
3202
- tabId: opts.tab,
3203
- width: parseInt(width, 10),
3204
- height: parseInt(height, 10),
3205
- mobile: opts.mobile,
3206
- deviceScaleFactor: opts.scale,
3207
- });
3208
- if (!response.ok) {
3209
- console.error(response.error);
3210
- process.exit(1);
3211
- }
3212
- console.log(`Viewport set to ${width}x${height}${opts.mobile ? ' (mobile)' : ''}`);
3213
- });
3214
- setCmd
3215
- .command('device <device-name>')
3216
- .description('Emulate a device (iPhone 14, iPad, MacBook Pro)')
3217
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3218
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3219
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3220
- .action(async (deviceName, opts) => {
3221
- const task = resolveTaskName(opts);
3222
- const response = await sendIPCRequest({
3223
- action: 'set-device',
3224
- task,
3225
- tabId: opts.tab,
3226
- deviceName,
3227
- });
3228
- if (!response.ok) {
3229
- console.error(response.error);
3230
- process.exit(1);
3231
- }
3232
- console.log(`Device set to ${deviceName}`);
3233
- });
3234
- browser
3235
- .command('devices')
3236
- .description('List available device presets')
3237
- .action(async () => {
3238
- const { DEVICES } = await import('../lib/browser/devices.js');
3239
- console.log('Available devices:');
3240
- for (const [name, desc] of Object.entries(DEVICES)) {
3241
- console.log(` ${name.padEnd(16)} ${desc.width}x${desc.height} @${desc.deviceScaleFactor}x${desc.mobile ? ' (mobile)' : ''}`);
3242
- }
3243
- });
3244
- // ─── Console & Errors ────────────────────────────────────────────────────────
3245
- browser
3246
- .command('console')
3247
- .description('Read console logs from a tab')
3248
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3249
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3250
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3251
- .option('-l, --level <level>', 'Filter by level (log, info, warn, error)')
3252
- .option('--clear', 'Clear logs after reading')
3253
- .action(async (opts) => {
3254
- const task = resolveTaskName(opts);
3255
- const response = await sendIPCRequest({
3256
- action: 'console',
3257
- task,
3258
- tabId: opts.tab,
3259
- level: opts.level,
3260
- clear: opts.clear,
3261
- });
3262
- if (!response.ok) {
3263
- console.error(response.error);
3264
- process.exit(1);
3265
- }
3266
- if (!response.logs || response.logs.length === 0) {
3267
- console.log('No console logs');
3268
- return;
3269
- }
3270
- for (const log of response.logs) {
3271
- const prefix = `[${log.level.toUpperCase()}]`.padEnd(8);
3272
- const loc = log.url ? ` (${log.url}${log.line ? `:${log.line}` : ''})` : '';
3273
- console.log(`${prefix} ${log.text}${loc}`);
3274
- }
3275
- });
3276
- browser
3277
- .command('errors')
3278
- .description('Read page errors from a tab')
3279
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3280
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3281
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3282
- .option('--clear', 'Clear errors after reading')
3283
- .action(async (opts) => {
3284
- const task = resolveTaskName(opts);
3285
- const response = await sendIPCRequest({
3286
- action: 'errors',
3287
- task,
3288
- tabId: opts.tab,
3289
- clear: opts.clear,
3290
- });
3291
- if (!response.ok) {
3292
- console.error(response.error);
3293
- process.exit(1);
3294
- }
3295
- if (!response.errors || response.errors.length === 0) {
3296
- console.log('No errors');
3297
- return;
3298
- }
3299
- for (const err of response.errors) {
3300
- console.log(`[ERROR] ${err.message}`);
3301
- if (err.stack)
3302
- console.log(err.stack);
3303
- if (err.url)
3304
- console.log(` at ${err.url}${err.line ? `:${err.line}` : ''}`);
3305
- console.log();
3306
- }
3307
- });
3308
- // ─── Network ─────────────────────────────────────────────────────────────────
3309
- browser
3310
- .command('requests')
3311
- .description('Read captured network requests. --format har emits a HAR 1.2 JSON document.')
3312
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3313
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3314
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3315
- .option('-f, --filter <text>', 'Filter URLs containing text')
3316
- .option('--clear', 'Clear requests after reading')
3317
- .option('--format <format>', 'Output format: table (default) or har', 'table')
3318
- .action(async (opts) => {
3319
- const task = resolveTaskName(opts);
3320
- if (opts.format !== 'table' && opts.format !== 'har') {
3321
- console.error('--format must be "table" or "har"');
3322
- process.exit(1);
3323
- }
3324
- const response = await sendIPCRequest({
3325
- action: 'requests',
3326
- task,
3327
- tabId: opts.tab,
3328
- filter: opts.filter,
3329
- clear: opts.clear,
3330
- });
3331
- if (!response.ok) {
3332
- console.error(response.error);
3333
- process.exit(1);
3334
- }
3335
- const requests = response.requests ?? [];
3336
- if (opts.format === 'har') {
3337
- const har = buildHar(requests, {
3338
- creatorName: 'agents-cli',
3339
- creatorVersion: getCliVersion(),
3340
- });
3341
- console.log(JSON.stringify(har, null, 2));
3342
- return;
3343
- }
3344
- if (requests.length === 0) {
3345
- console.log('No requests captured');
3346
- return;
3347
- }
3348
- console.log('METHOD'.padEnd(8) + 'STATUS'.padEnd(8) + 'URL');
3349
- console.log('-'.repeat(72));
3350
- for (const req of requests) {
3351
- const status = req.status ? String(req.status) : '...';
3352
- console.log(`${req.method.padEnd(8)}${status.padEnd(8)}${req.url.slice(0, 100)}`);
3353
- }
3354
- });
3355
- browser
3356
- .command('logs')
3357
- .description('Read merged rush-app + rush-cli JSONL logs for a task')
3358
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3359
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3360
- .option('--source <name>', 'Source to scope to: rush-app or rush-cli (default both)')
3361
- .option('--lines <n>', 'Tail N entries (default 200; ignored when --since)', (v) => parseInt(v, 10))
3362
- .option('--since <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
3363
- .option('--until <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
3364
- .option('--level <level>', 'Filter entries by level field')
3365
- .option('--message <name>', 'Filter entries by exact message field')
3366
- .option('--filter <text>', 'Filter entries whose JSON contains this substring')
3367
- .option('-f, --follow', 'Follow mode (not yet implemented)')
3368
- .action(async (opts) => {
3369
- if (opts.follow) {
3370
- process.stderr.write('follow mode not yet implemented; coming next pass\n');
3371
- process.exit(1);
3372
- }
3373
- const task = resolveTaskName(opts);
3374
- const response = await sendIPCRequest({
3375
- action: 'getAppLogs',
3376
- task,
3377
- source: opts.source,
3378
- lines: opts.lines,
3379
- since: opts.since,
3380
- until: opts.until,
3381
- appLevel: opts.level,
3382
- message: opts.message,
3383
- filter: opts.filter,
3384
- });
3385
- if (!response.ok) {
3386
- console.error(response.error);
3387
- process.exit(1);
3388
- }
3389
- const entries = response.appLogs ?? [];
3390
- for (const entry of entries) {
3391
- console.log(JSON.stringify(entry));
3392
- }
3393
- });
3394
- browser
3395
- .command('responsebody <url-pattern>')
3396
- .description('Wait for and read a response body by URL pattern')
3397
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3398
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3399
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3400
- .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
3401
- .option('--max-chars <n>', 'Max characters to return', parseInt)
3402
- .action(async (urlPattern, opts) => {
3403
- const task = resolveTaskName(opts);
3404
- const response = await sendIPCRequest({
3405
- action: 'response-body',
3406
- task,
3407
- tabId: opts.tab,
3408
- urlPattern,
3409
- timeout: opts.timeout,
3410
- maxChars: opts.maxChars,
3411
- });
3412
- if (!response.ok) {
3413
- console.error(response.error);
3414
- process.exit(1);
3415
- }
3416
- console.log(response.body);
3417
- });
3418
- // ─── Wait ────────────────────────────────────────────────────────────────────
3419
- browser
3420
- .command('wait')
3421
- .description('Wait for a condition')
3422
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3423
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3424
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3425
- .option('--time <ms>', 'Wait for milliseconds')
3426
- .option('--selector <css>', 'Wait for CSS selector to appear')
3427
- .option('--url <pattern>', 'Wait for URL to match pattern')
3428
- .option('--fn <js>', 'Wait for JS expression to return truthy')
3429
- .option('--state <state>', 'Wait for load state (domcontentloaded, load, networkidle)')
3430
- .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
3431
- .action(async (opts) => {
3432
- const task = resolveTaskName(opts);
3433
- let waitType;
3434
- let waitValue;
3435
- if (opts.time) {
3436
- waitType = 'time';
3437
- waitValue = parseInt(opts.time, 10);
3438
- }
3439
- else if (opts.selector) {
3440
- waitType = 'selector';
3441
- waitValue = opts.selector;
3442
- }
3443
- else if (opts.url) {
3444
- waitType = 'url';
3445
- waitValue = opts.url;
3446
- }
3447
- else if (opts.fn) {
3448
- waitType = 'function';
3449
- waitValue = opts.fn;
3450
- }
3451
- else if (opts.state) {
3452
- waitType = 'load';
3453
- waitValue = opts.state;
3454
- }
3455
- else {
3456
- console.error('One of --time, --selector, --url, --fn, or --state required');
3457
- process.exit(1);
3458
- }
3459
- const response = await sendIPCRequest({
3460
- action: 'wait',
3461
- task,
3462
- tabId: opts.tab,
3463
- waitType,
3464
- waitValue,
3465
- timeout: opts.timeout,
3466
- });
3467
- if (!response.ok) {
3468
- console.error(response.error);
3469
- process.exit(1);
3470
- }
3471
- console.log('Wait condition met');
3472
- });
3473
- // ─── Downloads ───────────────────────────────────────────────────────────────
3474
- browser
3475
- .command('download')
3476
- .description("Set the download directory for a task (defaults to the profile's downloads dir)")
3477
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3478
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3479
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3480
- .option('-p, --path <dir>', "Download directory path (default: the profile's downloads dir)")
3481
- .action(async (opts) => {
3482
- const task = resolveTaskName(opts);
3483
- const response = await sendIPCRequest({
3484
- action: 'set-download-path',
3485
- task,
3486
- tabId: opts.tab,
3487
- downloadPath: opts.path,
3488
- });
3489
- if (!response.ok) {
3490
- console.error(response.error);
3491
- process.exit(1);
3492
- }
3493
- console.log(response.downloadPath ? `Download path set to ${response.downloadPath}` : 'Download path set');
3494
- });
3495
- browser
3496
- .command('sessions')
3497
- .description('Browse a profile\'s captured screenshots, PDFs, recordings, and downloads, grouped by task')
3498
- .option('--profile <name>', 'Only this profile (default: all profiles with captures)')
3499
- .option('--open [selector]', "Open a capture in the OS default app: 'latest' or a filename")
3500
- .option('--json', 'Emit machine-readable JSON')
3501
- .option('--no-interactive', 'Print the flat listing instead of opening the interactive task browser')
3502
- .action(async (opts) => {
3503
- await runBrowserSessionsCommand({ profile: opts.profile, open: opts.open, json: opts.json, interactive: opts.interactive });
3504
- });
3505
- // ─── Recording ─────────────────────────────────────────────────────────────
3506
- const record = browser.command('record').description('Record a video of the page');
3507
- record
3508
- .command('start')
3509
- .description('Start recording — auto-saved under sessions/<task>/recordings/. Bounded by --fps, --duration, --max-mb.')
3510
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3511
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3512
- .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
3513
- .option('--fps <n>', 'Frames per second (1–30, default 5)', (v) => parseInt(v, 10))
3514
- .option('--duration <sec>', 'Hard duration cap in seconds (default 60)', (v) => parseInt(v, 10))
3515
- .option('--max-mb <mb>', 'Stop when output exceeds this many MB (default 25)', (v) => parseInt(v, 10))
3516
- .action(async (opts) => {
3517
- const task = resolveTaskName(opts);
3518
- const response = await sendIPCRequest({
3519
- action: 'record-start',
3520
- task,
3521
- tabId: opts.tab,
3522
- fps: opts.fps,
3523
- duration: opts.duration,
3524
- maxMb: opts.maxMb,
3525
- });
3526
- if (!response.ok) {
3527
- console.error(response.error);
3528
- process.exit(1);
3529
- }
3530
- // stdout: path (for capture into a variable). stderr: human commentary.
3531
- console.log(response.path);
3532
- console.error(`Recording task "${task}" at ${response.fps} fps (cap ${response.durationCapSec}s / ${response.maxMb} MB) → ${response.path}`);
3533
- console.error('Stop with: agents browser record stop');
3534
- });
3535
- record
3536
- .command('stop')
3537
- .description('Stop an in-progress recording')
3538
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3539
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3540
- .action(async (opts) => {
3541
- const task = resolveTaskName(opts);
3542
- const response = await sendIPCRequest({ action: 'record-stop', task });
3543
- if (!response.ok) {
3544
- console.error(response.error);
3545
- process.exit(1);
3546
- }
3547
- console.log(response.path);
3548
- const size = humanizeBytes(response.bytes);
3549
- const seconds = ((response.durationMs ?? 0) / 1000).toFixed(1);
3550
- console.error(`Saved recording to ${response.path} (${size}, ${seconds}s, stopped: ${response.stopReason})`);
3551
- });
3552
- browser
3553
- .command('waitdownload')
3554
- .description('Wait for a download to complete')
3555
- .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
3556
- .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
3557
- .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
3558
- .action(async (opts) => {
3559
- const task = resolveTaskName(opts);
3560
- const response = await sendIPCRequest({
3561
- action: 'wait-download',
3562
- task,
3563
- timeout: opts.timeout,
3564
- });
3565
- if (!response.ok) {
3566
- console.error(response.error);
3567
- process.exit(1);
3568
- }
3569
- console.log(`Downloaded: ${response.downloadPath}`);
3570
- });
3571
- }
3572
- function collect(val, memo) {
3573
- memo.push(val);
3574
- return memo;
3575
- }
3576
- function formatAge(timestamp) {
3577
- const seconds = Math.floor((Date.now() - timestamp) / 1000);
3578
- if (seconds < 60)
3579
- return `${seconds}s ago`;
3580
- const minutes = Math.floor(seconds / 60);
3581
- if (minutes < 60)
3582
- return `${minutes}m ago`;
3583
- const hours = Math.floor(minutes / 60);
3584
- return `${hours}h ago`;
3585
- }
3586
- function humanizeBytes(n) {
3587
- if (n === undefined)
3588
- return 'unknown size';
3589
- if (n < 1024)
3590
- return `${n} B`;
3591
- if (n < 1024 * 1024)
3592
- return `${(n / 1024).toFixed(0)} KB`;
3593
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
3594
- }
3595
- function formatDuration(ms) {
3596
- const seconds = Math.floor(ms / 1000);
3597
- if (seconds < 60)
3598
- return `${seconds}s`;
3599
- const minutes = Math.floor(seconds / 60);
3600
- if (minutes < 60)
3601
- return `${minutes}m`;
3602
- const hours = Math.floor(minutes / 60);
3603
- const mm = minutes % 60;
3604
- return mm ? `${hours}h ${mm}m` : `${hours}h`;
3605
235
  }