@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +220 -174
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -223,9 +223,9 @@ export declare function isDaemonEnabled(): boolean;
223
223
  /**
224
224
  * Throw when the daemon is disabled on this machine, naming the setting and
225
225
  * the fix. Every AUTO-start surface (routines add/start/catchup/webhook,
226
- * `ensureDaemonStarted`) refuses with this before calling `startDaemon()`.
227
- * `agents daemon start` is the deliberate override and does NOT call this —
228
- * disable only blocks auto-start, mirroring `systemctl disable`.
226
+ * monitors add, `ensureDaemonStarted`) refuses with this before calling
227
+ * `startDaemon()`. `agents daemon start` is the deliberate override and does
228
+ * NOT call this — disable only blocks auto-start, mirroring `systemctl disable`.
229
229
  */
230
230
  export declare function assertDaemonEnabled(): void;
231
231
  /**
@@ -145,7 +145,7 @@ export const CONFIG_KEYS = [
145
145
  defaultValue: true,
146
146
  description: 'Whether the daemon may run on this device at all (secrets broker, browser IPC, watchdog, and the ' +
147
147
  'routines scheduler). Disabling is the top-level kill switch: nothing auto-starts the daemon while it ' +
148
- 'is set, including `routines add`/`routines start`/`routines catchup`/webhook triggers. ' +
148
+ 'is set, including `routines add`/`routines start`/`routines catchup`/`monitors add`/webhook triggers. ' +
149
149
  '`agents daemon start` still starts it explicitly.',
150
150
  },
151
151
  {
@@ -164,8 +164,9 @@ export const CONFIG_KEYS = [
164
164
  visibility: 'machine',
165
165
  type: 'bool',
166
166
  defaultValue: false,
167
- description: 'Whether an interactive `agents run` on this device is wrapped in the shared-socket tmux session. ' +
168
- 'Off, the default, spawns the agent directly. Turn it on to give every agent an addressable pane for ' +
167
+ description: 'Whether an interactive `agents run` on this device is wrapped in the shared-socket tmux session — local runs and ' +
168
+ 'followed `--device` runs alike (PHNX-3316). Off, the default, spawns the agent directly; a remote run left bare ' +
169
+ 'is protected by reconnect-and-resume, not a pane. Turn it on to give every agent an addressable pane for ' +
169
170
  '`agents message`, injection, and `agents focus` once the tmux mouse, clipboard, and scrollback behavior suits this device.',
170
171
  },
171
172
  {
@@ -175,7 +176,7 @@ export const CONFIG_KEYS = [
175
176
  visibility: 'machine',
176
177
  type: 'bool',
177
178
  defaultValue: false,
178
- description: "Whether other fleet machines may drive THIS device's browser over `browser --device <this-device>`. " +
179
+ description: "Whether other fleet machines may drive THIS device's browser over `browser --device <this-device>` or `agents ssh <this-device> …` (`agents browser` / `ag browser` / standalone `browser`). " +
179
180
  'Default off — a fleet-remote drive is refused until the owner runs `agents browser remote-control on`.',
180
181
  },
181
182
  {
@@ -812,9 +813,9 @@ export function isDaemonEnabled() {
812
813
  /**
813
814
  * Throw when the daemon is disabled on this machine, naming the setting and
814
815
  * the fix. Every AUTO-start surface (routines add/start/catchup/webhook,
815
- * `ensureDaemonStarted`) refuses with this before calling `startDaemon()`.
816
- * `agents daemon start` is the deliberate override and does NOT call this —
817
- * disable only blocks auto-start, mirroring `systemctl disable`.
816
+ * monitors add, `ensureDaemonStarted`) refuses with this before calling
817
+ * `startDaemon()`. `agents daemon start` is the deliberate override and does
818
+ * NOT call this — disable only blocks auto-start, mirroring `systemctl disable`.
818
819
  */
