@phnx-labs/agents-cli 1.22.46 → 1.22.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/LICENSE +102 -182
  3. package/README.md +31 -18
  4. package/dist/cli/command-registry.d.ts +1 -1
  5. package/dist/cli/command-registry.js +4 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.js +7 -5
  8. package/dist/commands/browser-sessions-picker.js +9 -7
  9. package/dist/commands/browser.js +266 -15
  10. package/dist/commands/config.js +26 -4
  11. package/dist/commands/cost.d.ts +1 -1
  12. package/dist/commands/detach.d.ts +13 -0
  13. package/dist/commands/detach.js +11 -8
  14. package/dist/commands/exec.js +55 -38
  15. package/dist/commands/feed-watch.d.ts +2 -0
  16. package/dist/commands/feed-watch.js +28 -0
  17. package/dist/commands/feed.js +27 -0
  18. package/dist/commands/feedback.js +3 -19
  19. package/dist/commands/insights.d.ts +1 -1
  20. package/dist/commands/insights.js +2 -2
  21. package/dist/commands/lease.js +3 -3
  22. package/dist/commands/routines.js +1 -1
  23. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  24. package/dist/commands/sessions-picker-factory.js +1 -1
  25. package/dist/commands/sessions-stop.d.ts +24 -0
  26. package/dist/commands/sessions-stop.js +66 -0
  27. package/dist/commands/sessions-trace.d.ts +82 -0
  28. package/dist/commands/sessions-trace.js +387 -0
  29. package/dist/commands/sessions.js +4 -0
  30. package/dist/commands/ssh.d.ts +15 -0
  31. package/dist/commands/ssh.js +194 -16
  32. package/dist/commands/teams.js +34 -2
  33. package/dist/commands/view.d.ts +2 -0
  34. package/dist/commands/view.js +18 -31
  35. package/dist/commands/watchdog.js +9 -14
  36. package/dist/lib/account-capabilities.d.ts +4 -0
  37. package/dist/lib/account-capabilities.js +13 -9
  38. package/dist/lib/account-registry.d.ts +2 -0
  39. package/dist/lib/account-registry.js +30 -2
  40. package/dist/lib/accounting/rotate.d.ts +9 -0
  41. package/dist/lib/accounting/rotate.js +12 -2
  42. package/dist/lib/accounting/usage.d.ts +70 -3
  43. package/dist/lib/accounting/usage.js +235 -63
  44. package/dist/lib/agent-modes.js +29 -1
  45. package/dist/lib/agent-spec/agents.d.ts +4 -3
  46. package/dist/lib/agent-spec/agents.js +14 -6
  47. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  48. package/dist/lib/analytics/mix-commands.js +2 -2
  49. package/dist/lib/auth-health.d.ts +13 -3
  50. package/dist/lib/auth-health.js +50 -8
  51. package/dist/lib/browser/devices.d.ts +19 -0
  52. package/dist/lib/browser/devices.js +23 -0
  53. package/dist/lib/browser/ipc.d.ts +1 -1
  54. package/dist/lib/browser/ipc.js +33 -7
  55. package/dist/lib/browser/profiles.d.ts +106 -1
  56. package/dist/lib/browser/profiles.js +300 -17
  57. package/dist/lib/browser/remote-control.d.ts +26 -2
  58. package/dist/lib/browser/remote-control.js +33 -3
  59. package/dist/lib/browser/runtime-state.d.ts +9 -0
  60. package/dist/lib/browser/runtime-state.js +24 -8
  61. package/dist/lib/browser/service.d.ts +37 -0
  62. package/dist/lib/browser/service.js +78 -20
  63. package/dist/lib/browser/sessions-list.d.ts +1 -3
  64. package/dist/lib/browser/sessions-list.js +3 -16
  65. package/dist/lib/browser/types.d.ts +12 -1
  66. package/dist/lib/codex-policy.d.ts +11 -1
  67. package/dist/lib/codex-policy.js +20 -5
  68. package/dist/lib/config-keys.d.ts +4 -3
  69. package/dist/lib/config-keys.js +17 -7
  70. package/dist/lib/daemon/runner.js +15 -2
  71. package/dist/lib/daemon-ticks.js +2 -2
  72. package/dist/lib/device-config.d.ts +25 -8
  73. package/dist/lib/device-config.js +90 -17
  74. package/dist/lib/devices/config-migration.d.ts +7 -0
  75. package/dist/lib/devices/config-migration.js +67 -3
  76. package/dist/lib/devices/health.d.ts +27 -2
  77. package/dist/lib/devices/health.js +39 -9
  78. package/dist/lib/devices/interactive-host.d.ts +15 -0
  79. package/dist/lib/devices/interactive-host.js +53 -0
  80. package/dist/lib/devices/registry.d.ts +61 -4
  81. package/dist/lib/devices/registry.js +94 -38
  82. package/dist/lib/devices/stats-cache.d.ts +6 -0
  83. package/dist/lib/devices/stats-cache.js +10 -2
  84. package/dist/lib/exec.d.ts +5 -3
  85. package/dist/lib/exec.js +32 -16
  86. package/dist/lib/feed/answer.d.ts +29 -0
  87. package/dist/lib/feed/answer.js +145 -0
  88. package/dist/lib/feed/attention.d.ts +83 -0
  89. package/dist/lib/feed/attention.js +0 -0
  90. package/dist/lib/feed/feed.d.ts +109 -0
  91. package/dist/lib/feed/feed.js +154 -3
  92. package/dist/lib/feed/pr-status.d.ts +14 -0
  93. package/dist/lib/feed/pr-status.js +47 -0
  94. package/dist/lib/feed/watch.d.ts +59 -0
  95. package/dist/lib/feed/watch.js +180 -0
  96. package/dist/lib/fleet/capture.js +6 -0
  97. package/dist/lib/fleet/remote-login.js +8 -3
  98. package/dist/lib/fleet/types.d.ts +17 -0
  99. package/dist/lib/harness/adapter.d.ts +11 -0
  100. package/dist/lib/harness/adapters/claude.js +38 -24
  101. package/dist/lib/harness/adapters/codex.js +2 -4
  102. package/dist/lib/hooks/cache.js +8 -0
  103. package/dist/lib/hooks/match.d.ts +3 -0
  104. package/dist/lib/hooks/match.js +25 -0
  105. package/dist/lib/hosts/passthrough.js +16 -0
  106. package/dist/lib/hosts/registry.js +7 -0
  107. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  108. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  109. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  110. package/dist/lib/menubar/install-menubar.js +26 -2
  111. package/dist/lib/open-url.d.ts +60 -2
  112. package/dist/lib/open-url.js +167 -12
  113. package/dist/lib/refresh-coordinator.d.ts +1 -1
  114. package/dist/lib/refresh-coordinator.js +1 -1
  115. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  116. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  117. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  118. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  119. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  120. package/dist/lib/session/active.d.ts +67 -0
  121. package/dist/lib/session/active.js +69 -1
  122. package/dist/lib/session/db.js +4 -2
  123. package/dist/lib/session/discover.d.ts +2 -0
  124. package/dist/lib/session/discover.js +12 -6
  125. package/dist/lib/session/prompt.d.ts +35 -0
  126. package/dist/lib/session/prompt.js +56 -0
  127. package/dist/lib/session/remote/watch.d.ts +1 -1
  128. package/dist/lib/session/remote/watch.js +6 -1
  129. package/dist/lib/session/render.js +7 -3
  130. package/dist/lib/session/session-cache.js +5 -1
  131. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  132. package/dist/lib/session/trajectory-compare.js +116 -0
  133. package/dist/lib/session/trajectory-html.d.ts +15 -0
  134. package/dist/lib/session/trajectory-html.js +750 -0
  135. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  136. package/dist/lib/session/trajectory-lineage.js +195 -0
  137. package/dist/lib/session/trajectory-text.d.ts +37 -0
  138. package/dist/lib/session/trajectory-text.js +277 -0
  139. package/dist/lib/session/trajectory.d.ts +102 -0
  140. package/dist/lib/session/trajectory.js +335 -0
  141. package/dist/lib/startup/command-registry.d.ts +3 -1
  142. package/dist/lib/startup/command-registry.js +5 -2
  143. package/dist/lib/state.d.ts +1 -1
  144. package/dist/lib/state.js +4 -2
  145. package/dist/lib/teams/agents.js +9 -0
  146. package/dist/lib/terminal/inject.d.ts +19 -0
  147. package/dist/lib/terminal/inject.js +19 -9
  148. package/dist/lib/tmux/session.d.ts +46 -13
  149. package/dist/lib/tmux/session.js +173 -27
  150. package/dist/lib/types.d.ts +16 -0
  151. package/dist/lib/usage-backoff.d.ts +2 -1
  152. package/dist/lib/usage-backoff.js +60 -19
  153. package/dist/lib/usage-refresh.d.ts +17 -3
  154. package/dist/lib/usage-refresh.js +57 -11
  155. package/dist/lib/watchdog/log.d.ts +1 -1
  156. package/dist/lib/watchdog/log.js +1 -1
  157. package/dist/lib/watchdog/runner.d.ts +36 -45
  158. package/dist/lib/watchdog/runner.js +150 -119
  159. package/dist/lib/watchdog/service.d.ts +0 -1
  160. package/dist/lib/watchdog/service.js +0 -1
  161. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  162. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  163. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  164. package/dist/lib/watchdog/watchdog.js +37 -104
  165. package/package.json +2 -2
  166. package/dist/commands/usage.d.ts +0 -27
  167. package/dist/commands/usage.js +0 -96
