@phnx-labs/agents-cli 1.22.46 → 1.22.49

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 (348) hide show
  1. package/CHANGELOG.md +663 -0
  2. package/LICENSE +102 -182
  3. package/README.md +96 -49
  4. package/dist/bootstrap.js +4 -2
  5. package/dist/cli/command-registry.d.ts +2 -1
  6. package/dist/cli/command-registry.js +6 -2
  7. package/dist/commands/accounts.d.ts +58 -0
  8. package/dist/commands/accounts.js +243 -25
  9. package/dist/commands/artifacts.js +7 -6
  10. package/dist/commands/attach.js +1 -1
  11. package/dist/commands/auth.js +7 -5
  12. package/dist/commands/browser-sessions-picker.js +9 -7
  13. package/dist/commands/browser.js +711 -62
  14. package/dist/commands/config.d.ts +0 -1
  15. package/dist/commands/config.js +26 -24
  16. package/dist/commands/cost.d.ts +1 -1
  17. package/dist/commands/daemon.d.ts +21 -0
  18. package/dist/commands/daemon.js +147 -12
  19. package/dist/commands/detach.d.ts +13 -0
  20. package/dist/commands/detach.js +11 -8
  21. package/dist/commands/exec.js +107 -53
  22. package/dist/commands/feed-watch.d.ts +2 -0
  23. package/dist/commands/feed-watch.js +28 -0
  24. package/dist/commands/feed.js +27 -0
  25. package/dist/commands/feedback.js +3 -19
  26. package/dist/commands/focus.d.ts +14 -0
  27. package/dist/commands/focus.js +48 -5
  28. package/dist/commands/go.d.ts +3 -0
  29. package/dist/commands/go.js +16 -3
  30. package/dist/commands/insights.d.ts +1 -1
  31. package/dist/commands/insights.js +2 -2
  32. package/dist/commands/inspect.js +2 -2
  33. package/dist/commands/lease.d.ts +1 -1
  34. package/dist/commands/lease.js +7 -6
  35. package/dist/commands/mailboxes.js +4 -3
  36. package/dist/commands/menubar.js +11 -4
  37. package/dist/commands/projects.d.ts +16 -1
  38. package/dist/commands/projects.js +58 -64
  39. package/dist/commands/resume.js +1 -1
  40. package/dist/commands/route.js +4 -4
  41. package/dist/commands/routines.js +4 -3
  42. package/dist/commands/run-broadcast.d.ts +1 -1
  43. package/dist/commands/run-broadcast.js +1 -1
  44. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  45. package/dist/commands/sessions-picker-factory.js +1 -1
  46. package/dist/commands/sessions-picker.d.ts +22 -0
  47. package/dist/commands/sessions-picker.js +48 -9
  48. package/dist/commands/sessions-stop.d.ts +24 -0
  49. package/dist/commands/sessions-stop.js +66 -0
  50. package/dist/commands/sessions-trace.d.ts +82 -0
  51. package/dist/commands/sessions-trace.js +387 -0
  52. package/dist/commands/sessions.js +5 -0
  53. package/dist/commands/setup-secrets.js +23 -0
  54. package/dist/commands/share.d.ts +20 -5
  55. package/dist/commands/share.js +152 -52
  56. package/dist/commands/ssh.d.ts +15 -0
  57. package/dist/commands/ssh.js +243 -18
  58. package/dist/commands/teams.js +34 -2
  59. package/dist/commands/traces.d.ts +2 -0
  60. package/dist/commands/traces.js +184 -0
  61. package/dist/commands/view.d.ts +2 -0
  62. package/dist/commands/view.js +32 -62
  63. package/dist/commands/watchdog.js +9 -14
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js +5 -0
  66. package/dist/lib/account-capabilities.d.ts +4 -0
  67. package/dist/lib/account-capabilities.js +13 -9
  68. package/dist/lib/account-registry.d.ts +2 -0
  69. package/dist/lib/account-registry.js +30 -2
  70. package/dist/lib/accounting/rotate.d.ts +9 -0
  71. package/dist/lib/accounting/rotate.js +12 -2
  72. package/dist/lib/accounting/usage.d.ts +84 -6
  73. package/dist/lib/accounting/usage.js +293 -75
  74. package/dist/lib/agent-modes.js +29 -1
  75. package/dist/lib/agent-spec/agents.d.ts +4 -3
  76. package/dist/lib/agent-spec/agents.js +25 -15
  77. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  78. package/dist/lib/analytics/mix-commands.js +2 -2
  79. package/dist/lib/auth-health.d.ts +13 -3
  80. package/dist/lib/auth-health.js +50 -8
  81. package/dist/lib/browser/devices.d.ts +19 -0
  82. package/dist/lib/browser/devices.js +23 -0
  83. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  84. package/dist/lib/browser/drivers/ssh.js +36 -9
  85. package/dist/lib/browser/ipc.d.ts +1 -1
  86. package/dist/lib/browser/ipc.js +40 -9
  87. package/dist/lib/browser/profiles.d.ts +104 -65
  88. package/dist/lib/browser/profiles.js +277 -165
  89. package/dist/lib/browser/registry.d.ts +39 -0
  90. package/dist/lib/browser/registry.js +149 -0
  91. package/dist/lib/browser/remote-control.d.ts +28 -2
  92. package/dist/lib/browser/remote-control.js +35 -3
  93. package/dist/lib/browser/resolve-target.d.ts +68 -0
  94. package/dist/lib/browser/resolve-target.js +250 -0
  95. package/dist/lib/browser/runtime-state.d.ts +44 -11
  96. package/dist/lib/browser/runtime-state.js +98 -15
  97. package/dist/lib/browser/service.d.ts +65 -9
  98. package/dist/lib/browser/service.js +196 -61
  99. package/dist/lib/browser/sessions-list.d.ts +1 -3
  100. package/dist/lib/browser/sessions-list.js +3 -16
  101. package/dist/lib/browser/task-index.d.ts +70 -0
  102. package/dist/lib/browser/task-index.js +180 -0
  103. package/dist/lib/browser/types.d.ts +12 -1
  104. package/dist/lib/byok-usage.js +2 -2
  105. package/dist/lib/claude-statusline.d.ts +29 -0
  106. package/dist/lib/claude-statusline.js +139 -0
  107. package/dist/lib/cloud/host.d.ts +1 -1
  108. package/dist/lib/cloud/host.js +1 -1
  109. package/dist/lib/cloud/rush.js +1 -1
  110. package/dist/lib/codex-policy.d.ts +11 -1
  111. package/dist/lib/codex-policy.js +20 -5
  112. package/dist/lib/computer/computer-rpc.js +2 -2
  113. package/dist/lib/computer/download.d.ts +15 -15
  114. package/dist/lib/computer/download.js +35 -98
  115. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  116. package/dist/lib/computer/ssh-tunnel.js +12 -22
  117. package/dist/lib/config-keys.d.ts +6 -11
  118. package/dist/lib/config-keys.js +18 -19
  119. package/dist/lib/crabbox/cli.d.ts +1 -1
  120. package/dist/lib/crabbox/cli.js +2 -2
  121. package/dist/lib/crabbox/lease.d.ts +9 -0
  122. package/dist/lib/crabbox/lease.js +70 -15
  123. package/dist/lib/crabbox/runtimes.js +1 -1
  124. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  125. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  126. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  127. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  128. package/dist/lib/daemon/daemon.d.ts +3 -0
  129. package/dist/lib/daemon/daemon.js +212 -444
  130. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  131. package/dist/lib/daemon/device-probe-service.js +47 -0
  132. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  133. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  134. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  135. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  136. package/dist/lib/daemon/runner.js +15 -2
  137. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  138. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  139. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  140. package/dist/lib/daemon/self-heal-service.js +48 -0
  141. package/dist/lib/daemon/service.d.ts +102 -0
  142. package/dist/lib/daemon/service.js +98 -0
  143. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  144. package/dist/lib/daemon/session-index-service.js +40 -0
  145. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  146. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  147. package/dist/lib/daemon/supervisor.d.ts +87 -0
  148. package/dist/lib/daemon/supervisor.js +298 -0
  149. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  150. package/dist/lib/daemon/watchdog-service.js +40 -0
  151. package/dist/lib/daemon-health.d.ts +15 -0
  152. package/dist/lib/daemon-health.js +29 -5
  153. package/dist/lib/daemon-services.d.ts +8 -1
  154. package/dist/lib/daemon-services.js +55 -0
  155. package/dist/lib/daemon-ticks.d.ts +13 -2
  156. package/dist/lib/daemon-ticks.js +22 -20
  157. package/dist/lib/device-config.d.ts +25 -10
  158. package/dist/lib/device-config.js +90 -39
  159. package/dist/lib/devices/config-migration.d.ts +7 -0
  160. package/dist/lib/devices/config-migration.js +67 -3
  161. package/dist/lib/devices/health.d.ts +27 -2
  162. package/dist/lib/devices/health.js +39 -9
  163. package/dist/lib/devices/interactive-host.d.ts +15 -0
  164. package/dist/lib/devices/interactive-host.js +53 -0
  165. package/dist/lib/devices/registry.d.ts +61 -4
  166. package/dist/lib/devices/registry.js +94 -38
  167. package/dist/lib/devices/stats-cache.d.ts +26 -0
  168. package/dist/lib/devices/stats-cache.js +59 -7
  169. package/dist/lib/devices/worker-pick.d.ts +48 -0
  170. package/dist/lib/devices/worker-pick.js +113 -0
  171. package/dist/lib/exec.d.ts +70 -14
  172. package/dist/lib/exec.js +110 -38
  173. package/dist/lib/feed/answer.d.ts +29 -0
  174. package/dist/lib/feed/answer.js +145 -0
  175. package/dist/lib/feed/attention.d.ts +83 -0
  176. package/dist/lib/feed/attention.js +0 -0
  177. package/dist/lib/feed/feed.d.ts +109 -0
  178. package/dist/lib/feed/feed.js +154 -3
  179. package/dist/lib/feed/pr-status.d.ts +14 -0
  180. package/dist/lib/feed/pr-status.js +47 -0
  181. package/dist/lib/feed/watch.d.ts +59 -0
  182. package/dist/lib/feed/watch.js +180 -0
  183. package/dist/lib/fleet/capture.js +6 -0
  184. package/dist/lib/fleet/remote-login.js +8 -3
  185. package/dist/lib/fleet/types.d.ts +17 -0
  186. package/dist/lib/harness/adapter.d.ts +11 -0
  187. package/dist/lib/harness/adapters/claude.js +38 -24
  188. package/dist/lib/harness/adapters/codex.js +2 -4
  189. package/dist/lib/harness/adapters/cursor.js +8 -11
  190. package/dist/lib/helper-download.d.ts +108 -0
  191. package/dist/lib/helper-download.js +194 -0
  192. package/dist/lib/helper-versions.d.ts +52 -0
  193. package/dist/lib/helper-versions.js +58 -0
  194. package/dist/lib/hooks/cache.js +19 -0
  195. package/dist/lib/hooks/install.js +2 -2
  196. package/dist/lib/hooks/match.d.ts +3 -0
  197. package/dist/lib/hooks/match.js +25 -0
  198. package/dist/lib/hosts/credentials.d.ts +1 -1
  199. package/dist/lib/hosts/credentials.js +1 -1
  200. package/dist/lib/hosts/dispatch.d.ts +7 -1
  201. package/dist/lib/hosts/dispatch.js +24 -5
  202. package/dist/lib/hosts/passthrough.d.ts +8 -15
  203. package/dist/lib/hosts/passthrough.js +35 -16
  204. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  205. package/dist/lib/hosts/providers/devices.js +1 -1
  206. package/dist/lib/hosts/reconnect.d.ts +179 -21
  207. package/dist/lib/hosts/reconnect.js +266 -45
  208. package/dist/lib/hosts/registry.js +7 -0
  209. package/dist/lib/installations/migrate.d.ts +4 -5
  210. package/dist/lib/installations/migrate.js +6 -7
  211. package/dist/lib/installations/shims.d.ts +1 -1
  212. package/dist/lib/installations/shims.js +23 -13
  213. package/dist/lib/installations/versions.js +25 -2
  214. package/dist/lib/linear-autoclose.d.ts +1 -1
  215. package/dist/lib/linear-autoclose.js +1 -1
  216. package/dist/lib/loop.d.ts +2 -2
  217. package/dist/lib/loop.js +1 -1
  218. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  219. package/dist/lib/menubar/download-menubar.js +60 -0
  220. package/dist/lib/menubar/install-menubar.d.ts +44 -7
  221. package/dist/lib/menubar/install-menubar.js +105 -23
  222. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  223. package/dist/lib/menubar/notify-desktop.js +6 -6
  224. package/dist/lib/model-tiers.d.ts +1 -1
  225. package/dist/lib/open-url.d.ts +60 -2
  226. package/dist/lib/open-url.js +167 -12
  227. package/dist/lib/project-probe.d.ts +1 -1
  228. package/dist/lib/project-probe.js +1 -1
  229. package/dist/lib/project-pull.d.ts +1 -1
  230. package/dist/lib/project-pull.js +2 -2
  231. package/dist/lib/projects.d.ts +1 -1
  232. package/dist/lib/projects.js +1 -1
  233. package/dist/lib/refresh-coordinator.d.ts +1 -1
  234. package/dist/lib/refresh-coordinator.js +1 -1
  235. package/dist/lib/sandbox.js +6 -7
  236. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  237. package/dist/lib/secrets/download-keychain.js +70 -0
  238. package/dist/lib/secrets/install-helper.d.ts +20 -1
  239. package/dist/lib/secrets/install-helper.js +44 -3
  240. package/dist/lib/session/active.d.ts +67 -0
  241. package/dist/lib/session/active.js +86 -2
  242. package/dist/lib/session/db.d.ts +11 -1
  243. package/dist/lib/session/db.js +142 -8
  244. package/dist/lib/session/discover.d.ts +23 -1
  245. package/dist/lib/session/discover.js +44 -10
  246. package/dist/lib/session/highlights.d.ts +49 -1
  247. package/dist/lib/session/highlights.js +95 -0
  248. package/dist/lib/session/host-link.d.ts +16 -2
  249. package/dist/lib/session/host-link.js +7 -1
  250. package/dist/lib/session/parse.js +33 -1
  251. package/dist/lib/session/prompt.d.ts +35 -0
  252. package/dist/lib/session/prompt.js +56 -0
  253. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  254. package/dist/lib/session/remote/remote-list.js +19 -2
  255. package/dist/lib/session/remote/watch.d.ts +1 -1
  256. package/dist/lib/session/remote/watch.js +17 -3
  257. package/dist/lib/session/render.js +7 -3
  258. package/dist/lib/session/session-cache.d.ts +53 -0
  259. package/dist/lib/session/session-cache.js +93 -1
  260. package/dist/lib/session/state.js +1 -1
  261. package/dist/lib/session/tool-calls.d.ts +2 -0
  262. package/dist/lib/session/tool-calls.js +1 -1
  263. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  264. package/dist/lib/session/trajectory-compare.js +116 -0
  265. package/dist/lib/session/trajectory-html.d.ts +19 -0
  266. package/dist/lib/session/trajectory-html.js +770 -0
  267. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  268. package/dist/lib/session/trajectory-lineage.js +195 -0
  269. package/dist/lib/session/trajectory-text.d.ts +37 -0
  270. package/dist/lib/session/trajectory-text.js +284 -0
  271. package/dist/lib/session/trajectory.d.ts +102 -0
  272. package/dist/lib/session/trajectory.js +384 -0
  273. package/dist/lib/session/types.d.ts +15 -0
  274. package/dist/lib/sha256-asset.d.ts +26 -0
  275. package/dist/lib/sha256-asset.js +41 -0
  276. package/dist/lib/share/backend.d.ts +95 -0
  277. package/dist/lib/share/backend.js +139 -0
  278. package/dist/lib/share/delete.d.ts +6 -2
  279. package/dist/lib/share/delete.js +8 -9
  280. package/dist/lib/share/html.d.ts +22 -0
  281. package/dist/lib/share/html.js +88 -0
  282. package/dist/lib/share/provision.d.ts +18 -4
  283. package/dist/lib/share/provision.js +38 -4
  284. package/dist/lib/share/publish.d.ts +39 -15
  285. package/dist/lib/share/publish.js +55 -26
  286. package/dist/lib/share/worker-template.js +171 -11
  287. package/dist/lib/sqlite.js +16 -3
  288. package/dist/lib/ssh-exec.d.ts +65 -0
  289. package/dist/lib/ssh-exec.js +120 -4
  290. package/dist/lib/startup/command-registry.d.ts +3 -1
  291. package/dist/lib/startup/command-registry.js +6 -3
  292. package/dist/lib/startup/dev-build.js +3 -3
  293. package/dist/lib/state.d.ts +2 -2
  294. package/dist/lib/state.js +11 -5
  295. package/dist/lib/subagents-registry.js +1 -1
  296. package/dist/lib/teams/agents.js +9 -0
  297. package/dist/lib/terminal/index.d.ts +1 -1
  298. package/dist/lib/terminal/inject.d.ts +19 -0
  299. package/dist/lib/terminal/inject.js +19 -9
  300. package/dist/lib/terminal/types.d.ts +1 -1
  301. package/dist/lib/terminal/types.js +1 -1
  302. package/dist/lib/tmux/orphan-reap.js +1 -1
  303. package/dist/lib/tmux/session.d.ts +46 -13
  304. package/dist/lib/tmux/session.js +204 -27
  305. package/dist/lib/traces/backend.d.ts +29 -0
  306. package/dist/lib/traces/backend.js +46 -0
  307. package/dist/lib/traces/classify.d.ts +52 -0
  308. package/dist/lib/traces/classify.js +72 -0
  309. package/dist/lib/traces/config.d.ts +10 -0
  310. package/dist/lib/traces/config.js +2 -0
  311. package/dist/lib/traces/provision.d.ts +19 -0
  312. package/dist/lib/traces/provision.js +30 -0
  313. package/dist/lib/traces/sync.d.ts +150 -0
  314. package/dist/lib/traces/sync.js +447 -0
  315. package/dist/lib/traces/worker-template.d.ts +4 -0
  316. package/dist/lib/traces/worker-template.js +159 -0
  317. package/dist/lib/types.d.ts +34 -19
  318. package/dist/lib/types.js +14 -0
  319. package/dist/lib/usage-backoff.d.ts +2 -1
  320. package/dist/lib/usage-backoff.js +60 -19
  321. package/dist/lib/usage-refresh.d.ts +18 -4
  322. package/dist/lib/usage-refresh.js +68 -19
  323. package/dist/lib/watchdog/log.d.ts +1 -1
  324. package/dist/lib/watchdog/log.js +1 -1
  325. package/dist/lib/watchdog/runner.d.ts +36 -45
  326. package/dist/lib/watchdog/runner.js +150 -119
  327. package/dist/lib/watchdog/service.d.ts +0 -1
  328. package/dist/lib/watchdog/service.js +0 -1
  329. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  330. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  331. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  332. package/dist/lib/watchdog/watchdog.js +37 -104
  333. package/dist/lib/workflows.d.ts +1 -1
  334. package/package.json +5 -7
  335. package/dist/commands/usage.d.ts +0 -27
  336. package/dist/commands/usage.js +0 -96
  337. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  338. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  339. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  340. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  341. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  342. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  343. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -24
  344. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  345. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -123
  346. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  347. package/dist/lib/usage-fleet.d.ts +0 -32
  348. package/dist/lib/usage-fleet.js +0 -125
