@phnx-labs/agents-cli 1.22.52 → 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 (69) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/exec.js +16 -9
  5. package/dist/commands/fleet-capture.js +7 -0
  6. package/dist/commands/focus.js +2 -0
  7. package/dist/commands/go.js +2 -1
  8. package/dist/commands/sessions-inject.js +8 -3
  9. package/dist/commands/sessions-picker.js +2 -1
  10. package/dist/commands/sessions.js +30 -20
  11. package/dist/commands/ssh.js +35 -12
  12. package/dist/commands/sync.js +44 -0
  13. package/dist/lib/account-registry.d.ts +15 -5
  14. package/dist/lib/account-registry.js +150 -50
  15. package/dist/lib/answer-router.js +2 -1
  16. package/dist/lib/browser/profiles.d.ts +18 -0
  17. package/dist/lib/browser/profiles.js +26 -1
  18. package/dist/lib/browser/registry.d.ts +44 -14
  19. package/dist/lib/browser/registry.js +141 -45
  20. package/dist/lib/daemon/runner.js +10 -2
  21. package/dist/lib/device-config.js +3 -2
  22. package/dist/lib/devices/config-migration.js +147 -1
  23. package/dist/lib/devices/device-docs.d.ts +35 -0
  24. package/dist/lib/devices/device-docs.js +163 -0
  25. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  26. package/dist/lib/devices/discovery-policy.js +31 -21
  27. package/dist/lib/devices/registry.d.ts +11 -5
  28. package/dist/lib/devices/registry.js +46 -18
  29. package/dist/lib/exec.d.ts +60 -30
  30. package/dist/lib/exec.js +65 -27
  31. package/dist/lib/feed/feed.d.ts +10 -2
  32. package/dist/lib/feed/feed.js +12 -1
  33. package/dist/lib/hosts/dispatch.d.ts +4 -3
  34. package/dist/lib/hosts/dispatch.js +12 -8
  35. package/dist/lib/hosts/providers/local.d.ts +9 -3
  36. package/dist/lib/hosts/providers/local.js +23 -12
  37. package/dist/lib/hosts/reconnect.d.ts +7 -4
  38. package/dist/lib/hosts/reconnect.js +29 -25
  39. package/dist/lib/hosts/registry.js +4 -1
  40. package/dist/lib/hosts/remote-os.js +3 -1
  41. package/dist/lib/session/active.d.ts +10 -1
  42. package/dist/lib/session/active.js +7 -1
  43. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  44. package/dist/lib/session/actor-sidecar.js +2 -0
  45. package/dist/lib/session/db.d.ts +1 -1
  46. package/dist/lib/session/db.js +39 -3
  47. package/dist/lib/session/discover.js +7 -12
  48. package/dist/lib/session/live-metadata.js +1 -0
  49. package/dist/lib/session/pid-registry.d.ts +7 -0
  50. package/dist/lib/session/prompt.d.ts +15 -0
  51. package/dist/lib/session/prompt.js +21 -0
  52. package/dist/lib/session/types.d.ts +17 -0
  53. package/dist/lib/session/types.js +10 -0
  54. package/dist/lib/share/worker-template.js +12 -7
  55. package/dist/lib/state.d.ts +8 -0
  56. package/dist/lib/state.js +143 -11
  57. package/dist/lib/terminal/resolve.d.ts +7 -0
  58. package/dist/lib/terminal/resolve.js +41 -2
  59. package/dist/lib/traces/insights.d.ts +67 -0
  60. package/dist/lib/traces/insights.js +178 -0
  61. package/dist/lib/traces/phenotype.d.ts +67 -0
  62. package/dist/lib/traces/phenotype.js +437 -0
  63. package/dist/lib/traces/segments.d.ts +133 -0
  64. package/dist/lib/traces/segments.js +301 -0
  65. package/dist/lib/traces/sync.d.ts +33 -0
  66. package/dist/lib/traces/sync.js +11 -2
  67. package/dist/lib/types.d.ts +47 -1
  68. package/dist/lib/watchdog/runner.js +18 -4
  69. package/package.json +1 -1
