@phnx-labs/agents-cli 1.22.45 → 1.22.47

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 (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. package/dist/commands/usage.js +0 -96
@@ -1,4 +1,5 @@
1
1
  import * as path from 'path';
2
+ import * as fs from 'fs';
2
3
  import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, readMeta, updateMeta, } from '../state.js';
3
4
  import { getConfigValue } from '../device-config.js';
4
5
  import { findBrowserPath, findFirstInstalledBrowser, isPortInUse } from './chrome.js';
@@ -419,6 +420,32 @@ function hasSshEndpoint(endpoints) {
419
420
  }
420
421
  });
421
422
  }
423
+ /**
424
+ * Refuse a profile whose LOCAL port another profile already owns.
425
+ *
426
+ * Every CDP/SSH profile ends up listening on (or tunneling to) the same LOCAL
427
+ * port number as the one configured in the endpoint URL — SSH profiles reuse
428
+ * `?port=N` locally, so we no longer scope by host. Two profiles that would
429
+ * need the same local port can't both run at the same time.
430
+ *
431
+ * `opts.ignore` is the profile's own name on an edit. Without it every edit
432
+ * collides with itself, since the stored copy still owns the port being kept.
433
+ */
434
+ export function assertLocalPortFree(profile, opts = {}) {
435
+ const wanted = effectiveLocalPort(profile);
436
+ if (wanted === undefined)
437
+ return;
438
+ for (const [existingName, existingConfig] of Object.entries(allProfileConfigs())) {
439
+ if (existingName === opts.ignore)
440
+ continue;
441
+ const existingProfile = configToProfile(existingName, existingConfig);
442
+ if (effectiveLocalPort(existingProfile) === wanted) {
443
+ throw new Error(`Local port ${wanted} is already used by profile "${existingName}". ` +
444
+ `Each profile must own a unique local port (SSH tunnels now bind ` +
445
+ `to their configured port locally too). Pick a different port.`);
446
+ }
447
+ }
448
+ }
422
449
  /**
423
450
  * Create a profile. **It lands in this machine's own store by default** — pass
424
451
  * `{ fleet: true }` (the CLI's `--fleet`) to put it in the synced `agents.yaml`
@@ -442,23 +469,7 @@ export async function createProfile(profile, opts = {}) {
442
469
  if (existingConfigs[profile.name]) {
443
470
  throw new Error(`Profile "${profile.name}" already exists`);
444
471
  }
445
- // Collision check. Every CDP/SSH profile ends up listening on (or
446
- // tunneling to) the same LOCAL port number as the one configured in the
447
- // endpoint URL — SSH profiles now reuse `?port=N` locally so we no
448
- // longer need to scope by host. Two profiles that would need the same
449
- // local port can't both run at the same time.
450
- const newLocal = effectiveLocalPort(profile);
451
- if (newLocal !== undefined) {
452
- for (const [existingName, existingConfig] of Object.entries(existingConfigs)) {
453
- const existingProfile = configToProfile(existingName, existingConfig);
454
- const existingLocal = effectiveLocalPort(existingProfile);
455
- if (existingLocal === newLocal) {
456
- throw new Error(`Local port ${newLocal} is already used by profile "${existingName}". ` +
457
- `Each profile must own a unique local port (SSH tunnels now bind ` +
458
- `to their configured port locally too). Pick a different port.`);
459
- }
460
- }
461
- }
472
+ assertLocalPortFree(profile);
462
473
  // Resolve the browser binary at create time. Fails fast with an actionable
463
474
  // error ("Comet not installed at /Applications/Comet.app") rather than
464
475
  // deferring the failure to the first task. `findBrowserPath` short-circuits
@@ -496,6 +507,278 @@ export async function updateProfile(profile) {
496
507
  }
497
508
  updateMeta((m) => ({ ...m, browser: { ...m.browser, [profile.name]: config } }));
498
509
  }
510
+ /**
511
+ * Flag a fleet-synced profile whose contents only make sense on ONE machine.
512
+ *
513
+ * A `cdp://` endpoint on loopback always connects on the machine EVALUATING the
514
+ * profile, so a fleet-scope copy does not mean "that browser" — it means "port N
515
+ * on whichever box ran the command". The name then silently resolves to a
516
+ * different, usually logged-out browser on every other machine, which is the
517
+ * worst possible failure for a profile that carries live logins.
518
+ *
519
+ * Reported by `profiles doctor` (a failing `scope` check) and noted in the
520
+ * `kept` reason `profiles prune` prints for a fleet profile. The repair is
521
+ * {@link moveProfileScope}, never an automatic rewrite of the synced doc.
522
+ */
523
+ export function misfiledFleetProfile(profile, scope) {
524
+ if (scope !== 'fleet')
525
+ return { misfiled: false };
526
+ // Judge the endpoint the profile would actually CONNECT on, resolving
527
+ // `defaultEndpoint` exactly as effectiveLocalPort does. Scanning every preset
528
+ // instead would fail a legitimate fleet profile that defaults to `ssh://` and
529
+ // merely carries a loopback alternate for local debugging.
530
+ const presets = getEndpointPresets(profile);
531
+ const chosen = profile.defaultEndpoint && presets[profile.defaultEndpoint]
532
+ ? profile.defaultEndpoint
533
+ : Object.keys(presets)[0];
534
+ if (!chosen)
535
+ return { misfiled: false };
536
+ const loopback = presets[chosen].target;
537
+ if (!loopback.startsWith('cdp://'))
538
+ return { misfiled: false };
539
+ const parsed = parseEndpointUrl(loopback);
540
+ if (!parsed || !isLocalHost(parsed.host))
541
+ return { misfiled: false };
542
+ return {
543
+ misfiled: true,
544
+ why: `fleet-synced but "${loopback}" is a loopback endpoint — on every other machine ` +
545
+ `this name resolves to that box's own port, not this browser. ` +
546
+ `Repair on the machine that owns it: agents browser profiles scope ${profile.name} local`,
547
+ };
548
+ }
549
+ /**
550
+ * Merge `patch` onto the stored profile and persist it in the store it already
551
+ * lives in (see {@link updateProfile} for the scope rules).
552
+ *
553
+ * Runs {@link createProfile}'s validations against the MERGED record, not just
554
+ * the patched fields — a binary edit re-resolves the browser path, a
555
+ * targetFilter edit re-checks the electron gate, and the local-port scan runs
556
+ * with this profile excluded so an unchanged port is not a self-collision.
557
+ */
558
+ export async function editProfile(name, patch) {
559
+ const meta = readMeta();
560
+ const local = meta.deviceBrowser?.[name];
561
+ const fleet = meta.browser?.[name];
562
+ if (!local && !fleet) {
563
+ throw new Error(`Profile "${name}" does not exist`);
564
+ }
565
+ const current = configToProfile(name, (local ?? fleet));
566
+ const merged = { ...current, ...patch, name };
567
+ const changed = Object.keys(patch).filter((k) => JSON.stringify(current[k]) !== JSON.stringify(merged[k]));
568
+ // A target filter only means anything for an Electron app, and the gate must
569
+ // read the MERGED record: `--target-filter x` on a profile that is already
570
+ // electron is valid, and `--no-electron` on one that still carries a filter is
571
+ // not. Checking the patch alone would accept both.
572
+ if (merged.targetFilter && !merged.electron) {
573
+ throw new Error(`--target-filter only applies to an Electron profile. ` +
574
+ `Pass --electron, or clear the filter with --target-filter ''.`);
575
+ }
576
+ assertLocalPortFree(merged, { ignore: name });
577
+ // Same rule as create: the binary lives on the remote for an SSH profile, so a
578
+ // local lookup would validate the wrong machine.
579
+ if (!hasSshEndpoint(merged.endpoints)) {
580
+ findBrowserPath(merged.browser, merged.binary);
581
+ }
582
+ await updateProfile(merged);
583
+ const landedLocal = !!local || isMachineLocalProfile(name);
584
+ return {
585
+ profile: merged,
586
+ scope: landedLocal ? 'local' : 'fleet',
587
+ changed,
588
+ fleetCopyLeftStale: landedLocal && !local && !!fleet ? true : undefined,
589
+ };
590
+ }
591
+ /**
592
+ * Move an existing profile between the fleet-synced store and this machine's
593
+ * device store.
594
+ *
595
+ * `fleet -> local` is the repair for a profile whose endpoint is machine-bound
596
+ * (a loopback `cdp://`) but which was created with `--fleet`: the name resolves
597
+ * to a DIFFERENT browser on every box, because `cdp:` always connects on the
598
+ * machine evaluating it. Run it ON the machine that owns the browser — the copy
599
+ * that survives is the one that works.
600
+ *
601
+ * Deliberately not automatic. Rewriting the user's synced `agents.yaml` from a
602
+ * heuristic is the rewrite loop RUSH-2161 fixed; this is an explicit verb.
603
+ */
604
+ /** Profile names an agent has to be able to type and an fs path can hold. */
605
+ const PROFILE_NAME_RE = /^[a-z][a-z0-9-]*$/;
606
+ /**
607
+ * Throw if `name` cannot be used for a NEW profile.
608
+ *
609
+ * Shared by `profiles create` and {@link renameProfile} so the two cannot drift
610
+ * — the shape rule used to live inline in the command, which is how `rename`
611
+ * would have accepted a name `create` rejects.
612
+ *
613
+ * `default` is refused because it is the reserved ALIAS meaning "whatever
614
+ * profile this machine is configured to use" (RUSH-2709). A literal profile by
615
+ * that name makes `--profile default` mean two different things.
616
+ */
617
+ export function assertRegistrableProfileName(name) {
618
+ if (!PROFILE_NAME_RE.test(name)) {
619
+ throw new Error(`Invalid profile name ${JSON.stringify(name)}. Use lowercase letters, digits and hyphens, ` +
620
+ `starting with a letter — e.g. 'agents'.`);
621
+ }
622
+ if (name === DEFAULT_PROFILE_ALIAS) {
623
+ throw new Error(`"${DEFAULT_PROFILE_ALIAS}" is the reserved alias for this machine's configured profile, ` +
624
+ `not a profile name. Pick another name.`);
625
+ }
626
+ // `browser.viewer: os` means "use the OS default handler". A profile by that
627
+ // name turns the opt-out into a pointer at a profile — silently, since both
628
+ // are just strings in the same key.
629
+ if (name === 'os') {
630
+ throw new Error(`"os" is the reserved browser.viewer value meaning the OS default handler, ` +
631
+ `not a profile name. Pick another name.`);
632
+ }
633
+ }
634
+ /**
635
+ * Rename a profile, taking its on-disk state with it.
636
+ *
637
+ * `editProfile` deliberately refuses a name change, and this is why: the name
638
+ * keys the runtime dir ({@link getProfileRuntimeDir}), every fork/endpoint dir
639
+ * derived from it, and the `browser.profile` pointer. Delete-and-recreate — the
640
+ * only route before this — silently abandons the browser's `--user-data-dir`,
641
+ * which is where a profile's logins live. On a real agent browser that is
642
+ * gigabytes of session state and every account it has ever signed into.
643
+ *
644
+ * Refuses when the profile is in use: moving a `--user-data-dir` out from under
645
+ * a running browser corrupts it.
646
+ */
647
+ export async function renameProfile(from, to) {
648
+ if (from === to)
649
+ throw new Error(`"${from}" is already its own name.`);
650
+ const meta = readMeta();
651
+ const local = meta.deviceBrowser?.[from];
652
+ const fleet = meta.browser?.[from];
653
+ if (!local && !fleet)
654
+ throw new Error(`Profile "${from}" does not exist`);
655
+ if (allProfileConfigs()[to])
656
+ throw new Error(`Profile "${to}" already exists`);
657
+ assertRegistrableProfileName(to);
658
+ const { isProfileInUse, listProfileCacheDirs } = await import('./runtime-state.js');
659
+ if (isProfileInUse(from)) {
660
+ throw new Error(`"${from}" is in use (live browser, tunnel, or open task). Renaming would move its ` +
661
+ `user-data-dir out from under the running browser. Stop it first: agents browser stop --profile ${from}`);
662
+ }
663
+ // A name can exist in BOTH stores (local wins in allProfileConfigs). Rewriting
664
+ // only one leaves the other listed under the OLD name with its data dir already
665
+ // moved away — the abandoned-`--user-data-dir` state this command exists to
666
+ // prevent, produced by this command, and previously reported as a clean
667
+ // success. Refuse instead: which copy the user means is genuinely ambiguous,
668
+ // and `profiles scope` is the tool for collapsing the duplicate first.
669
+ if (local && fleet) {
670
+ // `scope <name> fleet`, NOT local: moveProfileScope computes `from` as
671
+ // 'local' whenever a local entry exists, so asking for local is a no-op that
672
+ // reports success and leaves the duplicate. Sending the user there and then
673
+ // to `profiles delete` — which drops the cached runtime dirs by default —
674
+ // walked a data-preservation command into a data-destroying one.
675
+ throw new Error(`"${from}" exists in BOTH this machine's store and the fleet-synced one. ` +
676
+ `Renaming would move its browser data while leaving one copy behind under the old name.\n` +
677
+ ` Collapse the duplicate first: agents browser profiles scope ${from} fleet\n` +
678
+ ` Then rename. (To drop a copy instead, agents browser profiles delete ${from} --keep-cache ` +
679
+ `keeps the browser data.)`);
680
+ }
681
+ const config = (local ?? fleet);
682
+ const scope = local ? 'local' : 'fleet';
683
+ // Move the on-disk state BEFORE the config, so a crash between the two leaves
684
+ // a profile whose dirs are already where the new name expects them rather than
685
+ // a config pointing at dirs that no longer exist.
686
+ // PRE-FLIGHT every destination before moving ANY of them. With the check
687
+ // inside the loop, dir N was validated only after dirs 0..N-1 had already
688
+ // moved: a collision on the second endpoint left the first one's logins
689
+ // stranded under a name with no config entry, and the error named only the
690
+ // squatter. Nothing repaired that, and the user was never told.
691
+ const plan = [];
692
+ for (const dir of listProfileCacheDirs(from)) {
693
+ const base = path.basename(dir);
694
+ const suffix = base.slice(from.length);
695
+ const dest = path.join(path.dirname(dir), `${to}${suffix}`);
696
+ if (fs.existsSync(dest)) {
697
+ throw new Error(`Cannot rename: ${dest} already exists. Nothing was moved. Remove or rename it first.`);
698
+ }
699
+ plan.push({ dir, dest });
700
+ }
701
+ const movedDirs = [];
702
+ for (const { dir, dest } of plan) {
703
+ fs.renameSync(dir, dest);
704
+ movedDirs.push(dest);
705
+ }
706
+ if (scope === 'local') {
707
+ updateMeta((m) => {
708
+ const next = { ...m.deviceBrowser };
709
+ delete next[from];
710
+ return { ...m, deviceBrowser: { ...next, [to]: config } };
711
+ });
712
+ }
713
+ else {
714
+ updateMeta((m) => {
715
+ const next = { ...m.browser };
716
+ delete next[from];
717
+ return { ...m, browser: { ...next, [to]: config } };
718
+ });
719
+ }
720
+ // Both pointers are separate keys. Leaving `browser.profile` behind falls back
721
+ // to auto-detect on the next `browser start`; leaving `browser.viewer` behind
722
+ // sends every artifact back to the OS default handler — which is the exact bug
723
+ // the viewer seam was built to fix, reintroduced by a rename.
724
+ const { setConfigValue, getConfigValue } = await import('../device-config.js');
725
+ let repointedDefault = false;
726
+ if (getConfiguredDefaultProfileName() === from) {
727
+ setConfigValue('browser.profile', to);
728
+ repointedDefault = true;
729
+ }
730
+ let repointedViewer = false;
731
+ if (getConfigValue('browser.viewer').value === from) {
732
+ setConfigValue('browser.viewer', to);
733
+ repointedViewer = true;
734
+ }
735
+ // A fleet rename changes the name on every machine, but each machine's own
736
+ // config doc is separate — a peer pinning the old name is left dangling. Not
737
+ // rewritten from here: that would be a cross-machine mutation, and on a peer
738
+ // the pin may be correct (a LOCAL profile of the same name). Reported instead.
739
+ const { devicesPinningBrowserProfile } = await import('../device-config.js');
740
+ const { machineId } = await import('../machine-id.js');
741
+ const stalePins = scope === 'fleet'
742
+ ? devicesPinningBrowserProfile(from).filter((p) => p.device !== machineId())
743
+ : [];
744
+ return { scope, movedDirs, repointedDefault, repointedViewer, stalePins };
745
+ }
746
+ export async function moveProfileScope(name, to) {
747
+ const meta = readMeta();
748
+ const local = meta.deviceBrowser?.[name];
749
+ const fleet = meta.browser?.[name];
750
+ if (!local && !fleet) {
751
+ throw new Error(`Profile "${name}" does not exist`);
752
+ }
753
+ const from = local ? 'local' : 'fleet';
754
+ if (from === to)
755
+ return { from, to };
756
+ if (to === 'fleet' && isMachineLocalProfile(name)) {
757
+ throw new Error(`"${name}" is always machine-local — its binary path and port are this box's. ` +
758
+ `Fleet-syncing it is the rewrite loop RUSH-2161 fixed.`);
759
+ }
760
+ const config = (local ?? fleet);
761
+ // Write the destination BEFORE dropping the source, so a crash between the two
762
+ // leaves a duplicate (harmless — local wins in allProfileConfigs) rather than
763
+ // no profile at all.
764
+ if (to === 'local') {
765
+ updateMeta((m) => ({ ...m, deviceBrowser: { ...m.deviceBrowser, [name]: config } }));
766
+ updateMeta((m) => {
767
+ const next = { ...m.browser };
768
+ delete next[name];
769
+ return { ...m, browser: Object.keys(next).length > 0 ? next : undefined };
770
+ });
771
+ }
772
+ else {
773
+ updateMeta((m) => ({ ...m, browser: { ...m.browser, [name]: config } }));
774
+ updateMeta((m) => {
775
+ const next = { ...m.deviceBrowser };
776
+ delete next[name];
777
+ return { ...m, deviceBrowser: Object.keys(next).length > 0 ? next : undefined };
778
+ });
779
+ }
780
+ return { from, to };
781
+ }
499
782
  export async function deleteProfile(name) {
500
783
  const meta = readMeta();
501
784
  const isLocal = !!meta.deviceBrowser?.[name];
@@ -9,8 +9,15 @@
9
9
  * `browser.remote-control` config key, never synced).