@@ -1,55 +1,259 @@
1
1
  import chalk from 'chalk';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import * as fs from 'fs';
3
4
  import * as path from 'path';
4
- import { listProfiles, getProfile, createProfile, deleteProfile, getConfiguredDefaultProfileName, resolveProfileRef, resolveProfileRefForStart, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, listProfilesWithScope, formatProfilesTable, } from '../lib/browser/profiles.js';
5
+ import { listProfiles, getProfile, createProfile, deleteProfile, getConfiguredDefaultProfileName, resolveProfileRef, resolveProfileRefForStart, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, formatProfilesTable, editProfile, renameProfile, assertRegistrableProfileName, isProfileLaunchableHere, } from '../lib/browser/profiles.js';
6
+ import { declaringDevices, migrateCentralBrowserProfiles, profileKind } from '../lib/browser/registry.js';
5
7
  import { resolveActor } from '../lib/actor.js';
6
8
  import { loginsForProfile, profilesLoggedInto, serviceForUrl, loginsWithAccountsForProfile, accountsForProfile, credKeysForService, AUTH_SIGNATURES, } from '../lib/browser/login-detection.js';
7
9
  import { parseSecretRef } from '../lib/browser/secret-ref.js';
8
10
  import { readAndResolveBundleEnv, bundleExists, readBundle, describeBundle } from '../lib/secrets/bundles.js';
