@phnx-labs/agents-cli 1.22.47 → 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 (249) hide show
  1. package/CHANGELOG.md +401 -17
  2. package/README.md +66 -32
  3. package/dist/bootstrap.js +4 -2
  4. package/dist/cli/command-registry.d.ts +1 -0
  5. package/dist/cli/command-registry.js +2 -0
  6. package/dist/commands/accounts.d.ts +58 -0
  7. package/dist/commands/accounts.js +206 -32
  8. package/dist/commands/artifacts.js +7 -6
  9. package/dist/commands/attach.js +1 -1
  10. package/dist/commands/browser.js +501 -103
  11. package/dist/commands/config.d.ts +0 -1
  12. package/dist/commands/config.js +0 -20
  13. package/dist/commands/daemon.d.ts +21 -0
  14. package/dist/commands/daemon.js +147 -12
  15. package/dist/commands/exec.js +52 -15
  16. package/dist/commands/focus.d.ts +14 -0
  17. package/dist/commands/focus.js +48 -5
  18. package/dist/commands/go.d.ts +3 -0
  19. package/dist/commands/go.js +16 -3
  20. package/dist/commands/inspect.js +2 -2
  21. package/dist/commands/lease.d.ts +1 -1
  22. package/dist/commands/lease.js +4 -3
  23. package/dist/commands/mailboxes.js +4 -3
  24. package/dist/commands/menubar.js +11 -4
  25. package/dist/commands/projects.d.ts +16 -1
  26. package/dist/commands/projects.js +58 -64
  27. package/dist/commands/resume.js +1 -1
  28. package/dist/commands/route.js +4 -4
  29. package/dist/commands/routines.js +3 -2
  30. package/dist/commands/run-broadcast.d.ts +1 -1
  31. package/dist/commands/run-broadcast.js +1 -1
  32. package/dist/commands/sessions-picker.d.ts +22 -0
  33. package/dist/commands/sessions-picker.js +48 -9
  34. package/dist/commands/sessions-trace.js +1 -1
  35. package/dist/commands/sessions.js +1 -0
  36. package/dist/commands/setup-secrets.js +23 -0
  37. package/dist/commands/share.d.ts +20 -5
  38. package/dist/commands/share.js +152 -52
  39. package/dist/commands/ssh.js +54 -7
  40. package/dist/commands/traces.d.ts +2 -0
  41. package/dist/commands/traces.js +184 -0
  42. package/dist/commands/view.d.ts +1 -1
  43. package/dist/commands/view.js +17 -34
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +5 -0
  46. package/dist/lib/accounting/usage.d.ts +14 -3
  47. package/dist/lib/accounting/usage.js +64 -18
  48. package/dist/lib/agent-spec/agents.js +11 -9
  49. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  50. package/dist/lib/browser/drivers/ssh.js +36 -9
  51. package/dist/lib/browser/ipc.js +8 -3
  52. package/dist/lib/browser/profiles.d.ts +35 -101
  53. package/dist/lib/browser/profiles.js +130 -301
  54. package/dist/lib/browser/registry.d.ts +39 -0
  55. package/dist/lib/browser/registry.js +149 -0
  56. package/dist/lib/browser/remote-control.d.ts +8 -6
  57. package/dist/lib/browser/remote-control.js +8 -6
  58. package/dist/lib/browser/resolve-target.d.ts +68 -0
  59. package/dist/lib/browser/resolve-target.js +250 -0
  60. package/dist/lib/browser/runtime-state.d.ts +40 -16
  61. package/dist/lib/browser/runtime-state.js +92 -25
  62. package/dist/lib/browser/service.d.ts +28 -9
  63. package/dist/lib/browser/service.js +139 -62
  64. package/dist/lib/browser/task-index.d.ts +70 -0
  65. package/dist/lib/browser/task-index.js +180 -0
  66. package/dist/lib/byok-usage.js +2 -2
  67. package/dist/lib/claude-statusline.d.ts +29 -0
  68. package/dist/lib/claude-statusline.js +139 -0
  69. package/dist/lib/cloud/host.d.ts +1 -1
  70. package/dist/lib/cloud/host.js +1 -1
  71. package/dist/lib/cloud/rush.js +1 -1
  72. package/dist/lib/computer/computer-rpc.js +2 -2
  73. package/dist/lib/computer/download.d.ts +15 -15
  74. package/dist/lib/computer/download.js +35 -98
  75. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  76. package/dist/lib/computer/ssh-tunnel.js +12 -22
  77. package/dist/lib/config-keys.d.ts +2 -8
  78. package/dist/lib/config-keys.js +2 -13
  79. package/dist/lib/crabbox/cli.d.ts +1 -1
  80. package/dist/lib/crabbox/cli.js +2 -2
  81. package/dist/lib/crabbox/lease.d.ts +9 -0
  82. package/dist/lib/crabbox/lease.js +70 -15
  83. package/dist/lib/crabbox/runtimes.js +1 -1
  84. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  85. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  86. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  87. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  88. package/dist/lib/daemon/daemon.d.ts +3 -0
  89. package/dist/lib/daemon/daemon.js +212 -444
  90. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  91. package/dist/lib/daemon/device-probe-service.js +47 -0
  92. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  93. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  94. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  95. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  96. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  97. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  98. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  99. package/dist/lib/daemon/self-heal-service.js +48 -0
  100. package/dist/lib/daemon/service.d.ts +102 -0
  101. package/dist/lib/daemon/service.js +98 -0
  102. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  103. package/dist/lib/daemon/session-index-service.js +40 -0
  104. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  105. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  106. package/dist/lib/daemon/supervisor.d.ts +87 -0
  107. package/dist/lib/daemon/supervisor.js +298 -0
  108. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  109. package/dist/lib/daemon/watchdog-service.js +40 -0
  110. package/dist/lib/daemon-health.d.ts +15 -0
  111. package/dist/lib/daemon-health.js +29 -5
  112. package/dist/lib/daemon-services.d.ts +8 -1
  113. package/dist/lib/daemon-services.js +55 -0
  114. package/dist/lib/daemon-ticks.d.ts +13 -2
  115. package/dist/lib/daemon-ticks.js +20 -18
  116. package/dist/lib/device-config.d.ts +0 -2
  117. package/dist/lib/device-config.js +1 -23
  118. package/dist/lib/devices/registry.d.ts +1 -1
  119. package/dist/lib/devices/registry.js +1 -1
  120. package/dist/lib/devices/stats-cache.d.ts +20 -0
  121. package/dist/lib/devices/stats-cache.js +49 -5
  122. package/dist/lib/devices/worker-pick.d.ts +48 -0
  123. package/dist/lib/devices/worker-pick.js +113 -0
  124. package/dist/lib/exec.d.ts +67 -13
  125. package/dist/lib/exec.js +85 -29
  126. package/dist/lib/harness/adapters/cursor.js +8 -11
  127. package/dist/lib/helper-download.d.ts +108 -0
  128. package/dist/lib/helper-download.js +194 -0
  129. package/dist/lib/helper-versions.d.ts +52 -0
  130. package/dist/lib/helper-versions.js +58 -0
  131. package/dist/lib/hooks/cache.js +11 -0
  132. package/dist/lib/hooks/install.js +2 -2
  133. package/dist/lib/hosts/credentials.d.ts +1 -1
  134. package/dist/lib/hosts/credentials.js +1 -1
  135. package/dist/lib/hosts/dispatch.d.ts +7 -1
  136. package/dist/lib/hosts/dispatch.js +24 -5
  137. package/dist/lib/hosts/passthrough.d.ts +8 -15
  138. package/dist/lib/hosts/passthrough.js +19 -16
  139. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  140. package/dist/lib/hosts/providers/devices.js +1 -1
  141. package/dist/lib/hosts/reconnect.d.ts +179 -21
  142. package/dist/lib/hosts/reconnect.js +266 -45
  143. package/dist/lib/installations/migrate.d.ts +4 -5
  144. package/dist/lib/installations/migrate.js +6 -7
  145. package/dist/lib/installations/shims.d.ts +1 -1
  146. package/dist/lib/installations/shims.js +23 -13
  147. package/dist/lib/installations/versions.js +25 -2
  148. package/dist/lib/linear-autoclose.d.ts +1 -1
  149. package/dist/lib/linear-autoclose.js +1 -1
  150. package/dist/lib/loop.d.ts +2 -2
  151. package/dist/lib/loop.js +1 -1
  152. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  153. package/dist/lib/menubar/download-menubar.js +60 -0
  154. package/dist/lib/menubar/install-menubar.d.ts +31 -7
  155. package/dist/lib/menubar/install-menubar.js +79 -21
  156. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  157. package/dist/lib/menubar/notify-desktop.js +6 -6
  158. package/dist/lib/model-tiers.d.ts +1 -1
  159. package/dist/lib/project-probe.d.ts +1 -1
  160. package/dist/lib/project-probe.js +1 -1
  161. package/dist/lib/project-pull.d.ts +1 -1
  162. package/dist/lib/project-pull.js +2 -2
  163. package/dist/lib/projects.d.ts +1 -1
  164. package/dist/lib/projects.js +1 -1
  165. package/dist/lib/sandbox.js +6 -7
  166. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  167. package/dist/lib/secrets/download-keychain.js +70 -0
  168. package/dist/lib/secrets/install-helper.d.ts +20 -1
  169. package/dist/lib/secrets/install-helper.js +44 -3
  170. package/dist/lib/session/active.js +17 -1
  171. package/dist/lib/session/db.d.ts +11 -1
  172. package/dist/lib/session/db.js +138 -6
  173. package/dist/lib/session/discover.d.ts +21 -1
  174. package/dist/lib/session/discover.js +33 -5
  175. package/dist/lib/session/highlights.d.ts +49 -1
  176. package/dist/lib/session/highlights.js +95 -0
  177. package/dist/lib/session/host-link.d.ts +16 -2
  178. package/dist/lib/session/host-link.js +7 -1
  179. package/dist/lib/session/parse.js +33 -1
  180. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  181. package/dist/lib/session/remote/remote-list.js +19 -2
  182. package/dist/lib/session/remote/watch.js +11 -2
  183. package/dist/lib/session/session-cache.d.ts +53 -0
  184. package/dist/lib/session/session-cache.js +88 -0
  185. package/dist/lib/session/state.js +1 -1
  186. package/dist/lib/session/tool-calls.d.ts +2 -0
  187. package/dist/lib/session/tool-calls.js +1 -1
  188. package/dist/lib/session/trajectory-html.d.ts +5 -1
  189. package/dist/lib/session/trajectory-html.js +42 -22
  190. package/dist/lib/session/trajectory-text.js +9 -2
  191. package/dist/lib/session/trajectory.js +53 -4
  192. package/dist/lib/session/types.d.ts +15 -0
  193. package/dist/lib/sha256-asset.d.ts +26 -0
  194. package/dist/lib/sha256-asset.js +41 -0
  195. package/dist/lib/share/backend.d.ts +95 -0
  196. package/dist/lib/share/backend.js +139 -0
  197. package/dist/lib/share/delete.d.ts +6 -2
  198. package/dist/lib/share/delete.js +8 -9
  199. package/dist/lib/share/html.d.ts +22 -0
  200. package/dist/lib/share/html.js +88 -0
  201. package/dist/lib/share/provision.d.ts +18 -4
  202. package/dist/lib/share/provision.js +38 -4
  203. package/dist/lib/share/publish.d.ts +39 -15
  204. package/dist/lib/share/publish.js +55 -26
  205. package/dist/lib/share/worker-template.js +171 -11
  206. package/dist/lib/sqlite.js +16 -3
  207. package/dist/lib/ssh-exec.d.ts +65 -0
  208. package/dist/lib/ssh-exec.js +120 -4
  209. package/dist/lib/startup/command-registry.js +1 -1
  210. package/dist/lib/startup/dev-build.js +3 -3
  211. package/dist/lib/state.d.ts +1 -1
  212. package/dist/lib/state.js +7 -3
  213. package/dist/lib/subagents-registry.js +1 -1
  214. package/dist/lib/terminal/index.d.ts +1 -1
  215. package/dist/lib/terminal/types.d.ts +1 -1
  216. package/dist/lib/terminal/types.js +1 -1
  217. package/dist/lib/tmux/orphan-reap.js +1 -1
  218. package/dist/lib/tmux/session.js +31 -0
  219. package/dist/lib/traces/backend.d.ts +29 -0
  220. package/dist/lib/traces/backend.js +46 -0
  221. package/dist/lib/traces/classify.d.ts +52 -0
  222. package/dist/lib/traces/classify.js +72 -0
  223. package/dist/lib/traces/config.d.ts +10 -0
  224. package/dist/lib/traces/config.js +2 -0
  225. package/dist/lib/traces/provision.d.ts +19 -0
  226. package/dist/lib/traces/provision.js +30 -0
  227. package/dist/lib/traces/sync.d.ts +150 -0
  228. package/dist/lib/traces/sync.js +447 -0
  229. package/dist/lib/traces/worker-template.d.ts +4 -0
  230. package/dist/lib/traces/worker-template.js +159 -0
  231. package/dist/lib/types.d.ts +18 -19
  232. package/dist/lib/types.js +14 -0
  233. package/dist/lib/usage-refresh.d.ts +1 -1
  234. package/dist/lib/usage-refresh.js +11 -8
  235. package/dist/lib/workflows.d.ts +1 -1
  236. package/package.json +4 -6
  237. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  238. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  239. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  240. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  241. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  242. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  243. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -26
  244. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  245. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  246. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -135
  247. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  248. package/dist/lib/usage-fleet.d.ts +0 -32
  249. package/dist/lib/usage-fleet.js +0 -125