10
10
  *
11
11
  * Local invocations (no marker) are never gated — this only governs cross-machine
12
- * drives. Read-only queries are not gated here; the gate sits at the drive entry
13
- * point (`browser start`), the one command that opens/attaches a browser.
12
+ * drives. Read-only queries are not gated.
13
+ *
14
+ * The authoritative gate is {@link assertRemoteControlAllowedForRequest}, called
15
+ * inside the browser daemon at the two points that can OPEN a browser
16
+ * (`BrowserService.start` and the create branch of `resolveOrCreateTask`). It has
17
+ * to live there because ~18 page verbs (`navigate`, `click`, `screenshot`, …)
18
+ * create a browser implicitly, and gating only the `browser start` command left
19
+ * every one of them ungated. {@link assertRemoteControlAllowed} remains as a
20
+ * fast-fail CLI-side check so a refused `start` never auto-creates a profile.
14
21
  */
15
22
  /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
16
23
  export declare const FLEET_REMOTE_ENV = "AGENTS_FLEET_REMOTE";
@@ -33,3 +40,20 @@ export declare function assertRemoteControlAllowed(opts?: {
33
40
  env?: NodeJS.ProcessEnv;
34
41
  enabled?: boolean;
35
42
  }): void;
43
+ /**
44
+ * The daemon-side consent gate.
45
+ *
46
+ * Reads ONLY the per-request marker, never `process.env`: the browser daemon is
47
+ * shared and long-lived, and it may have been auto-started by a fleet-remote CLI
48
+ * that leaked `AGENTS_FLEET_REMOTE=1` into its environment permanently. Reading
49
+ * the daemon's env would then refuse every subsequent LOCAL drive on this
50
+ * machine until the daemon restarted.
51
+ *
52
+ * `actor` is likewise the caller's forwarded identity, not the daemon's.
53
+ */
54
+ export declare function assertRemoteControlAllowedForRequest(fleetRemote: boolean | undefined, opts?: {
55
+ actor?: string;
56
+ enabled?: boolean;
57
+ }): void;
58
+ /** The refusal text, shared by the CLI-side and daemon-side gates. */
59
+ export declare function remoteControlRefusal(who: string): string;
@@ -9,8 +9,15 @@
9
9
  * `browser.remote-control` config key, never synced).
