@phnx-labs/agents-cli 1.22.43 → 1.22.44

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 (164) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +14 -12
  3. package/dist/bootstrap.js +8 -6
  4. package/dist/cli/command-registry.d.ts +0 -5
  5. package/dist/cli/command-registry.js +1 -17
  6. package/dist/commands/apply.d.ts +7 -10
  7. package/dist/commands/apply.js +15 -35
  8. package/dist/commands/artifacts.js +4 -5
  9. package/dist/commands/audit.d.ts +6 -6
  10. package/dist/commands/audit.js +12 -12
  11. package/dist/commands/auth.js +7 -3
  12. package/dist/commands/beta.d.ts +7 -1
  13. package/dist/commands/beta.js +16 -9
  14. package/dist/commands/commands.js +1 -1
  15. package/dist/commands/doctor.js +1 -4
  16. package/dist/commands/events.d.ts +2 -2
  17. package/dist/commands/events.js +6 -5
  18. package/dist/commands/exec.d.ts +1 -1
  19. package/dist/commands/exec.js +8 -4
  20. package/dist/commands/factory.d.ts +1 -1
  21. package/dist/commands/fleet-capture.d.ts +1 -1
  22. package/dist/commands/fleet-capture.js +4 -5
  23. package/dist/commands/harness.js +1 -49
  24. package/dist/commands/hooks.js +1 -1
  25. package/dist/commands/insights.d.ts +1 -1
  26. package/dist/commands/insights.js +3 -2
  27. package/dist/commands/menubar.js +58 -1
  28. package/dist/commands/org.d.ts +5 -0
  29. package/dist/commands/org.js +82 -29
  30. package/dist/commands/output.js +2 -2
  31. package/dist/commands/prune.js +5 -4
  32. package/dist/commands/routines.test-fixture.d.ts +86 -0
  33. package/dist/commands/routines.test-fixture.js +345 -0
  34. package/dist/commands/sessions.test-fixture.d.ts +24 -0
  35. package/dist/commands/sessions.test-fixture.js +224 -0
  36. package/dist/commands/setup.js +5 -1
  37. package/dist/commands/share.d.ts +9 -10
  38. package/dist/commands/share.js +68 -50
  39. package/dist/commands/skills.js +1 -1
  40. package/dist/commands/ssh.js +15 -62
  41. package/dist/commands/subagents.js +1 -1
  42. package/dist/commands/view.js +5 -2
  43. package/dist/lib/analytics/mix-commands.d.ts +6 -28
  44. package/dist/lib/analytics/mix-commands.js +14 -40
  45. package/dist/lib/audit/log.d.ts +2 -2
  46. package/dist/lib/audit/log.js +2 -2
  47. package/dist/lib/beta.js +1 -1
  48. package/dist/lib/browser/domain-skills.d.ts +26 -6
  49. package/dist/lib/browser/domain-skills.js +81 -43
  50. package/dist/lib/daemon/daemon.js +11 -0
  51. package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
  52. package/dist/lib/daemon/daemon.test-fixture.js +71 -0
  53. package/dist/lib/daemon-ticks.d.ts +38 -1
  54. package/dist/lib/daemon-ticks.js +52 -5
  55. package/dist/lib/device-config.js +1 -2
  56. package/dist/lib/devices/discovery-policy.d.ts +3 -3
  57. package/dist/lib/devices/discovery-policy.js +3 -3
  58. package/dist/lib/devices/fleet.d.ts +4 -8
  59. package/dist/lib/devices/fleet.js +3 -15
  60. package/dist/lib/devices/health-report.js +3 -3
  61. package/dist/lib/devices/pool.d.ts +0 -6
  62. package/dist/lib/devices/pool.js +0 -6
  63. package/dist/lib/devices/registry.d.ts +0 -15
  64. package/dist/lib/devices/registry.js +0 -9
  65. package/dist/lib/devices/stats-cache.d.ts +12 -0
  66. package/dist/lib/devices/stats-cache.js +24 -5
  67. package/dist/lib/fleet/capture.d.ts +1 -1
  68. package/dist/lib/fleet/manifest.js +1 -1
  69. package/dist/lib/fleet/remote-login.d.ts +2 -2
  70. package/dist/lib/fleet/remote-login.js +3 -4
  71. package/dist/lib/hosts/dispatch.d.ts +2 -0
  72. package/dist/lib/hosts/dispatch.js +8 -1
  73. package/dist/lib/hosts/passthrough.js +0 -1
  74. package/dist/lib/hosts/providers/devices.js +1 -13
  75. package/dist/lib/hosts/ready.d.ts +12 -3
  76. package/dist/lib/hosts/ready.js +27 -12
  77. package/dist/lib/hosts/registry.d.ts +4 -5
  78. package/dist/lib/hosts/registry.js +3 -7
  79. package/dist/lib/installations/versions.js +21 -2
  80. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  81. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  82. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  83. package/dist/lib/menubar/install-menubar.d.ts +109 -0
  84. package/dist/lib/menubar/install-menubar.js +199 -4
  85. package/dist/lib/prix-account.d.ts +2 -1
  86. package/dist/lib/prix-account.js +2 -1
  87. package/dist/lib/remote-agents-json.js +1 -7
  88. package/dist/lib/routines-placement.d.ts +1 -1
  89. package/dist/lib/routines-placement.js +1 -1
  90. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  94. package/dist/lib/session/active.d.ts +4 -16
  95. package/dist/lib/session/active.js +2 -6
  96. package/dist/lib/session/db.d.ts +13 -98
  97. package/dist/lib/session/db.js +4 -11
  98. package/dist/lib/session/discover.d.ts +5 -81
  99. package/dist/lib/session/discover.js +5 -5
  100. package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
  101. package/dist/lib/session/remote/remote-bundle.js +61 -0
  102. package/dist/lib/session/remote/remote-list.d.ts +148 -0
  103. package/dist/lib/session/remote/remote-list.js +607 -0
  104. package/dist/lib/session/remote/remote.d.ts +132 -0
  105. package/dist/lib/session/remote/remote.js +314 -0
  106. package/dist/lib/session/remote/watch.d.ts +101 -0
  107. package/dist/lib/session/remote/watch.js +241 -0
  108. package/dist/lib/session/remote-bundle.d.ts +1 -12
  109. package/dist/lib/session/remote-bundle.js +3 -61
  110. package/dist/lib/session/remote-list.d.ts +1 -148
  111. package/dist/lib/session/remote-list.js +3 -607
  112. package/dist/lib/session/remote.d.ts +1 -132
  113. package/dist/lib/session/remote.js +3 -314
  114. package/dist/lib/session/watch.d.ts +1 -101
  115. package/dist/lib/session/watch.js +3 -242
  116. package/dist/lib/share/config.d.ts +12 -2
  117. package/dist/lib/share/config.js +47 -11
  118. package/dist/lib/share/delete.js +1 -1
  119. package/dist/lib/share/publish.js +1 -1
  120. package/dist/lib/share/worker-template.js +2 -2
  121. package/dist/lib/smart-launch.d.ts +4 -4
  122. package/dist/lib/smart-launch.js +8 -18
  123. package/dist/lib/staleness/index.d.ts +3 -1
  124. package/dist/lib/staleness/index.js +21 -1
  125. package/dist/lib/staleness/types.d.ts +11 -0
  126. package/dist/lib/staleness/writers/commands.js +13 -4
  127. package/dist/lib/staleness/writers/hooks.js +3 -1
  128. package/dist/lib/staleness/writers/mcp.d.ts +0 -7
  129. package/dist/lib/staleness/writers/mcp.js +16 -1
  130. package/dist/lib/staleness/writers/rules.js +1 -1
  131. package/dist/lib/staleness/writers/skills.js +3 -1
  132. package/dist/lib/staleness/writers/subagents.d.ts +0 -9
  133. package/dist/lib/staleness/writers/subagents.js +18 -1
  134. package/dist/lib/staleness/writers/types.d.ts +10 -0
  135. package/dist/lib/startup/command-registry.d.ts +7 -1
  136. package/dist/lib/startup/command-registry.js +17 -5
  137. package/dist/lib/startup/root-command.d.ts +18 -1
  138. package/dist/lib/startup/root-command.js +18 -1
  139. package/dist/lib/state.js +7 -0
  140. package/dist/lib/teams/placement-probe.js +27 -12
  141. package/dist/lib/teams/scheduler.d.ts +3 -1
  142. package/dist/lib/types.d.ts +1 -1
  143. package/dist/lib/view-types.d.ts +3 -0
  144. package/package.json +2 -3
  145. package/dist/bin/agents +0 -0
  146. package/dist/commands/serve.d.ts +0 -10
  147. package/dist/commands/serve.js +0 -68
  148. package/dist/commands/trends.d.ts +0 -10
  149. package/dist/commands/trends.js +0 -12
  150. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  151. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  152. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  153. package/dist/lib/serve/control.d.ts +0 -95
  154. package/dist/lib/serve/control.js +0 -260
  155. package/dist/lib/serve/data.d.ts +0 -81
  156. package/dist/lib/serve/data.js +0 -91
  157. package/dist/lib/serve/page.d.ts +0 -7
  158. package/dist/lib/serve/page.js +0 -140
  159. package/dist/lib/serve/server.d.ts +0 -80
  160. package/dist/lib/serve/server.js +0 -145
  161. package/dist/lib/serve/stream.d.ts +0 -43
  162. package/dist/lib/serve/stream.js +0 -116
  163. package/dist/lib/serve/token.d.ts +0 -35
  164. package/dist/lib/serve/token.js +0 -85