819
820
  export function assertDaemonEnabled() {
820
821
  if (isDaemonEnabled())
@@ -44,6 +44,7 @@ import { META_HEADER, getDevicesAutoLaunchPath, getDevicesIgnoredPath, getDevice
44
44
  import { atomicWriteFileSync } from '../fs-atomic.js';
45
45
  import { machineId } from '../machine-id.js';
46
46
  import { withIgnoredAdded } from './registry.js';
47
+ import { addIgnoredEntry } from './device-docs.js';
47
48
  /** Read every device doc. A doc that fails to parse is loudly skipped. */
48
49
  function readDeviceDocs(devicesRoot) {
49
50
  const docs = [];
@@ -176,6 +177,21 @@ export function migrateDeviceConfigStores() {
176
177
  const legacyIgnoredPath = getDevicesIgnoredPath();
177
178
  const legacyIgnoredPending = fs.existsSync(legacyIgnoredPath);
178
179
  const legacyIgnored = legacyIgnoredPending ? readLegacyIgnoredFile(legacyIgnoredPath) : null;
180
+ // Central shared fleet.discovery / fleet.ignored (PHNX-3315): the maps N boxes
181
+ // used to rewrite. Folded into THIS box's device doc below, then stripped.
182
+ const centralFleetState = !!(fleet &&
183
+ ((fleet.discovery && Object.keys(fleet.discovery).length > 0) ||
184
+ (Array.isArray(fleet.ignored) && fleet.ignored.length > 0)));
185
+ // Central shared `hosts:` map (PHNX-3315): the host registry N boxes used to
186
+ // rewrite. Folded into THIS box's device doc below, then the central key is
187
+ // dropped entirely.
188
+ const centralHosts = readMeta().hosts;
189
+ const centralHostsPending = !!(centralHosts && Object.keys(centralHosts).length > 0);
190
+ // Central device-scoped native accounts (PHNX-3315): a native login is
191
+ // machine-local, so `scope:'device'` identities (and the bindings that target
192
+ // them) belong in this box's device doc, off the git-tracked shared file.
193
+ const centralAccounts = readMeta().accounts;
194
+ const accountsPending = !!(centralAccounts?.native && Object.values(centralAccounts.native).some((a) => a.scope === 'device'));
179
195
  const pinsPath = getDevicePinsPath();
180
196
  let selfPins = {};
181
197
  try {
@@ -209,7 +225,7 @@ export function migrateDeviceConfigStores() {
209
225
  : undefined;
210
226
  const hasDestinationWork = plans.length > 0 || newDocs.length > 0 || docAgents !== undefined || docIsolated !== undefined;
211
227
  const autoLaunchPending = fs.existsSync(autoLaunchPath);
212
- if (!centralHasConfig && !hasDestinationWork && !autoLaunchPending && !legacyIgnoredPending)
228
+ if (!centralHasConfig && !hasDestinationWork && !autoLaunchPending && !legacyIgnoredPending && !centralFleetState && !centralHostsPending && !accountsPending)
213
229
  return;
214
230
  // ── 2. Destination writes FIRST (crash-safe), under the meta lock so they
215
231
  // serialize against writeMetaUnlocked's own read-merge-write of the doc.
@@ -302,6 +318,136 @@ export function migrateDeviceConfigStores() {
302
318
  console.error(`device config migration: could not remove ${legacyIgnoredPath} (${err.message}); a later run retries`);
303
319
  }
304
320
  }
321
+ // ── 5. Central fleet.discovery / fleet.ignored → THIS box's device doc ─────
322
+ // (PHNX-3315) The shared maps every box used to rewrite fold into this box's
323
+ // deviceFleet, then are stripped from central. writeMetaUnlocked writes the
324
+ // device doc BEFORE the central strip, so a crash re-folds on the next run and
325
+ // the union-dedup (ignored beats approved; newest ignoredAt wins) makes the
326
+ // re-fold a no-op. Idempotent — once central holds neither key the gather
327
+ // guard above skips this whole pass.
328
+ if (centralFleetState) {
329
+ updateMeta((m) => {
330
+ const disc = m.fleet?.discovery;
331
+ const ign = m.fleet?.ignored;
332
+ const hasDisc = !!disc && Object.keys(disc).length > 0;
333
+ const hasIgn = Array.isArray(ign) && ign.length > 0;
334
+ if (!hasDisc && !hasIgn)
335
+ return m;
336
+ const discovery = { ...m.deviceFleet?.discovery };
337
+ if (hasDisc) {
338
+ for (const [name, status] of Object.entries(disc)) {
339
+ if (status !== 'approved' && status !== 'ignored')
340
+ continue;
341
+ if (discovery[name] === 'ignored')
342
+ continue; // ignored is never downgraded
343
+ discovery[name] = status;
344
+ }
345
+ }
346
+ const byName = new Map();
347
+ for (const e of m.deviceFleet?.ignored ?? [])
348
+ addIgnoredEntry(byName, e);
349
+ if (hasIgn)
350
+ for (const e of ign)
351
+ addIgnoredEntry(byName, e);
352
+ const ignored = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
353
+ const deviceFleet = {
354
+ ...(Object.keys(discovery).length > 0 ? { discovery } : {}),
355
+ ...(ignored.length > 0 ? { ignored } : {}),
356
+ };
357
+ const fleet = m.fleet ? { ...m.fleet } : undefined;
358
+ if (fleet) {
359
+ delete fleet.discovery;
360
+ delete fleet.ignored;
361
+ }
362
+ // Drop an emptied fleet block entirely (mirrors step 3a's guard) so the
363
+ // strip does not leave a bare `fleet: { devices: {} }` behind.
364
+ const fleetEmpty = !fleet ||
365
+ ((fleet.devices === undefined ||
366
+ (fleet.devices !== 'all' && Object.keys(fleet.devices).length === 0)) &&
367
+ !fleet.defaults &&
368
+ !fleet.secrets &&
369
+ !fleet.routines);
370
+ if (fleetEmpty) {
371
+ const { fleet: _drop, ...rest } = m;
372
+ void _drop;
373
+ return { ...rest, deviceFleet };
374
+ }
375
+ return { ...m, deviceFleet, fleet };
376
+ });
377
+ }
378
+ // ── 6. Central hosts map → THIS box's device doc (PHNX-3315) ───────────────
379
+ // The shared host registry folds into this box's deviceHosts, then the central
380
+ // key is dropped. writeMetaUnlocked writes the device doc before the central
381
+ // strip, so a crash re-folds and the newest-addedAt union makes it a no-op.
382
+ // Idempotent — once central holds no `hosts:` the gather guard skips this.
383
+ if (centralHostsPending) {
384
+ updateMeta((m) => {
385
+ const hosts = m.hosts;
386
+ if (!hosts || Object.keys(hosts).length === 0)
387
+ return m;
388
+ // This box's own device-doc entries win over the shared legacy on a name
389
+ // collision (a re-fold after a crash must not clobber a fresher local edit).
390
+ const deviceHosts = { ...hosts, ...m.deviceHosts };
391
+ const { hosts: _drop, ...rest } = m;
392
+ void _drop;
393
+ return { ...rest, deviceHosts };
394
+ });
395
+ }
396
+ // ── 7. Central device-scoped native accounts → THIS box's device doc ───────
397
+ // (PHNX-3315) Fold `scope:'device'` natives and the bindings that target them
398
+ // out of central and into the device doc, removing their identity PII from the
399
+ // git-tracked shared file. The selection is recomputed INSIDE the lock so a
400
+ // concurrent write is never clobbered; the device-doc write precedes the
401
+ // central strip, so a crash re-folds and the id-keyed merge makes it a no-op.
402
+ if (accountsPending) {
403
+ updateMeta((m) => {
404
+ const native = { ...m.accounts?.native };
405
+ const bindings = { ...m.accounts?.bindings };
406
+ const devNative = { ...m.deviceAccounts?.native };
407
+ const devBindings = { ...m.deviceAccounts?.bindings };
408
+ const movedIds = new Set();
409
+ let changed = false;
410
+ for (const [id, entry] of Object.entries(native)) {
411
+ if (entry.scope === 'device') {
412
+ devNative[id] = entry;
413
+ movedIds.add(id);
414
+ delete native[id];
415
+ changed = true;
416
+ }
417
+ }
418
+ for (const [target, id] of Object.entries(bindings)) {
419
+ if (movedIds.has(id)) {
420
+ devBindings[target] = id;
421
+ delete bindings[target];
422
+ changed = true;
423
+ }
424
+ }
425
+ if (!changed)
426
+ return m;
427
+ const deviceAccounts = {
428
+ ...(Object.keys(devNative).length > 0 ? { native: devNative } : {}),
429
+ ...(Object.keys(devBindings).length > 0 ? { bindings: devBindings } : {}),
430
+ };
431
+ const accounts = { ...m.accounts };
432
+ if (Object.keys(native).length > 0)
433
+ accounts.native = native;
434
+ else
435
+ delete accounts.native;
436
+ if (Object.keys(bindings).length > 0)
437
+ accounts.bindings = bindings;
438
+ else
439
+ delete accounts.bindings;
440
+ // Drop the whole central `accounts` block only when nothing fleet-shared
441
+ // remains (no version natives, no bindings, no defaults).
442
+ const accountsEmpty = !accounts.native && !accounts.bindings && !accounts.defaults;
443
+ if (accountsEmpty) {
444
+ const { accounts: _drop, ...rest } = m;
445
+ void _drop;
446
+ return { ...rest, deviceAccounts };
447
+ }
448
+ return { ...m, accounts, deviceAccounts };
449
+ });
450
+ }
305
451
  }
306
452
  /** Shallow-set equality for folded doc content (values compared deep via JSON). */
307
453
  function sameDocContent(a, b) {
@@ -37,6 +37,26 @@ export declare function fleetDialTarget(device: DeviceProfile): string;
37
37
  * login).
38
38
  */
39
39
  export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
40
+ /**
41
+ * True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
42
+ * the standalone `browser` binary (`cli/package.json` `bin.browser` →
43
+ * `src/browser.ts`), including the quoted single-string form
44
+ * `agents ssh box 'browser …'`. The `--device` passthrough stamps
45
+ * {@link markFleetRemote} on this shape so the far-side consent gate can fire;
46
+ * `agents ssh` must too (PHNX-3065).
47
+ */
48
+ export declare function isAgentsBrowserDrive(cmd: string[]): boolean;
49
+ /**
50
+ * Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
51
+ * marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
52
+ * with spaces (POSIX) or base64-encodes it for PowerShell, so a
53
+ * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
54
+ * `$env:VAR='1'; …` on PowerShell.
55
+ *
56
+ * Already-marked argv (the `--device` fan-out stamps this before
57
+ * {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
58
+ */
59
+ export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>): string[];
40
60
  /**
41
61
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
42
62
  * mirrored project directory, falling back to the remote home when that
@@ -97,6 +117,12 @@ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
97
117
  * to the remote home), matching `agents run --device`. It is ignored when a `cmd`
98
118
  * is given: an explicit command keeps its current cwd (the remote home) and its
99
119
  * behavior unchanged (RUSH-2412).
120
+ *
121
+ * An `agents browser …` / `ag browser …` / standalone `browser …` command is
122
+ * prefixed with {@link markFleetRemote} so the far-side consent gate sees
123
+ * `AGENTS_FLEET_REMOTE=1` the same way `browser --device` does. The local `env`
124
+ * overlay here is for the ssh *client* (askpass); it cannot carry the marker —
125
+ * OpenSSH does not forward arbitrary env (PHNX-3065).
100
126
  */
101
127
  export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
102
128
  agentOnly?: boolean;
@@ -86,6 +86,43 @@ export function wrapRemoteCommand(device, cmd) {
86
86
  }
87
87
  return joined;
88
88
  }
89
+ /**
90
+ * True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
91
+ * the standalone `browser` binary (`cli/package.json` `bin.browser` →
92
+ * `src/browser.ts`), including the quoted single-string form
93
+ * `agents ssh box 'browser …'`. The `--device` passthrough stamps
94
+ * {@link markFleetRemote} on this shape so the far-side consent gate can fire;
95
+ * `agents ssh` must too (PHNX-3065).
96
+ */
97
+ export function isAgentsBrowserDrive(cmd) {
98
+ if (cmd.length === 0)
99
+ return false;
100
+ const tokens = cmd.length === 1 && /\s/.test(cmd[0]) ? cmd[0].trim().split(/\s+/) : cmd;
101
+ const bin = tokens[0];
102
+ if (bin === 'browser')
103
+ return true;
104
+ return (bin === 'agents' || bin === 'ag') && tokens[1] === 'browser';
105
+ }
106
+ /**
107
+ * Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
108
+ * marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
109
+ * with spaces (POSIX) or base64-encodes it for PowerShell, so a
110
+ * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
111
+ * `$env:VAR='1'; …` on PowerShell.
112
+ *
113
+ * Already-marked argv (the `--device` fan-out stamps this before
114
+ * {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
115
+ */
116
+ export function markFleetRemote(cmd, device) {
117
+ if (device.shell === 'powershell') {
118
+ return cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`
119
+ ? cmd
120
+ : [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd];
121
+ }
122
+ return cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1'
123
+ ? cmd
124
+ : ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
125
+ }
89
126
  /**
90
127
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
91
128
  * mirrored project directory, falling back to the remote home when that
@@ -156,6 +193,12 @@ export function deviceIdentityArgs(device) {
156
193
  * to the remote home), matching `agents run --device`. It is ignored when a `cmd`
157
194
  * is given: an explicit command keeps its current cwd (the remote home) and its
158
195
  * behavior unchanged (RUSH-2412).
196
+ *
197
+ * An `agents browser …` / `ag browser …` / standalone `browser …` command is
198
+ * prefixed with {@link markFleetRemote} so the far-side consent gate sees
199
+ * `AGENTS_FLEET_REMOTE=1` the same way `browser --device` does. The local `env`
200
+ * overlay here is for the ssh *client* (askpass); it cannot carry the marker —
201
+ * OpenSSH does not forward arbitrary env (PHNX-3065).
159
202
  */
160
203
  export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, opts = {}) {
161
204
  // The effective profile: central config (ssh.*/platform/user) overlaid on
@@ -166,9 +209,13 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
166
209
  // wrapper (interactiveCwd), which is an interactive login too and still needs
167
210
  // a real tty below.
168
211
  const interactive = cmd.length === 0;
212
+ // Stamp the consent marker on the REMOTE command, not the local ssh env:
213
+ // SSH_ASKPASS lives on this side; AGENTS_FLEET_REMOTE must be visible to the
214
+ // process that runs on the peer.
215
+ const remoteCmd = !interactive && isAgentsBrowserDrive(cmd) ? markFleetRemote(cmd, device) : cmd;
169
216
  const remote = interactive
170
217
  ? buildInteractiveShellCommand(device, opts.interactiveCwd)
171
- : wrapRemoteCommand(device, cmd);
218
+ : wrapRemoteCommand(device, remoteCmd);
172
219
  const env = {};
173
220
  const args = [
174
221
  ...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),
@@ -0,0 +1,35 @@
1
+ import type { HostEntry } from '../types.js';
2
+ import type { IgnoredDeviceEntry } from '../fleet/types.js';
3
+ /** One parsed device doc under `~/.agents/devices/`. */
4
+ export interface DeviceDoc {
5
+ device: string;
6
+ doc: Record<string, unknown>;
7
+ }
8
+ /**
9
+ * Every device doc, sorted by device name so the union is order-independent. A
10
+ * corrupt or non-map doc throws (loud), never silently skipped — a dropped doc
11
+ * would let the next writer clobber that peer's slice.
12
+ */
13
+ export declare function readAllDeviceDocs(): DeviceDoc[];
14
+ /**
15
+ * Union the per-box `fleet.discovery` maps. Precedence for a name declared by
16
+ * more than one box is deterministic and order-independent: `ignored` beats
17
+ * `approved` (a dismissal on any box wins), so every box computes the identical
18
+ * effective policy regardless of walk order.
19
+ */
20
+ export declare function unionDeviceDiscovery(docs?: DeviceDoc[]): Record<string, 'approved' | 'ignored'>;
21
+ /**
22
+ * Union the per-box `fleet.ignored` dismissal lists by node name. When two boxes
23
+ * dismissed the same node, the entry with the newest `ignoredAt` wins (ties
24
+ * broken by `ignoredOn`), so the attribution is deterministic and
25
+ * order-independent. Sorted by name to match the central writer's ordering.
26
+ */
27
+ export declare function unionDeviceIgnored(docs?: DeviceDoc[]): IgnoredDeviceEntry[];
28
+ /** Insert/replace a dismissal, newest ignoredAt (then ignoredOn) winning. */
29
+ export declare function addIgnoredEntry(byName: Map<string, IgnoredDeviceEntry>, entry: IgnoredDeviceEntry): void;
30
+ /**
31
+ * Union the per-box `hosts:` registries by host name. When two boxes registered
32
+ * the same name, the newest `addedAt` wins (ties broken by device name), so the
33
+ * merged directory is deterministic. Entries missing `addedAt` sort oldest.
34
+ */
35
+ export declare function unionDeviceHosts(docs?: DeviceDoc[]): Record<string, HostEntry>;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Union-on-read across every tracked device document (PHNX-3315).
3
+ *
4
+ * Phase 2 device-scopes three writers that used to rewrite one fleet-shared
5
+ * block in the central `agents.yaml` (fleet discovery/dismissals, the host
6
+ * registry, and device-scoped accounts). Each box now records only its OWN
7
+ * decisions in `~/.agents/devices/<machine>/agents.yaml`, so pulls never
8
+ * conflict — and the effective fleet view is recomputed here as a deterministic,
9
+ * order-independent UNION across every device doc, exactly like the browser
10
+ * profile registry (`lib/browser/registry.ts` `profileRegistry`).
11
+ *
12
+ * These readers walk the device docs ONLY. The central-legacy values (present
13
+ * until the fold-then-delete migration drains them) are merged in by each
14
+ * caller, so a value mid-migration is never lost. A doc that fails to parse is a
15
+ * hard error — silently returning an empty union would let a later write wipe a
16
+ * peer's decision, the same corruption contract `routine-activation.ts` and the
17
+ * device registry keep.
18
+ */
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import * as yaml from 'yaml';
22
+ import { getUserAgentsDir } from '../state.js';
23
+ function isRecord(value) {
24
+ return !!value && typeof value === 'object' && !Array.isArray(value);
25
+ }
26
+ /**
27
+ * Every device doc, sorted by device name so the union is order-independent. A
28
+ * corrupt or non-map doc throws (loud), never silently skipped — a dropped doc
29
+ * would let the next writer clobber that peer's slice.
30
+ */
31
+ export function readAllDeviceDocs() {
32
+ const devicesDir = path.join(getUserAgentsDir(), 'devices');
33
+ if (!fs.existsSync(devicesDir))
34
+ return [];
35
+ const out = [];
36
+ const names = fs
37
+ .readdirSync(devicesDir, { withFileTypes: true })
38
+ .filter((entry) => entry.isDirectory())
39
+ .map((entry) => entry.name)
40
+ .sort();
41
+ for (const device of names) {
42
+ const file = path.join(devicesDir, device, 'agents.yaml');
43
+ if (!fs.existsSync(file))
44
+ continue;
45
+ let parsed;
46
+ try {
47
+ parsed = yaml.parse(fs.readFileSync(file, 'utf8'));
48
+ }
49
+ catch (err) {
50
+ throw new Error(`Device config corrupted at ${file}: ${err.message}. Inspect and restore from backup.`);
51
+ }
52
+ if (parsed == null)
53
+ continue;
54
+ if (!isRecord(parsed)) {
55
+ throw new Error(`Device config corrupted at ${file}: document root must be a map.`);
56
+ }
57
+ out.push({ device, doc: parsed });
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Union the per-box `fleet.discovery` maps. Precedence for a name declared by
63
+ * more than one box is deterministic and order-independent: `ignored` beats
64
+ * `approved` (a dismissal on any box wins), so every box computes the identical
65
+ * effective policy regardless of walk order.
66
+ */
67
+ export function unionDeviceDiscovery(docs = readAllDeviceDocs()) {
68
+ const out = {};
69
+ for (const { device, doc } of docs) {
70
+ const fleet = doc.fleet;
71
+ if (fleet === undefined)
72
+ continue;
73
+ if (!isRecord(fleet))
74
+ throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet must be a map.`);
75
+ const discovery = fleet.discovery;
76
+ if (discovery === undefined)
77
+ continue;
78
+ if (!isRecord(discovery))
79
+ throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet.discovery must be a map.`);
80
+ for (const [name, status] of Object.entries(discovery)) {
81
+ if (status !== 'approved' && status !== 'ignored') {
82
+ throw new Error(`Device discovery policy for '${name}' in devices/${device}/agents.yaml must be approved or ignored.`);
83
+ }
84
+ // ignored beats approved; once ignored, never downgraded by another box.
85
+ if (out[name] === 'ignored')
86
+ continue;
87
+ out[name] = status;
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ /**
93
+ * Union the per-box `fleet.ignored` dismissal lists by node name. When two boxes
94
+ * dismissed the same node, the entry with the newest `ignoredAt` wins (ties
95
+ * broken by `ignoredOn`), so the attribution is deterministic and
96
+ * order-independent. Sorted by name to match the central writer's ordering.
97
+ */
98
+ export function unionDeviceIgnored(docs = readAllDeviceDocs()) {
99
+ const byName = new Map();
100
+ for (const { device, doc } of docs) {
101
+ const fleet = doc.fleet;
102
+ if (fleet === undefined)
103
+ continue;
104
+ if (!isRecord(fleet))
105
+ throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet must be a map.`);
106
+ const ignored = fleet.ignored;
107
+ if (ignored === undefined)
108
+ continue;
109
+ if (!Array.isArray(ignored))
110
+ throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet.ignored must be a list.`);
111
+ for (const raw of ignored) {
112
+ if (!raw || typeof raw.name !== 'string' || typeof raw.ignoredAt !== 'string' || typeof raw.ignoredOn !== 'string') {
113
+ throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet.ignored entries must be { name, ignoredAt, ignoredOn }.`);
114
+ }
115
+ addIgnoredEntry(byName, raw);
116
+ }
117
+ }
118
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
119
+ }
120
+ /** Insert/replace a dismissal, newest ignoredAt (then ignoredOn) winning. */
121
+ export function addIgnoredEntry(byName, entry) {
122
+ const prev = byName.get(entry.name);
123
+ if (!prev) {
124
+ byName.set(entry.name, entry);
125
+ return;
126
+ }
127
+ const at = Date.parse(entry.ignoredAt);
128
+ const prevAt = Date.parse(prev.ignoredAt);
129
+ if (at > prevAt || (at === prevAt && entry.ignoredOn.localeCompare(prev.ignoredOn) > 0)) {
130
+ byName.set(entry.name, entry);
131
+ }
132
+ }
133
+ /**
134
+ * Union the per-box `hosts:` registries by host name. When two boxes registered
135
+ * the same name, the newest `addedAt` wins (ties broken by device name), so the
136
+ * merged directory is deterministic. Entries missing `addedAt` sort oldest.
137
+ */
138
+ export function unionDeviceHosts(docs = readAllDeviceDocs()) {
139
+ const out = {};
140
+ const wonAt = new Map();
141
+ for (const { device, doc } of docs) {
142
+ const hosts = doc.hosts;
143
+ if (hosts === undefined)
144
+ continue;
145
+ if (!isRecord(hosts))
146
+ throw new Error(`Device config corrupted at devices/${device}/agents.yaml: hosts must be a map.`);
147
+ for (const [name, entry] of Object.entries(hosts)) {
148
+ if (!isRecord(entry))
149
+ throw new Error(`Device config corrupted at devices/${device}/agents.yaml: host '${name}' must be a map.`);
150
+ const at = typeof entry.addedAt === 'string' ? Date.parse(entry.addedAt) : 0;
151
+ const prev = wonAt.get(name);
152
+ if (prev === undefined || at > prev) {
153
+ out[name] = entry;
154
+ wonAt.set(name, Number.isNaN(at) ? 0 : at);
155
+ }
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+ // Native accounts and their bindings are machine-local (a native login lives in
161
+ // the harness home on ONE box), so account-registry.ts reads THIS box's own
162
+ // slice (meta.deviceAccounts) merged with the fleet-shared central store rather
163
+ // than a cross-box union — there is deliberately no unionDeviceAccounts here.
@@ -7,9 +7,21 @@ export interface DeviceDiscoveryReconcileResult {
7
7
  }
8
8
  /** Read one portable decision. Absence means pending. */
9
9
  export declare function getDeviceDiscoveryStatus(name: string): DeviceDiscoveryStatus | undefined;
10
- /** Persist one portable decision in the central fleet manifest. */
10
+ /**
11
+ * Persist ONE discovery decision in THIS box's device doc (PHNX-3315). Each box
12
+ * records only its own choices in `devices/<machine>/agents.yaml` `fleet.discovery`,
13
+ * so N boxes no longer rewrite one shared central map (the guaranteed pull
14
+ * conflict). The effective policy is the union across every box
15
+ * ({@link loadDeviceDiscoveryPolicies}).
16
+ */
11
17
  export declare function setDeviceDiscoveryStatus(name: string, status: DeviceDiscoveryStatus | undefined): void;
12
- /** Load every explicit decision from the synced central fleet manifest. */
18
+ /**
19
+ * The effective discovery policy: the UNION across every box's device doc, plus
20
+ * any lingering central-legacy map (drained by the fold-then-delete migration).
21
+ * Precedence for a name declared by more than one box is deterministic and
22
+ * order-independent — `ignored` beats `approved` — so every box computes the
23
+ * identical policy. Absence means pending.
24
+ */
13
25
  export declare function loadDeviceDiscoveryPolicies(): Map<string, DeviceDiscoveryStatus>;
14
26
  /**
15
27
  * Apply synced intent to this machine's local registry. Approval resolves live
@@ -1,5 +1,6 @@
1
1
  /** Synced device approval/ignore policy and local registry reconciliation. */
2
2
  import { readMeta, updateMeta } from '../state.js';
3
+ import { unionDeviceDiscovery } from './device-docs.js';
3
4
  import { addIgnored, assertValidDeviceName, loadDevices, removeDevice, removeIgnored, upsertDevice, } from './registry.js';
4
5
  import { localLoginUser, withDefaultUser } from './sync.js';
5
6
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson } from './tailscale.js';
@@ -8,36 +9,46 @@ export function getDeviceDiscoveryStatus(name) {
8
9
  assertValidDeviceName(name);
9
10
  return loadDeviceDiscoveryPolicies().get(name);
10
11
  }
11
- /** Persist one portable decision in the central fleet manifest. */
12
+ /**
13
+ * Persist ONE discovery decision in THIS box's device doc (PHNX-3315). Each box
14
+ * records only its own choices in `devices/<machine>/agents.yaml` `fleet.discovery`,
15
+ * so N boxes no longer rewrite one shared central map (the guaranteed pull
16
+ * conflict). The effective policy is the union across every box
17
+ * ({@link loadDeviceDiscoveryPolicies}).
18
+ */
12
19
  export function setDeviceDiscoveryStatus(name, status) {
13
20
  assertValidDeviceName(name);
14
21
  updateMeta((meta) => {
15
- const discovery = { ...meta.fleet?.discovery };
22
+ const discovery = { ...meta.deviceFleet?.discovery };
16
23
  if (status)
17
24
  discovery[name] = status;
18
25
  else
19
26
  delete discovery[name];
20
- const fleet = {
21
- ...meta.fleet,
22
- devices: meta.fleet?.devices ?? {},
23
- // Keep an empty map as the synced tombstone: it distinguishes "policy is
24
- // authoritative and every device is pending" from an older config that
25
- // has never opted into portable discovery decisions.
26
- discovery,
27
- };
28
- return { ...meta, fleet };
27
+ return { ...meta, deviceFleet: { ...meta.deviceFleet, discovery } };
29
28
  });
30
29
  }
31
- /** Load every explicit decision from the synced central fleet manifest. */
30
+ /**
31
+ * The effective discovery policy: the UNION across every box's device doc, plus
32
+ * any lingering central-legacy map (drained by the fold-then-delete migration).
33
+ * Precedence for a name declared by more than one box is deterministic and
34
+ * order-independent — `ignored` beats `approved` — so every box computes the
35
+ * identical policy. Absence means pending.
36
+ */
32
37
  export function loadDeviceDiscoveryPolicies() {
33
38
  const policies = new Map();
34
- for (const [name, status] of Object.entries(readMeta().fleet?.discovery ?? {})) {
35
- assertValidDeviceName(name);
36
- if (status !== 'approved' && status !== 'ignored') {
37
- throw new Error(`Device discovery policy for '${name}' must be approved or ignored.`);
39
+ const apply = (rec) => {
40
+ for (const [name, status] of Object.entries(rec ?? {})) {
41
+ assertValidDeviceName(name);
42
+ if (status !== 'approved' && status !== 'ignored') {
43
+ throw new Error(`Device discovery policy for '${name}' must be approved or ignored.`);
44
+ }
45
+ if (policies.get(name) === 'ignored')
46
+ continue; // ignored is never downgraded
47
+ policies.set(name, status);
38
48
  }
39
- policies.set(name, status);
40
- }
49
+ };
50
+ apply(readMeta().fleet?.discovery); // central legacy, until the migration drains it
51
+ apply(unionDeviceDiscovery()); // per-box device docs (ignored still wins)
41
52
  return policies;
42
53
  }
43
54
  /**
@@ -55,11 +66,10 @@ export function loadDeviceDiscoveryPolicies() {
55
66
  * that never meant to touch them.
56
67
  */
57
68
  export async function reconcileDeviceDiscoveryPolicies() {
58
- const configured = readMeta().fleet?.discovery;
59
- if (configured === undefined) {
69
+ const policies = loadDeviceDiscoveryPolicies();
70
+ if (policies.size === 0) {
60
71
  return { approved: [], ignored: [], registered: [], unresolved: [] };
61
72
  }
62
- const policies = loadDeviceDiscoveryPolicies();
63
73
  const approved = [...policies].filter(([, s]) => s === 'approved').map(([n]) => n).sort();
64
74
  const ignored = [...policies].filter(([, s]) => s === 'ignored').map(([n]) => n).sort();
65
75
  for (const name of ignored) {