10
10
  *
11
11
  * Local invocations (no marker) are never gated — this only governs cross-machine
12
- * drives. Read-only queries are not gated here; the gate sits at the drive entry
13
- * point (`browser start`), the one command that opens/attaches a browser.
12
+ * drives. Read-only queries are not gated.
13
+ *
14
+ * The authoritative gate is {@link assertRemoteControlAllowedForRequest}, called
15
+ * inside the browser daemon at the two points that can OPEN a browser
16
+ * (`BrowserService.start` and the create branch of `resolveOrCreateTask`). It has
17
+ * to live there because ~18 page verbs (`navigate`, `click`, `screenshot`, …)
18
+ * create a browser implicitly, and gating only the `browser start` command left
19
+ * every one of them ungated. {@link assertRemoteControlAllowed} remains as a
20
+ * fast-fail CLI-side check so a refused `start` never auto-creates a profile.
14
21
  */
15
22
  import { getConfigValue } from '../device-config.js';
16
23
  /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
@@ -42,7 +49,30 @@ export function assertRemoteControlAllowed(opts) {
42
49
  if (enabled)
43
50
  return;
44
51
  const who = env.AGENTS_ACTOR_HOST || env.AGENTS_ACTOR || 'A fleet machine';
45
- throw new Error(`${who} tried to drive this machine's browser over \`browser --device\`, but remote ` +
52
+ throw new Error(remoteControlRefusal(who));
53
+ }
54
+ /**
55
+ * The daemon-side consent gate.
56
+ *
57
+ * Reads ONLY the per-request marker, never `process.env`: the browser daemon is
58
+ * shared and long-lived, and it may have been auto-started by a fleet-remote CLI
59
+ * that leaked `AGENTS_FLEET_REMOTE=1` into its environment permanently. Reading
60
+ * the daemon's env would then refuse every subsequent LOCAL drive on this
61
+ * machine until the daemon restarted.
62
+ *
63
+ * `actor` is likewise the caller's forwarded identity, not the daemon's.
64
+ */
65
+ export function assertRemoteControlAllowedForRequest(fleetRemote, opts = {}) {
66
+ if (!fleetRemote)
67
+ return;
68
+ const enabled = opts.enabled ?? remoteControlEnabled();
69
+ if (enabled)
70
+ return;
71
+ throw new Error(remoteControlRefusal(opts.actor || 'A fleet machine'));
72
+ }
73
+ /** The refusal text, shared by the CLI-side and daemon-side gates. */
74
+ export function remoteControlRefusal(who) {
75
+ return (`${who} tried to drive this machine's browser over \`browser --device\`, but remote ` +
46
76
  `browser control is off here. To allow it, run on THIS machine:\n` +
47
77
  ` agents browser remote-control on`);
48
78
  }
@@ -148,6 +148,13 @@ export interface PrunePlan {
148
148
  name: string;
149
149
  scope: ProfileScope;
150
150
  why: string;
151
+ /**
152
+ * True for a fleet profile whose endpoint only makes sense on one machine.
153
+ * A structured flag, not a substring of `why`: both the CLI's human output
154
+ * and `--json` consumers branch on it, and sniffing the reason text would
155
+ * couple them to wording.
156
+ */
157
+ misfiled?: boolean;
151
158
  }>;
152
159
  }