@@ -1,8 +1,55 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { execFileSync } from 'child_process';
4
- import { getProfileRuntimeDir, getBrowserRuntimeDir, listProfilesWithScope, getConfiguredDefaultProfileName, isProfileLaunchableHere, deleteProfile, DEFAULT_BROWSER_PROFILE_NAME, LEGACY_DEFAULT_BROWSER_PROFILE_NAME, misfiledFleetProfile, } from './profiles.js';
4
+ import { getProfileRuntimeDir, getBrowserRuntimeDir, getEndpointPresets, listProfiles, getConfiguredDefaultProfileName, isProfileLaunchableHere, deleteProfile, DEFAULT_BROWSER_PROFILE_NAME, LEGACY_DEFAULT_BROWSER_PROFILE_NAME, } from './profiles.js';
5
5
  import { keyBelongsToProfile } from './types.js';
6
+ import { declaringDevices, profileKind } from './registry.js';
7
+ import { machineId } from '../machine-id.js';
8
+ /**
9
+ * Detect an identity-bearing profile whose stored endpoint is loopback, viewed
10
+ * from a machine that is NOT the declaring device.
11
+ *
12
+ * That is the original `comet-local` bug: five real logins on the declaring
13
+ * box, a bare logged-out chromium answering to the same name on a worker
14
+ * because `cdp://localhost:N` is evaluated on the machine running the command.
15
+ * The daemon now tunnels that shape (`resolve-target.ts`); doctor still flags
16
+ * it so a worker does not report a green local binary/port for someone else's
17
+ * browser. Kind comes from the registry (exactly one declaring device), never
18
+ * from a stored field.
19
+ */
20
+ export function identityLoopbackMismatch(profile) {
21
+ if (profileKind(profile.name) !== 'identity')
22
+ return { misfiled: false };
23
+ const declaringDevice = declaringDevices(profile.name)[0];
24
+ if (!declaringDevice || declaringDevice === machineId())
25
+ return { misfiled: false };
26
+ const presets = getEndpointPresets(profile);
27
+ const chosen = profile.defaultEndpoint && presets[profile.defaultEndpoint]
28
+ ? profile.defaultEndpoint
29
+ : Object.keys(presets)[0];
30
+ if (!chosen)
31
+ return { misfiled: false };
32
+ const target = presets[chosen].target;
33
+ if (!target.startsWith('cdp://'))
34
+ return { misfiled: false };
35
+ let host;
36
+ try {
37
+ host = new URL(target).hostname;
38
+ }
39
+ catch {
40
+ return { misfiled: false };
41
+ }
42
+ if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]') {
43
+ return { misfiled: false };
44
+ }
45
+ const here = machineId();
46
+ return {
47
+ misfiled: true,
48
+ why: `identity-bearing profile declared on ${declaringDevice}, but "${target}" is a loopback endpoint — ` +
49
+ `on ${here} that address is this device's own browser, not ${declaringDevice}'s. ` +
50
+ `Diagnose the real browser on ${declaringDevice}: agents browser profiles doctor ${profile.name}`,
51
+ };
52
+ }
6
53
  const PID_FILE = 'pid';