@@ -2,34 +2,108 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import * as yaml from 'yaml';
5
- import { getUserAgentsDir, readMeta, updateMeta } from '../state.js';
5
+ import { getUserAgentsDir, readMeta, updateMeta, withMetaLock, writeMetaUnlocked } from '../state.js';
6
6
  function isRecord(value) {
7
7
  return !!value && typeof value === 'object' && !Array.isArray(value);
8
8
  }
9
- /** Leftover central `browser:` map from before per-device declarations. Empty when none remain. */
9
+ /**
10
+ * The leftover central `browser:` tombstone from before per-device declarations.
11
+ * Empty when none remain — and `agents sync` now drains it automatically via
12
+ * {@link autoEvictCentralBrowserProfiles}, so on a synced box this is empty and
13
+ * {@link profileRegistry} is the single source of truth. It survives only to
14
+ * fold in migration and to explain an as-yet-unclaimed profile in a resolve
15
+ * error (a profile hostable on a peer that has not synced yet), never as a
16
+ * parallel store callers read for resolution.
17
+ */
10
18
  export function centralBrowserProfiles() {
11
19
  const central = readMeta().browser;
12
20
  if (!central || Object.keys(central).length === 0)
13
21
  return {};
14
22
  return { ...central };
15
23
  }
24
+ /**
25
+ * Pick the central `browser:` entries this machine may fold into its device doc.
26
+ *
27
+ * A profile is claimable only when `canHostHere` accepts it — the endpoint or
28
+ * binary resolves to THIS machine — so a `cdp://localhost:*` profile owned by a
29
+ * peer is never claimed here (the logged-out-headless-browser bug this module
30
+ * exists to prevent). A name already declared locally with an IDENTICAL config
31
+ * is folded too, which just drops the redundant central copy. A name declared
32
+ * locally with a DIFFERENT config is a genuine conflict: `onConflict: 'throw'`
33
+ * (the explicit `claim` command) surfaces it; `onConflict: 'skip'` (automatic
34
+ * eviction on sync) leaves it central so a stray duplicate can never wedge a
35
+ * sync — an operator resolves it with an explicit claim.
36
+ */
37
+ function selectClaimableCentral(central, local, canHostHere, opts) {
38
+ const toClaim = {};
39
+ const skipped = [];
40
+ for (const [profileName, config] of Object.entries(central)) {
41
+ if (opts.name && profileName !== opts.name)
42
+ continue;
43
+ if (!canHostHere(config)) {
44
+ skipped.push(profileName);
45
+ continue;
46
+ }
47
+ const existing = local[profileName];
48
+ if (existing && !isDeepStrictEqual(existing, config)) {
49
+ if (opts.onConflict === 'throw') {
50
+ throw new Error(`Cannot migrate browser profile "${profileName}": central agents.yaml and this device's ` +
51
+ `agents.yaml declare different configurations. Resolve the duplicate before retrying.`);
52
+ }
53
+ skipped.push(profileName);
54
+ continue;
55
+ }
56
+ toClaim[profileName] = config;
57
+ }
58
+ return { toClaim, skipped };
59
+ }
60
+ /**
61
+ * Return `current` with `toClaim` moved out of the central `browser:` map and
62
+ * into this device's `deviceBrowser` doc. The central key is dropped entirely
63
+ * once drained, so the tombstone disappears rather than lingering as an empty
64
+ * map. Pure — the caller applies it under the meta lock.
65
+ */
66
+ function buildEvictedMeta(current, toClaim) {
67
+ const legacy = current;
68
+ const remaining = {};
69
+ for (const [profileName, config] of Object.entries(legacy.browser ?? {})) {
70
+ if (!(profileName in toClaim))
71
+ remaining[profileName] = config;
72
+ }
73
+ const { browser: _removed, ...withoutCentralBrowser } = legacy;
74
+ return {
75
+ ...withoutCentralBrowser,
76
+ ...(Object.keys(remaining).length > 0 ? { browser: remaining } : {}),
77
+ deviceBrowser: { ...current.deviceBrowser, ...toClaim },
78
+ };
79
+ }
80
+ /**
81
+ * Commit a claim computed by the EXPLICIT path (`migrateCentralBrowserProfiles`).
82
+ * `toClaim` was selected from an unlocked snapshot — acceptable for that manual,
83
+ * deliberate command; the automatic path instead selects INSIDE the lock (see
84
+ * {@link autoEvictCentralBrowserProfiles}) so an unrelated concurrent write can
85
+ * never be clobbered by a stale selection.
86
+ */
87
+ function commitCentralClaim(toClaim) {
88
+ updateMeta((current) => buildEvictedMeta(current, toClaim));
89
+ }
16
90
  /**
17
91
  * Fold leftover central `browser:` entries into THIS device's declaration file.
18
92
  *
19
- * Never called implicitly, and that is the point. Every device can read the
20
- * central map, so an implicit claim races: whichever box happens to read first
21
- * claims the name, {@link profileKind} then reports it `identity`, and the
22
- * daemon tunnels to that box — which for a fleet-wide `cdp://localhost:*`
23
- * profile is a logged-out headless browser wearing a credentialed browser's
24
- * name. That is the exact bug this module exists to remove, and an implicit
25
- * migration would write it to disk as a stored fact.
93
+ * The EXPLICIT operator action (`agents browser profiles claim`), run on the
94
+ * machine that actually owns the browser. It is never called implicitly from a
95
+ * read, and that is the point: every device can read the central map, so an
96
+ * implicit claim races — whichever box reads first claims the name, {@link
97
+ * profileKind} then reports it `identity`, and the daemon tunnels to that box,
98
+ * which for a fleet-wide `cdp://localhost:*` profile is a logged-out headless
99
+ * browser wearing a credentialed browser's name. {@link autoEvictCentralBrowserProfiles}
100
+ * closes that race for the automatic path (host-gated + non-throwing on sync);
101
+ * this one is the manual, throw-on-conflict counterpart.
26
102
  *
27
- * The claim is an explicit operator action (`agents browser profiles claim`),
28
- * run on the machine that actually owns the browser. `canHostHere` is supplied
29
- * by the command layer so this module stays a leaf — it must not import
30
- * `isProfileLaunchableHere` (that would cycle through chrome.ts → profiles.ts).
31
- * Only profiles this machine can host are claimed; the rest stay central,
32
- * undeclared, and fail loudly on resolve.
103
+ * `canHostHere` is supplied by the command layer so this module stays a leaf —
104
+ * it must not import `isProfileLaunchableHere` (that would cycle through
105
+ * chrome.ts → profiles.ts). Only profiles this machine can host are claimed; the
106
+ * rest stay central, undeclared, and fail loudly on resolve.
33
107
  */