9
11
  import { findBrowserPath, getPortOccupant, isLauncherScript, listInstalledBrowsers } from '../lib/browser/chrome.js';
10
- import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, buildProfilePrunePlan, pruneProfiles, PRUNE_REASON_TEXT, } from '../lib/browser/runtime-state.js';
11
- import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
12
+ import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, buildProfilePrunePlan, pruneProfiles, PRUNE_REASON_TEXT, identityLoopbackMismatch, } from '../lib/browser/runtime-state.js';
13
+ import { DEFAULT_VIEWPORT, parseWindowSize, parseWindowPosition } from '../lib/browser/devices.js';
12
14
  import { runBrowserSessionsCommand } from './browser-sessions-picker.js';
13
15
  import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
14
16
  import { parseTargetFilter } from '../lib/browser/service.js';
15
- import { BrowserDaemonNotRunningError, formatBrowserDaemonNotRunningError, sendIPCRequest, } from '../lib/browser/ipc.js';
17
+ import { BrowserDaemonNotRunningError, formatBrowserDaemonNotRunningError, sendIPCRequest as sendRawIPCRequest, } from '../lib/browser/ipc.js';
18
+ import { bindTask, getTaskBinding, honorScreenshotOutput, REJECT_DEVICE_MESSAGE, resolveTaskRoute, unbindTask, unbindTasksForProfile, updateTaskBinding, } from '../lib/browser/task-index.js';
19
+ import { isSelfHost } from '../lib/devices/self-host.js';
20
+ import { resolveHost } from '../lib/hosts/registry.js';
21
+ import { sshTargetFor } from '../lib/hosts/types.js';
22
+ import { withActorEnv } from '../lib/hosts/dispatch.js';
23
+ import { streamAgentsOnHost, passthroughSshOptions, } from '../lib/hosts/passthrough.js';
24
+ import { buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, stripRoutingFlags, } from '../lib/hosts/remote-cmd.js';
25
+ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
26
+ import { sshExec, SSH_OPTS } from '../lib/ssh-exec.js';
27
+ import { flagValue } from '../lib/hosts/routing-flag.js';
16
28
  import { browserTaskPicker } from './browser-picker.js';
17
- import { assertRemoteControlAllowed } from '../lib/browser/remote-control.js';
29
+ import { assertRemoteControlAllowed, isFleetRemoteInvocation } from '../lib/browser/remote-control.js';
18
30
  import { getConfigValue, setConfigValue, unsetConfigValue } from '../lib/device-config.js';
19
31
  import { isInteractiveTerminal } from './utils.js';
20
32
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
21
33
  import { buildHar } from '../lib/browser/har.js';
22
34
  import { getCliVersion } from '../lib/version.js';
23
35
  import { runBrowserIPCStream } from '../lib/browser/stream.js';
36
+ import { machineId } from '../lib/machine-id.js';
37
+ /**
38
+ * Task name inferred from the local task→device index when `--task` was
39
+ * omitted and this session owns exactly one open task. Set by the page-verb
40
+ * preAction hook; consumed by {@link resolveTaskName}.
41
+ */
42
+ let inferredTaskName;
24
43
  /**
25
44
  * Resolve which browser task a command targets. Order:
26
45
  * 1. `--task <name>` flag (explicit per-command override)
27
46
  * 2. `$AGENTS_BROWSER_TASK` (optional shell default)
28
- * 3. `undefined` the daemon resolves from the caller's identity
29
- * (session / launch id stamped in sendIPCRequest)
47
+ * 3. the local task-index match for this session (exactly one)
48
+ * 4. `undefined` the daemon resolves from the caller's identity
49
+ *
50
+ * `--device` on a page verb is rejected here so commander-parsed flags cannot
51
+ * silently re-route a later verb. Bind the device at `start`.
30
52
  *
31
53
  * `undefined` is valid: page verbs create a task when none resolves, and
32
54
  * done/stop report "nothing to close". Agents no longer need to type a handle
33
55
  * in the common case.
34
56
  */
35
57
  function resolveTaskName(opts) {
58
+ if (opts.device) {
59
+ const route = resolveTaskRoute({ device: opts.device });
60
+ console.error(route.kind === 'reject-device' ? route.message : REJECT_DEVICE_MESSAGE);
61
+ process.exit(1);
62
+ }
36
63
  if (opts.task)
37
64
  return opts.task;
38
65
  const fromEnv = process.env.AGENTS_BROWSER_TASK;
39
66
  if (fromEnv)
40
67
  return fromEnv;
41
- return undefined;
68
+ return inferredTaskName;
42
69
  }
43
70
  // `-t` is taken by `--tab` on most commands, so `--task` is long-form only.
44
71
  // The daemon resolves from caller identity when omitted.
45
72
  const TASK_OPTION_FLAG = '--task <name>';
46
73
  const TASK_OPTION_DESC = 'Task name (defaults to $AGENTS_BROWSER_TASK, else the caller\'s live task)';
74
+ const DEVICE_ON_PAGE_VERB_FLAG = '--device <name>';
75
+ const DEVICE_ON_PAGE_VERB_DESC = 'Not valid here — bind the device at `agents browser start --device`';
76
+ const TASK_ROUTED_COMMANDS = new Set([
77
+ 'stream',
78
+ 'done',
79
+ 'stop',
80
+ 'navigate',
81
+ 'goto',
82
+ 'tab',
83
+ 'tabs',
84
+ 'screenshot',
85
+ 'pdf',
86
+ 'evaluate',
87
+ 'refs',
88
+ 'click',
89
+ 'type',
90
+ 'press',
91
+ 'hover',
92
+ 'scroll',
93
+ 'upload',
94
+ 'set',
95
+ 'console',
96
+ 'errors',
97
+ 'requests',
98
+ 'logs',
99
+ 'responsebody',
100
+ 'wait',
101
+ 'download',
102
+ 'record',
103
+ 'waitdownload',
104
+ ]);
105
+ function commandPath(actionCommand) {
106
+ const parts = [];
107
+ let current = actionCommand;
108
+ while (current) {
109
+ const name = current.name();
110
+ if (name === 'agents' || name === 'browser')
111
+ break;
112
+ parts.unshift(name);
113
+ current = current.parent ?? null;
114
+ }
115
+ return parts;
116
+ }
117
+ function callerSessionId() {
118
+ return process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID;
119
+ }
120
+ function callerLaunchId() {
121
+ return process.env.AGENT_LAUNCH_ID;
122
+ }
123
+ function browserForwardedArgv() {
124
+ const raw = process.argv.slice(2);
125
+ const withCommand = raw[0] === 'browser' ? raw : ['browser', ...raw];
126
+ return stripRoutingFlags(withCommand, HOST_ROUTING_SPECS);
127
+ }
128
+ async function dispatchBrowserToDevice(device, forwardedArgs, mode) {
129
+ const host = await resolveHost(device);
130
+ if (!host) {
131
+ throw new Error(`Unknown device "${device}". Next: agents devices list`);
132
+ }
133
+ const target = sshTargetFor(host);
134
+ const remoteOs = resolveRemoteOsSync(host.name);
135
+ const env = withActorEnv({ AGENTS_FLEET_REMOTE: '1' });
136
+ const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, undefined, remoteOs, env);
137
+ if (mode === 'stream') {
138
+ const code = streamAgentsOnHost(host, forwardedArgs, {
139
+ interactive: !!process.stdout.isTTY && !process.argv.includes('--no-tty'),
140
+ extraEnv: { AGENTS_FLEET_REMOTE: '1' },
141
+ remoteOs,
142
+ target,
143
+ });
144
+ return { code, stdout: '', stderr: '' };
145
+ }
146
+ const sshOpts = passthroughSshOptions(host, false);
147
+ const result = sshExec(target, remoteCmd, {
148
+ multiplex: sshOpts.multiplex,
149
+ extraSshArgs: sshOpts.extraSshArgs,
150
+ });
151
+ return {
152
+ code: result.code ?? 1,
153
+ stdout: result.stdout,
154
+ stderr: result.stderr,
155
+ };
156
+ }
157
+ async function pullRemoteFile(device, remotePath, localPath) {
158
+ const host = await resolveHost(device);
159
+ if (!host) {
160
+ throw new Error(`Unknown device "${device}". Next: agents devices list`);
161
+ }
162
+ const target = sshTargetFor(host);
163
+ fs.mkdirSync(path.dirname(path.resolve(localPath)), { recursive: true });
164
+ const sshOpts = passthroughSshOptions(host, false);
165
+ const copied = spawnSync('scp', [...sshOpts.extraSshArgs, ...SSH_OPTS, `${target}:${remotePath}`, path.resolve(localPath)], { encoding: 'utf8' });
166
+ if (copied.status !== 0) {
167
+ throw new Error(`Failed to copy screenshot from ${device}:${remotePath}: ${(copied.stderr || copied.stdout || '').trim()}`);
168
+ }
169
+ }
170
+ function syncTaskIndex(request, response) {
171
+ if (!response.ok)
172
+ return;
173
+ if (request.action === 'stop' && request.profile && !request.task) {
174
+ unbindTasksForProfile(request.profile);
175
+ return;
176
+ }
177
+ if (request.action === 'gc') {
178
+ if (!request.dryRun) {
179
+ for (const closed of response.reaped?.closed ?? []) {
180
+ unbindTask(closed.task);
181
+ }
182
+ }
183
+ return;
184
+ }
185
+ const name = response.task ?? request.task ?? request.taskName;
186
+ if (!name)
187
+ return;
188
+ if (request.action === 'done' || (request.action === 'stop' && request.task)) {
189
+ unbindTask(name);
190
+ return;
191
+ }
192
+ if (request.action === 'start') {
193
+ const existing = getTaskBinding(name);
194
+ if (!existing) {
195
+ bindTask(name, {
196
+ device: machineId(),
197
+ profile: request.profile,
198
+ url: request.url,
199
+ sessionId: request.sessionId ?? callerSessionId(),
200
+ launchId: request.launchId ?? callerLaunchId(),
201
+ createdAt: Date.now(),
202
+ });
203
+ }
204
+ else if (request.url) {
205
+ updateTaskBinding(name, { url: request.url });
206
+ }
207
+ return;
208
+ }
209
+ if (request.url) {
210
+ const existing = getTaskBinding(name);
211
+ if (existing) {
212
+ updateTaskBinding(name, { url: request.url });
213
+ }
214
+ else {
215
+ bindTask(name, {
216
+ device: machineId(),
217
+ profile: request.profile,
218
+ url: request.url,
219
+ sessionId: request.sessionId ?? callerSessionId(),
220
+ launchId: request.launchId ?? callerLaunchId(),
221
+ createdAt: Date.now(),
222
+ });
223
+ }
224
+ return;
225
+ }
226
+ if (!getTaskBinding(name)) {
227
+ bindTask(name, {
228
+ device: machineId(),
229
+ profile: request.profile,
230
+ sessionId: request.sessionId ?? callerSessionId(),
231
+ launchId: request.launchId ?? callerLaunchId(),
232
+ createdAt: Date.now(),
233
+ });
234
+ }
235
+ }
236
+ async function sendIPCRequest(request, opts) {
237
+ const response = await sendRawIPCRequest(request, opts);
238
+ syncTaskIndex(request, response);
239
+ return response;
240
+ }
241
+ function assertDeviceDeclaresProfile(device, profileName) {
242
+ const declared = declaringDevices(profileName);
243
+ if (declared.includes(device))
244
+ return;
245
+ const where = declared.length > 0
246
+ ? `Declared on: ${declared.join(', ')}.`
247
+ : 'No device declares that profile.';
248
+ throw new Error(`Device "${device}" does not declare browser profile "${profileName}". ${where}\n` +
249
+ `Next: run \`agents browser profiles add ${profileName} --browser <b>\` on ${device}.`);
250
+ }
47
251
  // Help groups — surfaces the actual mental model an agent follows
