@phnx-labs/agents-cli 1.22.51 → 1.22.53

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 (117) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/attach.js +7 -0
  5. package/dist/commands/browser.js +118 -56
  6. package/dist/commands/daemon.d.ts +2 -0
  7. package/dist/commands/daemon.js +8 -4
  8. package/dist/commands/detach.js +1 -1
  9. package/dist/commands/exec.js +16 -9
  10. package/dist/commands/fleet-capture.js +7 -0
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +16 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +65 -6
  15. package/dist/commands/monitors.js +1 -1
  16. package/dist/commands/repo.js +31 -3
  17. package/dist/commands/sessions-inject.js +8 -3
  18. package/dist/commands/sessions-picker.js +2 -1
  19. package/dist/commands/sessions-resume.d.ts +1 -0
  20. package/dist/commands/sessions-resume.js +13 -2
  21. package/dist/commands/sessions-stop.js +1 -1
  22. package/dist/commands/sessions.d.ts +23 -13
  23. package/dist/commands/sessions.js +69 -39
  24. package/dist/commands/setup-browser.d.ts +5 -2
  25. package/dist/commands/setup-browser.js +14 -29
  26. package/dist/commands/setup-preferences.d.ts +22 -3
  27. package/dist/commands/setup-preferences.js +25 -8
  28. package/dist/commands/share.js +12 -8
  29. package/dist/commands/ssh.js +35 -12
  30. package/dist/commands/status.js +5 -0
  31. package/dist/commands/sync.js +102 -2
  32. package/dist/commands/tmux.d.ts +8 -1
  33. package/dist/commands/tmux.js +167 -17
  34. package/dist/lib/account-registry.d.ts +15 -5
  35. package/dist/lib/account-registry.js +150 -50
  36. package/dist/lib/answer-router.js +2 -1
  37. package/dist/lib/browser/ipc.d.ts +44 -0
  38. package/dist/lib/browser/ipc.js +120 -8
  39. package/dist/lib/browser/profiles.d.ts +57 -17
  40. package/dist/lib/browser/profiles.js +77 -53
  41. package/dist/lib/browser/registry.d.ts +44 -14
  42. package/dist/lib/browser/registry.js +141 -45
  43. package/dist/lib/browser/runtime-state.d.ts +4 -2
  44. package/dist/lib/browser/runtime-state.js +4 -2
  45. package/dist/lib/browser/service.js +4 -3
  46. package/dist/lib/channels/owner-forward.d.ts +88 -0
  47. package/dist/lib/channels/owner-forward.js +116 -0
  48. package/dist/lib/channels/owner-sink.js +7 -0
  49. package/dist/lib/daemon/runner.js +10 -2
  50. package/dist/lib/device-config.js +3 -2
  51. package/dist/lib/devices/config-migration.js +147 -1
  52. package/dist/lib/devices/device-docs.d.ts +35 -0
  53. package/dist/lib/devices/device-docs.js +163 -0
  54. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  55. package/dist/lib/devices/discovery-policy.js +31 -21
  56. package/dist/lib/devices/registry.d.ts +11 -5
  57. package/dist/lib/devices/registry.js +46 -18
  58. package/dist/lib/exec.d.ts +66 -28
  59. package/dist/lib/exec.js +71 -26
  60. package/dist/lib/feed/feed.d.ts +10 -2
  61. package/dist/lib/feed/feed.js +12 -1
  62. package/dist/lib/feed-broadcast.js +15 -1
  63. package/dist/lib/git.d.ts +93 -0
  64. package/dist/lib/git.js +232 -0
  65. package/dist/lib/hosts/dispatch.d.ts +4 -3
  66. package/dist/lib/hosts/dispatch.js +12 -8
  67. package/dist/lib/hosts/providers/local.d.ts +9 -3
  68. package/dist/lib/hosts/providers/local.js +23 -12
  69. package/dist/lib/hosts/reconnect.d.ts +7 -4
  70. package/dist/lib/hosts/reconnect.js +29 -25
  71. package/dist/lib/hosts/registry.js +4 -1
  72. package/dist/lib/hosts/remote-os.js +3 -1
  73. package/dist/lib/monitors/remote.d.ts +18 -1
  74. package/dist/lib/monitors/remote.js +15 -2
  75. package/dist/lib/notify.d.ts +7 -0
  76. package/dist/lib/notify.js +15 -1
  77. package/dist/lib/session/active.d.ts +10 -1
  78. package/dist/lib/session/active.js +7 -1
  79. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  80. package/dist/lib/session/actor-sidecar.js +2 -0
  81. package/dist/lib/session/db.d.ts +1 -1
  82. package/dist/lib/session/db.js +39 -3
  83. package/dist/lib/session/discover.js +7 -12
  84. package/dist/lib/session/live-metadata.js +1 -0
  85. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  86. package/dist/lib/session/local-tmux-attach.js +164 -0
  87. package/dist/lib/session/pid-registry.d.ts +7 -0
  88. package/dist/lib/session/prompt.d.ts +15 -0
  89. package/dist/lib/session/prompt.js +21 -0
  90. package/dist/lib/session/remote-active.d.ts +8 -0
  91. package/dist/lib/session/remote-active.js +1 -0
  92. package/dist/lib/session/types.d.ts +17 -0
  93. package/dist/lib/session/types.js +10 -0
  94. package/dist/lib/share/publish.d.ts +8 -11
  95. package/dist/lib/share/publish.js +16 -20
  96. package/dist/lib/share/worker-template.js +104 -12
  97. package/dist/lib/state.d.ts +8 -0
  98. package/dist/lib/state.js +143 -11
  99. package/dist/lib/sync-status.d.ts +17 -0
  100. package/dist/lib/sync-status.js +21 -2
  101. package/dist/lib/terminal/resolve.d.ts +7 -0
  102. package/dist/lib/terminal/resolve.js +41 -2
  103. package/dist/lib/tmux/index.d.ts +1 -1
  104. package/dist/lib/tmux/index.js +1 -1
  105. package/dist/lib/tmux/session.d.ts +10 -0
  106. package/dist/lib/tmux/session.js +29 -0
  107. package/dist/lib/traces/insights.d.ts +67 -0
  108. package/dist/lib/traces/insights.js +178 -0
  109. package/dist/lib/traces/phenotype.d.ts +67 -0
  110. package/dist/lib/traces/phenotype.js +437 -0
  111. package/dist/lib/traces/segments.d.ts +133 -0
  112. package/dist/lib/traces/segments.js +301 -0
  113. package/dist/lib/traces/sync.d.ts +33 -0
  114. package/dist/lib/traces/sync.js +11 -2
  115. package/dist/lib/types.d.ts +47 -1
  116. package/dist/lib/watchdog/runner.js +18 -4
  117. package/package.json +1 -1