34
108
  export function migrateCentralBrowserProfiles(canHostHere, name) {
35
109
  const meta = readMeta();
@@ -53,42 +127,64 @@ export function migrateCentralBrowserProfiles(canHostHere, name) {
53
127
  `(its browser/binary isn't installed here). Run this command on the machine that has that browser.`);
54
128
  }
55
129
  }
56
- const toClaim = {};
57
- const skipped = [];
58
- for (const [profileName, config] of Object.entries(central)) {
59
- if (name && profileName !== name)
60
- continue;
61
- if (!canHostHere(config)) {
62
- skipped.push(profileName);
63
- continue;
64
- }
65
- const existing = local[profileName];
66
- if (existing && !isDeepStrictEqual(existing, config)) {
67
- throw new Error(`Cannot migrate browser profile "${profileName}": central agents.yaml and this device's ` +
68
- `agents.yaml declare different configurations. Resolve the duplicate before retrying.`);
69
- }
70
- toClaim[profileName] = config;
71
- }
130
+ const { toClaim, skipped } = selectClaimableCentral(central, local, canHostHere, {
131
+ name,
132
+ onConflict: 'throw',
133
+ });
72
134
  const claimed = Object.keys(toClaim).sort();
73
135
  skipped.sort();
74
136
  if (claimed.length === 0)