48
252
  // ("open a session / drive the page / capture evidence / rare extras")
49
253
  // instead of an alphabetical dump. Everything not listed falls into a
50
254
  // trailing "Other commands" section automatically.
51
255
  const BROWSER_HELP_GROUPS = [
52
- { title: 'Session lifecycle', names: ['use', 'start', 'done', 'status', 'gc'] },
256
+ { title: 'Session lifecycle', names: ['use', 'start', 'done', 'status', 'prune'] },
53
257
  { title: 'Fast action loop', names: ['stream'] },
54
258
  {
55
259
  title: 'Drive the page',
@@ -86,8 +290,10 @@ export function registerBrowserCommand(program) {
86
290
  # Keep one process and daemon socket warm for repeated actions
87
291
  agents browser stream --task "$AGENTS_BROWSER_TASK"
88
292
 
89
- # Drive another machine's browser (needs its consent see remote-control)
90
- agents browser start --device zion
293
+ # Bind a device at start; later verbs resolve it from the task
294
+ agents browser start --task post --device zion --url https://x.com/
295
+ agents browser type --task post --ref @e3 "hello"
296
+ agents browser screenshot --task post
91
297
 
92
298
  # Browse a task-heavy profile's captures: one row per task, not per file
93
299
  agents browser sessions
@@ -99,7 +305,7 @@ export function registerBrowserCommand(program) {
99
305
  agents browser done
100
306
 
101
307
  # Close tabs the daemon's own reaper would have caught on its next 5-min tick
102
- agents browser gc --dry-run
308
+ agents browser prune --dry-run
103
309
  `,
104
310
  notes: `
105
311
  Most agent workflows should use the 'browser' skill instead of raw subcommands.
@@ -233,27 +439,28 @@ function registerProfilesCommands(browser) {
233
439
  profiles
234
440
  .command('list')
235
441
  .alias('ls')
236
- .description('List all browser profiles, with the store each lives in (local / fleet)')
237
- .option('--json', 'Output machine-readable JSON')
442
+ .description('List all browser profiles and the devices declaring each one (WHERE)')
443
+ .option('--json', 'Output machine-readable JSON (includes devices + kind)')
238
444
  .action(async (opts) => {
239
- const scoped = await listProfilesWithScope();
445
+ const allProfiles = await listProfiles();
240
446
  const configuredDefault = getConfiguredDefaultProfileName();
241
447
  if (opts.json) {
242
- console.log(JSON.stringify(scoped.map(({ profile, scope }) => ({
448
+ console.log(JSON.stringify(allProfiles.map((profile) => ({
243
449
  ...profile,
244
- scope,
450
+ devices: profile.devices,
451
+ kind: profileKind(profile.name),
245
452
  isConfiguredDefault: profile.name === configuredDefault,
246
453
  })), null, 2));
247
454
  return;
248
455
  }
249
- if (scoped.length === 0) {
456
+ if (allProfiles.length === 0) {
250
457
  console.log('No browser profiles configured.');
251
458
  console.log('Create one with: agents browser profiles create <name> --browser chrome');
252
459
  return;
253
460
  }
254
461
  // Rendering lives in lib/browser/profiles.ts so the column widths and the
255
462
  // default-marking rules are unit-tested rather than eyeballed (RUSH-2710).
256
- for (const line of formatProfilesTable(scoped, configuredDefault))
463
+ for (const line of formatProfilesTable(allProfiles, configuredDefault))
257
464
  console.log(line);
258
465
  });
259
466
  profiles
@@ -284,6 +491,44 @@ function registerProfilesCommands(browser) {
284
491
  }
285
492
  console.log('Pick your default with: agents browser use <name>');
286
493
  });
494
+ profiles
495
+ .command('claim [name]')
496
+ .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.')
497
+ .option('--json', 'Output machine-readable JSON')
498
+ .action(async (name, opts) => {
499
+ const canHostHere = (config) => isProfileLaunchableHere({
500
+ name: '_',
501
+ browser: config.browser,
502
+ binary: config.binary,
503
+ endpoints: config.endpoints,
504
+ });
505
+ let result;
506
+ try {
507
+ result = migrateCentralBrowserProfiles(canHostHere, name);
508
+ }
509
+ catch (err) {
510
+ console.error(err instanceof Error ? err.message : String(err));
511
+ process.exit(1);
512
+ }
513
+ if (opts.json) {
514
+ console.log(JSON.stringify({ ...result, device: machineId() }, null, 2));
515
+ return;
516
+ }
517
+ if (result.claimed.length === 0 && result.skipped.length === 0) {
518
+ console.log('No leftover central browser profiles to claim.');
519
+ return;
520
+ }
521
+ for (const claimed of result.claimed) {
522
+ console.log(`Claimed ${claimed} on ${machineId()}`);
523
+ }
524
+ for (const skipped of result.skipped) {
525
+ console.log(`Skipped ${skipped}: this machine cannot host it (browser/binary isn't installed here). ` +
526
+ `Run \`agents browser profiles claim ${skipped}\` on the machine that has that browser.`);
527
+ }
528
+ if (result.claimed.length === 0) {
529
+ console.log('No leftover central profiles this machine can host.');
530
+ }
531
+ });
287
532
  configureBrowserUseCommand(profiles.command('use [name]'));
288
533
  configureBrowserUseCommand(profiles.command('set-default [name]', { hidden: true }), true);
289
534
  profiles
@@ -317,9 +562,9 @@ function registerProfilesCommands(browser) {
317
562
  const VALID_BROWSERS = ['chrome', 'comet', 'chromium', 'brave', 'edge', 'arc', 'custom'];
318
563
  profiles
319
564
  .command('create <name>')
320
- .description('Create a new browser profile (machine-local unless --fleet)')
565
+ .alias('add')
566
+ .description('Create a new browser profile on this device')
321
567
  .requiredOption('-b, --browser <type>', `Browser type: ${VALID_BROWSERS.join(', ')}`)
322
- .option('--fleet', 'Store in the synced agents.yaml so every machine sees it. Default is machine-local: a profile pins an OS-specific binary path and a locally chosen port, so a synced copy is wrong on every other box.')
323
568
  .option('-e, --endpoint <url>', 'CDP endpoint URL (repeatable; auto-assigned if omitted)', collect, [])
324
569
  .option('-s, --secrets <bundle>', 'Secrets bundle to inject')
325
570
  .option('-d, --description <text>', 'Profile description')
@@ -330,8 +575,11 @@ function registerProfilesCommands(browser) {
330
575
  .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')
331
576
  .option('--target-filter <expr>', 'Pick the visible CDP page target when the app exposes more than one. Format: url:<substring> or title:<substring>')
332
577
  .action(async (name, opts) => {
333
- if (!/^[a-z][a-z0-9-]*$/.test(name)) {
334
- console.error('Profile name must be lowercase alphanumeric with hyphens');
578
+ try {
579
+ assertRegistrableProfileName(name);
580
+ }
581
+ catch (err) {
582
+ console.error(err instanceof Error ? err.message : String(err));
335
583
  process.exit(1);
336
584
  }
337
585
  if (!VALID_BROWSERS.includes(opts.browser)) {
@@ -370,22 +618,22 @@ function registerProfilesCommands(browser) {
370
618
  height: DEFAULT_VIEWPORT.height,
371
619
  };
372
620
  if (opts.window) {
373
- const m = String(opts.window).match(/^(\d+)x(\d+)$/);
374
- if (!m) {
621
+ const size = parseWindowSize(String(opts.window));
622
+ if (!size) {
375
623
  console.error(`--window must be WxH, e.g. ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
376
624
  process.exit(1);
377
625
  }
378
- viewport.width = parseInt(m[1], 10);
379
- viewport.height = parseInt(m[2], 10);
626
+ viewport.width = size.width;
627
+ viewport.height = size.height;
380
628
  }
381
629
  if (opts.position) {
382
- const m = String(opts.position).match(/^(-?\d+),(-?\d+)$/);
383
- if (!m) {
630
+ const pos = parseWindowPosition(String(opts.position));
631
+ if (!pos) {
384
632
  console.error('--position must be X,Y, e.g. 80,80');
385
633
  process.exit(1);
386
634
  }
387
- viewport.x = parseInt(m[1], 10);
388
- viewport.y = parseInt(m[2], 10);
635
+ viewport.x = pos.x;
636
+ viewport.y = pos.y;
389
637
  }
390
638
  const profile = {
391
639
  name,
@@ -399,14 +647,169 @@ function registerProfilesCommands(browser) {
399
647
  chrome: opts.headless ? { headless: true } : undefined,
400
648
  viewport,
401
649
  };
402
- await createProfile(profile, { fleet: !!opts.fleet });
403
- console.log(`Created profile: ${name} (${opts.fleet ? 'fleet-synced — visible on every machine' : 'machine-local'})`);
650
+ await createProfile(profile);
651
+ const port = extractConfiguredPort(profile);
652
+ console.log(port !== undefined
653
+ ? `Added "${name}" on ${machineId()} (port ${port}).`
654
+ : `Added "${name}" on ${machineId()}.`);
404
655
  // Warn (don't fail) if the declared secrets bundle doesn't exist yet — it
405
656
  // may be created later, but a typo should surface now.
406
657
  if (opts.secrets && !bundleExists(opts.secrets)) {
407
658
  console.error(`warning: secrets bundle "${opts.secrets}" does not exist yet. Create it with: agents secrets create ${opts.secrets}`);
408
659
  }
409
660
  });
661
+ profiles
662
+ .command('edit <name>')
663
+ .description('Edit an existing profile in place (stays in the store it already lives in)')
664
+ // Declared only so the action can explain WHY the browser type is not
665
+ // editable. Without it commander rejects `-b` as an unknown option before
666
+ // the action runs, and the delete-and-recreate guidance never reaches anyone.
667
+ .option('-b, --browser <type>', 'Not editable — see the error for the delete-and-recreate path')
668
+ .option('-d, --description <text>', "Profile description (pass '' to clear)")
669
+ .option('-e, --endpoint <url>', 'Replace the endpoint list (repeatable)', collect, [])
670
+ .option('-s, --secrets <bundle>', 'Secrets bundle to inject')
671
+ .option('--headless', 'Run in headless mode')
672
+ .option('--no-headless', 'Run headed')
673
+ .option('--window <WxH>', 'Window size in CSS pixels')
674
+ .option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
675
+ .option('--binary <path>', 'Absolute path to the browser/app binary')
676
+ .option('--electron', 'Treat this profile as an Electron desktop app')
677
+ .option('--no-electron', 'Stop treating it as an Electron app')
678
+ .option('--target-filter <expr>', "url:<substring> or title:<substring>; requires --electron (pass '' to clear)")
679
+ .option('--json', 'Output machine-readable JSON')
680
+ .action(async (name, opts) => {
681
+ // The browser type and the name are identity, not settings: both key the
682
+ // on-disk runtime dir and any live `<name>@<endpoint>` connection, so
683
+ // changing either orphans the cached browser data (and its logins).
684
+ if (opts.browser) {
685
+ console.error('browser type is not editable — it keys the on-disk profile cache. ' +
686
+ `Remove and recreate instead: agents browser profiles remove ${name} && agents browser profiles create ${name} -b <type>`);
687
+ process.exit(1);
688
+ }
689
+ if (opts.targetFilter) {
690
+ const parsed = parseTargetFilter(String(opts.targetFilter));
691
+ if (!parsed) {
692
+ console.error('--target-filter must be url:<substring> or title:<substring> (non-empty value, no leading whitespace)');
693
+ process.exit(1);
694
+ }
695
+ }
696
+ const patch = {};
697
+ if (opts.description !== undefined)
698
+ patch.description = opts.description || undefined;
699
+ if (opts.endpoint && opts.endpoint.length > 0)
700
+ patch.endpoints = opts.endpoint;
701
+ if (opts.secrets !== undefined)
702
+ patch.secrets = opts.secrets;
703
+ if (opts.binary !== undefined)
704
+ patch.binary = opts.binary;
705
+ if (opts.targetFilter !== undefined)
706
+ patch.targetFilter = opts.targetFilter || undefined;
707
+ // commander maps --electron/--no-electron and --headless/--no-headless onto
708
+ // one boolean each, and leaves it undefined when neither was passed.
709
+ if (opts.electron !== undefined)
710
+ patch.electron = opts.electron || undefined;
711
+ if (opts.headless !== undefined || opts.window || opts.position) {
712
+ const current = await getProfile(name);
713
+ if (!current) {
714
+ console.error(`Profile "${name}" does not exist`);
715
+ process.exit(1);
716
+ }
717
+ if (opts.headless !== undefined) {
718
+ const chrome = { ...current.chrome };
719
+ if (opts.headless)
720
+ chrome.headless = true;
721
+ else
722
+ delete chrome.headless;
723
+ // `{ headless: undefined }` is truthy, so profileToConfig would persist
724
+ // a bare `chrome: {}` into the YAML and the change-detector would report
725
+ // a phantom edit on an already-headed profile. Drop the key entirely.
726
+ patch.chrome = Object.keys(chrome).length > 0 ? chrome : undefined;
727
+ }
728
+ if (opts.window || opts.position) {
729
+ const viewport = { ...(current.viewport ?? { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height }) };
730
+ if (opts.window) {
731
+ const size = parseWindowSize(String(opts.window));
732
+ if (!size) {
733
+ console.error('--window must be WxH, e.g. 1512x982');
734
+ process.exit(1);
735
+ }
736
+ viewport.width = size.width;
737
+ viewport.height = size.height;
738
+ }
739
+ if (opts.position) {
740
+ const pos = parseWindowPosition(String(opts.position));
741
+ if (!pos) {
742
+ console.error('--position must be X,Y, e.g. 80,80');
743
+ process.exit(1);
744
+ }
745
+ viewport.x = pos.x;
746
+ viewport.y = pos.y;
747
+ }
748
+ patch.viewport = viewport;
749
+ }
750
+ }
751
+ if (Object.keys(patch).length === 0) {
752
+ console.error('Nothing to edit. Pass at least one field, e.g. -d "<description>".');
753
+ process.exit(1);
754
+ }
755
+ let result;
756
+ try {
757
+ result = await editProfile(name, patch);
758
+ }
759
+ catch (err) {
760
+ console.error(err instanceof Error ? err.message : String(err));
761
+ process.exit(1);
762
+ }
763
+ if (opts.json) {
764
+ console.log(JSON.stringify({ ...result.profile, devices: result.devices, changed: result.changed }, null, 2));
765
+ }
766
+ else if (result.changed.length === 0) {
767
+ console.log(`No change: ${name} already had those values.`);
768
+ }
769
+ else {
770
+ console.log(`Updated ${name} on ${machineId()}: ${result.changed.join(', ')}`);
771
+ }
772
+ if (patch.secrets && !bundleExists(patch.secrets)) {
773
+ console.error(`warning: secrets bundle "${patch.secrets}" does not exist yet. Create it with: agents secrets create ${patch.secrets}`);
774
+ }
775
+ });
776
+ profiles
777
+ .command('rename <from> <to>')
778
+ .description("Rename a profile, moving its browser data with it (logins survive)")
779
+ .option('--json', 'Output machine-readable JSON')
780
+ .action(async (from, to, opts) => {
781
+ let res;
782
+ try {
783
+ res = await renameProfile(from, to);
784
+ }
785
+ catch (err) {
786
+ console.error(err instanceof Error ? err.message : String(err));
787
+ process.exit(1);
788
+ }
789
+ if (opts.json) {
790
+ console.log(JSON.stringify({ from, to, ...res }, null, 2));
791
+ return;
792
+ }
793
+ console.log(`Renamed ${from} -> ${to} on ${machineId()}`);
794
+ if (res.movedDirs.length > 0) {
795
+ console.log(` moved ${res.movedDirs.length} browser data dir${res.movedDirs.length === 1 ? '' : 's'} — logins preserved`);
796
+ }
797
+ if (res.repointedDefault) {
798
+ console.log(` browser.profile now points at ${to}`);
799
+ }
800
+ if (res.repointedViewer) {
801
+ console.log(` browser.viewer now points at ${to}`);
802
+ }
803
+ if (res.stalePins.length > 0) {
804
+ const devices = [...new Set(res.stalePins.map((p) => p.device))];
805
+ const one = devices.length === 1;
806
+ console.error(`warning: ${devices.join(', ')} still ${one ? 'pins' : 'pin'} "${from}" in ` +
807
+ `${one ? 'its' : 'their'} own device config. Fix with:`);
808
+ for (const pin of res.stalePins) {
809
+ console.error(` agents config set ${pin.key} ${to} --device ${pin.device}`);
810
+ }
811
+ }
812
+ });
410
813
  profiles
411
814
  .command('show <name>')
412
815
  .description('Show profile details')
@@ -485,8 +888,9 @@ function registerProfilesCommands(browser) {
485
888
  }
486
889
  });
487
890
  profiles
488
- .command('delete <name>')
489
- .description('Delete a browser profile (drops YAML config + all cached runtime dirs)')
891
+ .command('remove <name>')
892
+ .alias('delete')
893
+ .description('Remove a browser profile (drops YAML config + all cached runtime dirs)')
490
894
  .option('--keep-cache', "Leave ~/.agents/.cache/browser/<name>* dirs in place (don't wipe chrome-data)")
491
895
  .action(async (name, opts) => {
492
896
  await deleteProfile(name);
@@ -519,24 +923,41 @@ function registerProfilesCommands(browser) {
519
923
  });
520
924
  profiles
521
925
  .command('prune')
522
- .description('Remove dead machine-local profiles: browser not installed here, or never started')
926
+ .description('Remove dead profiles this device declares: browser not installed here, or never started')
523
927
  .option('-n, --dry-run', 'Print what would be removed and exit without changing anything')
524
- .option('--fleet', 'Also consider fleet-synced profiles (removing one removes it from EVERY machine)')
525
928
  .option('--json', 'Output machine-readable JSON')
526
929
  .action(async (opts) => {
527
- const plan = await buildProfilePrunePlan({ includeFleet: opts.fleet });
930
+ const plan = await buildProfilePrunePlan();
528
931
  if (!opts.dryRun)
529
932
  await pruneProfiles(plan);
530
933
  if (opts.json) {
531
934
  console.log(JSON.stringify({ dryRun: !!opts.dryRun, ...plan }, null, 2));
532
935
  return;
533
936
  }
937
+ // A misfiled profile is never a prune candidate — the entry belongs to the
938
+ // device that declared it, not to this one — so without this it would only
939
+ // ever surface in --json. It is the one kept-reason a user has to act on.
940
+ const misfiled = plan.kept.filter((k) => k.misfiled);
941
+ const reportMisfiled = () => {
942
+ if (misfiled.length === 0)
943
+ return;
944
+ console.log('');
945
+ console.log(`${misfiled.length} profile${misfiled.length === 1 ? '' : 's'} ${misfiled.length === 1 ? 'is' : 'are'} misfiled — ` +
946
+ `declared on another device but bound to a loopback port, so the name resolves to ` +
947
+ `this machine's own browser rather than the declaring device's:`);
948
+ for (const k of misfiled) {
949
+ console.log(` ${k.name} — ${k.why}`);
950
+ }
951
+ console.log('Re-declare each on the machine that owns the browser. Nothing was moved for you.');
952
+ console.log('(Prune never deletes these — the declaring device owns the entry.)');
953
+ };
534
954
  if (plan.candidates.length === 0) {
535
955
  console.log('Nothing to prune — every profile is in use, healthy, or protected.');
536
956
  if (plan.kept.length > 0) {
537
957
  for (const k of plan.kept)
538
958
  console.log(` kept ${k.name} (${k.scope}) — ${k.why}`);
539
959
  }
960
+ reportMisfiled();
540
961
  return;
541
962
  }
542
963
  const verb = opts.dryRun ? 'Would remove' : 'Removed';
@@ -547,6 +968,7 @@ function registerProfilesCommands(browser) {
547
968
  : '';
548
969
  console.log(` ${c.name} (${c.scope}) — ${PRUNE_REASON_TEXT[c.reason]}${cache}`);
549
970
  }
971
+ reportMisfiled();
550
972
  if (opts.dryRun) {
551
973
  console.log('');
552
974
  console.log('Nothing was changed. Re-run without --dry-run to apply.');
@@ -554,16 +976,18 @@ function registerProfilesCommands(browser) {
554
976
  });
555
977
  setHelpSections(profiles, {
556
978
  examples: `
557
- # What exists here, and whether each is this machine's or the whole fleet's
979
+ # WHERE is the devices whose own file declares the name
558
980
  agents browser profiles list
559
981
 
560
- # Create one machine-local by default
982
+ # Create one on this device
983
+ agents browser profiles add work --browser chrome
561
984
  agents browser profiles create work --browser chrome
562
985
 
563
- # Create one every machine should see (e.g. a remote ssh:// endpoint)
564
- agents browser profiles create shared-remote --browser chrome --fleet
986
+ # Claim leftover central profiles on the machine that hosts the browser
987
+ agents browser profiles claim
988
+ agents browser profiles claim comet-local
565
989
 
566
- # Clean up dead ones: check first, then apply
990
+ # Clean up dead ones this device declares: check first, then apply
567
991
  agents browser profiles prune --dry-run
568
992
  agents browser profiles prune
569
993
 
@@ -571,22 +995,24 @@ function registerProfilesCommands(browser) {
571
995
  agents browser use work
572
996
  `,
573
997
  notes: `
574
- Profiles are machine-local by default. A profile pins an OS-specific binary
575
- path and a locally chosen CDP port, so a fleet-synced copy is wrong on every
576
- other machine pass --fleet only when the profile really is fleet config.
998
+ A device declares its own browsers in its own \`devices/<machine>/agents.yaml\`.
999
+ A name declared by exactly one device is identity-bearing; a name declared
1000
+ by several is fungible. Leftover central \`browser:\` entries are claimed
1001
+ with \`agents browser profiles claim\` on the machine that hosts the browser.
577
1002
 
578
1003
  In \`list\`, the \`*\` marker means "this machine's configured default"
579
1004
  (\`agents browser start\` with no --profile). The auto-detected profile is
580
1005
  named \`auto-chrome\`; \`default\` is only an ALIAS for whichever profile
581
1006
  this machine is configured to use, resolved the same way by every command.
582
1007
 
583
- \`prune\` never removes a profile that is in use, the configured default, the
584
- auto-detected \`auto-chrome\`, or (without --fleet) a fleet-synced one.
1008
+ \`prune\` only considers profiles this device declares. It never removes a
1009
+ profile that is in use, the configured default, or the auto-detected
1010
+ \`auto-chrome\`.
585
1011
  `,
586
1012
  });
587
1013
  profiles
588
1014
  .command('doctor <name>')
589
- .description('Diagnose a browser profile: binary, port, user-data-dir, onboarding state')
1015
+ .description('Diagnose a browser profile: where it is declared, binary, port, user-data-dir, onboarding state')
590
1016
  .action(async (name) => {
591
1017
  const profile = await getProfile(name);
592
1018
  if (!profile) {
@@ -594,6 +1020,29 @@ function registerProfilesCommands(browser) {
594
1020
  process.exit(1);
595
1021
  }
596
1022
  const checks = [];
1023
+ // 0. Declaration topology. An identity-bearing profile declared by a
1024
+ // different device cannot be diagnosed through a loopback CDP endpoint
1025
+ // on this one — that is the original comet-local bug. Checked first
1026
+ // because it invalidates every local check.
1027
+ const misfiled = identityLoopbackMismatch(profile);
1028
+ const kind = profileKind(profile.name);
1029
+ const where = profile.devices.length > 0 ? profile.devices.join(', ') : 'no device';
1030
+ checks.push(misfiled.misfiled
1031
+ ? { label: 'where', ok: false, detail: misfiled.why }
1032
+ : {
1033
+ label: 'where',
1034
+ ok: true,
1035
+ detail: kind
1036
+ ? `${kind}, declared on ${where}`
1037
+ : `declared on ${where}`,
1038
+ });
1039
+ if (misfiled.misfiled) {
1040
+ for (const c of checks) {
1041
+ const marker = c.ok ? 'OK ' : 'FAIL';
1042
+ console.log(`${marker} ${c.label.padEnd(15)} ${c.detail}`);
1043
+ }
1044
+ process.exit(1);
1045
+ }
597
1046
  // 1. Binary exists for declared browser type, and is a real executable we
598
1047
  // can drive — not a distro launcher script. findBrowserPath already
599
1048
  // unwraps the known Chromium wrappers to their ELF; if it still hands
@@ -754,6 +1203,80 @@ function registerProfilesCommands(browser) {
754
1203
  });
755
1204
  }
756
1205
  function registerTaskCommands(browser) {
1206
+ browser.hook('preAction', async (_thisCommand, actionCommand) => {
1207
+ inferredTaskName = undefined;
1208
+ if (isFleetRemoteInvocation())
1209
+ return;
1210
+ const pathNames = commandPath(actionCommand);
1211
+ const top = pathNames[0];
1212
+ if (!top || !TASK_ROUTED_COMMANDS.has(top))
1213
+ return;
1214
+ if (pathNames.length === 1 && top === 'start')
1215
+ return;
1216
+ const opts = actionCommand.opts();
1217
+ const deviceFlag = opts.device ?? flagValue(process.argv.slice(2), 'device', 'D');
1218
+ const taskFlag = opts.task ?? flagValue(process.argv.slice(2), 'task');
1219
+ // Reject --device on every later verb, including `stop --profile`.
1220
+ if (deviceFlag) {
1221
+ console.error(REJECT_DEVICE_MESSAGE);
1222
+ process.exit(1);
1223
+ }
1224
+ if (top === 'stop' && opts.profile && !taskFlag)
1225
+ return;
1226
+ const route = resolveTaskRoute({
1227
+ task: taskFlag ?? process.env.AGENTS_BROWSER_TASK,
1228
+ sessionId: callerSessionId(),
1229
+ launchId: callerLaunchId(),
1230
+ });
1231
+ if (route.kind !== 'proceed') {
1232
+ console.error(route.message);
1233
+ process.exit(1);
1234
+ }
1235
+ inferredTaskName = route.task;
1236
+ if (isSelfHost(route.device) || !route.task)
1237
+ return;
1238
+ const forwarded = browserForwardedArgv();
1239
+ if (route.task && !forwarded.includes('--task')) {
1240
+ forwarded.push('--task', route.task);
1241
+ }
1242
+ const outputPath = top === 'screenshot'
1243
+ ? actionCommand.opts().output ??
1244
+ flagValue(process.argv.slice(2), 'output', 'o')
1245
+ : undefined;
1246
+ if (top === 'screenshot' && outputPath) {
1247
+ const withoutOutput = stripRoutingFlags(forwarded, [
1248
+ { long: 'output', short: 'o', takesValue: true },
1249
+ ]);
1250
+ const captured = await dispatchBrowserToDevice(route.device, withoutOutput, 'capture');
1251
+ if (captured.code !== 0) {
1252
+ process.stderr.write(captured.stderr);
1253
+ process.exit(captured.code);
1254
+ }
1255
+ const remotePath = captured.stdout
1256
+ .split('\n')
1257
+ .map((line) => line.trim())
1258
+ .find((line) => line.length > 0);
1259
+ if (!remotePath) {
1260
+ console.error(`Remote screenshot on ${route.device} produced no path.`);
1261
+ process.exit(1);
1262
+ }
1263
+ try {
1264
+ await pullRemoteFile(route.device, remotePath, outputPath);
1265
+ }
1266
+ catch (err) {
1267
+ console.error(err instanceof Error ? err.message : String(err));
1268
+ process.exit(1);
1269
+ }
1270
+ const saved = path.resolve(outputPath);
1271
+ console.log(saved);
1272
+ process.exit(0);
1273
+ }
1274
+ const result = await dispatchBrowserToDevice(route.device, forwarded, 'stream');
1275
+ if (result.code === 0 && (top === 'done' || top === 'stop') && route.task) {
1276
+ unbindTask(route.task);
1277
+ }
1278
+ process.exit(result.code);
1279
+ });
757
1280
  browser
758
1281
  .command('remote-control [state]')
759
1282
  .description("Allow or deny other fleet machines driving THIS machine's browser over `browser --device`. " +
@@ -794,6 +1317,7 @@ function registerTaskCommands(browser) {
794
1317
  .command('stream')
795
1318
  .description('Keep one process and daemon IPC socket open; read NDJSON requests from stdin and write NDJSON responses')
796
1319
  .option(TASK_OPTION_FLAG, 'Default task for requests that omit `task` (defaults to $AGENTS_BROWSER_TASK)')
1320
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
797
1321
  .action(async (opts) => {
798
1322
  await runBrowserIPCStream({
799
1323
  input: process.stdin,
@@ -829,6 +1353,7 @@ function registerTaskCommands(browser) {
829
1353
  .option('--title <label>', 'Human label shown in `browser status` (defaults to first navigated host)')
830
1354
  .option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
831
1355
  .option('-u, --url <url>', 'Open URL in first tab')
1356
+ .option('--device <name>', 'Device that hosts this task; later verbs resolve it from --task (not valid on page verbs)')
832
1357
  .option('--fresh', 'Always open a new tab, skipping the reclaim of a tab an abandoned task is holding on that URL')
833
1358
  .option('--no-skills', 'Skip auto-discovery of site-specific SKILL.md from ~/.agents/skills/browser/domain-skills/')
834
1359
  .option('--record', 'Start recording right after the tab opens (shorthand for `agents browser record start` as a follow-up)')
@@ -836,9 +1361,11 @@ function registerTaskCommands(browser) {
836
1361
  .option('--duration <sec>', 'Recording duration cap in seconds (with --record; default 60)', (v) => parseInt(v, 10))
837
1362
  .option('--max-mb <mb>', 'Recording size cap in MB (with --record; default 25)', (v) => parseInt(v, 10))
838
1363
  .action(async (opts) => {
839
- // Consent gate: a fleet-remote `browser --device <this-machine> start` may
840
- // only open a browser here if the owner opted in. Refuse before we resolve
841
- // or auto-create any profile. Local starts are never gated.
1364
+ // Fast-fail copy of the consent gate, so a refused start never resolves or
1365
+ // auto-creates a profile. The AUTHORITATIVE gate is in the daemon
1366
+ // (BrowserService.start / resolveOrCreateTask, via
1367
+ // assertRemoteControlAllowedForRequest) — it has to be, because the page
1368
+ // verbs create a browser implicitly and never reach this command.
842
1369
  try {
843
1370
  assertRemoteControlAllowed();
844
1371
  }
@@ -882,6 +1409,52 @@ function registerTaskCommands(browser) {
882
1409
  process.exit(1);
883
1410
  }
884
1411
  }
1412
+ const deviceName = opts.device;
1413
+ if (deviceName) {
1414
+ const lowered = deviceName.toLowerCase();
1415
+ if (lowered === 'all' || lowered === 'auto') {
1416
+ console.error(`--device ${deviceName} is not valid on \`agents browser start\`: a task lives on one device.\n` +
1417
+ `Next: agents browser start --task <name> --device <device>`);
1418
+ process.exit(1);
1419
+ }
1420
+ try {
1421
+ assertDeviceDeclaresProfile(deviceName, profileName);
1422
+ }
1423
+ catch (err) {
1424
+ console.error(err instanceof Error ? err.message : String(err));
1425
+ process.exit(1);
1426
+ }
1427
+ if (!isSelfHost(deviceName) && !isFleetRemoteInvocation()) {
1428
+ try {
1429
+ const result = await dispatchBrowserToDevice(deviceName, browserForwardedArgv(), 'capture');
1430
+ process.stdout.write(result.stdout);
1431
+ process.stderr.write(result.stderr);
1432
+ if (result.code !== 0)
1433
+ process.exit(result.code);
1434
+ const taskName = opts.task || result.stdout.trim().split('\n').find((line) => line.length > 0);
1435
+ if (!taskName) {
1436
+ console.error(`Remote start on ${deviceName} produced no task name.`);
1437
+ process.exit(1);
1438
+ }
1439
+ bindTask(taskName, {
1440
+ device: deviceName,
1441
+ profile: profileName,
1442
+ url: opts.url,
1443
+ sessionId: callerSessionId(),
1444
+ launchId: callerLaunchId(),
1445
+ createdAt: Date.now(),
1446
+ });
1447
+ if (!result.stderr.includes(`started on ${deviceName}`)) {
1448
+ console.error(`Task "${taskName}" started on ${deviceName} (profile: ${profileName}).`);
1449
+ }
1450
+ }
1451
+ catch (err) {
1452
+ console.error(err instanceof Error ? err.message : String(err));
1453
+ process.exit(1);
1454
+ }
1455
+ return;
1456
+ }
1457
+ }
885
1458
  // Grounded login guardrail: if the opening URL is a known login-gated
886
1459
  // service and the resolved profile has no session for it, say so on stderr
887
1460
  // and name a profile that IS logged in. Reads the profile's real cookie DB
@@ -917,18 +1490,27 @@ function registerTaskCommands(browser) {
917
1490
  console.error(response.error);
918
1491
  process.exit(1);
919
1492
  }
1493
+ const boundDevice = deviceName && isSelfHost(deviceName) ? deviceName : machineId();
1494
+ if (response.task) {
1495
+ bindTask(response.task, {
1496
+ device: boundDevice,
1497
+ profile: profileName,
1498
+ url: opts.url,
1499
+ sessionId: callerSessionId(),
1500
+ launchId: callerLaunchId(),
1501
+ createdAt: Date.now(),
1502
+ });
1503
+ }
920
1504
  // stdout: just the resolved name, one line, no decoration. Lets callers do:
921
1505
  // export AGENTS_BROWSER_TASK=$(agents browser start --profile work)
922
1506
  console.log(response.task);
923
1507
  // stderr: human-friendly commentary so a TTY user still sees what happened.
924
1508
  // Shell substitution captures stdout only, so $(...) stays clean.
1509
+ console.error(`Task "${response.task}" started on ${boundDevice} (profile: ${profileName}).`);
925
1510
  if (opts.url && response.tabId) {
926
- console.error(`Started task "${response.task}" with tab ${response.tabId}`);
927
- }
928
- else {
929
- console.error(`Started task "${response.task}"`);
1511
+ console.error(`Tab ${response.tabId}`);
930
1512
  }
931
- console.error('Tip: page verbs resolve this task from your session — --task only when running two at once.');
1513
+ console.error('Tip: page verbs resolve this task from --task; --device is only valid on start.');
932
1514
  console.error('Try: agents browser screenshot | agents browser console --level error');
933
1515
  // Surface the matched domain-skill (if any) so an agent driving the
934
1516
  // task picks up site-specific selectors and gotchas before it starts
@@ -965,6 +1547,7 @@ function registerTaskCommands(browser) {
965
1547
  .command('done')
966
1548
  .description('Complete a task and close its tabs (resolves from caller identity when --task is omitted)')
967
1549
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1550
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
968
1551
  .action(async (opts) => {
969
1552
  const task = resolveTaskName(opts);
970
1553
  const response = await sendIPCRequest({
@@ -985,6 +1568,7 @@ function registerTaskCommands(browser) {
985
1568
  .command('stop')
986
1569
  .description('Stop a browser task and close its tabs; with --profile, detach the whole profile (close browser + drop cached connection)')
987
1570
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1571
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
988
1572
  .option('-p, --profile <name>', 'Detach the whole profile (incl. composite "name@endpoint") instead of stopping a single task')
989
1573
  .action(async (opts) => {
990
1574
  if (opts.profile) {
@@ -1016,7 +1600,8 @@ function registerTaskCommands(browser) {
1016
1600
  console.log(`Stopped task: ${response.task}`);
1017
1601
  });
1018
1602
  browser
1019
- .command('gc')
1603
+ .command('prune')
1604
+ .alias('gc')
1020
1605
  .description('Close tabs for abandoned tasks — owning agent session exited, or idle past the window — and mark them done. ' +
1021
1606
  'The same reaper the daemon already runs every 5 minutes; use this to run it now.')
1022
1607
  .option('--dry-run', 'List what would be closed without closing anything')
@@ -1053,11 +1638,44 @@ function registerTaskCommands(browser) {
1053
1638
  console.log(` ${c.task} (${c.profile}, ${c.reason})`);
1054
1639
  }
1055
1640
  });
1641
+ browser
1642
+ .command('show <url>')
1643
+ .description('Open a URL for a human to read: goes to browser.viewer (default: browser.profile), and binds no task')
1644
+ .option('--os-browser', 'Use the OS default handler instead of the configured viewer')
1645
+ .option('--json', 'Output machine-readable JSON')
1646
+ .action(async (url, opts) => {
1647
+ // The entry point external tools need. `navigate` binds a task, which the
1648
+ // abandoned-task reaper closes when the calling session ends — wrong for a
1649
+ // page a person is reading. This does not.
1650
+ const { showUrl, showFile } = await import('../lib/open-url.js');
1651
+ // `+` not `*` on the scheme body: `[a-z][a-z0-9+.-]*:` matches a Windows
1652
+ // drive letter (`C:\Users\me\plan.html`), so a real path was treated as a
1653
+ // URL. A scheme is at least two characters.
1654
+ const isLocalFile = !/^[a-z][a-z0-9+.-]+:/i.test(url);
1655
+ const outcome = isLocalFile
1656
+ ? await showFile(path.resolve(url), { osBrowser: opts.osBrowser })
1657
+ : await showUrl(url, { osBrowser: opts.osBrowser });
1658
+ if (opts.json) {
1659
+ console.log(JSON.stringify(outcome, null, 2));
1660
+ }
1661
+ else if (outcome.via === 'none') {
1662
+ console.error(`Could not open a browser — open this yourself:\n ${url}`);
1663
+ }
1664
+ else if (outcome.via === 'profile') {
1665
+ console.log(`Shown in ${outcome.profile}: ${url}`);
1666
+ }
1667
+ else {
1668
+ console.log(`Opened in the OS default browser: ${url}`);
1669
+ }
1670
+ if (outcome.via === 'none')
1671
+ process.exit(1);
1672
+ });
1056
1673
  browser
1057
1674
  .command('navigate [url]')
1058
1675
  .alias('goto')
1059
1676
  .description('Navigate current tab to URL (creates a task and tab when none exist)')
1060
1677
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1678
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1061
1679
  .option('-u, --url <url>', 'URL to navigate to (or pass it positionally)')
1062
1680
  .option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
1063
1681
  .action(async (urlPos, opts) => {
@@ -1088,6 +1706,7 @@ function registerTaskCommands(browser) {
1088
1706
  .command('add')
1089
1707
  .description('Open URL in new tab (becomes current)')
1090
1708
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1709
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1091
1710
  .requiredOption('--url <url>', 'URL to open in the new tab')
1092
1711
  .option('-p, --profile <name>', 'Browser profile')
1093
1712
  .action(async (opts) => {
@@ -1108,6 +1727,7 @@ function registerTaskCommands(browser) {
1108
1727
  .command('focus <tabId>')
1109
1728
  .description('Switch to tab (by ID, prefix, or URL substring)')
1110
1729
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1730
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1111
1731
  .action(async (tabId, opts) => {
1112
1732
  const task = resolveTaskName(opts);
1113
1733
  const response = await sendIPCRequest({
@@ -1125,6 +1745,7 @@ function registerTaskCommands(browser) {
1125
1745
  .command('close [tabId]')
1126
1746
  .description('Close tab(s) — omit tabId to close all')
1127
1747
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1748
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1128
1749
  .action(async (tabId, opts) => {
1129
1750
  const task = resolveTaskName(opts);
1130
1751
  const response = await sendIPCRequest({
@@ -1142,6 +1763,7 @@ function registerTaskCommands(browser) {
1142
1763
  .command('tabs')
1143
1764
  .description('List tabs open for the current task')
1144
1765
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1766
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1145
1767
  .option('--json', 'Output machine-readable JSON')
1146
1768
  .action(async (opts) => {
1147
1769
  const task = resolveTaskName(opts);
@@ -1177,6 +1799,7 @@ function registerTaskCommands(browser) {
1177
1799
  .command('screenshot')
1178
1800
  .description('Take a screenshot — auto-saved per task; --output only needed when you want a specific path')
1179
1801
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1802
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1180
1803
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1181
1804
  .option('-o, --output <path>', 'Specific output path (otherwise auto-saved under sessions/<task>/)')
1182
1805
  .option('-q, --quality <mode>', 'compressed (JPEG, capped at ~100 KB — default) or raw (PNG, pixel-faithful)', 'compressed')
@@ -1197,17 +1820,22 @@ function registerTaskCommands(browser) {
1197
1820
  console.error(response.error);
1198
1821
  process.exit(1);
1199
1822
  }
1823
+ // RUSH-3086: the daemon sandboxes writes to the runtime dir and silently
1824
+ // ignores `-o` outside it. Honor the requested path here.
1825
+ const savedPath = response.path
1826
+ ? honorScreenshotOutput(opts.output, response.path)
1827
+ : response.path;
1200
1828
  // stdout: just the path, so `P=$(agents browser screenshot)` works.
1201
- console.log(response.path);
1829
+ console.log(savedPath);
1202
1830
  // stderr: human commentary with size + dimensions, so an agent can
1203
1831
  // see at a glance what was captured without `ls -l && file` round-trips.
1204
1832
  const size = humanizeBytes(response.bytes);
1205
1833
  const dims = response.width && response.height ? `${response.width}×${response.height}` : 'unknown size';
1206
- console.error(`Saved screenshot to ${response.path} (${size}, ${dims})`);
1834
+ console.error(`Saved screenshot to ${savedPath} (${size}, ${dims})`);
1207
1835
  // When auto-saving (no --output), surface the directory once so the
1208
1836
  // agent doesn't have to dirname() the path or guess where files land.
1209
- if (!opts.output && response.path) {
1210
- const dir = path.dirname(response.path);
1837
+ if (!opts.output && savedPath) {
1838
+ const dir = path.dirname(savedPath);
1211
1839
  console.error(`Tip: auto-saving to ${dir}. Pass --output <path> to choose a path.`);
1212
1840
  }
1213
1841
  });
@@ -1215,6 +1843,7 @@ function registerTaskCommands(browser) {
1215
1843
  .command('pdf [output]')
1216
1844
  .description('Export the current tab as PDF via CDP Page.printToPDF — auto-saved under sessions/<task>/ when [output] is omitted')
1217
1845
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1846
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1218
1847
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1219
1848
  .action(async (output, opts) => {
1220
1849
  const task = resolveTaskName(opts);
@@ -1241,6 +1870,7 @@ function registerTaskCommands(browser) {
1241
1870
  .alias('eval')
1242
1871
  .description('Evaluate JavaScript in current tab')
1243
1872
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1873
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1244
1874
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1245
1875
  .option('-e, --expression <js>', 'JavaScript expression to evaluate (or pass it positionally)')
1246
1876
  .option('-f, --file <path>', 'Path to a .js file whose contents will be evaluated')
@@ -1590,6 +2220,7 @@ function registerTaskCommands(browser) {
1590
2220
  .command('refs')
1591
2221
  .description('Get DOM refs for interactive elements')
1592
2222
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2223
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1593
2224
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1594
2225
  .option('--all', 'Include non-interactive elements')
1595
2226
  .option('-l, --limit <n>', 'Max elements (default 500)', '500')
@@ -1632,6 +2263,7 @@ function registerTaskCommands(browser) {
1632
2263
  .command('click [ref]')
1633
2264
  .description('Click an element by ref, or raw coordinates with --at X,Y')
1634
2265
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2266
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1635
2267
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1636
2268
  .option('--at <x,y>', 'Click viewport coordinates (e.g. --at 320,540), bypassing ref resolution')
1637
2269
  .action(async (ref, opts) => {
@@ -1674,6 +2306,7 @@ function registerTaskCommands(browser) {
1674
2306
  .command('type <ref>')
1675
2307
  .description('Type text into an element by ref')
1676
2308
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2309
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1677
2310
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1678
2311
  .option('--text <text>', 'Text to type (use quotes for spaces/special chars)')
1679
2312
  .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.')
@@ -1738,6 +2371,7 @@ function registerTaskCommands(browser) {
1738
2371
  .command('press <key>')
1739
2372
  .description('Press a key (Enter, Tab, Escape, etc)')
1740
2373
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2374
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1741
2375
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1742
2376
  .action(async (key, opts) => {
1743
2377
  const task = resolveTaskName(opts);
@@ -1757,6 +2391,7 @@ function registerTaskCommands(browser) {
1757
2391
  .command('hover <ref>')
1758
2392
  .description('Hover over an element by ref')
1759
2393
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2394
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1760
2395
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1761
2396
  .action(async (ref, opts) => {
1762
2397
  const task = resolveTaskName(opts);
@@ -1776,6 +2411,7 @@ function registerTaskCommands(browser) {
1776
2411
  .command('scroll')
1777
2412
  .description('Scroll the page by pixel amount (negatives scroll up/left)')
1778
2413
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2414
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1779
2415
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1780
2416
  .option('--dx <n>', 'Horizontal pixels (negative = left)', (v) => parseInt(v, 10), 0)
1781
2417
  .option('--dy <n>', 'Vertical pixels (negative = up)', (v) => parseInt(v, 10), 0)
@@ -1810,6 +2446,7 @@ function registerTaskCommands(browser) {
1810
2446
  .command('upload')
1811
2447
  .description('Upload file(s) — supports hidden file inputs, drag-drop targets, and OS chooser interception')
1812
2448
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2449
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1813
2450
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1814
2451
  .option('-r, --ref <n>', 'Ref of the upload target element (file input or drop zone)', (v) => parseInt(v, 10))
1815
2452
  .option('--trigger <n>', 'Ref of a button that opens the OS file chooser (Pattern C)', (v) => parseInt(v, 10))
@@ -1861,6 +2498,7 @@ function registerTaskCommands(browser) {
1861
2498
  .command('viewport <width> <height>')
1862
2499
  .description('Set viewport size')
1863
2500
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2501
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1864
2502
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1865
2503
  .option('-m, --mobile', 'Enable mobile emulation')
1866
2504
  .option('-s, --scale <factor>', 'Device scale factor', parseFloat)
@@ -1885,6 +2523,7 @@ function registerTaskCommands(browser) {
1885
2523
  .command('device <device-name>')
1886
2524
  .description('Emulate a device (iPhone 14, iPad, MacBook Pro)')
1887
2525
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2526
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1888
2527
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1889
2528
  .action(async (deviceName, opts) => {
1890
2529
  const task = resolveTaskName(opts);
@@ -1915,6 +2554,7 @@ function registerTaskCommands(browser) {
1915
2554
  .command('console')
1916
2555
  .description('Read console logs from a tab')
1917
2556
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2557
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1918
2558
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1919
2559
  .option('-l, --level <level>', 'Filter by level (log, info, warn, error)')
1920
2560
  .option('--clear', 'Clear logs after reading')
@@ -1945,6 +2585,7 @@ function registerTaskCommands(browser) {
1945
2585
  .command('errors')
1946
2586
  .description('Read page errors from a tab')
1947
2587
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2588
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1948
2589
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1949
2590
  .option('--clear', 'Clear errors after reading')
1950
2591
  .action(async (opts) => {
@@ -1977,6 +2618,7 @@ function registerTaskCommands(browser) {
1977
2618
  .command('requests')
1978
2619
  .description('Read captured network requests. --format har emits a HAR 1.2 JSON document.')
1979
2620
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2621
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1980
2622
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1981
2623
  .option('-f, --filter <text>', 'Filter URLs containing text')
1982
2624
  .option('--clear', 'Clear requests after reading')
@@ -2022,6 +2664,7 @@ function registerTaskCommands(browser) {
2022
2664
  .command('logs')
2023
2665
  .description('Read merged rush-app + rush-cli JSONL logs for a task')
2024
2666
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2667
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2025
2668
  .option('--source <name>', 'Source to scope to: rush-app or rush-cli (default both)')
2026
2669
  .option('--lines <n>', 'Tail N entries (default 200; ignored when --since)', (v) => parseInt(v, 10))
2027
2670
  .option('--since <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
@@ -2060,6 +2703,7 @@ function registerTaskCommands(browser) {
2060
2703
  .command('responsebody <url-pattern>')
2061
2704
  .description('Wait for and read a response body by URL pattern')
2062
2705
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2706
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2063
2707
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2064
2708
  .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
2065
2709
  .option('--max-chars <n>', 'Max characters to return', parseInt)
@@ -2084,6 +2728,7 @@ function registerTaskCommands(browser) {
2084
2728
  .command('wait')
2085
2729
  .description('Wait for a condition')
2086
2730
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2731
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2087
2732
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2088
2733
  .option('--time <ms>', 'Wait for milliseconds')
2089
2734
  .option('--selector <css>', 'Wait for CSS selector to appear')
@@ -2138,6 +2783,7 @@ function registerTaskCommands(browser) {
2138
2783
  .command('download')
2139
2784
  .description("Set the download directory for a task (defaults to the profile's downloads dir)")
2140
2785
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2786
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2141
2787
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2142
2788
  .option('-p, --path <dir>', "Download directory path (default: the profile's downloads dir)")
2143
2789
  .action(async (opts) => {
@@ -2170,6 +2816,7 @@ function registerTaskCommands(browser) {
2170
2816
  .command('start')
2171
2817
  .description('Start recording — auto-saved under sessions/<task>/recordings/. Bounded by --fps, --duration, --max-mb.')
2172
2818
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2819
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2173
2820
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
2174
2821
  .option('--fps <n>', 'Frames per second (1–30, default 5)', (v) => parseInt(v, 10))
2175
2822
  .option('--duration <sec>', 'Hard duration cap in seconds (default 60)', (v) => parseInt(v, 10))
@@ -2197,6 +2844,7 @@ function registerTaskCommands(browser) {
2197
2844
  .command('stop')
2198
2845
  .description('Stop an in-progress recording')
2199
2846
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2847
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2200
2848
  .action(async (opts) => {
2201
2849
  const task = resolveTaskName(opts);
2202
2850
  const response = await sendIPCRequest({ action: 'record-stop', task });
@@ -2213,6 +2861,7 @@ function registerTaskCommands(browser) {
2213
2861
  .command('waitdownload')
2214
2862
  .description('Wait for a download to complete')
2215
2863
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2864
+ .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2216
2865
  .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
2217
2866
  .action(async (opts) => {
2218
2867
  const task = resolveTaskName(opts);