@@ -19,9 +19,9 @@ export declare function loadDeviceDiscoveryPolicies(): Map<string, DeviceDiscove
19
19
  * Only devices with an EXPLICIT entry in the synced policy are touched. A
20
20
  * device absent from the map is left alone, whether or not the map itself is
21
21
  * defined — the map is not treated as authoritative-by-omission, because not
22
- * every local registration path writes a policy entry (pair-ios control
23
- * devices, daemon/umbrella auto-refresh, and any device registered before this
24
- * feature shipped). Wiping those on the next `agents repo pull user` would be
22
+ * every local registration path writes a policy entry (daemon/umbrella
23
+ * auto-refresh, and any device registered before this feature shipped).
24
+ * Wiping those on the next `agents repo pull user` would be
25
25
  * silent, fleet-wide data loss for a decision (e.g. "ignore this one iPad")
26
26
  * that never meant to touch them.
27
27
  */
@@ -48,9 +48,9 @@ export function loadDeviceDiscoveryPolicies() {
48
48
  * Only devices with an EXPLICIT entry in the synced policy are touched. A
49
49
  * device absent from the map is left alone, whether or not the map itself is
50
50
  * defined — the map is not treated as authoritative-by-omission, because not
51
- * every local registration path writes a policy entry (pair-ios control
52
- * devices, daemon/umbrella auto-refresh, and any device registered before this
53
- * feature shipped). Wiping those on the next `agents repo pull user` would be
51
+ * every local registration path writes a policy entry (daemon/umbrella
52
+ * auto-refresh, and any device registered before this feature shipped).
53
+ * Wiping those on the next `agents repo pull user` would be
54
54
  * silent, fleet-wide data loss for a decision (e.g. "ignore this one iPad")
55
55
  * that never meant to touch them.
56
56
  */
@@ -7,9 +7,9 @@
7
7
  * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
8
  * the remaining devices.
9
9
  */
10
- import { type DeviceProfile, type DeviceRegistry } from './registry.js';
10
+ import type { DeviceProfile, DeviceRegistry } from './registry.js';
11
11
  import type { DeviceStats } from './health.js';
12
- export type FleetSkipReason = 'offline' | 'no-address' | 'control';
12
+ export type FleetSkipReason = 'offline' | 'no-address';
13
13
  /** npm dist-tags / semver pins only — rejects shell metacharacters. */
14
14
  export declare const FLEET_VERSION_RE: RegExp;
15
15
  export interface FleetTarget {
@@ -39,7 +39,6 @@ export interface FanOutDeviceResult<T> {
39
39
  /**
40
40
  * Classify each registered device for a fleet operation.
41
41
  *
42
- * - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
43
42
  * - Tailscale-offline → skip `offline`
44
43
  * - No address → skip `no-address`
45
44
  * - Everything else is a target (including this machine, reached over ssh when
@@ -48,10 +47,7 @@ export interface FanOutDeviceResult<T> {
48
47
  export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
49
48
  /**
50
49
  * Remote fan-out targets for the fleet health/drift gates (`fleet status`,
51
- * `doctor --check --devices`): every planned device except this machine and control-only
52
- * cockpits. A control device never runs agents (mirrors doctor's fan-out, which
53
- * drops it via `isControlDevice`), so counting it as unreachable/drift would make
54
- * the CI gate fail on every run for a fleet that merely has a cockpit registered.
50
+ * `doctor --check --devices`): every planned device except this machine.
55
51
  * Offline / no-address devices are kept — those are genuine faults a gate should
56
52
  * surface — so their `skip` reason still flows through as an `unreachable` row.
57
53
  */
@@ -72,7 +68,7 @@ export declare function remoteFleetTargets(planned: FleetTarget[], self: string)
72
68
  * minutes is the only false-negative window — it renders `unreachable` until the
73
69
  * next stats warm, which beats letting it hang the status glance for 45s.
74
70
  *
75
- * An existing skip (control/offline/no-address from {@link planFleetTargets})
71
+ * An existing skip (offline/no-address from {@link planFleetTargets})
76
72
  * always wins — those are classified before any probe.
77
73
  */
78
74
  export declare function fleetHealthSkip(currentSkip: FleetSkipReason | string | undefined, stats: DeviceStats | undefined): FleetSkipReason | string | undefined;
@@ -8,7 +8,6 @@
8
8
  * the remaining devices.
9
9
  */
10
10
  import { spawnSync } from 'child_process';
11
- import { isControlDevice } from './registry.js';
12
11
  import { isSelfHost } from './self-host.js';
13
12
  import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
14
13
  /** npm dist-tags / semver pins only — rejects shell metacharacters. */
@@ -16,7 +15,6 @@ export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
16
15
  /**
17
16
  * Classify each registered device for a fleet operation.
18
17
  *
19
- * - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
20
18
  * - Tailscale-offline → skip `offline`
21
19
  * - No address → skip `no-address`
22
20
  * - Everything else is a target (including this machine, reached over ssh when
@@ -26,11 +24,6 @@ export function planFleetTargets(reg) {
26
24
  const names = Object.keys(reg).sort();
27
25
  return names.map((name) => {
28
26
  const device = reg[name];
29
- // A control device drives the fleet but never runs agents — never a target
30
- // for update/run/stats, whatever its platform reads as.
31
- if (isControlDevice(device)) {
32
- return { device, skip: 'control' };
33
- }
34
27
  if (device.tailscale && !device.tailscale.online) {
35
28
  return { device, skip: 'offline' };
36
29
  }
@@ -45,10 +38,7 @@ export function planFleetTargets(reg) {
45
38
  }
46
39
  /**
47
40
  * Remote fan-out targets for the fleet health/drift gates (`fleet status`,
48
- * `doctor --check --devices`): every planned device except this machine and control-only
49
- * cockpits. A control device never runs agents (mirrors doctor's fan-out, which
50
- * drops it via `isControlDevice`), so counting it as unreachable/drift would make
51
- * the CI gate fail on every run for a fleet that merely has a cockpit registered.
41
+ * `doctor --check --devices`): every planned device except this machine.
52
42
  * Offline / no-address devices are kept — those are genuine faults a gate should
53
43
  * surface — so their `skip` reason still flows through as an `unreachable` row.
54
44
  */
@@ -57,7 +47,7 @@ export function remoteFleetTargets(planned, self) {
57
47
  // device referenced by its dnsName slipped past the bare name check and got a
58
48
  // remote version+doctor dial back to THIS box, which orphaned on timeout and
59
49
  // piled up (RUSH-2114). `isSelfHost` matches every alias the box answers to.
60
- return planned.filter((t) => t.device.name !== self && !isSelfHost(t.device.name) && t.skip !== 'control');
50
+ return planned.filter((t) => t.device.name !== self && !isSelfHost(t.device.name));
61
51
  }
62
52
  /**
63
53
  * Decide whether a fleet-health target should skip the expensive version+doctor
@@ -75,7 +65,7 @@ export function remoteFleetTargets(planned, self) {
75
65
  * minutes is the only false-negative window — it renders `unreachable` until the
76
66
  * next stats warm, which beats letting it hang the status glance for 45s.
77
67
  *
78
- * An existing skip (control/offline/no-address from {@link planFleetTargets})
68
+ * An existing skip (offline/no-address from {@link planFleetTargets})
79
69
  * always wins — those are classified before any probe.
80
70
  */
81
71
  export function fleetHealthSkip(currentSkip, stats) {
@@ -92,8 +82,6 @@ export function skipLabel(reason) {
92
82
  return 'offline';
93
83
  case 'no-address':
94
84
  return 'no address';
95
- case 'control':
96
- return 'control device';
97
85
  }
98
86
  }
99
87
  /**
@@ -314,7 +314,7 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
314
314
  const seen = row.lastSeen ? ` · last seen ${formatCheckedAge(Date.parse(row.lastSeen), now)}` : '';
315
315
  items.push({ glyph: 'offline', subject: row.name, detail: `offline${seen}`, fix: 'check the box' });
316
316
  }
317
- // 2) Boxes that need `agents apply` — merge config drift and a stark CLI gap
317
+ // 2) Boxes that need `agents fleet apply` — merge config drift and a stark CLI gap
318
318
  // into ONE item per box (both are fixed by the same command, so don't
319
319
  // double-list). An offline box's config/CLI is unknowable, so skip it.
320
320
  for (const row of report.devices) {
@@ -327,7 +327,7 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
327
327
  if (stark)
328
328
  reasons.push(`only ${stark.installed} of ${stark.total} agent CLIs installed`);
329
329
  if (reasons.length > 0) {
330
- items.push({ glyph: 'warn', subject: row.name, detail: reasons.join(' · '), fix: `agents apply ${row.name}` });
330
+ items.push({ glyph: 'warn', subject: row.name, detail: reasons.join(' · '), fix: `agents fleet apply --device ${row.name}` });
331
331
  }
332
332
  }
333
333
  // 3) Version skew across the fleet — one line.
@@ -354,7 +354,7 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
354
354
  glyph: 'warn',
355
355
  subject: w.devices[0] ?? 'fleet',
356
356
  detail: w.message.replace(`${w.devices[0]} `, ''),
357
- fix: `agents apply ${w.devices[0] ?? ''}`.trim(),
357
+ fix: `agents fleet apply --device ${w.devices[0] ?? ''}`.trim(),
358
358
  });
359
359
  }
360
360
  return items;
@@ -19,12 +19,6 @@
19
19
  *
20
20
  * `auto.pool all` turns the allowlist off; `personal` stays excluded, because a
21
21
  * machine the user sits at is marked precisely so agents stay off it.
22
- *
23
- * Paired cockpits (an iPhone/iPad, `role: control` in the device registry, set
24
- * by `agents devices pair-ios`) are excluded by the CALLER that reads the
25
- * registry — `listOnlineDeviceNames` in `lib/smart-launch.ts` — not here. That
26
- * role is machine-local by nature and already has a home; duplicating it in the
27
- * shared config would be a second store for one concept.
28
22
  */
29
23
  import { type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
30
24
  export interface AutoPoolOptions {
@@ -19,12 +19,6 @@
19
19
  *
20
20
  * `auto.pool all` turns the allowlist off; `personal` stays excluded, because a
21
21
  * machine the user sits at is marked precisely so agents stay off it.
22
- *
23
- * Paired cockpits (an iPhone/iPad, `role: control` in the device registry, set
24
- * by `agents devices pair-ios`) are excluded by the CALLER that reads the
25
- * registry — `listOnlineDeviceNames` in `lib/smart-launch.ts` — not here. That
26
- * role is machine-local by nature and already has a home; duplicating it in the
27
- * shared config would be a second store for one concept.
28
22
  */
29
23
  import { autoPoolMode, listConfiguredDeviceRoles } from '../device-config.js';
30
24
  import { normalizeHost } from '../machine-id.js';
@@ -1,13 +1,5 @@
1
1
  /** Operating-system family of a device, used to pick the remote shell. */
2
2
  export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
3
- /**
4
- * What a device is *for*. A `worker` (the default) can run agents — it's dialed
5
- * over SSH for sessions and eligible for run/team placement. A `control` device
6
- * is a cockpit that drives the fleet but never executes agents itself (an
7
- * iPhone/iPad running the companion app): it appears in the fleet but is never
8
- * dialed for SSH nor scheduled work. Absent `role` means `worker`.
9
- */
10
- export type DeviceRole = 'worker' | 'control';
11
3
  /** Remote shell dialect derived from the platform. */
12
4
  export type DeviceShell = 'powershell' | 'posix';
13
5
  /** How `agents ssh` authenticates to a device. Both are first-class, fully
@@ -73,15 +65,9 @@ export interface DeviceProfile {
73
65
  * cached {@link DeviceTailscale.online} snapshot when rendering online/offline,
74
66
  * because the live probe reflects whether the box answered right now. */
75
67
  reachability?: DeviceReachability;
76
- /** What the device is for. Absent means `worker` (see {@link DeviceRole}). */
77
- role?: DeviceRole;
78
68
  createdAt: string;
79
69
  updatedAt: string;
80
70
  }
81
- /** A device's effective role, defaulting to `worker` when unset. */
82
- export declare function deviceRole(d: DeviceProfile): DeviceRole;
83
- /** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
84
- export declare function isControlDevice(d: DeviceProfile): boolean;
85
71
  /**
86
72
  * Whether a fan-out should dial this device, honouring the preference stated on
87
73
  * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
@@ -127,7 +113,6 @@ export interface DeviceInput {
127
113
  auth?: DeviceAuth;
128
114
  tailscale?: DeviceTailscale;
129
115
  reachability?: DeviceReachability;
130
- role?: DeviceRole;
131
116
  }
132
117
  /**
133
118
  * Create the device if absent, otherwise merge the supplied fields into the
@@ -19,14 +19,6 @@ import * as path from 'path';
19
19
  import lockfile from 'proper-lockfile';
20
20
  import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
21
21
  import { atomicWriteJsonSync } from '../fs-atomic.js';
22
- /** A device's effective role, defaulting to `worker` when unset. */
23
- export function deviceRole(d) {
24
- return d.role ?? 'worker';
25
- }
26
- /** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
27
- export function isControlDevice(d) {
28
- return deviceRole(d) === 'control';
29
- }
30
22
  /**
31
23
  * Whether a fan-out should dial this device, honouring the preference stated on
32
24
  * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
@@ -198,7 +190,6 @@ export async function upsertDevice(name, input) {
198
190
  auth: input.auth ?? prev?.auth ?? { method: 'key' },
199
191
  tailscale: input.tailscale ?? prev?.tailscale,
200
192
  reachability: input.reachability ?? prev?.reachability,
201
- role: input.role ?? prev?.role,
202
193
  createdAt: prev?.createdAt ?? now,
203
194
  updatedAt: now,
204
195
  };
@@ -1,5 +1,15 @@
1
1
  import { probeFleetStats, probeLocalStats, type DeviceStats } from './health.js';
2
2
  import type { DeviceProfile } from './registry.js';
3
+ /**
4
+ * Freshness bound for a cached row. Matches the agent-count mirror's window
5
+ * (`AGENT_STATUS_STALE_MS` in `commands/ssh.ts`) so both columns of
6
+ * `devices list` / `fleet status` share one staleness model. A row older than
7
+ * this is never served as current — it is re-probed live (and the probe result
8
+ * rewrites the cache, unreachable boxes included).
9
+ */
10
+ export declare const STATS_STALE_MS: number;
11
+ /** True when a cached row is still within {@link STATS_STALE_MS}. */
12
+ export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
3
13
  /** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
4
14
  export declare function readStatsCache(): Record<string, DeviceStats>;
5
15
  /**
@@ -27,6 +37,8 @@ export interface LoadFleetStatsOptions {
27
37
  probeLocal?: typeof probeLocalStats;
28
38
  readCache?: typeof readStatsCache;
29
39
  writeCache?: typeof writeStatsCache;
40
+ /** Clock override for tests (drives the {@link STATS_STALE_MS} bound). */
41
+ now?: number;
30
42
  }
31
43
  /**
32
44
  * Load fleet stats cache-first. See the module doc for the default vs
@@ -15,16 +15,32 @@
15
15
  * - **`--refresh` / `--live`:** skip the cache and live-probe every device,
16
16
  * rewriting the cache.
17
17
  *
18
- * The daemon warms this cache (~every 3 min, see `lib/daemon/daemon.ts
19
- * runFleetCacheWarm`), so in steady state a default read has zero
20
- * remote ssh round-trips and returns immediately with data that is at most a
21
- * few minutes old surfaced to the user as an "as of …" freshness note.
18
+ * Every cached row is bounded by {@link STATS_STALE_MS}: a row older than the
19
+ * window is treated exactly like a missing one re-probed live this call and
20
+ * rewritten so the default read is itself the cache's writer and a value can
21
+ * never fossilize at the last manual `--refresh`. (The daemon used to warm this
22
+ * cache every ~3 min; RUSH-2061 removed that N² fleet probe, which left the
23
+ * unbounded read serving multi-day-old rows as current fleet state — the
24
+ * scheduler, `--device auto`, and the session-start banner all read these
25
+ * numbers. See #2666.)
22
26
  */
23
27
  import * as fs from 'fs';
24
28
  import * as path from 'path';
25
29
  import { getCacheDir } from '../state.js';
26
30
  import { probeFleetStats, probeLocalStats } from './health.js';
27
31
  const CACHE_FILE = '.fleet-stats.json';
32
+ /**
33
+ * Freshness bound for a cached row. Matches the agent-count mirror's window
34
+ * (`AGENT_STATUS_STALE_MS` in `commands/ssh.ts`) so both columns of
35
+ * `devices list` / `fleet status` share one staleness model. A row older than
36
+ * this is never served as current — it is re-probed live (and the probe result
37
+ * rewrites the cache, unreachable boxes included).
38
+ */
39
+ export const STATS_STALE_MS = 3 * 60_000;
40
+ /** True when a cached row is still within {@link STATS_STALE_MS}. */
41
+ export function isFreshDeviceStats(stats, now = Date.now()) {
42
+ return now - stats.fetchedAt <= STATS_STALE_MS;
43
+ }
28
44
  function cacheFilePath() {
29
45
  return path.join(getCacheDir(), CACHE_FILE);
30
46
  }
@@ -71,6 +87,7 @@ export async function loadFleetStats(devices, opts = {}) {
71
87
  const readCache = opts.readCache ?? readStatsCache;
72
88
  const writeCache = opts.writeCache ?? writeStatsCache;
73
89
  const self = opts.selfName;
90
+ const now = opts.now ?? Date.now();
74
91
  const cache = opts.forceRefresh ? {} : readCache();
75
92
  const stats = new Map();
76
93
  const toProbe = [];
@@ -82,11 +99,13 @@ export async function loadFleetStats(devices, opts = {}) {
82
99
  continue;
83
100
  }
84
101
  const cached = cache[d.name];
85
- if (cached) {
102
+ if (cached && isFreshDeviceStats(cached, now)) {
86
103
  stats.set(d.name, cached);
87
104
  servedFromCache = true;
88
105
  }
89
106
  else {
107
+ // Missing OR beyond the staleness bound: a stale number rendered as
108
+ // current is worse than the probe's cost, so re-probe live (#2666).
90
109
  toProbe.push(d);
91
110
  }
92
111
  }
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import type { FleetManifest, FleetDefaults } from './types.js';
17
17
  export interface CaptureInputs {
18
- /** Registered, non-control device names to record (the roster — names only). */
18
+ /** Registered device names to record (the roster — names only). */
19
19
  devices: string[];
20
20
  /** Optional per-device agent specs (e.g. from `--from-pins`), keyed by name. */
21
21
  agentsByDevice?: Record<string, string[]>;
@@ -124,7 +124,7 @@ export function readFleetFile(filePath) {
124
124
  }
125
125
  const fleet = doc.fleet;
126
126
  if (fleet === undefined) {
127
- throw new Error(`${filePath} has no fleet: block. Add one to declare the profile (see \`agents apply --help\`).`);
127
+ throw new Error(`${filePath} has no fleet: block. Add one to declare the profile (see \`agents fleet apply --help\`).`);
128
128
  }
129
129
  return parseFleetManifest(fleet);
130
130
  }
@@ -63,7 +63,7 @@ export interface SelectLoginOptions {
63
63
  *
64
64
  * A pair is pending when its cached verdict {@link isPendingVerdict} (or
65
65
  * `includeLoggedIn` forces it). Agents with no defined login flow are dropped
66
- * silently (nothing to log into). Control devices are excluded by the caller.
66
+ * silently (nothing to log into).
67
67
  */
68
68
  export declare function selectLoginTargets(devices: DeviceProfile[], agents: (AgentId | string)[], cache: Record<string, AuthHealth>, opts?: SelectLoginOptions): PendingLogin[];
69
69
  /**
@@ -146,7 +146,7 @@ export interface DetectOptions {
146
146
  includeLoggedIn?: boolean;
147
147
  }
148
148
  /**
149
- * Resolve the online (non-control) fleet devices, then compute pending logins
149
+ * Resolve the online fleet devices, then compute pending logins
150
150
  * from the shared auth-health cache (populated by `agents fleet ping`). Thin I/O
151
151
  * over the pure {@link selectLoginTargets}. Devices/agents can be narrowed by the
152
152
  * caller's flags; the default agent set is every agent with a defined flow.
@@ -27,7 +27,6 @@
27
27
  * human works through the earlier ones.
28
28
  */
29
29
  import * as http from 'http';
30
- import { isControlDevice } from '../devices/registry.js';
31
30
  import { loadDevices } from '../devices/registry.js';
32
31
  import { deviceIdentityArgs, fleetDialTarget } from '../devices/connect.js';
33
32
  import { planFleetTargets } from '../devices/fleet.js';
@@ -117,7 +116,7 @@ function worstCachedVerdict(cache, host, agent) {
117
116
  *
118
117
  * A pair is pending when its cached verdict {@link isPendingVerdict} (or
119
118
  * `includeLoggedIn` forces it). Agents with no defined login flow are dropped
120
- * silently (nothing to log into). Control devices are excluded by the caller.
119
+ * silently (nothing to log into).
121
120
  */
122
121
  export function selectLoginTargets(devices, agents, cache, opts = {}) {
123
122
  const out = [];
@@ -408,7 +407,7 @@ export async function driveRemoteLogin(target, flow, driver, opts = {}, extraSsh
408
407
  }
409
408
  }
410
409
  /**
411
- * Resolve the online (non-control) fleet devices, then compute pending logins
410
+ * Resolve the online fleet devices, then compute pending logins
412
411
  * from the shared auth-health cache (populated by `agents fleet ping`). Thin I/O
413
412
  * over the pure {@link selectLoginTargets}. Devices/agents can be narrowed by the
414
413
  * caller's flags; the default agent set is every agent with a defined flow.
@@ -417,7 +416,7 @@ export async function detectPending(opts = {}) {
417
416
  const reg = await loadDevices();
418
417
  const self = machineId();
419
418
  const online = planFleetTargets(reg)
420
- .filter((t) => !t.skip && t.device.name !== self && !isControlDevice(t.device))
419
+ .filter((t) => !t.skip && t.device.name !== self)
421
420
  .map((t) => t.device);
422
421
  const deviceFilter = opts.devices && opts.devices.length > 0 ? new Set(opts.devices) : null;
423
422
  const devices = deviceFilter ? online.filter((d) => deviceFilter.has(d.name)) : online;
@@ -168,6 +168,8 @@ export interface InteractiveDispatchOptions {
168
168
  agent: string;
169
169
  /** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
170
170
  version?: string;
171
+ /** Preserve the trailing-@ account picker so selection happens on the execution host. */
172
+ accountPicker?: boolean;
171
173
  /** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
172
174
  strategy?: string;
173
175
  /** Named provider account to resolve on the remote host. */
@@ -417,7 +417,14 @@ export function buildRunForwardedArgs(opts) {
417
417
  * infer headless from the prompt).
418
418
  */
419
419
  export function buildInteractiveRunForwardedArgs(opts) {
420
- const agentArg = opts.version ? `${opts.agent}@${opts.version}` : opts.agent;
420
+ if (opts.version && opts.accountPicker) {
421
+ throw new Error('Interactive host dispatch cannot combine an account picker with a version pin');
422
+ }
423
+ const agentArg = opts.accountPicker
424
+ ? `${opts.agent}@`
425
+ : opts.version
426
+ ? `${opts.agent}@${opts.version}`
427
+ : opts.agent;
421
428
  const args = ['run', agentArg];
422
429
  if (opts.prompt && opts.forceInteractive)
423
430
  args.push(opts.prompt);
@@ -95,7 +95,6 @@ export const REMOTE_PASSTHROUGH = {
95
95
  restore: {},
96
96
  worktree: {},
97
97
  events: {},
98
- audit: {},
99
98
  feedback: {},
100
99
  pty: {},
101
100
  tmux: {},
@@ -17,7 +17,7 @@
17
17
  * enrolled host shadows a same-name device in both list dedup and resolve
18
18
  * order, exactly like the old tier-2 devices fall-through in resolveHost.
19
19
  */
20
- import { loadDevices, getDevice, isControlDevice } from '../../devices/registry.js';
20
+ import { loadDevices, getDevice } from '../../devices/registry.js';
21
21
  import { resolveDeviceProfile } from '../../devices/resolve-profile.js';
22
22
  import { DeviceOffloadUnsupportedError } from '../types.js';
23
23
  /** Tailscale's own presence bit, when the sync captured one. */
@@ -34,12 +34,6 @@ function statusOf(device) {
34
34
  * precedence, carrying the caps.
35
35
  */
36
36
  function deviceToPoolHost(rawDevice) {
37
- // A control device (a cockpit, e.g. a paired iPhone) drives the fleet but
38
- // never runs agents — it must never enter the host pool or be resolvable as a
39
- // dispatch target, whatever platform it reports (an iPhone syncs as `unknown`,
40
- // which remoteShellFor would otherwise default to POSIX and try to SSH).
41
- if (isControlDevice(rawDevice))
42
- return null;
43
37
  // Effective profile: central config (ssh.*/platform) overlays discovery.
44
38
  const device = resolveDeviceProfile(rawDevice);
45
39
  const address = device.address.dnsName ?? device.address.ip;
@@ -78,12 +72,6 @@ export class DevicesHostProvider {
78
72
  const raw = await getDevice(name);
79
73
  if (!raw)
80
74
  return null;
81
- // Resolving is asking to dispatch. A control device can't run agents — fail
82
- // loud with a clear message instead of attempting an SSH dispatch onto a
83
- // phone (which remoteShellFor would treat as a POSIX host).
84
- if (isControlDevice(raw)) {
85
- throw new Error(`Device "${raw.name}" is a control device (a cockpit), not an executor — it can't run agents. Dispatch to a worker device instead.`);
86
- }
87
75
  // Effective profile: central config (ssh.*/platform) overlays discovery —
88
76
  // the password-auth refusal and the dial shape both follow the config.
89
77
  const device = resolveDeviceProfile(raw);
@@ -88,11 +88,20 @@ export declare function viewAgentVersions(view: string, agent: string): string[]
88
88
  export declare function viewHasAgentVersion(view: string, agent: string, version: string): boolean | undefined;
89
89
  /** Actionable fail-loud message for a missing remote pin (RUSH-2313). */
90
90
  export declare function missingPinnedVersionMessage(hostName: string, agent: string, version: string, installed?: string[]): string;
91
+ /** Account eligibility extracted from one device's `agents view --json`. */
92
+ export interface ViewAgentAccountEligibility {
93
+ /** At least one account can launch immediately. */
94
+ signedIn: boolean | undefined;
95
+ /** At least one account can launch immediately or enter the harness login flow. */
96
+ pickerEligible: boolean | undefined;
97
+ }
91
98
  /**
92
- * Read whether the requested harness has at least one launch-eligible account
93
- * from `agents view --json`. Signed-in accounts that are rate-limited or out of
94
- * credits are deliberately false so automatic placement cannot select them.
99
+ * Read the two account gates automatic placement needs from `agents view
100
+ * --json`. A picker may route to a signed-out version because launching it is
101
+ * the login flow, but it must not route to a device whose every signed-in
102
+ * account is throttled.
95
103
  */
104
+ export declare function viewAgentAccountEligibility(view: string, agent: string): ViewAgentAccountEligibility;
96
105
  export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
97
106
  export interface EnsureReadyOptions {
98
107
  agent: string;
@@ -12,6 +12,7 @@ import { sshExec, shellQuote } from '../ssh-exec.js';
12
12
  import { hostIdentityArgs, sshTargetFor } from './types.js';
13
13
  import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
14
14
  import { resolveRemoteOsSync } from './remote-os.js';
15
+ import { isDeadVerdict } from '../auth-health.js';
15
16
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
16
17
  export function localCliVersion() {
17
18
  let dir = path.dirname(fileURLToPath(import.meta.url));
@@ -208,27 +209,41 @@ export function missingPinnedVersionMessage(hostName, agent, version, installed)
208
209
  `Install it on that box: agents ssh ${hostName} -- agents add ${agent}@${version}`);
209
210
  }
210
211
  /**
211
- * Read whether the requested harness has at least one launch-eligible account
212
- * from `agents view --json`. Signed-in accounts that are rate-limited or out of
213
- * credits are deliberately false so automatic placement cannot select them.
212
+ * Read the two account gates automatic placement needs from `agents view
213
+ * --json`. A picker may route to a signed-out version because launching it is
214
+ * the login flow, but it must not route to a device whose every signed-in
215
+ * account is throttled.
214
216
  */
215
- export function viewAgentSignedIn(view, agent) {
217
+ export function viewAgentAccountEligibility(view, agent) {
216
218
  try {
217
219
  const rows = JSON.parse(view);
218
220
  const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
219
221
  if (!row)
220
- return undefined;
221
- const verdicts = (row.versions ?? [])
222
- .map((version) => typeof version.signedIn === 'boolean'
223
- ? version.signedIn && version.usageStatus !== 'rate_limited' && version.usageStatus !== 'out_of_credits'
224
- : undefined)
225
- .filter((value) => typeof value === 'boolean');
226
- return verdicts.length === 0 ? undefined : verdicts.some(Boolean);
222
+ return { signedIn: undefined, pickerEligible: undefined };
223
+ const verdicts = (row.versions ?? []).flatMap((version) => {
224
+ if (typeof version.signedIn !== 'boolean')
225
+ return [];
226
+ const throttled = version.usageStatus === 'rate_limited' || version.usageStatus === 'out_of_credits';
227
+ const authBlocked = version.authVerdict !== null
228
+ && version.authVerdict !== undefined
229
+ && isDeadVerdict(version.authVerdict);
230
+ const ready = version.signedIn && !authBlocked && !throttled;
231
+ return [{ ready, pickerEligible: ready || !version.signedIn || authBlocked }];
232
+ });
233
+ if (verdicts.length === 0)
234
+ return { signedIn: undefined, pickerEligible: undefined };
235
+ return {
236
+ signedIn: verdicts.some((verdict) => verdict.ready),
237
+ pickerEligible: verdicts.some((verdict) => verdict.pickerEligible),
238
+ };
227
239
  }
228
240
  catch {
229
- return undefined;
241
+ return { signedIn: undefined, pickerEligible: undefined };
230
242
  }
231
243
  }
244
+ export function viewAgentSignedIn(view, agent) {
245
+ return viewAgentAccountEligibility(view, agent).signedIn;
246
+ }
232
247
  /**
233
248
  * Pure readiness verdict for the agent/version half of {@link ensureHostReady}
234
249
  * (unit-tested without SSH). Returns warnings for soft agent-missing cases, or
@@ -27,8 +27,8 @@ export declare function getAllProviders(): HostProvider[];
27
27
  * A resolved host, carrying the live {@link DeviceProfile} when the token
28
28
  * matched a registered device (by normalized name). The device reference lets
29
29
  * the `agents ssh` wrapper reach auth/shell/tailscale metadata a plain `Host`
30
- * doesn't hold, and lets dispatch callers apply the device-only refusals
31
- * (control device, password auth) without re-reading the registry.
30
+ * doesn't hold, and lets dispatch callers apply the device-only refusal
31
+ * (password auth) without re-reading the registry.
32
32
  */
33
33
  export interface ResolvedHost extends Host {
34
34
  device?: DeviceProfile;
@@ -65,7 +65,7 @@ export interface MatchHostOptions {
65
65
  * all resolve the same way regardless of which subcommand called.
66
66
  *
67
67
  * Non-throwing: returns null on an injection-guard failure or an unresolved bare
68
- * token. Device-only refusals (control device, password auth) are NOT applied
68
+ * token. The device-only refusal (password auth) is NOT applied
69
69
  * here — that is the dispatch wrapper's job ({@link resolveHost}), so the fan-out
70
70
  * and `agents ssh` paths, which handle those cases differently, aren't forced
71
71
  * into a dispatch verdict.
@@ -81,8 +81,7 @@ export declare function listAllHosts(): Promise<Host[]>;
81
81
  * caller consumes (`run --device`, the generic passthrough, teams placement, the
82
82
  * cloud host provider, doctor, funnel, remote secrets). Grammar and merge come
83
83
  * from {@link matchHost}; on top, this layer applies the device-only dispatch
84
- * refusals so they hold even when an inline overlay shadows the device:
85
- * - a control device (a paired iPhone) can never be a dispatch target;
84
+ * refusal so it holds even when an inline overlay shadows the device:
86
85
  * - a password-auth device throws the typed {@link DeviceOffloadUnsupportedError}
87
86
  * (offload rides `BatchMode=yes` ssh, which can't answer a prompt);
88
87
  * - an addressless device has nothing to dial.