@@ -36,7 +36,7 @@ import * as yaml from 'yaml';
36
36
  import { META_HEADER, getUserAgentsDir, readMeta, updateMeta, withMetaLock } from './state.js';
37
37
  import { atomicWriteFileSync } from './fs-atomic.js';
38
38
  import { machineId } from './machine-id.js';
39
- import { assertValidDeviceName } from './devices/registry.js';
39
+ import { assertValidDeviceName, assertRegistrableDeviceName } from './devices/registry.js';
40
40
  import { migrateDeviceConfigStores } from './devices/config-migration.js';
41
41
  const DEVICE_PLATFORMS = ['windows', 'linux', 'macos', 'unknown'];
42
42
  const SSH_AUTH_METHODS = ['key', 'password'];
@@ -53,7 +53,7 @@ export const CONFIG_KEYS = [
53
53
  description: 'Device that shows the user artifacts (browser opens, dashboards) — the "online macOS box" skills should use instead of guessing.',
54
54
  validate: (v) => {
55
55
  try {
56
- assertValidDeviceName(v);
56
+ assertRegistrableDeviceName(v);
57
57
  return null;
58
58
  }
59
59
  catch (err) {
@@ -69,7 +69,7 @@ export const CONFIG_KEYS = [
69
69
  description: 'Device whose usage snapshots are authoritative for fleet-wide usage reporting.',
70
70
  validate: (v) => {
71
71
  try {
72
- assertValidDeviceName(v);
72
+ assertRegistrableDeviceName(v);
73
73
  return null;
74
74
  }
75
75
  catch (err) {
@@ -90,6 +90,18 @@ export const CONFIG_KEYS = [
90
90
  ? null
91
91
  : `auto.pool must be one of ${AUTO_POOL_MODES.join(' | ')}.`,
92
92
  },
93
+ {
94
+ name: 'browser.viewer',
95
+ yamlKey: 'browserViewer',
96
+ scope: 'device',
97
+ visibility: 'machine',
98
+ type: 'string',
99
+ description: "Which browser THIS machine shows YOU a page in (an .html artifact, `agents feedback`, a login " +
100
+ "dashboard): a profile name, or `os` for the OS default handler. Unset follows browser.profile. " +
101
+ "Distinct from browser.profile, which is the profile agents DRIVE — see RUSH-2709 for why " +
102
+ "collapsing the two was a mistake. Set it to `os` to keep the OS default handler.",
103
+ validate: (v) => typeof v === 'string' && v.length > 0 ? null : 'browser.viewer must be `os` or a profile name.',
104
+ },
93
105
  {
94
106
  name: 'browser.profile',
95
107
  yamlKey: 'defaultBrowserProfile',
@@ -144,11 +156,10 @@ export const CONFIG_KEYS = [
144
156
  scope: 'device',
145
157
  visibility: 'machine',
146
158
  type: 'bool',
147
- defaultValue: true,
159
+ defaultValue: false,
148
160
  description: 'Whether an interactive `agents run` on this device is wrapped in the shared-socket tmux session. ' +
149
- 'On gives every agent an addressable pane (`agents sessions --active` tells co-located agents apart, ' +
150
- '`agents focus` re-attaches without forking). Off spawns the agent directly on this box the durable ' +
151
- 'form of `--no-tmux`, for a machine whose tmux is broken or unwanted.',
161
+ 'Off, the default, spawns the agent directly. Turn it on to give every agent an addressable pane for ' +
162
+ '`agents message`, injection, and `agents focus` once the tmux mouse, clipboard, and scrollback behavior suits this device.',
152
163
  },
153
164
  {
154
165
  name: 'browser.remote-control',
@@ -179,7 +190,26 @@ export const CONFIG_KEYS = [
179
190
  scope: 'device',
180
191
  visibility: 'shared',
181
192
  type: 'string-list',
182
- description: 'Free-form operator notes about this device (one entry per `agents devices config <name> notes <text>`).',
193
+ description: 'Free-form operator notes about this device (one entry per `agents devices config <name> notes <text>`). ' +
194
+ 'Long-form scratch, never shown in device listings — for the one-line synced summary of what the box is for, use `description`.',
195
+ },
196
+ {
197
+ name: 'description',
198
+ yamlKey: 'description',
199
+ scope: 'device',
200
+ visibility: 'shared',
201
+ type: 'string',
202
+ description: 'One line saying what this device is FOR ("gpu box — cuda 12.4"), synced fleet-wide. Kept to one short line ' +
203
+ 'because the device-list renderer will show it (RUSH-3062, `surface` track). ' +
204
+ 'Replaces the value on each set; for appended long-form scratch use `notes`.',
205
+ validate: (v) => {
206
+ const s = v;
207
+ if (s.includes('\n') || s.includes('\r'))
208
+ return 'description must be a single line.';
209
+ if (s.length > 80)
210
+ return `description must be at most 80 characters (got ${s.length}) — it is a one-line table cell.`;
211
+ return null;
212
+ },
183
213
  },
184
214
  {
185
215
  name: 'ssh.user',
@@ -593,6 +623,20 @@ export function configuredDeviceRole(name) {
593
623
  assertValidDeviceName(name);
594
624
  return getConfigValue('role', { device: name }).value;
595
625
  }
626
+ /**
627
+ * The role marked on THIS machine — the one running the CLI — or undefined when
628
+ * it was never marked. Keyed off {@link machineId} (overridable via
629
+ * AGENTS_SYNC_MACHINE_ID), so it matches the device's own config-folder key.
630
+ *
631
+ * The auth strategy reads this: a `personal` device (the user's own interactive
632
+ * box) holds a real per-version login and MUST authenticate from it for EVERY
633
+ * run, interactive or headless; only a `worker` uses the file-based setup-token
634
+ * (RUSH-2395). `undefined` is treated as non-personal (worker-equivalent) by
635
+ * that gate — an unmarked box has no login to defer to.
636
+ */
637
+ export function selfConfiguredDeviceRole() {
638
+ return configuredDeviceRole(machineId());
639
+ }
596
640
  /** Mark a device's role fleet-wide; `undefined` clears the mark. */
597
641
  export function setConfiguredDeviceRole(name, role) {
598
642
  assertValidDeviceName(name);
@@ -601,6 +645,42 @@ export function setConfiguredDeviceRole(name, role) {
601
645
  else
602
646
  setConfigValue('role', role, { device: name });
603
647
  }
648
+ /**
649
+ * Devices whose OWN config pins one of the browser profile keys to `profile`.
650
+ *
651
+ * Used by `profiles rename` to warn rather than silently leave a peer pointing at
652
+ * a name that no longer exists. Deliberately read-only: rewriting another
653
+ * machine's device doc from here would be a cross-machine mutation nobody asked
654
+ * for, and the pin may well be correct there (a local profile of the same name).
655
+ */
656
+ export function devicesPinningBrowserProfile(profile) {
657
+ ensureDeviceConfigMigrated();
658
+ // Reports WHICH key each device used, not just that it matched. A caller
659
+ // telling the user to fix `browser.profile` on a device that actually pinned
660
+ // `browser.viewer` leaves the real pin broken and sets a second key nobody
661
+ // asked for.
662
+ const hits = [];
663
+ const devicesRoot = path.join(getUserAgentsDir(), 'devices');
664
+ let names = [];
665
+ try {
666
+ names = fs
667
+ .readdirSync(devicesRoot, { withFileTypes: true })
668
+ .filter((e) => e.isDirectory())
669
+ .map((e) => e.name);
670
+ }
671
+ catch {
672
+ return hits;
673
+ }
674
+ for (const name of names.sort()) {
675
+ const cfg = readDeviceDocConfig(name);
676
+ // A device can pin BOTH; each needs its own fix line.
677
+ if (cfg.defaultBrowserProfile === profile)
678
+ hits.push({ device: name, key: 'browser.profile' });
679
+ if (cfg.browserViewer === profile)
680
+ hits.push({ device: name, key: 'browser.viewer' });
681
+ }
682
+ return hits;
683
+ }
604
684
  /**
605
685
  * Every device with an effective role, keyed by device name. Layers like every
606
686
  * other device-scope key: the fleet default (central fleet.defaults.config)
@@ -720,16 +800,9 @@ export function assertSchedulerEnabled() {
720
800
  throw new Error(`The routines scheduler is disabled on this device (scheduler.enabled=false in ~/.agents/devices/${machineId()}/agents.yaml). ` +
721
801
  `Re-enable with: agents devices config ${machineId()} scheduler.enabled on`);
722
802
  }
723
- /**
724
- * True unless this machine's config turns off the managed tmux wrap for
725
- * interactive `agents run` launches (`tmux.enabled=false`).
726
- *
727
- * Read as one of the guards in `shouldWrapInTmux` (lib/exec.ts) — the durable,
728
- * per-machine form of `--no-tmux` / `AGENTS_NO_TMUX=1`, for a box whose tmux is
729
- * broken or unwanted. Unset means today's behavior: wrap.
730
- */
803
+ /** True only when this machine explicitly enables the managed tmux wrap. */
731
804
  export function isTmuxEnabled() {
732
- return getConfigValue('tmux.enabled').value !== false;
805
+ return getConfigValue('tmux.enabled').value === true;
733
806
  }
734
807
  /** True unless this machine's config disables the daemon outright (top-level kill switch). */
735
808
  export function isDaemonEnabled() {
@@ -13,6 +13,13 @@
13
13
  * `.history/devices/pins-<host>.json` (pins file wins on conflict — it
14
14
  * is the destination); peers' pins are simply dropped from the tracked
15
15
  * file (each peer owns/rewrites its own pins locally).
16
+ * (e) legacy `.history/devices/ignored.json` (per-machine, UNTRACKED — a
17
+ * dismissal never reached the rest of the fleet) folds into the central
18
+ * TRACKED `fleet.ignored` list (RUSH-3062). Entries keep the legacy
19
+ * file's `updatedAt` as `ignoredAt` and take THIS machine's id as
20
+ * `ignoredOn` — the legacy store recorded neither, so the folding box
21
+ * is the only attribution available. The legacy file is removed only
22
+ * AFTER the central write lands.
16
23
  *
17
24
  * What stays put: a device doc's existing `config:` (already the right home)
18
25
  * and its `routines:` list (operator-owned, read cross-device by
@@ -13,6 +13,13 @@
13
13
  * `.history/devices/pins-<host>.json` (pins file wins on conflict — it
14
14
  * is the destination); peers' pins are simply dropped from the tracked
15
15
  * file (each peer owns/rewrites its own pins locally).
16
+ * (e) legacy `.history/devices/ignored.json` (per-machine, UNTRACKED — a
17
+ * dismissal never reached the rest of the fleet) folds into the central
18
+ * TRACKED `fleet.ignored` list (RUSH-3062). Entries keep the legacy
19
+ * file's `updatedAt` as `ignoredAt` and take THIS machine's id as
20
+ * `ignoredOn` — the legacy store recorded neither, so the folding box
21
+ * is the only attribution available. The legacy file is removed only
22
+ * AFTER the central write lands.
16
23
  *
17
24
  * What stays put: a device doc's existing `config:` (already the right home)
18
25
  * and its `routines:` list (operator-owned, read cross-device by
@@ -33,9 +40,10 @@
33
40
  import * as fs from 'fs';
34
41
  import * as path from 'path';
35
42
  import * as yaml from 'yaml';
36
- import { META_HEADER, getDevicesAutoLaunchPath, getDevicePinsPath, getUserAgentsDir, readMeta, updateMeta, withMetaLock, } from '../state.js';
43
+ import { META_HEADER, getDevicesAutoLaunchPath, getDevicesIgnoredPath, getDevicePinsPath, getUserAgentsDir, readMeta, updateMeta, withMetaLock, } from '../state.js';
37
44
  import { atomicWriteFileSync } from '../fs-atomic.js';
38
45
  import { machineId } from '../machine-id.js';
46
+ import { withIgnoredAdded } from './registry.js';
39
47
  /** Read every device doc. A doc that fails to parse is loudly skipped. */
40
48
  function readDeviceDocs(devicesRoot) {
41
49
  const docs = [];
@@ -124,6 +132,32 @@ function readAutoLaunchFlags(autoLaunchPath) {
124
132
  function isConfigMap(v) {
125
133
  return typeof v === 'object' && v !== null && !Array.isArray(v);
126
134
  }
135
+ /**
136
+ * The legacy per-machine ignored.json. Absent => null. A parse failure is
137
+ * logged loudly and reported as `undefined` so the caller LEAVES the file in
138
+ * place for a later retry — never silently emptied, the same corruption
139
+ * contract the ignore-list itself keeps (registry.ts's loadIgnoredEntries).
140
+ */
141
+ function readLegacyIgnoredFile(p) {
142
+ let raw;
143
+ try {
144
+ raw = fs.readFileSync(p, 'utf-8');
145
+ }
146
+ catch {
147
+ return null; // absent — nothing to fold
148
+ }
149
+ try {
150
+ const parsed = JSON.parse(raw);
151
+ return {
152
+ names: Array.isArray(parsed.ignored) ? parsed.ignored.filter((n) => typeof n === 'string') : [],
153
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : undefined,
154
+ };
155
+ }
156
+ catch (err) {
157
+ console.error(`device config migration: could not parse ${p} (${err.message}); leaving it for a later retry`);
158
+ return undefined;
159
+ }
160
+ }
127
161
  /**
128
162
  * Fold every legacy device-config/pins location into the current layout. Safe
129
163
  * to call on every boot / config access: cheap no-op once folded.
@@ -139,6 +173,9 @@ export function migrateDeviceConfigStores() {
139
173
  const centralHasConfig = Object.values(centralDevices).some((ov) => ov?.config && Object.keys(ov.config).length > 0);
140
174
  const autoLaunchFlags = readAutoLaunchFlags(autoLaunchPath);
141
175
  const docs = readDeviceDocs(devicesRoot);
176
+ const legacyIgnoredPath = getDevicesIgnoredPath();
177
+ const legacyIgnoredPending = fs.existsSync(legacyIgnoredPath);
178
+ const legacyIgnored = legacyIgnoredPending ? readLegacyIgnoredFile(legacyIgnoredPath) : null;
142
179
  const pinsPath = getDevicePinsPath();
143
180
  let selfPins = {};
144
181
  try {
@@ -172,7 +209,7 @@ export function migrateDeviceConfigStores() {
172
209
  : undefined;
173
210
  const hasDestinationWork = plans.length > 0 || newDocs.length > 0 || docAgents !== undefined || docIsolated !== undefined;
174
211
  const autoLaunchPending = fs.existsSync(autoLaunchPath);
175
- if (!centralHasConfig && !hasDestinationWork && !autoLaunchPending)
212
+ if (!centralHasConfig && !hasDestinationWork && !autoLaunchPending && !legacyIgnoredPending)
176
213
  return;
177
214
  // ── 2. Destination writes FIRST (crash-safe), under the meta lock so they
178
215
  // serialize against writeMetaUnlocked's own read-merge-write of the doc.
@@ -221,7 +258,18 @@ export function migrateDeviceConfigStores() {
221
258
  nextDevices[name] = rest;
222
259
  }
223
260
  const fleet = { ...m.fleet, devices: nextDevices };
224
- if (Object.keys(nextDevices).length === 0 && !fleet.defaults && !fleet.secrets && !fleet.routines) {
261
+ // Drop the whole `fleet` block only when NOTHING else lives in it. Every
262
+ // resident must be named here: `ignored` holds the user's dismissals and
263
+ // `discovery` their discovery policy, and deleting the block would not
264
+ // just lose them locally — `agents repo push` would sync the deletion
265
+ // fleet-wide. Add any new fleet.* key to this guard.
266
+ const fleetIsEmpty = Object.keys(nextDevices).length === 0 &&
267
+ !fleet.defaults &&
268
+ !fleet.secrets &&
269
+ !fleet.routines &&
270
+ !fleet.discovery &&
271
+ !(fleet.ignored && fleet.ignored.length > 0);
272
+ if (fleetIsEmpty) {
225
273
  const { fleet: _, ...rest } = m;
226
274
  void _;
227
275
  return rest;
@@ -238,6 +286,22 @@ export function migrateDeviceConfigStores() {
238
286
  console.error(`device config migration: could not remove ${autoLaunchPath} (${err.message}); a later run retries`);
239
287
  }
240
288
  }
289
+ // ── 4. Legacy ignored.json → central fleet.ignored ───────────────────────
290
+ // Destination write (updateMeta: withMetaLock + atomic write) lands BEFORE
291
+ // the legacy file is removed, so a crash re-folds on the next run and
292
+ // withIgnoredAdded's union-by-name makes that a no-op. A file that failed to
293
+ // parse (legacyIgnored === undefined) is left in place for that retry.
294
+ if (legacyIgnored) {
295
+ if (legacyIgnored.names.length > 0) {
296
+ updateMeta((m) => withIgnoredAdded(m, legacyIgnored.names, legacyIgnored.updatedAt ?? new Date().toISOString()));
297
+ }
298
+ try {
299
+ fs.rmSync(legacyIgnoredPath, { force: true });
300
+ }
301
+ catch (err) {
302
+ console.error(`device config migration: could not remove ${legacyIgnoredPath} (${err.message}); a later run retries`);
303
+ }
304
+ }
241
305
  }
242
306
  /** Shallow-set equality for folded doc content (values compared deep via JSON). */
243
307
  function sameDocContent(a, b) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Device resource probing for `agents devices list`.
3
3
  *
4
- * One SSH round-trip per device gathers load average, memory pressure, and core
4
+ * One SSH round-trip per device gathers load average, memory pressure, disk, and core
5
5
  * count (mac + linux via a POSIX snippet, windows via a CIM one-liner), parsed
6
6
  * into a {@link DeviceStats}. Probes run in parallel with a bounded timeout so
7
7
  * the list stays responsive — a slow or hung box degrades to "no stats" instead
@@ -19,6 +19,19 @@ export declare const PROBE_TIMEOUT_MS = 2500;
19
19
  * "Preparing modules for first use" cost on top of PowerShell startup, which
20
20
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
21
21
  export declare const WIN_PROBE_TIMEOUT_MS = 6000;
22
+ /** One-shot remote snapshot: load, memory, core count, then root filesystem. */
23
+ export declare const PROBE_SNIPPET = "uptime; echo ---AGSTAT---; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ---AGSTAT---; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null); echo ---AGSTAT---; df -Pk / 2>/dev/null | tail -1";
24
+ /** Windows equivalent, one labeled line via CIM. PowerShell 5.1-safe: no `||`
25
+ * chaining, plain string concatenation. `LoadPercentage` is $null on some
26
+ * hosts/VMs, which concatenates to an empty field — the parser treats that as
27
+ * "no load signal" and headroom falls back to memory pressure alone.
28
+ * wrapRemoteCommand base64-encodes this for powershell-shell devices, so the
29
+ * quoting survives ssh intact. */
30
+ export declare const WIN_PROBE_SNIPPET = "$os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; $disk = Get-CimInstance Win32_LogicalDisk -Filter \"DeviceID='C:'\"; Write-Output ('AGWINSTAT load=' + $cpu + ' freeKb=' + $os.FreePhysicalMemory + ' totalKb=' + $os.TotalVisibleMemorySize + ' ncpu=' + $env:NUMBER_OF_PROCESSORS + ' diskFreeKb=' + ($disk.FreeSpace / 1KB) + ' diskTotalKb=' + ($disk.Size / 1KB))";
31
+ export declare function localProbeInvocation(platform: NodeJS.Platform): {
32
+ file: string;
33
+ args: string[];
34
+ };
22
35
  export interface DeviceStats {
23
36
  host: string;
24
37
  reachable: boolean;
@@ -30,6 +43,11 @@ export interface DeviceStats {
30
43
  memPercent?: number;
31
44
  memTotalBytes?: number;
32
45
  memFreeBytes?: number;
46
+ diskTotalBytes?: number;
47
+ diskFreeBytes?: number;
48
+ diskUsedPercent?: number;
49
+ /** When the static ncpu/RAM/disk totals were last observed. */
50
+ specsFetchedAt?: number;
33
51
  fetchedAt: number;
34
52
  }
35
53
  /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
@@ -47,7 +65,14 @@ export declare function parseLinuxMemInfo(out: string): MemStats;
47
65
  export declare function parseNcpu(out: string): {
48
66
  ncpu?: number;
49
67
  };
50
- /** Assemble a DeviceStats from the three snippet sections. */
68
+ interface DiskStats {
69
+ diskTotalBytes?: number;
70
+ diskFreeBytes?: number;
71
+ diskUsedPercent?: number;
72
+ }
73
+ /** Parse the final data row from `df -k /`, deriving usage from block counts. */
74
+ export declare function parseDf(out: string): DiskStats;
75
+ /** Assemble a DeviceStats from the four snippet sections. */
51
76
  export declare function parseProbeOutput(host: string, stdout: string, fetchedAt: number): DeviceStats;
52
77
  /** Assemble a DeviceStats from the windows one-liner. A missing marker line
53
78
  * (e.g. CIM unavailable) keeps `reachable: true` — ssh answered — with no
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Device resource probing for `agents devices list`.
3
3
  *
4
- * One SSH round-trip per device gathers load average, memory pressure, and core
4
+ * One SSH round-trip per device gathers load average, memory pressure, disk, and core
5
5
  * count (mac + linux via a POSIX snippet, windows via a CIM one-liner), parsed
6
6
  * into a {@link DeviceStats}. Probes run in parallel with a bounded timeout so
7
7
  * the list stays responsive — a slow or hung box degrades to "no stats" instead
@@ -21,16 +21,20 @@ export const PROBE_TIMEOUT_MS = 2_500;
21
21
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
22
22
  export const WIN_PROBE_TIMEOUT_MS = 6_000;
23
23
  const SEP = '---AGSTAT---';
24
- /** One-shot remote snapshot: load, then memory (mac vm_stat else linux
25
- * meminfo), then core count (linux nproc else mac hw.ncpu). */
26
- const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)`;
24
+ /** One-shot remote snapshot: load, memory, core count, then root filesystem. */
25
+ export const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null); echo ${SEP}; df -Pk / 2>/dev/null | tail -1`;
27
26
  /** Windows equivalent, one labeled line via CIM. PowerShell 5.1-safe: no `||`
28
27
  * chaining, plain string concatenation. `LoadPercentage` is $null on some
29
28
  * hosts/VMs, which concatenates to an empty field — the parser treats that as
30
29
  * "no load signal" and headroom falls back to memory pressure alone.
31
30
  * wrapRemoteCommand base64-encodes this for powershell-shell devices, so the
32
31
  * quoting survives ssh intact. */
33
- const WIN_PROBE_SNIPPET = `$os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; Write-Output ('AGWINSTAT load=' + $cpu + ' freeKb=' + $os.FreePhysicalMemory + ' totalKb=' + $os.TotalVisibleMemorySize + ' ncpu=' + $env:NUMBER_OF_PROCESSORS)`;
32
+ export const WIN_PROBE_SNIPPET = `$os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"; Write-Output ('AGWINSTAT load=' + $cpu + ' freeKb=' + $os.FreePhysicalMemory + ' totalKb=' + $os.TotalVisibleMemorySize + ' ncpu=' + $env:NUMBER_OF_PROCESSORS + ' diskFreeKb=' + ($disk.FreeSpace / 1KB) + ' diskTotalKb=' + ($disk.Size / 1KB))`;
33
+ export function localProbeInvocation(platform) {
34
+ return platform === 'win32'
35
+ ? { file: 'powershell', args: ['-NoProfile', '-Command', WIN_PROBE_SNIPPET] }
36
+ : { file: 'sh', args: ['-c', PROBE_SNIPPET] };
37
+ }
34
38
  /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
35
39
  export function fmtBytes(bytes) {
36
40
  if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
@@ -99,12 +103,28 @@ export function parseNcpu(out) {
99
103
  const n = parseInt(out.trim().split(/\s+/)[0] ?? '', 10);
100
104
  return Number.isFinite(n) && n > 0 ? { ncpu: n } : {};
101
105
  }
102
- /** Assemble a DeviceStats from the three snippet sections. */
106
+ /** Parse the final data row from `df -k /`, deriving usage from block counts. */
107
+ export function parseDf(out) {
108
+ const columns = out.trim().split(/\s+/);
109
+ if (columns.length < 4)
110
+ return {};
111
+ const totalKb = Number(columns[1]);
112
+ const freeKb = Number(columns[3]);
113
+ if (!Number.isFinite(totalKb) || totalKb <= 0 || !Number.isFinite(freeKb) || freeKb < 0)
114
+ return {};
115
+ return {
116
+ diskTotalBytes: totalKb * 1024,
117
+ diskFreeBytes: freeKb * 1024,
118
+ diskUsedPercent: Math.max(0, Math.min(100, ((totalKb - freeKb) / totalKb) * 100)),
119
+ };
120
+ }
121
+ /** Assemble a DeviceStats from the four snippet sections. */
103
122
  export function parseProbeOutput(host, stdout, fetchedAt) {
104
- const [uptimePart = '', memPart = '', ncpuPart = ''] = stdout.split(SEP);
123
+ const [uptimePart = '', memPart = '', ncpuPart = '', diskPart = ''] = stdout.split(SEP);
105
124
  const { loadAvg1 } = parseUptime(uptimePart);
106
125
  const mem = memPart.includes('MemTotal') ? parseLinuxMemInfo(memPart) : parseVmStat(memPart);
107
126
  const { ncpu } = parseNcpu(ncpuPart);
127
+ const disk = parseDf(diskPart);
108
128
  const loadPercent = loadAvg1 !== undefined && ncpu ? (loadAvg1 / ncpu) * 100 : undefined;
109
129
  return {
110
130
  host,
@@ -115,6 +135,8 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
115
135
  memPercent: mem.memPercent,
116
136
  memTotalBytes: mem.memTotalBytes,
117
137
  memFreeBytes: mem.memFreeBytes,
138
+ ...disk,
139
+ specsFetchedAt: fetchedAt,
118
140
  fetchedAt,
119
141
  };
120
142
  }
@@ -122,13 +144,16 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
122
144
  * (e.g. CIM unavailable) keeps `reachable: true` — ssh answered — with no
123
145
  * numbers, mirroring how garbage POSIX output degrades. */
124
146
  export function parseWinProbeOutput(host, stdout, fetchedAt) {
125
- const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)/);
147
+ const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)(?: diskFreeKb=([0-9.]+) diskTotalKb=([0-9.]+))?/);
126
148
  if (!m)
127
149
  return { host, reachable: true, fetchedAt };
128
150
  const loadPercent = m[1] === '' ? undefined : parseFloat(m[1]);
129
151
  const freeKb = parseInt(m[2], 10);
130
152
  const totalKb = parseInt(m[3], 10);
131
153
  const ncpu = parseInt(m[4], 10);
154
+ const diskFreeKb = m[5] === undefined ? undefined : Number(m[5]);
155
+ const diskTotalKb = m[6] === undefined ? undefined : Number(m[6]);
156
+ const hasDisk = diskTotalKb !== undefined && diskTotalKb > 0 && diskFreeKb !== undefined && diskFreeKb >= 0;
132
157
  return {
133
158
  host,
134
159
  reachable: true,
@@ -137,6 +162,10 @@ export function parseWinProbeOutput(host, stdout, fetchedAt) {
137
162
  memPercent: totalKb > 0 ? Math.max(0, Math.min(100, ((totalKb - freeKb) / totalKb) * 100)) : undefined,
138
163
  memTotalBytes: totalKb > 0 ? totalKb * 1024 : undefined,
139
164
  memFreeBytes: totalKb > 0 ? freeKb * 1024 : undefined,
165
+ diskTotalBytes: hasDisk ? diskTotalKb * 1024 : undefined,
166
+ diskFreeBytes: hasDisk ? diskFreeKb * 1024 : undefined,
167
+ diskUsedPercent: hasDisk ? Math.max(0, Math.min(100, ((diskTotalKb - diskFreeKb) / diskTotalKb) * 100)) : undefined,
168
+ specsFetchedAt: fetchedAt,
140
169
  fetchedAt,
141
170
  };
142
171
  }
@@ -208,8 +237,9 @@ export function probeDeviceStats(device, opts = {}) {
208
237
  export function probeLocalStats(host, opts = {}) {
209
238
  const fetchedAt = opts.now ?? Date.now();
210
239
  const isWin = process.platform === 'win32';
240
+ const invocation = localProbeInvocation(process.platform);
211
241
  return new Promise((resolve) => {
212
- execFile(isWin ? 'powershell' : 'sh', isWin ? ['-NoProfile', '-Command', WIN_PROBE_SNIPPET] : ['-c', PROBE_SNIPPET], { encoding: 'utf-8', timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS) }, (err, stdout) => {
242
+ execFile(invocation.file, invocation.args, { encoding: 'utf-8', timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS) }, (err, stdout) => {
213
243
  if (err || !stdout)
214
244
  return resolve({ host, reachable: false, fetchedAt });
215
245
  resolve(isWin ? parseWinProbeOutput(host, stdout, fetchedAt) : parseProbeOutput(host, stdout, fetchedAt));
@@ -0,0 +1,15 @@
1
+ /** The reserved `--device` value meaning the box the human is at. */
2
+ export declare const INTERACTIVE_DEVICE_SENTINEL = "interactive";
3
+ /** True when a host flag value is the interactive sentinel. */
4
+ export declare function isDeviceInteractive(value: string | undefined | null): boolean;
5
+ /**
6
+ * The device pinned as `interactive.host`, or null when unset.
7
+ *
8
+ * Callers MUST refuse on null rather than falling back to the local machine.
9
+ * The whole point of the sentinel is reaching a screen someone is watching;
10
+ * silently running on a headless worker instead is the exact failure it exists
11
+ * to prevent, and it would fail invisibly.
12
+ */
13
+ export declare function resolveInteractiveDevice(): string | null;
14
+ /** The actionable error for an unset pin — shared so every call site says the same thing. */
15
+ export declare function interactiveUnsetError(): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The `interactive` device sentinel: "wherever the human is sitting".
3
+ *
4
+ * `--device auto` means "pick a box by load". This means the opposite — one
5
+ * specific box, the one whose screen a person is actually looking at, pinned as
6
+ * `interactive.host`.
7
+ *
8
+ * It exists because a skill cannot teach a host name. Guidance that says
9
+ * "deliver it to zion" is wrong on every other fleet and stale the moment the
10
+ * pin changes, so agents were left to infer the target or to skip the step. A
11
+ * fixed token is something a SKILL.md can state literally and have be correct
12
+ * everywhere.
13
+ *
14
+ * Resolution happens at the dispatch site, before `isSelfHost`, exactly like
15
+ * `auto` — so a pin naming this machine runs locally instead of self-SSHing, and
16
+ * the forwarded argv has the routing flag stripped, which is what keeps this
17
+ * from recursing.
18
+ */
19
+ import { getConfigValue } from '../device-config.js';
20
+ import { RESERVED_DEVICE_NAMES } from './registry.js';
21
+ /** The reserved `--device` value meaning the box the human is at. */
22
+ export const INTERACTIVE_DEVICE_SENTINEL = 'interactive';
23
+ /** True when a host flag value is the interactive sentinel. */
24
+ export function isDeviceInteractive(value) {
25
+ return typeof value === 'string' && value.trim().toLowerCase() === INTERACTIVE_DEVICE_SENTINEL;
26
+ }
27
+ /**
28
+ * The device pinned as `interactive.host`, or null when unset.
29
+ *
30
+ * Callers MUST refuse on null rather than falling back to the local machine.
31
+ * The whole point of the sentinel is reaching a screen someone is watching;
32
+ * silently running on a headless worker instead is the exact failure it exists
33
+ * to prevent, and it would fail invisibly.
34
+ */
35
+ export function resolveInteractiveDevice() {
36
+ const pinned = getConfigValue('interactive.host').value;
37
+ if (typeof pinned !== 'string' || !pinned.trim())
38
+ return null;
39
+ const host = pinned.trim();
40
+ // Defensive only. `interactive.host` rejects every reserved sentinel at WRITE
41
+ // time (assertValidDeviceName), so a pin of "interactive" or "auto" cannot be
42
+ // stored in the first place — which is the right layer, because refusing on
43
+ // read could only ever say "none is set" and send the user back to the command
44
+ // they just ran. This catches a config written by an older version.
45
+ if (RESERVED_DEVICE_NAMES.has(host.toLowerCase()))
46
+ return null;
47
+ return host;
48
+ }
49
+ /** The actionable error for an unset pin — shared so every call site says the same thing. */
50
+ export function interactiveUnsetError() {
51
+ return (`--device interactive needs an interactive host pinned, and none is set.\n` +
52
+ ` Set it on the machine you sit at: agents config set interactive.host <device>`);
53
+ }
@@ -1,3 +1,5 @@
1
+ import type { Meta } from '../types.js';
2
+ import type { IgnoredDeviceEntry } from '../fleet/types.js';
1
3
  /** Operating-system family of a device, used to pick the remote shell. */
2
4
  export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
3
5
  /** Remote shell dialect derived from the platform. */
@@ -84,8 +86,36 @@ export interface DeviceProfile {
84
86
  export declare function isDialableDevice(d: DeviceProfile): boolean;
85
87
  /** Map of device name to profile. */
86
88
  export type DeviceRegistry = Record<string, DeviceProfile>;
87
- /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
89
+ /**
90
+ * `--device` values that mean "resolve me", not "connect to a box with this
91
+ * name". A real device registered under one of these would be unreachable,
92
+ * and pinning `interactive.host` to one is a misconfiguration that has to fail
93
+ * at WRITE time — otherwise the read side can only report "none is set", which
94
+ * tells the user to run the command they just ran.
95
+ */
96
+ export declare const RESERVED_DEVICE_NAMES: Set<string>;
97
+ /**
98
+ * Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
99
+ *
100
+ * SHAPE ONLY. Safe on read paths, which must keep working for a name that is
101
+ * already registered — including one this version would refuse to create.
102
+ */
88
103
  export declare function assertValidDeviceName(name: string): void;
104
+ /**
105
+ * Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
106
+ * routing sentinel.
107
+ *
108
+ * Deliberately separate from {@link assertValidDeviceName}, and called from
109
+ * exactly two kinds of place: `agents devices add <name>`, and the config keys
110
+ * that point AT a device (`interactive.host`, `usage.primary-host`).
111
+ *
112
+ * Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
113
+ * receive tailnet node names the user never typed — `devices sync` upserts every
114
+ * observed node in a loop with no per-node catch, so one node named `auto` would
115
+ * abort the entire sync and register nothing after it. A name the fleet OBSERVES
116
+ * is not a name anyone CHOSE; only the second kind is policy's business.
117
+ */
118
+ export declare function assertRegistrableDeviceName(name: string): void;
89
119
  /** Map a Tailscale `OS` field to our platform enum. */
90
120
  export declare function platformFromOs(os: string | undefined): DevicePlatform;
91
121
  /** The remote shell a platform speaks. */
@@ -132,12 +162,39 @@ export declare function upsertDevice(name: string, input: DeviceInput): Promise<
132
162
  export declare function writeReachability(updates: Record<string, DeviceReachability>): Promise<string[]>;
133
163
  /** Remove a device. Returns false if it was not registered. */
134
164
  export declare function removeDevice(name: string): Promise<boolean>;
135
- /** Load the set of ignored node names. Missing file => empty set. A malformed
136
- * file is a hard error for the same reason the registry is: silently returning
137
- * [] would let the next write wipe the user's dismissals. */
165
+ /**
166
+ * The ignore-list: tailscale node names the user explicitly dismissed from
167
+ * auto-discovery. A dismissed node is deliberately NOT a device (it never
168
+ * enters the registry), so it has no per-device doc — its home is the central,
169
+ * TRACKED `~/.agents/agents.yaml` under a `fleet.ignored` list, which syncs
170
+ * fleet-wide via `agents repo push/pull`: a dismissal on one box stops the
171
+ * suggestion on every box (RUSH-3062). Auto-discovery (`runDeviceSync`'s
172
+ * pending diff) subtracts this set, so an ignored node never re-surfaces as a
173
+ * suggestion. Writes go through `updateMeta` — the central-config write path
174
+ * (withMetaLock + atomic write) — so two agents writing concurrently cannot
175
+ * corrupt it, and no second lock is hand-rolled here.
176
+ */
177
+ export type { IgnoredDeviceEntry } from '../fleet/types.js';
178
+ /**
179
+ * The full ignore-list entries — who dismissed a node, when, and on which box —
180
+ * the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
181
+ * []. A malformed block is a hard error for the same reason the registry is:
182
+ * silently returning [] would let the next write wipe the user's dismissals.
183
+ */
184
+ export declare function loadIgnoredEntries(meta?: Meta): IgnoredDeviceEntry[];
185
+ /** Load the set of ignored node names. Same corruption contract as
186
+ * {@link loadIgnoredEntries}. */
138
187
  export declare function loadIgnored(): Promise<Set<string>>;
139
188
  /** True if `name` is on the ignore-list. */
140
189
  export declare function isIgnored(name: string): Promise<boolean>;
190
+ /**
191
+ * Union `names` into the meta's ignore-list, stamping new entries with
192
+ * `ignoredAt` and THIS machine's id. Existing entries keep their original
193
+ * who/when, so re-adding a name is a true no-op. Returns the input unchanged
194
+ * when no name is new. Exported for the one-shot legacy-store migration in
195
+ * lib/devices/config-migration.ts.
196
+ */
197
+ export declare function withIgnoredAdded(meta: Meta, names: string[], ignoredAt: string): Meta;
141
198
  /** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
142
199
  export declare function addIgnored(name: string): Promise<Set<string>>;
143
200
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was