@@ -0,0 +1,116 @@
1
+ import { loadDevices, isDialableDevice } from '../devices/registry.js';
2
+ import { machineId, normalizeHost } from '../machine-id.js';
3
+ import { RUSH_CHANNELS } from './providers/rush.js';
4
+ import { resolvePeerTarget, sshCapture } from '../session/remote/remote-list.js';
5
+ import { buildRemoteAgentsInvocation, stripClixml } from '../hosts/remote-cmd.js';
6
+ /**
7
+ * Env marker set on the forwarded `agents send` so a box that received a
8
+ * forwarded owner notify never forwards it onward. `agents send` does not route
9
+ * through this module today, so this is defense-in-depth against a future
10
+ * consumer wiring forwarding into the send path and creating a fan-out loop.
11
+ */
12
+ export const OWNER_FORWARD_GUARD_ENV = 'AGENTS_OWNER_NO_FORWARD';
13
+ /** Per-peer SSH deadline for a one-shot owner delivery. */
14
+ const PEER_SEND_TIMEOUT_MS = 15_000;
15
+ /**
16
+ * True when the resolved owner transport is the macOS-only rush family — the
17
+ * one case a Linux/headless box structurally cannot deliver and a peer can.
18
+ * `openclaw-telegram` and the local `desktop`/`mailbox` providers are NOT
19
+ * rush-backed, so a failure there is not a wrong-OS problem and is left as-is.
20
+ * Mirrors the same `RUSH_CHANNELS.includes(transport)` gate in owner-sink.ts.
21
+ */
22
+ export function isRushBackedTransport(channel, meta) {
23
+ const transport = meta.notify?.transports?.[channel] ?? channel;
24
+ return RUSH_CHANNELS.includes(transport);
25
+ }
26
+ /**
27
+ * Decide which peers can deliver the owner notification, in try order. Pure —
28
+ * no I/O — so the channel gate, the recursion guard, self-exclusion, and the
29
+ * capability/ordering rules are unit-testable without a live tailnet.
30
+ *
31
+ * Only macOS peers are candidates: the rush owner transport is macOS-only, so a
32
+ * Linux/Windows peer could not deliver it either. The configured
33
+ * `interactive.host` (the box the operator sits at, where rush is signed in) is
34
+ * tried first when it is among the candidates.
35
+ */
36
+ export function planOwnerForward(channel, meta, devices, self, opts = {}) {
37
+ if (opts.guarded)
38
+ return { candidates: [], skip: 'guarded' };
39
+ if (!isRushBackedTransport(channel, meta))
40
+ return { candidates: [], skip: 'not-rush-backed' };
41
+ const selfId = normalizeHost(self);
42
+ const capable = devices.filter((d) => d.platform === 'macos' && isDialableDevice(d) && normalizeHost(d.name) !== selfId);
43
+ const interactiveHost = typeof meta.config?.interactiveHost === 'string'
44
+ ? normalizeHost(meta.config.interactiveHost)
45
+ : undefined;
46
+ const rank = (name) => (interactiveHost && normalizeHost(name) === interactiveHost ? 0 : 1);
47
+ const candidates = capable
48
+ .map((d) => normalizeHost(d.name))
49
+ .sort((a, b) => rank(a) - rank(b));
50
+ if (candidates.length === 0)
51
+ return { candidates: [], skip: 'no-capable-peer' };
52
+ return { candidates };
53
+ }
54
+ async function sendOnPeer(machine, text) {
55
+ const peer = await resolvePeerTarget(machine);
56
+ if (!peer)
57
+ return undefined;
58
+ const args = ['send', '--to', 'owner', '--text', text, '--json'];
59
+ // Reuse the one injection-tested remote-command builder every `--device`
60
+ // dispatch uses (posix `bash -lc` / Windows `-EncodedCommand`), rather than a
61
+ // second hand-rolled quoting path on a security-sensitive seam. The env map is
62
+ // the loop guard, exported the same way every remote invocation exports env.
63
+ const remoteCmd = buildRemoteAgentsInvocation(args, undefined, peer.os, { [OWNER_FORWARD_GUARD_ENV]: '1' });
64
+ const capture = await sshCapture(peer.target, remoteCmd, PEER_SEND_TIMEOUT_MS);
65
+ if (capture.code !== 0)
66
+ return undefined;
67
+ try {
68
+ const parsed = JSON.parse(stripClixml(capture.stdout));
69
+ if (parsed && typeof parsed === 'object' && typeof parsed.ok === 'boolean')
70
+ return parsed;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ return undefined;
76
+ }
77
+ /**
78
+ * Try each capable peer in order and return the first successful delivery. A
79
+ * peer that is unreachable or reports its own delivery failure is skipped and
80
+ * the next is tried; the first `ok:true` wins and stops the sweep so the owner's
81
+ * phone rings once. Resolves `undefined` when forwarding does not apply or no
82
+ * peer delivered — the caller then keeps its original local error.
83
+ *
84
+ * The transport (`send`) is injectable so the try-order / first-success / stop
85
+ * orchestration is testable without a live SSH host; the default runs the real
86
+ * `agents send --to owner` over SSH.
87
+ */
88
+ export async function forwardOwnerNotifyToPeer(text, channel, meta, opts = {}) {
89
+ // Cheap, I/O-free gate first: a box that already received a forward, or an
90
+ // owner channel that isn't the macOS-only rush family, can never forward — so
91
+ // a normal local success/failure never pays a device-registry disk read.
92
+ if (process.env[OWNER_FORWARD_GUARD_ENV] === '1')
93
+ return undefined;
94
+ if (!isRushBackedTransport(channel, meta))
95
+ return undefined;
96
+ const self = opts.self ?? machineId();
97
+ let devices = opts.devices;
98
+ if (!devices) {
99
+ try {
100
+ devices = Object.values(await loadDevices());
101
+ }
102
+ catch {
103
+ return undefined; // no registry, nothing to forward to
104
+ }
105
+ }
106
+ const plan = planOwnerForward(channel, meta, devices, self);
107
+ if (plan.candidates.length === 0)
108
+ return undefined;
109
+ const send = opts.send ?? sendOnPeer;
110
+ for (const machine of plan.candidates) {
111
+ const result = await send(machine, text);
112
+ if (result?.ok)
113
+ return result;
114
+ }
115
+ return undefined;
116
+ }
@@ -11,6 +11,13 @@
11
11
  * after-the-fact `owner failed: …` line. `agents doctor` had no signal for it,
12
12
  * which is exactly the gap RUSH-2258 / RUSH-2262 flagged.
13
13
  *
14
+ * At runtime the owner-delivery lane no longer strands this failure: when local
15
+ * delivery fails on a box that structurally cannot reach the owner,
16
+ * `forwardOwnerNotifyToPeer` (owner-forward.ts) hands it to a capable macOS peer
17
+ * over SSH (PHNX-3303). This finding still stands as a diagnostic — the forward
18
+ * is best-effort and only lands when a reachable mac peer exists, so a box that
19
+ * cannot deliver locally is worth surfacing regardless.
20
+ *
14
21
  * This probes the SAME transport the lane uses, from the SAME context doctor runs
15
22
  * in, so `agents doctor` can fail loud when this box cannot reach the owner. It is
16
23
  * deliberately honest about context: `rush whoami` is what tells a real signed-in
@@ -1313,10 +1313,17 @@ async function executeJobPlaced(config, deps, attempt) {
1313
1313
  // Workflows run via `agents run <workflow>` which delegates to claude under the hood.
1314
1314
  // Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
1315
1315
  // (command jobs branched out earlier, so config.agent is set on the non-workflow path.)
1316
+ // Custom harness/profile name (e.g. `deepseek`) stays on harnessName; the
1317
+ // HOST CLI is effectiveAgent. The loop path spawns in-process via runLoop
1318
+ // and never re-enters `agents run`, so it must stamp harnessName itself
1319
+ // (PHNX-2935) — same field commands/exec.ts sets on ExecOptions.
1320
+ const harnessName = !config.workflow && config.agent && isCustomHarnessName(config.agent)
1321
+ ? config.agent
1322
+ : undefined;
1316
1323
  const effectiveAgent = config.workflow
1317
1324
  ? 'claude'
1318
- : isCustomHarnessName(config.agent)
1319
- ? readProfile(config.agent).host.agent
1325
+ : harnessName
1326
+ ? readProfile(harnessName).host.agent
1320
1327
  : config.agent;
1321
1328
  if (!dispatchesViaAgentsRun(config)) {
1322
1329
  const { findUnifiedAccount, resolveAccountSelection, resolveCredentialAccount } = await import('../account-registry.js');
@@ -1377,6 +1384,7 @@ async function executeJobPlaced(config, deps, attempt) {
1377
1384
  const spawnEnv = buildRoutineSpawnEnv(baseEnv, effectiveAgent, primaryVersion, config.timezone, overlayHome);
1378
1385
  const execOptions = {
1379
1386
  agent: effectiveAgent,
1387
+ harnessName,
1380
1388
  // Routine-supported self-updating CLIs (Cursor/Droid) use one global
1381
1389
  // binary; a versioned shim would point at a nonexistent isolated install.
1382
1390
  version: isSelfUpdatingAgent(effectiveAgent) ? undefined : primaryVersion,
@@ -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
  {
@@ -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) {
@@ -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) {