7
54
  const PORT_FILE = 'port';
8
55
  const COMMAND_FILE = 'command';
@@ -96,7 +143,7 @@ export function clearProfileRuntime(profileName) {
96
143
  }
97
144
  /**
98
145
  * Recursively remove the whole profile cache (chrome-data, tasks.json,
99
- * everything). Used by `profiles delete` so an old profile name doesn't
146
+ * everything). Used by `profiles remove` so an old profile name doesn't
100
147
  * leak its history into a freshly-recreated one.
101
148
  */
102
149
  export function removeProfileCache(profileName) {
@@ -298,8 +345,6 @@ export const PRUNE_REASON_TEXT = {
298
345
  * - **the auto-detected profile** (`auto-chrome`, or the `default` an older
299
346
  * build wrote) — auto-regenerated by `ensureDefaultBrowserProfile`, so
300
347
  * pruning it is pure churn.
301
- * - **fleet-synced** — deleting one removes it from EVERY machine, so it takes
302
- * the explicit `includeFleet` opt-in.
303
348
  *
304
349
  * Known limitation: `BrowserProfileConfig` records no creation time, so a
305
350
  * profile created seconds ago and not yet started is indistinguishable from an
@@ -312,20 +357,15 @@ export function planProfilePrune(profiles, opts = {}) {
312
357
  const keep = (why, misfiled = false) => {
313
358
  plan.kept.push(misfiled ? { name, scope, why, misfiled } : { name, scope, why });
314
359
  };
315
- // Checked FIRST, ahead of every other rule including --fleet. Deleting a
316
- // fleet entry deletes it on EVERY machine, and a misfiled profile's defining
317
- // symptom is that the browser is absent on whatever box you are standing on
318
- // so `prune --fleet` would otherwise read `binary-missing` and destroy it
319
- // fleet-wide, which is the opposite of the repair. The repair is a scope
320
- // move; an intentional delete is still available as `profiles delete`.
360
+ // Checked FIRST. A misfiled identity profile's defining symptom is that the
361
+ // browser is absent on whatever box you are standing on — pruning it would
362
+ // delete the declaring device's entry (or throw, for a peer declaration).
363
+ // The repair is to declare it only on the machine that owns the browser;
364
+ // an intentional delete is still available as `profiles remove`.
321
365
  if (misfiledWhy) {
322
366
  keep(`MISFILED — ${misfiledWhy}`, true);
323
367
  continue;
324
368
  }
325
- if (scope === 'fleet' && !opts.includeFleet) {
326
- keep('fleet-synced (pass --fleet to include it)');
327
- continue;
328
- }
329
369
  if (name === DEFAULT_BROWSER_PROFILE_NAME || name === LEGACY_DEFAULT_BROWSER_PROFILE_NAME) {
330
370
  keep('the auto-detected default profile');
331
371
  continue;
@@ -355,17 +395,44 @@ export function planProfilePrune(profiles, opts = {}) {
355
395
  * Build the prune plan from live config. Split from {@link planProfilePrune} so
356
396
  * the decision rules stay unit-testable without a configured machine.
357
397
  */
358
- export async function buildProfilePrunePlan(opts = {}) {
359
- const scoped = await listProfilesWithScope();
360
- return planProfilePrune(scoped.map(({ profile, scope }) => {
361
- const misfiled = misfiledFleetProfile(profile, scope);
362
- return {
363
- name: profile.name,
364
- scope,
365
- launchableHere: isProfileLaunchableHere(profile),
366
- misfiledWhy: misfiled.misfiled ? misfiled.why : undefined,
367
- };
368
- }), { includeFleet: opts.includeFleet, configuredDefault: getConfiguredDefaultProfileName() });
398
+ export async function buildProfilePrunePlan() {
399
+ const current = machineId();
400
+ const profiles = await listProfiles();
401
+ const declaredHere = [];
402
+ const peerKept = [];
403
+ for (const profile of profiles) {
404
+ const scope = profile.devices.length > 1 ? 'fungible' : 'identity';
405
+ const misfiled = identityLoopbackMismatch(profile);
406
+ if (profile.devices.includes(current)) {
407
+ declaredHere.push({
408
+ name: profile.name,
409
+ scope,
410
+ launchableHere: isProfileLaunchableHere(profile),
411
+ misfiledWhy: misfiled.misfiled ? misfiled.why : undefined,
412
+ });
413
+ continue;
414
+ }
415
+ if (misfiled.misfiled) {
416
+ peerKept.push({
417
+ name: profile.name,
418
+ scope,
419
+ why: `MISFILED — ${misfiled.why}`,
420
+ misfiled: true,
421
+ });
422
+ }
423
+ else {
424
+ peerKept.push({
425
+ name: profile.name,
426
+ scope,
427
+ why: `declared on ${profile.devices.join(', ')}, not this device`,
428
+ });
429
+ }
430
+ }
431
+ const plan = planProfilePrune(declaredHere, {
432
+ configuredDefault: getConfiguredDefaultProfileName(),
433
+ });
434
+ plan.kept.push(...peerKept);
435
+ return plan;
369
436
  }
370
437
  /**
371
438
  * Execute a prune plan: drop each candidate's config entry and wipe its cache
@@ -1,4 +1,5 @@
1
1
  import { CDPClient } from './cdp.js';
2
+ import { type DeviceProbe } from './resolve-target.js';
2
3
  import { type ResolvedDomainSkill } from './domain-skills.js';
3
4
  import { type Task, type TabInfo, type ProfileStatus, type BrowserType, type HistoricalTask, type ReapResult, type ProfileName, type ConnectionKey } from './types.js';
4
5
  import { type ReapOptions } from './hygiene.js';
@@ -136,7 +137,8 @@ export declare class BrowserService {
136
137
  * Live browsers, keyed by {@link ConnectionKey} — NOT by profile name. The
137
138
  * branded key type is what makes `connections.get(someProfileName)` (the
138
139
  * RUSH-2709 miss that made `status --profile <name>` return empty for a
139
- * running `<name>@endpoint-0`) fail to compile.
140
+ * running `<name>@<device>`) fail to compile. Keys are `<profile>@<device>`
141
+ * (legacy leftover `<profile>@endpoint-N` dirs are still recognized).
140
142
  */
141
143
  private connections;
142
144
  private forkingProfiles;
@@ -165,6 +167,8 @@ export declare class BrowserService {
165
167
  fleetRemote?: boolean;
166
168
  /** Explicit human label (`--title`). */
167
169
  title?: string;
170
+ /** Injected reachability probe — production uses ssh; tests pass a fake. */
171
+ probe?: DeviceProbe;
168
172
  }): Promise<{
169
173
  task: string;
170
174
  name: string;
@@ -172,8 +176,12 @@ export declare class BrowserService {
172
176
  windowId?: string;
173
177
  /** BARE profile name — what the caller asked for, never the runtime key. */
174
178
  profile: ProfileName;
175
- /** Runtime key the task actually landed on (`<profile>@<endpoint>`). */
179
+ /** Runtime key the task actually landed on (`<profile>@<device>`). */
176
180
  key: ConnectionKey;
181
+ /** Device the daemon connected to. */
182
+ device: string;
183
+ /** Set when the daemon picked a remote declaring device. */
184
+ picked?: string;
177
185
  skill?: ResolvedDomainSkill;
178
186
  }>;
179
187
  /**
@@ -240,7 +248,7 @@ export declare class BrowserService {
240
248
  /**
241
249
  * Close tasks whose owning agent session is gone, or that have sat untouched
242
250
  * past the idle window. The entry point the daemon's periodic tick and
243
- * `agents browser gc` both call; the policy lives in `hygiene.ts`.
251
+ * `agents browser prune` both call; the policy lives in `hygiene.ts`.
244
252
  */
245
253
  reapAbandoned(opts?: ReapOptions): Promise<ReapResult>;
246
254
  stop(taskName: string): Promise<{
@@ -361,8 +369,9 @@ export declare class BrowserService {
361
369
  * `profileName` is a {@link ProfileName} — a BARE name, never a runtime key.
362
370
  * Every candidate is selected by the one rule ({@link keyBelongsToProfile}),
363
371
  * and a scoped query that finds no live connection falls through to disk, so
364
- * `status --profile comet-local` reports the LIVE `comet-local@endpoint-0`
365
- * instead of the empty list it used to return (RUSH-2709).
372
+ * `status --profile comet-local` reports the LIVE `comet-local@<device>`
373
+ * (and leftover `comet-local@endpoint-0` dirs) instead of the empty list
374
+ * it used to return (RUSH-2709).
366
375
  */
367
376
  status(profileRef?: ProfileName | ConnectionKey): Promise<ProfileStatus[]>;
368
377
  /**
@@ -438,10 +447,9 @@ export declare class BrowserService {
438
447
  * already resolved the endpoint and built the `effectiveProfile` with
439
448
  * the per-endpoint binary/targetFilter overrides applied; we just use it.
440
449
  *
441
- * `key` is the runtime key (`<profile>@<endpoint>`) and is what every on-disk
442
- * lookup uses, so per-endpoint pid/port files don't collide when the same app
443
- * runs locally and remotely at the same time. It is a SEPARATE argument
444
- * because `effectiveProfile.name` stays the bare, user-facing name (RUSH-2709).
450
+ * `key` is the runtime key (`<profile>@<device>`) and is what every on-disk
451
+ * lookup uses. It is a SEPARATE argument because `effectiveProfile.name`
452
+ * stays the bare, user-facing name (RUSH-2709).
445
453
  */
446
454
  private connectProfile;
447
455
  private connectEndpoint;
@@ -486,6 +494,8 @@ export declare class BrowserService {
486
494
  task: Task;
487
495
  key: ConnectionKey;
488
496
  created: boolean;
497
+ picked?: string;
498
+ device?: string;
489
499
  } | null>;
490
500
  private listTasksForCaller;
491
501
  private currentUrlHint;
@@ -550,6 +560,12 @@ export declare class BrowserService {
550
560
  * of killing the daemon.
551
561
  */
552
562
  private reuseHealthyConnection;
563
+ /**
564
+ * Reuse a live connection for this profile: the new `<name>@<device>` key
565
+ * first, then any leftover `<name>@endpoint-N` key still in the map so a
566
+ * browser started before the key collapse is not abandoned.
567
+ */
568
+ private reuseProfileConnection;
553
569
  /** Connect a profile at `target`, register it under `key`, and arm downloads. */
554
570
  private openConnection;
555
571
  /**
@@ -568,10 +584,13 @@ export declare class BrowserService {
568
584
  endpointName?: string;
569
585
  fleetRemote?: boolean;
570
586
  actor?: string;
587
+ probe?: DeviceProbe;
571
588
  }): Promise<{
572
589
  profile: ProfileName;
573
590
  key: ConnectionKey;
574
591
  tabId: string;
592
+ device: string;
593
+ picked?: string;
575
594
  }>;
576
595
  getHistory(limit?: number): Promise<HistoricalTask[]>;
577
596
  }
@@ -7,9 +7,10 @@ import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from '.
7
7
  import { assertRemoteControlAllowedForRequest } from './remote-control.js';
8
8
  import { connectLocal } from './drivers/local.js';
9
9
  import { connectSSH, shellQuote } from './drivers/ssh.js';
10
+ import { adoptLegacyRuntimeIfLocal, isLegacyEndpointKey, resolveBrowserTarget, shouldForkProfile, } from './resolve-target.js';
10
11
  import { clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
11
12
  import { resolveDomainSkill } from './domain-skills.js';
12
- import { generateTaskId, generateShortId, connectionKey, asConnectionKey, parseConnectionKey, keyBelongsToProfile, } from './types.js';
13
+ import { generateTaskId, generateShortId, asConnectionKey, parseConnectionKey, keyBelongsToProfile, } from './types.js';
13
14
  import { reapAbandonedTasks, resolveLiveIdentities, taskOwnerIsGone, } from './hygiene.js';
14
15
  import { taskMatchesCaller } from './caller-identity.js';
15
16
  import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
@@ -298,7 +299,8 @@ export class BrowserService {
298
299
  * Live browsers, keyed by {@link ConnectionKey} — NOT by profile name. The
299
300
  * branded key type is what makes `connections.get(someProfileName)` (the
300
301
  * RUSH-2709 miss that made `status --profile <name>` return empty for a
301
- * running `<name>@endpoint-0`) fail to compile.
302
+ * running `<name>@<device>`) fail to compile. Keys are `<profile>@<device>`
303
+ * (legacy leftover `<profile>@endpoint-N` dirs are still recognized).
302
304
  */
303
305
  connections = new Map();
304
306
  forkingProfiles = new Set();
@@ -318,26 +320,16 @@ export class BrowserService {
318
320
  // page verbs that create a browser implicitly (navigate, click, screenshot,
319
321
  // …) able to open a browser on a machine whose owner never opted in.
320
322
  assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
321
- const profile = await getProfile(profileName);
322
- if (!profile) {
323
- throw new Error(`Profile "${profileName}" not found`);
324
- }
325
- // Pick the endpoint preset. Throws with the candidate list if the user
326
- // passed an unknown name. The runtime key `<profile>@<endpoint>` is what
327
- // the connection map + per-profile runtime dirs are keyed on, so a single
328
- // YAML profile can run at multiple endpoints concurrently.
329
- //
330
- // `effectiveProfile.name` stays BARE. Overwriting it with the runtime key
331
- // here (pre-RUSH-2709) is what leaked `comet-local@endpoint-0` into every
332
- // listing and made `status --profile comet-local` miss its own browser.
333
- // The key travels as its own argument instead.
334
- const resolved = resolveEndpoint(profile, opts.endpointName);
335
- const composite = connectionKey(profileName, resolved.name);
336
- const effectiveProfile = {
337
- ...profile,
338
- binary: resolved.binary,
339
- targetFilter: resolved.targetFilter,
340
- };
323
+ // Registry decides WHERE. Three outcomes, no fourth: local if this
324
+ // device declares the name; tunnel to a declaring device otherwise;
325
+ // fail loud if nobody does. Never auto-create a local browser under a
326
+ // name that means a logged-in browser somewhere else.
327
+ const routed = resolveBrowserTarget(profileName, {
328
+ endpointName: opts.endpointName,
329
+ probe: opts.probe,
330
+ });
331
+ const composite = routed.key;
332
+ const effectiveProfile = routed.profile;
341
333
  const taskId = generateTaskId();
342
334
  let taskName;
343
335
  if (opts.taskName) {
@@ -351,9 +343,12 @@ export class BrowserService {
351
343
  taskName = taskId;
352
344
  }
353
345
  const taskLabel = deriveTaskLabel({ title: opts.title, url: opts.url });
354
- let conn = await this.reuseHealthyConnection(composite);
355
- let effectiveKey = composite;
356
- if (conn && conn.electron && conn.tasks.size > 0) {
346
+ const reused = await this.reuseProfileConnection(profileName, composite, routed.local);
347
+ let conn = reused?.conn;
348
+ let effectiveKey = reused?.key ?? composite;
349
+ // Fork is a local Electron chrome-data mint. Never fork a tunnelled
350
+ // connection — that would launch a logged-out browser on THIS box.
351
+ if (conn && routed.local && shouldForkProfile(routed.kind, conn)) {
357
352
  if (this.forkingProfiles.has(composite)) {
358
353
  while (this.forkingProfiles.has(composite)) {
359
354
  await new Promise((r) => setTimeout(r, 50));
@@ -380,9 +375,14 @@ export class BrowserService {
380
375
  }
381
376
  }
382
377
  else if (!conn) {
383
- conn = await this.openConnection(effectiveProfile, resolved.target, composite, profileName);
378
+ adoptLegacyRuntimeIfLocal(routed.local, profileName, composite, getBrowserRuntimeDir());
379
+ conn = await this.openConnection(effectiveProfile, routed.target, composite, profileName, { persistRemote: !routed.local });
380
+ effectiveKey = composite;
384
381
  }
385
- if (conn && !conn.profile)
382
+ if (!conn) {
383
+ throw new Error(`Could not connect to profile "${profileName}"`);
384
+ }
385
+ if (!conn.profile)
386
386
  conn.profile = profileName;
387
387
  // Browsers launch with --no-startup-window (session-cookie persistence,
388
388
  // see launchBrowser), so a bare `start` with no --url would otherwise
@@ -503,7 +503,16 @@ export class BrowserService {
503
503
  if (resolved)
504
504
  skill = resolved;
505
505
  }
506
- return { task: taskId, name: taskName, tabId, profile: profileName, key: effectiveKey, skill };
506
+ return {
507
+ task: taskId,
508
+ name: taskName,
509
+ tabId,
510
+ profile: profileName,
511
+ key: effectiveKey,
512
+ device: routed.device,
513
+ picked: routed.picked,
514
+ skill,
515
+ };
507
516
  }
508
517
  /**
509
518
  * Reclaim a live page already showing `url` from an ABANDONED task, instead
@@ -656,7 +665,7 @@ export class BrowserService {
656
665
  /**
657
666
  * Close tasks whose owning agent session is gone, or that have sat untouched
658
667
  * past the idle window. The entry point the daemon's periodic tick and
659
- * `agents browser gc` both call; the policy lives in `hygiene.ts`.
668
+ * `agents browser prune` both call; the policy lives in `hygiene.ts`.
660
669
  */
661
670
  async reapAbandoned(opts = {}) {
662
671
  return reapAbandonedTasks(this, opts);
@@ -736,7 +745,7 @@ export class BrowserService {
736
745
  return this.stop(taskName);
737
746
  }
738
747
  async stopProfile(profileRef) {
739
- // Connections are keyed by the runtime key `<profile>@<endpoint>` (see
748
+ // Connections are keyed by the runtime key `<profile>@<device>` (see
740
749
  // start()) while callers pass the bare profile name (or, occasionally, an
741
750
  // exact key). A plain `connections.get(profileRef)` therefore missed every
742
751
  // real remote connection, so `cleanup()` never ran — leaving the SSH tunnel
@@ -1517,8 +1526,9 @@ export class BrowserService {
1517
1526
  * `profileName` is a {@link ProfileName} — a BARE name, never a runtime key.
1518
1527
  * Every candidate is selected by the one rule ({@link keyBelongsToProfile}),
1519
1528
  * and a scoped query that finds no live connection falls through to disk, so
1520
- * `status --profile comet-local` reports the LIVE `comet-local@endpoint-0`
1521
- * instead of the empty list it used to return (RUSH-2709).
1529
+ * `status --profile comet-local` reports the LIVE `comet-local@<device>`
1530
+ * (and leftover `comet-local@endpoint-0` dirs) instead of the empty list
1531
+ * it used to return (RUSH-2709).
1522
1532
  */
1523
1533
  async status(profileRef) {
1524
1534
  // Reconnect live browsers after a daemon restart so status is not empty
@@ -2052,14 +2062,24 @@ export class BrowserService {
2052
2062
  * already resolved the endpoint and built the `effectiveProfile` with
2053
2063
  * the per-endpoint binary/targetFilter overrides applied; we just use it.
2054
2064
  *
2055
- * `key` is the runtime key (`<profile>@<endpoint>`) and is what every on-disk
2056
- * lookup uses, so per-endpoint pid/port files don't collide when the same app
2057
- * runs locally and remotely at the same time. It is a SEPARATE argument
2058
- * because `effectiveProfile.name` stays the bare, user-facing name (RUSH-2709).
2065
+ * `key` is the runtime key (`<profile>@<device>`) and is what every on-disk
2066
+ * lookup uses. It is a SEPARATE argument because `effectiveProfile.name`
2067
+ * stays the bare, user-facing name (RUSH-2709).
2059
2068
  */
2060
- async connectProfile(effectiveProfile, target, key) {
2069
+ async connectProfile(effectiveProfile, target, key, opts = {}) {
2061
2070
  const existingInfo = getRunningChromeInfo(key);
2062
- if (existingInfo) {
2071
+ const tunnelled = opts.persistRemote || target.startsWith('ssh:');
2072
+ if (tunnelled) {
2073
+ // A leftover local chrome-data / pid file under this key is the
2074
+ // pre-T2 logged-out browser on THIS box. Attaching localhost CDP
2075
+ // here is the original bug. Only a recorded SSH tunnel is ours to
2076
+ // reuse, and connectSSH already does that via isOwnTunnel.
2077
+ const meta = readProfileRuntimeMeta(key);
2078
+ if (meta && meta.kind !== 'tunnel') {
2079
+ clearProfileRuntime(key);
2080
+ }
2081
+ }
2082
+ else if (existingInfo) {
2063
2083
  try {
2064
2084
  const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
2065
2085
  verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
@@ -2088,13 +2108,13 @@ export class BrowserService {
2088
2108
  clearProfileRuntime(key);
2089
2109
  }
2090
2110
  }
2091
- const conn = await this.connectEndpoint(effectiveProfile, target, key);
2111
+ const conn = await this.connectEndpoint(effectiveProfile, target, key, opts);
2092
2112
  if (!conn) {
2093
2113
  throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
2094
2114
  }
2095
2115
  return conn;
2096
2116
  }
2097
- async connectEndpoint(profile, endpoint, key) {
2117
+ async connectEndpoint(profile, endpoint, key, opts = {}) {
2098
2118
  const url = new URL(endpoint);
2099
2119
  if (url.protocol === 'cdp:') {
2100
2120
  const conn = await connectLocal(endpoint, profile, key);
@@ -2111,7 +2131,9 @@ export class BrowserService {
2111
2131
  };
2112
2132
  }
2113
2133
  if (url.protocol === 'ssh:') {
2114
- const conn = await connectSSH(endpoint, profile, key);
2134
+ const conn = await connectSSH(endpoint, profile, key, {
2135
+ persistRemote: opts.persistRemote,
2136
+ });
2115
2137
  await this.enableDomains(conn.cdp);
2116
2138
  return {
2117
2139
  cdp: conn.cdp,
@@ -2265,6 +2287,15 @@ export class BrowserService {
2265
2287
  * - no handle, zero, !createIfMissing → return null (caller reports "nothing to close")
2266
2288
  */
2267
2289
  async resolveOrCreateTask(opts) {
2290
+ // Consent gate — the one chokepoint every drive/mutate verb passes through.
2291
+ // ipc.ts routes each PAGE_RESOLVE_VERB and CLOSE_VERB here via bindTask, so
2292
+ // gating at the top refuses a fleet-remote ATTACH (an explicit --task, or a
2293
+ // lone identity match) exactly as it already refused an implicit CREATE.
2294
+ // Gating only the create branch below left tab-add/navigate/done on an
2295
+ // already-running browser ungated, so a remote caller could drive the
2296
+ // owner's authenticated profile with consent off (RUSH-3064). Read the
2297
+ // per-request marker, never the daemon's env — see remote-control.ts.
2298
+ assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
2268
2299
  if (opts.task) {
2269
2300
  const found = await this.findTask(opts.task, opts.profile);
2270
2301
  return { ...found, created: false };
@@ -2298,10 +2329,10 @@ export class BrowserService {
2298
2329
  // Zero matches for this caller.
2299
2330
  if (!opts.createIfMissing)
2300
2331
  return null;
2301
- // Refuse BEFORE ensureDefaultBrowserProfile() below, which probes local
2302
- // browser installs and may CREATE and persist an `auto-chrome` profile — a
2303
- // refused request must not leave state behind on the target machine.
2304
- assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
2332
+ // The top-of-function consent gate already refused a fleet-remote create
2333
+ // here before ensureDefaultBrowserProfile() below can probe local browser
2334
+ // installs and persist an `auto-chrome` profile so a refused request never
2335
+ // leaves state behind on the target machine.
2305
2336
  // Implicit start on the default / named profile.
2306
2337
  let profileName = opts.profile;
2307
2338
  if (!profileName) {
@@ -2317,7 +2348,12 @@ export class BrowserService {
2317
2348
  sessionId: opts.sessionId,
2318
2349
  title: opts.title,
2319
2350
  });
2320
- return { ...(await this.findTask(started.name, started.profile)), created: true };
2351
+ return {
2352
+ ...(await this.findTask(started.name, started.profile)),
2353
+ created: true,
2354
+ picked: started.picked,
2355
+ device: started.device,
2356
+ };
2321
2357
  }
2322
2358
  listTasksForCaller(caller) {
2323
2359
  const out = [];
@@ -2458,7 +2494,15 @@ export class BrowserService {
2458
2494
  return null;
2459
2495
  let resolved;
2460
2496
  try {
2461
- resolved = resolveEndpoint(profile, endpointFromKey);
2497
+ // Legacy keys encode the preset (`endpoint-0`). New keys encode the
2498
+ // declaring device, which is not a preset name — resolve via registry.
2499
+ if (endpointFromKey && /^endpoint-\d+$/.test(endpointFromKey)) {
2500
+ resolved = resolveEndpoint(profile, endpointFromKey);
2501
+ }
2502
+ else {
2503
+ const routed = resolveBrowserTarget(bare);
2504
+ resolved = { name: routed.device, target: routed.target };
2505
+ }
2462
2506
  }
2463
2507
  catch {
2464
2508
  return null;
@@ -2851,9 +2895,35 @@ export class BrowserService {
2851
2895
  this.connections.delete(key);
2852
2896
  return undefined;
2853
2897
  }
2898
+ /**
2899
+ * Reuse a live connection for this profile: the new `<name>@<device>` key
2900
+ * first, then any leftover `<name>@endpoint-N` key still in the map so a
2901
+ * browser started before the key collapse is not abandoned.
2902
+ */
2903
+ async reuseProfileConnection(profileName, preferred, local) {
2904
+ const preferredConn = await this.reuseHealthyConnection(preferred);
2905
+ if (preferredConn)
2906
+ return { conn: preferredConn, key: preferred };
2907
+ // Leftover `@endpoint-N` connections are THIS machine's pre-T2 local
2908
+ // browser. Reusing them on a tunnelled resolve is the original bug.
2909
+ if (!local)
2910
+ return undefined;
2911
+ for (const key of [...this.connections.keys()]) {
2912
+ if (key === preferred)
2913
+ continue;
2914
+ if (!isLegacyEndpointKey(key, profileName))
2915
+ continue;
2916
+ if (parseConnectionKey(key).fork !== undefined)
2917
+ continue;
2918
+ const conn = await this.reuseHealthyConnection(key);
2919
+ if (conn)
2920
+ return { conn, key };
2921
+ }
2922
+ return undefined;
2923
+ }
2854
2924
  /** Connect a profile at `target`, register it under `key`, and arm downloads. */
2855
- async openConnection(profile, target, key, profileName) {
2856
- const conn = await this.connectProfile(profile, target, key);
2925
+ async openConnection(profile, target, key, profileName, opts = {}) {
2926
+ const conn = await this.connectProfile(profile, target, key, opts);
2857
2927
  conn.key = key;
2858
2928
  conn.profile = profileName;
2859
2929
  this.connections.set(key, conn);
@@ -2876,22 +2946,29 @@ export class BrowserService {
2876
2946
  // Same consent gate as start(): this opens a browser, so a fleet-remote
2877
2947
  // caller needs the target machine's opt-in.
2878
2948
  assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
2879
- const profile = await getProfile(profileName);
2880
- if (!profile)
2881
- throw new Error(`Profile "${profileName}" not found`);
2882
- const resolved = resolveEndpoint(profile, opts.endpointName);
2883
- const key = connectionKey(profileName, resolved.name);
2884
- const effectiveProfile = {
2885
- ...profile,
2886
- binary: resolved.binary,
2887
- targetFilter: resolved.targetFilter,
2888
- };
2889
- const conn = (await this.reuseHealthyConnection(key)) ??
2890
- (await this.openConnection(effectiveProfile, resolved.target, key, profileName));
2949
+ const routed = resolveBrowserTarget(profileName, {
2950
+ endpointName: opts.endpointName,
2951
+ probe: opts.probe,
2952
+ });
2953
+ const key = routed.key;
2954
+ const effectiveProfile = routed.profile;
2955
+ const reused = await this.reuseProfileConnection(profileName, key, routed.local);
2956
+ let conn = reused?.conn;
2957
+ const effectiveKey = reused?.key ?? key;
2958
+ if (!conn) {
2959
+ adoptLegacyRuntimeIfLocal(routed.local, profileName, key, getBrowserRuntimeDir());
2960
+ conn = await this.openConnection(effectiveProfile, routed.target, key, profileName, { persistRemote: !routed.local });
2961
+ }
2891
2962
  // createPageTarget refuses Arc with an actionable error rather than crashing it.
2892
2963
  const created = await this.createPageTarget(conn, { url });
2893
2964
  this.invalidateTargetCache(conn);
2894
- return { profile: profileName, key, tabId: created.targetId };
2965
+ return {
2966
+ profile: profileName,
2967
+ key: effectiveKey,
2968
+ tabId: created.targetId,
2969
+ device: routed.device,
2970
+ picked: routed.picked,
2971
+ };
2895
2972
  }
2896
2973
  async getHistory(limit = 10) {
2897
2974
  const historyFile = path.join(getBrowserRuntimeDir(), 'history.json');