153
160
  export declare const PRUNE_REASON_TEXT: Record<PruneReason, string>;
@@ -182,6 +189,8 @@ export declare function planProfilePrune(profiles: Array<{
182
189
  name: string;
183
190
  scope: ProfileScope;
184
191
  launchableHere: boolean;
192
+ /** Set when this fleet profile's endpoint only makes sense on one machine. */
193
+ misfiledWhy?: string;
185
194
  }>, opts?: {
186
195
  includeFleet?: boolean;
187
196
  configuredDefault?: string;
@@ -1,7 +1,7 @@
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, } from './profiles.js';
4
+ import { getProfileRuntimeDir, getBrowserRuntimeDir, listProfilesWithScope, getConfiguredDefaultProfileName, isProfileLaunchableHere, deleteProfile, DEFAULT_BROWSER_PROFILE_NAME, LEGACY_DEFAULT_BROWSER_PROFILE_NAME, misfiledFleetProfile, } from './profiles.js';
5
5
  import { keyBelongsToProfile } from './types.js';
6
6
  const PID_FILE = 'pid';
7
7
  const PORT_FILE = 'port';
@@ -308,8 +308,20 @@ export const PRUNE_REASON_TEXT = {
308
308
  */
309
309
  export function planProfilePrune(profiles, opts = {}) {
310
310
  const plan = { candidates: [], kept: [] };
311
- for (const { name, scope, launchableHere } of profiles) {
312
- const keep = (why) => { plan.kept.push({ name, scope, why }); };
311
+ for (const { name, scope, launchableHere, misfiledWhy } of profiles) {
312
+ const keep = (why, misfiled = false) => {
313
+ plan.kept.push(misfiled ? { name, scope, why, misfiled } : { name, scope, why });
314
+ };
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`.
321
+ if (misfiledWhy) {
322
+ keep(`MISFILED — ${misfiledWhy}`, true);
323
+ continue;
324
+ }
313
325
  if (scope === 'fleet' && !opts.includeFleet) {
314
326
  keep('fleet-synced (pass --fleet to include it)');
315
327
  continue;
@@ -345,11 +357,15 @@ export function planProfilePrune(profiles, opts = {}) {
345
357
  */
346
358
  export async function buildProfilePrunePlan(opts = {}) {
347
359
  const scoped = await listProfilesWithScope();
348
- return planProfilePrune(scoped.map(({ profile, scope }) => ({
349
- name: profile.name,
350
- scope,
351
- launchableHere: isProfileLaunchableHere(profile),
352
- })), { includeFleet: opts.includeFleet, configuredDefault: getConfiguredDefaultProfileName() });
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() });
353
369
  }
354
370
  /**
355
371
  * Execute a prune plan: drop each candidate's config entry and wipe its cache
@@ -161,6 +161,8 @@ export declare class BrowserService {
161
161
  launchId?: string;
162
162
  /** Calling agent session, forwarded from the CLI (see IPCRequest.sessionId). */
163
163
  sessionId?: string;
164
+ /** Whether the CALLER was dispatched here by a fleet `--device` hop. */
165
+ fleetRemote?: boolean;
164
166
  /** Explicit human label (`--title`). */
165
167
  title?: string;
166
168
  }): Promise<{
@@ -469,6 +471,8 @@ export declare class BrowserService {
469
471
  * - no handle, zero, !createIfMissing → return null (caller reports "nothing to close")
470
472
  */
471
473
  resolveOrCreateTask(opts: {
474
+ /** Whether the CALLER was dispatched here by a fleet `--device` hop. */
475
+ fleetRemote?: boolean;
472
476
  task?: string;
473
477
  profile?: string;
474
478
  actor?: string;
@@ -536,6 +540,39 @@ export declare class BrowserService {
536
540
  private saveTaskState;
537
541
  private loadTaskState;
538
542
  private saveToHistory;
543
+ /**
544
+ * The cached connection for `key`, or undefined — evicting it first if the
545
+ * browser died underneath us.
546
+ *
547
+ * A browser killed externally (Cmd-Q, crash, a user clearing the profile by
548
+ * hand) leaves a closed WebSocket in the map. Without this the next
549
+ * `cdp.send` throws "CDP connection not open" and there is no recovery short
550
+ * of killing the daemon.
551
+ */
552
+ private reuseHealthyConnection;
553
+ /** Connect a profile at `target`, register it under `key`, and arm downloads. */
554
+ private openConnection;
555
+ /**
556
+ * Open a tab for a HUMAN to read, bound to no task.
557
+ *
558
+ * That is the entire difference from {@link start}, and it is the point. The
559
+ * abandoned-task reaper closes a task's tabs when the calling session dies or
560
+ * after the idle window, but it "deliberately never touches a tab that is not
561
+ * in `task.tabs`" (hygiene.ts) — so a viewer tab must not be in one, or the
562
+ * artifact the user is reading vanishes when the agent that rendered it exits.
563
+ *
564
+ * Consequently `stop`/`done` do not close it either, which is correct: it is
565
+ * the user's tab now.
566
+ */
567
+ showUrl(profileName: ProfileName, url: string, opts?: {
568
+ endpointName?: string;
569
+ fleetRemote?: boolean;
570
+ actor?: string;
571
+ }): Promise<{
572
+ profile: ProfileName;
573
+ key: ConnectionKey;
574
+ tabId: string;
575
+ }>;
539
576
  getHistory(limit?: number): Promise<HistoricalTask[]>;
540
577
  }
541
578
  export {};