75
137
  return { claimed, skipped };
76
- updateMeta((current) => {
77
- const legacy = current;
78
- const remaining = {};
79
- for (const [profileName, config] of Object.entries(legacy.browser ?? {})) {
80
- if (!(profileName in toClaim))
81
- remaining[profileName] = config;
82
- }
83
- const { browser: _removed, ...withoutCentralBrowser } = legacy;
84
- return {
85
- ...withoutCentralBrowser,
86
- ...(Object.keys(remaining).length > 0 ? { browser: remaining } : {}),
87
- deviceBrowser: { ...current.deviceBrowser, ...toClaim },
88
- };
89
- });
138
+ commitCentralClaim(toClaim);
90
139
  return { claimed, skipped };
91
140
  }
141
+ /**
142
+ * Automatic, self-draining counterpart to {@link migrateCentralBrowserProfiles}:
143
+ * fold every lingering central `browser:` profile THIS machine can host into its
144
+ * device doc and clear it from central, so the tombstone drains itself on
145
+ * `agents sync` with no manual `agents browser profiles claim`.
146
+ *
147
+ * Safe to call implicitly, unlike a claim at registry-read time (which races —
148
+ * see the `profileRegistry does not claim central declarations` guard), but the
149
+ * CALLER owns one ordering invariant: invoke it AFTER the sync's repo pull. Two
150
+ * boxes can both host the same profile (`canHostHere` is launchability, not
151
+ * ownership), so a box acting on a PRE-pull view of central could re-claim a
152
+ * profile a peer already drained and pushed — both writes land in different
153
+ * device files with no git conflict, and the profile flips identity->fungible.
154
+ * Running post-pull means central already reflects peers' drains, so the tombstone
155
+ * is gone locally before this box looks. The selection is also computed INSIDE
156
+ * the meta lock, and the path never throws — an unhostable profile or one that
157
+ * conflicts with an existing local declaration is left central for an explicit
158
+ * claim rather than wedging the sync. After it runs, {@link profileRegistry} is
159
+ * the single source of truth: a claimed profile lives in the device doc alone,
160
+ * never double-counted across the two stores.
161
+ */
162
+ export function autoEvictCentralBrowserProfiles(canHostHere) {
163
+ // Read fresh, select, and commit inside ONE meta-lock acquisition. Reading
164
+ // central before the lock and committing after (the manual path's shape) leaves
165
+ // a window where a concurrent write on THIS machine changes central/deviceBrowser
166
+ // between snapshot and commit, so the stale selection would be merged over the
167
+ // fresher value. Selecting from the state the lock just handed us closes that
168
+ // window. We write ONLY when something is claimed, so a no-op sync never touches
169
+ // any doc (writeMetaUnlocked would otherwise re-serialize the device doc every
170
+ // run). This mirrors updateMeta's own body, minus the unconditional write.
171
+ return withMetaLock(() => {
172
+ const meta = readMeta();
173
+ const central = meta.browser;
174
+ if (!central || Object.keys(central).length === 0)
175
+ return { claimed: [], skipped: [] };
176
+ const local = meta.deviceBrowser ?? {};
177
+ const { toClaim, skipped } = selectClaimableCentral(central, local, canHostHere, {
178
+ onConflict: 'skip',
179
+ });
180
+ const claimed = Object.keys(toClaim).sort();
181
+ skipped.sort();
182
+ if (claimed.length === 0)
183
+ return { claimed, skipped };
184
+ writeMetaUnlocked(buildEvictedMeta(meta, toClaim));
185
+ return { claimed, skipped };
186
+ });
187
+ }
92
188
  /**
93
189
  * Every profile any device declares, keyed by name to all declaring devices.
94
190
  * Reads every `devices/<name>/agents.yaml`; declarations never overwrite.
@@ -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