@phnx-labs/agents-cli 1.22.47 → 1.22.49

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 (249) hide show
  1. package/CHANGELOG.md +401 -17
  2. package/README.md +66 -32
  3. package/dist/bootstrap.js +4 -2
  4. package/dist/cli/command-registry.d.ts +1 -0
  5. package/dist/cli/command-registry.js +2 -0
  6. package/dist/commands/accounts.d.ts +58 -0
  7. package/dist/commands/accounts.js +206 -32
  8. package/dist/commands/artifacts.js +7 -6
  9. package/dist/commands/attach.js +1 -1
  10. package/dist/commands/browser.js +501 -103
  11. package/dist/commands/config.d.ts +0 -1
  12. package/dist/commands/config.js +0 -20
  13. package/dist/commands/daemon.d.ts +21 -0
  14. package/dist/commands/daemon.js +147 -12
  15. package/dist/commands/exec.js +52 -15
  16. package/dist/commands/focus.d.ts +14 -0
  17. package/dist/commands/focus.js +48 -5
  18. package/dist/commands/go.d.ts +3 -0
  19. package/dist/commands/go.js +16 -3
  20. package/dist/commands/inspect.js +2 -2
  21. package/dist/commands/lease.d.ts +1 -1
  22. package/dist/commands/lease.js +4 -3
  23. package/dist/commands/mailboxes.js +4 -3
  24. package/dist/commands/menubar.js +11 -4
  25. package/dist/commands/projects.d.ts +16 -1
  26. package/dist/commands/projects.js +58 -64
  27. package/dist/commands/resume.js +1 -1
  28. package/dist/commands/route.js +4 -4
  29. package/dist/commands/routines.js +3 -2
  30. package/dist/commands/run-broadcast.d.ts +1 -1
  31. package/dist/commands/run-broadcast.js +1 -1
  32. package/dist/commands/sessions-picker.d.ts +22 -0
  33. package/dist/commands/sessions-picker.js +48 -9
  34. package/dist/commands/sessions-trace.js +1 -1
  35. package/dist/commands/sessions.js +1 -0
  36. package/dist/commands/setup-secrets.js +23 -0
  37. package/dist/commands/share.d.ts +20 -5
  38. package/dist/commands/share.js +152 -52
  39. package/dist/commands/ssh.js +54 -7
  40. package/dist/commands/traces.d.ts +2 -0
  41. package/dist/commands/traces.js +184 -0
  42. package/dist/commands/view.d.ts +1 -1
  43. package/dist/commands/view.js +17 -34
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +5 -0
  46. package/dist/lib/accounting/usage.d.ts +14 -3
  47. package/dist/lib/accounting/usage.js +64 -18
  48. package/dist/lib/agent-spec/agents.js +11 -9
  49. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  50. package/dist/lib/browser/drivers/ssh.js +36 -9
  51. package/dist/lib/browser/ipc.js +8 -3
  52. package/dist/lib/browser/profiles.d.ts +35 -101
  53. package/dist/lib/browser/profiles.js +130 -301
  54. package/dist/lib/browser/registry.d.ts +39 -0
  55. package/dist/lib/browser/registry.js +149 -0
  56. package/dist/lib/browser/remote-control.d.ts +8 -6
  57. package/dist/lib/browser/remote-control.js +8 -6
  58. package/dist/lib/browser/resolve-target.d.ts +68 -0
  59. package/dist/lib/browser/resolve-target.js +250 -0
  60. package/dist/lib/browser/runtime-state.d.ts +40 -16
  61. package/dist/lib/browser/runtime-state.js +92 -25
  62. package/dist/lib/browser/service.d.ts +28 -9
  63. package/dist/lib/browser/service.js +139 -62
  64. package/dist/lib/browser/task-index.d.ts +70 -0
  65. package/dist/lib/browser/task-index.js +180 -0
  66. package/dist/lib/byok-usage.js +2 -2
  67. package/dist/lib/claude-statusline.d.ts +29 -0
  68. package/dist/lib/claude-statusline.js +139 -0
  69. package/dist/lib/cloud/host.d.ts +1 -1
  70. package/dist/lib/cloud/host.js +1 -1
  71. package/dist/lib/cloud/rush.js +1 -1
  72. package/dist/lib/computer/computer-rpc.js +2 -2
  73. package/dist/lib/computer/download.d.ts +15 -15
  74. package/dist/lib/computer/download.js +35 -98
  75. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  76. package/dist/lib/computer/ssh-tunnel.js +12 -22
  77. package/dist/lib/config-keys.d.ts +2 -8
  78. package/dist/lib/config-keys.js +2 -13
  79. package/dist/lib/crabbox/cli.d.ts +1 -1
  80. package/dist/lib/crabbox/cli.js +2 -2
  81. package/dist/lib/crabbox/lease.d.ts +9 -0
  82. package/dist/lib/crabbox/lease.js +70 -15
  83. package/dist/lib/crabbox/runtimes.js +1 -1
  84. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  85. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  86. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  87. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  88. package/dist/lib/daemon/daemon.d.ts +3 -0
  89. package/dist/lib/daemon/daemon.js +212 -444
  90. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  91. package/dist/lib/daemon/device-probe-service.js +47 -0
  92. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  93. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  94. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  95. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  96. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  97. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  98. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  99. package/dist/lib/daemon/self-heal-service.js +48 -0
  100. package/dist/lib/daemon/service.d.ts +102 -0
  101. package/dist/lib/daemon/service.js +98 -0
  102. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  103. package/dist/lib/daemon/session-index-service.js +40 -0
  104. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  105. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  106. package/dist/lib/daemon/supervisor.d.ts +87 -0
  107. package/dist/lib/daemon/supervisor.js +298 -0
  108. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  109. package/dist/lib/daemon/watchdog-service.js +40 -0
  110. package/dist/lib/daemon-health.d.ts +15 -0
  111. package/dist/lib/daemon-health.js +29 -5
  112. package/dist/lib/daemon-services.d.ts +8 -1
  113. package/dist/lib/daemon-services.js +55 -0
  114. package/dist/lib/daemon-ticks.d.ts +13 -2
  115. package/dist/lib/daemon-ticks.js +20 -18
  116. package/dist/lib/device-config.d.ts +0 -2
  117. package/dist/lib/device-config.js +1 -23
  118. package/dist/lib/devices/registry.d.ts +1 -1
  119. package/dist/lib/devices/registry.js +1 -1
  120. package/dist/lib/devices/stats-cache.d.ts +20 -0
  121. package/dist/lib/devices/stats-cache.js +49 -5
  122. package/dist/lib/devices/worker-pick.d.ts +48 -0
  123. package/dist/lib/devices/worker-pick.js +113 -0
  124. package/dist/lib/exec.d.ts +67 -13
  125. package/dist/lib/exec.js +85 -29
  126. package/dist/lib/harness/adapters/cursor.js +8 -11
  127. package/dist/lib/helper-download.d.ts +108 -0
  128. package/dist/lib/helper-download.js +194 -0
  129. package/dist/lib/helper-versions.d.ts +52 -0
  130. package/dist/lib/helper-versions.js +58 -0
  131. package/dist/lib/hooks/cache.js +11 -0
  132. package/dist/lib/hooks/install.js +2 -2
  133. package/dist/lib/hosts/credentials.d.ts +1 -1
  134. package/dist/lib/hosts/credentials.js +1 -1
  135. package/dist/lib/hosts/dispatch.d.ts +7 -1
  136. package/dist/lib/hosts/dispatch.js +24 -5
  137. package/dist/lib/hosts/passthrough.d.ts +8 -15
  138. package/dist/lib/hosts/passthrough.js +19 -16
  139. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  140. package/dist/lib/hosts/providers/devices.js +1 -1
  141. package/dist/lib/hosts/reconnect.d.ts +179 -21
  142. package/dist/lib/hosts/reconnect.js +266 -45
  143. package/dist/lib/installations/migrate.d.ts +4 -5
  144. package/dist/lib/installations/migrate.js +6 -7
  145. package/dist/lib/installations/shims.d.ts +1 -1
  146. package/dist/lib/installations/shims.js +23 -13
  147. package/dist/lib/installations/versions.js +25 -2
  148. package/dist/lib/linear-autoclose.d.ts +1 -1
  149. package/dist/lib/linear-autoclose.js +1 -1
  150. package/dist/lib/loop.d.ts +2 -2
  151. package/dist/lib/loop.js +1 -1
  152. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  153. package/dist/lib/menubar/download-menubar.js +60 -0
  154. package/dist/lib/menubar/install-menubar.d.ts +31 -7
  155. package/dist/lib/menubar/install-menubar.js +79 -21
  156. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  157. package/dist/lib/menubar/notify-desktop.js +6 -6
  158. package/dist/lib/model-tiers.d.ts +1 -1
  159. package/dist/lib/project-probe.d.ts +1 -1
  160. package/dist/lib/project-probe.js +1 -1
  161. package/dist/lib/project-pull.d.ts +1 -1
  162. package/dist/lib/project-pull.js +2 -2
  163. package/dist/lib/projects.d.ts +1 -1
  164. package/dist/lib/projects.js +1 -1
  165. package/dist/lib/sandbox.js +6 -7
  166. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  167. package/dist/lib/secrets/download-keychain.js +70 -0
  168. package/dist/lib/secrets/install-helper.d.ts +20 -1
  169. package/dist/lib/secrets/install-helper.js +44 -3
  170. package/dist/lib/session/active.js +17 -1
  171. package/dist/lib/session/db.d.ts +11 -1
  172. package/dist/lib/session/db.js +138 -6
  173. package/dist/lib/session/discover.d.ts +21 -1
  174. package/dist/lib/session/discover.js +33 -5
  175. package/dist/lib/session/highlights.d.ts +49 -1
  176. package/dist/lib/session/highlights.js +95 -0
  177. package/dist/lib/session/host-link.d.ts +16 -2
  178. package/dist/lib/session/host-link.js +7 -1
  179. package/dist/lib/session/parse.js +33 -1
  180. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  181. package/dist/lib/session/remote/remote-list.js +19 -2
  182. package/dist/lib/session/remote/watch.js +11 -2
  183. package/dist/lib/session/session-cache.d.ts +53 -0
  184. package/dist/lib/session/session-cache.js +88 -0
  185. package/dist/lib/session/state.js +1 -1
  186. package/dist/lib/session/tool-calls.d.ts +2 -0
  187. package/dist/lib/session/tool-calls.js +1 -1
  188. package/dist/lib/session/trajectory-html.d.ts +5 -1
  189. package/dist/lib/session/trajectory-html.js +42 -22
  190. package/dist/lib/session/trajectory-text.js +9 -2
  191. package/dist/lib/session/trajectory.js +53 -4
  192. package/dist/lib/session/types.d.ts +15 -0
  193. package/dist/lib/sha256-asset.d.ts +26 -0
  194. package/dist/lib/sha256-asset.js +41 -0
  195. package/dist/lib/share/backend.d.ts +95 -0
  196. package/dist/lib/share/backend.js +139 -0
  197. package/dist/lib/share/delete.d.ts +6 -2
  198. package/dist/lib/share/delete.js +8 -9
  199. package/dist/lib/share/html.d.ts +22 -0
  200. package/dist/lib/share/html.js +88 -0
  201. package/dist/lib/share/provision.d.ts +18 -4
  202. package/dist/lib/share/provision.js +38 -4
  203. package/dist/lib/share/publish.d.ts +39 -15
  204. package/dist/lib/share/publish.js +55 -26
  205. package/dist/lib/share/worker-template.js +171 -11
  206. package/dist/lib/sqlite.js +16 -3
  207. package/dist/lib/ssh-exec.d.ts +65 -0
  208. package/dist/lib/ssh-exec.js +120 -4
  209. package/dist/lib/startup/command-registry.js +1 -1
  210. package/dist/lib/startup/dev-build.js +3 -3
  211. package/dist/lib/state.d.ts +1 -1
  212. package/dist/lib/state.js +7 -3
  213. package/dist/lib/subagents-registry.js +1 -1
  214. package/dist/lib/terminal/index.d.ts +1 -1
  215. package/dist/lib/terminal/types.d.ts +1 -1
  216. package/dist/lib/terminal/types.js +1 -1
  217. package/dist/lib/tmux/orphan-reap.js +1 -1
  218. package/dist/lib/tmux/session.js +31 -0
  219. package/dist/lib/traces/backend.d.ts +29 -0
  220. package/dist/lib/traces/backend.js +46 -0
  221. package/dist/lib/traces/classify.d.ts +52 -0
  222. package/dist/lib/traces/classify.js +72 -0
  223. package/dist/lib/traces/config.d.ts +10 -0
  224. package/dist/lib/traces/config.js +2 -0
  225. package/dist/lib/traces/provision.d.ts +19 -0
  226. package/dist/lib/traces/provision.js +30 -0
  227. package/dist/lib/traces/sync.d.ts +150 -0
  228. package/dist/lib/traces/sync.js +447 -0
  229. package/dist/lib/traces/worker-template.d.ts +4 -0
  230. package/dist/lib/traces/worker-template.js +159 -0
  231. package/dist/lib/types.d.ts +18 -19
  232. package/dist/lib/types.js +14 -0
  233. package/dist/lib/usage-refresh.d.ts +1 -1
  234. package/dist/lib/usage-refresh.js +11 -8
  235. package/dist/lib/workflows.d.ts +1 -1
  236. package/package.json +4 -6
  237. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  238. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  239. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  240. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  241. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  242. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  243. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -26
  244. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  245. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  246. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -135
  247. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  248. package/dist/lib/usage-fleet.d.ts +0 -32
  249. package/dist/lib/usage-fleet.js +0 -125
@@ -67,8 +67,9 @@ export declare function runFleetCacheWarmTick(): Promise<void>;
67
67
  /**
68
68
  * Usage refresh: keep the usage cache the `agents run` router reads
69
69
  * (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
70
- * The configured primary host is the fleet's sole provider-facing writer;
71
- * subscribers import its token-free derived cache.
70
+ * Every host is its own provider-facing writer — it refreshes only the
71
+ * accounts it holds credentials for, straight from the provider APIs
72
+ * (RUSH-3193 #15; no cross-host broadcast).
72
73
  */
73
74
  export declare function runUsageRefreshTick(): Promise<void>;
74
75
  /**
@@ -79,9 +80,19 @@ export declare function runUsageRefreshTick(): Promise<void>;
79
80
  * Cadence matches {@link DEFAULT_ACTIVE_CACHE_MAX_AGE_MS} so one-shot readers and
80
81
  * long-lived watchers share one writer. Without this tick the journal has no
81
82
  * continuous producer and Factory freezes after the initial cache snapshot.
83
+ *
84
+ * Gated on reader presence (RUSH-3193): when no `sessions watch` / `feed watch`
85
+ * consumer has checked in within {@link ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS} the
86
+ * expensive `ps`+`lsof` gather is skipped entirely on this scheduled tick. A
87
+ * watcher connecting to a cold/idle daemon is NOT served by this tick alone —
88
+ * {@link noteActiveSessionsJournalReader} only writes a presence timestamp, with
89
+ * no path back to this timer — so the daemon separately runs
90
+ * {@link watchActiveSessionsReaderPresence} to detect that idle→recent edge and
91
+ * fire an out-of-band call into this same function immediately (RUSH-2484).
82
92
  */
83
93
  export declare function runActiveSessionsWarmTick(opts?: {
84
94
  gather?: () => Promise<import('./session/active.js').ActiveSession[]>;
95
+ nowMs?: number;
85
96
  }): Promise<{
86
97
  sessions: number;
87
98
  }>;
@@ -108,21 +108,11 @@ export async function runFleetCacheWarmTick() {
108
108
  /**
109
109
  * Usage refresh: keep the usage cache the `agents run` router reads
110
110
  * (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
111
- * The configured primary host is the fleet's sole provider-facing writer;
112
- * subscribers import its token-free derived cache.
111
+ * Every host is its own provider-facing writer — it refreshes only the
112
+ * accounts it holds credentials for, straight from the provider APIs
113
+ * (RUSH-3193 #15; no cross-host broadcast).
113
114
  */
114
115
  export async function runUsageRefreshTick() {
115
- const { resolveUsagePrimaryHost } = await import('./device-config.js');
116
- const { machineId } = await import('./machine-id.js');
117
- // usage.primary-host, else interactive.host, else null (standalone local refresh)
118
- const primaryHost = resolveUsagePrimaryHost() ?? undefined;
119
- const self = machineId();
120
- const { importUsageFleetFromHost, usageRefreshRole } = await import('./usage-fleet.js');
121
- if (usageRefreshRole(primaryHost, self) === 'subscriber') {
122
- const imported = await importUsageFleetFromHost(primaryHost);
123
- console.log(`usage refresh: imported ${Object.keys(imported.usage).length} account(s) from primary host ${primaryHost}`);
124
- return;
125
- }
126
116
  const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
127
117
  const { writeClaudeUsageCache } = await import('./accounting/usage.js');
128
118
  const { usageRateLimitedUntil } = await import('./usage-backoff.js');
@@ -134,9 +124,7 @@ export async function runUsageRefreshTick() {
134
124
  const { listProfiles } = await import('./profiles.js');
135
125
  const { refreshDueByokUsage } = await import('./byok-usage.js');
136
126
  const byok = await refreshDueByokUsage(listProfiles());
137
- const { exportUsageFleet } = await import('./usage-fleet.js');
138
- const published = exportUsageFleet();
139
- console.log(`usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped; BYOK ${byok.refreshed} refreshed, ${byok.skipped} not-due; published ${Object.keys(published.usage).length} account(s)`);
127
+ console.log(`usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped; BYOK ${byok.refreshed} refreshed, ${byok.skipped} not-due`);
140
128
  }
141
129
  /**
142
130
  * Active-sessions warm (RUSH-2062 / RUSH-2484): publish THIS host's live session
@@ -146,10 +134,24 @@ export async function runUsageRefreshTick() {
146
134
  * Cadence matches {@link DEFAULT_ACTIVE_CACHE_MAX_AGE_MS} so one-shot readers and
147
135
  * long-lived watchers share one writer. Without this tick the journal has no
148
136
  * continuous producer and Factory freezes after the initial cache snapshot.
137
+ *
138
+ * Gated on reader presence (RUSH-3193): when no `sessions watch` / `feed watch`
139
+ * consumer has checked in within {@link ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS} the
140
+ * expensive `ps`+`lsof` gather is skipped entirely on this scheduled tick. A
141
+ * watcher connecting to a cold/idle daemon is NOT served by this tick alone —
142
+ * {@link noteActiveSessionsJournalReader} only writes a presence timestamp, with
143
+ * no path back to this timer — so the daemon separately runs
144
+ * {@link watchActiveSessionsReaderPresence} to detect that idle→recent edge and
145
+ * fire an out-of-band call into this same function immediately (RUSH-2484).
149
146
  */
150
147
  export async function runActiveSessionsWarmTick(opts = {}) {
151
- const { publishLocalActiveSessions } = await import('./session/session-cache.js');
152
- const r = await publishLocalActiveSessions({ gather: opts.gather });
148
+ const { publishLocalActiveSessions, isActiveSessionsJournalReaderRecent } = await import('./session/session-cache.js');
149
+ const nowMs = opts.nowMs ?? Date.now();
150
+ if (!isActiveSessionsJournalReaderRecent(nowMs)) {
151
+ console.log('active-sessions warm: idle (no recent reader), skipping gather');
152
+ return { sessions: 0 };
153
+ }
154
+ const r = await publishLocalActiveSessions({ gather: opts.gather, nowMs });
153
155
  console.log(`active-sessions warm: ${r.sessions.length} session(s) published`);
154
156
  return { sessions: r.sessions.length };
155
157
  }
@@ -124,8 +124,6 @@ export declare function listConfig(opts?: ConfigTarget): ConfigEntry[];
124
124
  /** List user-scope config keys with their values. Used to show inherited settings
125
125
  * in per-device views without implying those keys are device-local. */
126
126
  export declare function listUserConfig(): ConfigEntry[];
127
- /** Resolve the explicit usage host, falling back to the user's interactive host. */
128
- export declare function resolveUsagePrimaryHost(): string | null;
129
127
  /**
130
128
  * Set a config key (validated). Device-scope keys target this machine unless
131
129
  * `opts.device` names a peer; `opts.fleet` writes the fleet-wide defaults layer
@@ -61,22 +61,6 @@ export const CONFIG_KEYS = [
61
61
  }
62
62
  },
63
63
  },
64
- {
65
- name: 'usage.primary-host',
66
- yamlKey: 'usagePrimaryHost',
67
- scope: 'user',
68
- type: 'string',
69
- description: 'Device whose usage snapshots are authoritative for fleet-wide usage reporting.',
70
- validate: (v) => {
71
- try {
72
- assertRegistrableDeviceName(v);
73
- return null;
74
- }
75
- catch (err) {
76
- return err?.message ?? String(err);
77
- }
78
- },
79
- },
80
64
  {
81
65
  name: 'auto.pool',
82
66
  yamlKey: 'autoPool',
@@ -181,7 +165,7 @@ export const CONFIG_KEYS = [
181
165
  description: 'Minutes a browser task may sit with no IPC action (navigate, click, type, screenshot, …) before the daemon\'s ' +
182
166
  'abandoned-task reaper closes its tabs and marks it done (RUSH-2622). 0 disables idle reaping — the reaper still ' +
183
167
  "closes a task whose owning agent session has exited, whatever this is set to. Read only on THIS box's own " +
184
- 'reaper tick and `agents browser gc`, so it never applies to a peer.',
168
+ 'reaper tick and `agents browser prune`, so it never applies to a peer.',
185
169
  validate: (v) => v >= 0 ? null : 'browser.task-idle-minutes must be >= 0 (0 disables idle reaping).',
186
170
  },
187
171
  {
@@ -486,12 +470,6 @@ export function listConfig(opts) {
486
470
  export function listUserConfig() {
487
471
  return CONFIG_KEYS.filter((spec) => spec.scope === 'user').map((spec) => getConfigValue(spec.name));
488
472
  }
489
- /** Resolve the explicit usage host, falling back to the user's interactive host. */
490
- export function resolveUsagePrimaryHost() {
491
- return getConfigValue('usage.primary-host').value
492
- ?? getConfigValue('interactive.host').value
493
- ?? null;
494
- }
495
473
  // ─── Writes ───────────────────────────────────────────────────────────────────
496
474
  /** The fleet manifest for a defaults write: `devices` materializes as an
497
475
  * explicit empty map (NOT 'all') so `agents apply` targets nothing until the
@@ -107,7 +107,7 @@ export declare function assertValidDeviceName(name: string): void;
107
107
  *
108
108
  * Deliberately separate from {@link assertValidDeviceName}, and called from
109
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`).
110
+ * that point AT a device (`interactive.host`).
111
111
  *
112
112
  * Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
113
113
  * receive tailnet node names the user never typed — `devices sync` upserts every
@@ -88,7 +88,7 @@ export function assertValidDeviceName(name) {
88
88
  *
89
89
  * Deliberately separate from {@link assertValidDeviceName}, and called from
90
90
  * exactly two kinds of place: `agents devices add <name>`, and the config keys
91
- * that point AT a device (`interactive.host`, `usage.primary-host`).
91
+ * that point AT a device (`interactive.host`).
92
92
  *
93
93
  * Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
94
94
  * receive tailnet node names the user never typed — `devices sync` upserts every
@@ -14,6 +14,26 @@ export declare const SPECS_STALE_MS: number;
14
14
  export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
15
15
  /** True when cached core, RAM-total, and disk-total facts remain current. */
16
16
  export declare function isFreshDeviceSpecs(stats: DeviceStats, now?: number): boolean;
17
+ /**
18
+ * Carry a device's last successfully-probed hardware facts onto a row whose
19
+ * probe just came back unreachable (RUSH-3096).
20
+ *
21
+ * A failed probe knows nothing about the box — `probeDeviceStats` resolves
22
+ * `{ host, reachable: false, fetchedAt }` — and that bare row then replaced the
23
+ * cached one, so `agents devices list` rendered an offline device with an empty
24
+ * `spec` cell and forgot its cores/RAM/disk until it came back up. Cores, total
25
+ * RAM, and root-disk capacity do not change while a machine is down, so they
26
+ * survive the failure; `loadPercent`, `memPercent`, and the free-byte counts are
27
+ * current-state readings and stay absent rather than render a stale number as
28
+ * live.
29
+ *
30
+ * `specsFetchedAt` keeps the moment the facts were actually observed while
31
+ * `fetchedAt` advances to this attempt, so the row reads "this hardware, seen
32
+ * then; unreachable, as of now" instead of backdating a reading that never
33
+ * happened. A reachable probe is returned untouched — its own reading is the
34
+ * truth, including a fact the box has stopped reporting.
35
+ */
36
+ export declare function retainHardwareFacts(probed: DeviceStats, prior: DeviceStats | undefined): DeviceStats;
17
37
  /** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
18
38
  export declare function readStatsCache(): Record<string, DeviceStats>;
19
39
  /**
@@ -48,6 +48,44 @@ export function isFreshDeviceSpecs(stats, now = Date.now()) {
48
48
  const fetchedAt = stats.specsFetchedAt ?? stats.fetchedAt;
49
49
  return now - fetchedAt <= SPECS_STALE_MS;
50
50
  }
51
+ /**
52
+ * Carry a device's last successfully-probed hardware facts onto a row whose
53
+ * probe just came back unreachable (RUSH-3096).
54
+ *
55
+ * A failed probe knows nothing about the box — `probeDeviceStats` resolves
56
+ * `{ host, reachable: false, fetchedAt }` — and that bare row then replaced the
57
+ * cached one, so `agents devices list` rendered an offline device with an empty
58
+ * `spec` cell and forgot its cores/RAM/disk until it came back up. Cores, total
59
+ * RAM, and root-disk capacity do not change while a machine is down, so they
60
+ * survive the failure; `loadPercent`, `memPercent`, and the free-byte counts are
61
+ * current-state readings and stay absent rather than render a stale number as
62
+ * live.
63
+ *
64
+ * `specsFetchedAt` keeps the moment the facts were actually observed while
65
+ * `fetchedAt` advances to this attempt, so the row reads "this hardware, seen
66
+ * then; unreachable, as of now" instead of backdating a reading that never
67
+ * happened. A reachable probe is returned untouched — its own reading is the
68
+ * truth, including a fact the box has stopped reporting.
69
+ */
70
+ export function retainHardwareFacts(probed, prior) {
71
+ if (probed.reachable || !prior)
72
+ return probed;
73
+ const ncpu = probed.ncpu ?? prior.ncpu;
74
+ const memTotalBytes = probed.memTotalBytes ?? prior.memTotalBytes;
75
+ const diskTotalBytes = probed.diskTotalBytes ?? prior.diskTotalBytes;
76
+ if (ncpu === probed.ncpu &&
77
+ memTotalBytes === probed.memTotalBytes &&
78
+ diskTotalBytes === probed.diskTotalBytes) {
79
+ return probed;
80
+ }
81
+ return {
82
+ ...probed,
83
+ ncpu,
84
+ memTotalBytes,
85
+ diskTotalBytes,
86
+ specsFetchedAt: prior.specsFetchedAt ?? prior.fetchedAt,
87
+ };
88
+ }
51
89
  function cacheFilePath() {
52
90
  return path.join(getCacheDir(), CACHE_FILE);
53
91
  }
@@ -95,7 +133,10 @@ export async function loadFleetStats(devices, opts = {}) {
95
133
  const writeCache = opts.writeCache ?? writeStatsCache;
96
134
  const self = opts.selfName;
97
135
  const now = opts.now ?? Date.now();
98
- const cache = opts.forceRefresh ? {} : readCache();
136
+ // Always read the cache, even under --refresh. It is not *served* then, but it
137
+ // is the only record of each box's hardware, and a failed probe must not erase
138
+ // it — see {@link retainHardwareFacts}.
139
+ const cache = readCache();
99
140
  const stats = new Map();
100
141
  const toProbe = [];
101
142
  let servedFromCache = false;
@@ -105,7 +146,7 @@ export async function loadFleetStats(devices, opts = {}) {
105
146
  toProbe.push(d);
106
147
  continue;
107
148
  }
108
- const cached = cache[d.name];
149
+ const cached = opts.forceRefresh ? undefined : cache[d.name];
109
150
  const cacheFresh = cached && (opts.specsOnly ? isFreshDeviceSpecs(cached, now) : isFreshDeviceStats(cached, now));
110
151
  if (cached && cacheFresh) {
111
152
  stats.set(d.name, cached);
@@ -121,8 +162,11 @@ export async function loadFleetStats(devices, opts = {}) {
121
162
  const probed = await probeFleet(toProbe, { selfName: self });
122
163
  const fresh = {};
123
164
  for (const [name, s] of probed) {
124
- stats.set(name, s);
125
- fresh[name] = s;
165
+ // A box that just failed to answer keeps the hardware it was last seen
166
+ // with, so this call and the cache it writes both carry the spec.
167
+ const row = retainHardwareFacts(s, cache[name]);
168
+ stats.set(name, row);
169
+ fresh[name] = row;
126
170
  }
127
171
  if (Object.keys(fresh).length > 0)
128
172
  writeCache(fresh);
@@ -131,7 +175,7 @@ export async function loadFleetStats(devices, opts = {}) {
131
175
  // list (e.g. self not registered as an ssh target) — matches the old callers'
132
176
  // explicit local fallback.
133
177
  if (self && !stats.has(self)) {
134
- stats.set(self, await probeLocal(self));
178
+ stats.set(self, retainHardwareFacts(await probeLocal(self), cache[self]));
135
179
  }
136
180
  let oldest = null;
137
181
  for (const s of stats.values()) {
@@ -0,0 +1,48 @@
1
+ import { type DevicePlacementSignal } from '../teams/scheduler.js';
2
+ /** Why a candidate was dropped, for the fail-loud error message. */
3
+ export interface WorkerExclusion {
4
+ device: string;
5
+ reason: 'unreachable' | 'overloaded' | 'wrong-platform' | 'interactive';
6
+ }
7
+ export interface WorkerPickPlan {
8
+ /** The chosen device name. Never the local box unless it is an auto-pool member. */
9
+ device: string;
10
+ /** True when the pick IS this machine (the caller should run in place). */
11
+ isLocal: boolean;
12
+ candidates: Array<{
13
+ device: string;
14
+ loadPercent?: number;
15
+ headroom?: string;
16
+ }>;
17
+ excluded: WorkerExclusion[];
18
+ }
19
+ export interface WorkerPickOptions {
20
+ /** Restrict to these platforms. Defaults to POSIX (`linux`, `macos`). */
21
+ platforms?: string[];
22
+ /** Override the candidate pool (tests). */
23
+ eligibleHosts?: string[];
24
+ localMachine?: string;
25
+ /** Injected probe (tests). */
26
+ probe?: (pool: string[]) => Promise<Map<string, DevicePlacementSignal>>;
27
+ }
28
+ /**
29
+ * Platforms a bun/vitest offload can actually run on.
30
+ *
31
+ * Windows is excluded by policy, not by accident: `AGENTS.md` states Windows is
32
+ * not a required PR or ordinary-release platform and `tests.yml` runs it only as
33
+ * a `continue-on-error` post-merge smoke. Silently shipping the tree to
34
+ * `win-mini` would produce a confusing failure for a platform whose result
35
+ * nobody gates on.
36
+ */
37
+ export declare const POSIX_PLATFORMS: readonly ["linux", "macos"];
38
+ /**
39
+ * Resolve the least-loaded eligible worker.
40
+ *
41
+ * Throws — never returns a degraded answer. An offload helper that quietly
42
+ * decided "no worker, run here" would recreate exactly the failure the offload
43
+ * exists to prevent: the operator believes the work went to the fleet while
44
+ * their laptop is pinned. Callers surface the thrown message verbatim.
45
+ */
46
+ export declare function resolveWorkerDevice(opts?: WorkerPickOptions): Promise<WorkerPickPlan>;
47
+ /** The one no-worker message, naming every candidate and why it was dropped. */
48
+ export declare function formatNoWorkerError(excluded: WorkerExclusion[], platforms: Set<string>): string;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pick a worker device for offloaded machine work — the suite, a build, any job
3
+ * that pins a box for minutes and must never land on the machine someone is
4
+ * sitting at.
5
+ *
6
+ * This is deliberately NOT `resolveDeviceAuto`. That resolver answers "which box
7
+ * should run this AGENT", so it gates on harness installation and a launch-ready
8
+ * account (`smart-launch.ts:200-210`). A vitest run needs neither: it needs a
9
+ * reachable POSIX box with headroom. Gating a test offload on a signed-in Claude
10
+ * account would exclude a perfectly good worker for a reason that has nothing to
11
+ * do with running tests.
12
+ *
13
+ * What it DOES share, on purpose, is everything that decides *where agents may
14
+ * go*: the same {@link listOnlineDeviceNames} pool (so `role=worker` /
15
+ * `role=personal` marks move this surface too), the same
16
+ * {@link probePoolSignals} reachability+load probe (so one cached fleet probe
17
+ * serves both), and the same {@link pickBestDevice} least-loaded ranking. Before
18
+ * this module, `scripts/test.sh` hand-rolled its own resolver in inline Python
19
+ * and therefore could not offer `--device auto` at all.
20
+ */
21
+ import { normalizeHost } from '../machine-id.js';
22
+ import { localMachineId } from '../session/origin-machine.js';
23
+ import { loadDevicesSync } from './registry.js';
24
+ import { isAutoPoolMember } from './pool.js';
25
+ import { formatEmptyAutoPoolError, listOnlineDeviceNames, } from '../smart-launch.js';
26
+ import { pickBestDevice } from '../teams/scheduler.js';
27
+ import { probePoolSignals } from '../teams/placement-probe.js';
28
+ /**
29
+ * Platforms a bun/vitest offload can actually run on.
30
+ *
31
+ * Windows is excluded by policy, not by accident: `AGENTS.md` states Windows is
32
+ * not a required PR or ordinary-release platform and `tests.yml` runs it only as
33
+ * a `continue-on-error` post-merge smoke. Silently shipping the tree to
34
+ * `win-mini` would produce a confusing failure for a platform whose result
35
+ * nobody gates on.
36
+ */
37
+ export const POSIX_PLATFORMS = ['linux', 'macos'];
38
+ /**
39
+ * Resolve the least-loaded eligible worker.
40
+ *
41
+ * Throws — never returns a degraded answer. An offload helper that quietly
42
+ * decided "no worker, run here" would recreate exactly the failure the offload
43
+ * exists to prevent: the operator believes the work went to the fleet while
44
+ * their laptop is pinned. Callers surface the thrown message verbatim.
45
+ */
46
+ export async function resolveWorkerDevice(opts = {}) {
47
+ const local = normalizeHost(opts.localMachine ?? localMachineId());
48
+ const pool = [...new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost))];
49
+ // The local box joins the pool only when a role does not exclude it — the same
50
+ // rule resolveDeviceAuto applies. A box marked `personal` is marked precisely
51
+ // so long jobs stay off it, and that mark must hold for the suite too.
52
+ if (!pool.includes(local) && isAutoPoolMember(local))
53
+ pool.push(local);
54
+ if (pool.length === 0)
55
+ throw new Error(formatEmptyAutoPoolError());
56
+ const excluded = [];
57
+ const wanted = new Set((opts.platforms ?? POSIX_PLATFORMS).map((p) => p.toLowerCase()));
58
+ const reg = loadDevicesSync();
59
+ const platformOf = (name) => {
60
+ const d = reg[name] ?? reg[normalizeHost(name)]
61
+ ?? Object.values(reg).find((p) => p && normalizeHost(p.name) === normalizeHost(name));
62
+ return d?.platform ? String(d.platform).toLowerCase() : undefined;
63
+ };
64
+ // Platform first: it is a static registry fact, so filtering here keeps the
65
+ // live probe off boxes that could never be picked anyway.
66
+ const onPlatform = pool.filter((device) => {
67
+ const platform = platformOf(device);
68
+ // Unknown platform stays a candidate — a registry-only box with no recorded
69
+ // platform is a gap in the registry, not positive evidence it is Windows.
70
+ if (platform && !wanted.has(platform)) {
71
+ excluded.push({ device, reason: 'wrong-platform' });
72
+ return false;
73
+ }
74
+ return true;
75
+ });
76
+ if (onPlatform.length === 0)
77
+ throw new Error(formatNoWorkerError(excluded, wanted));
78
+ const signals = await (opts.probe ?? ((p) => probePoolSignals(p)))(onPlatform);
79
+ const eligible = onPlatform.filter((device) => {
80
+ const signal = signals.get(device);
81
+ if (signal?.reachable !== true) {
82
+ excluded.push({ device, reason: 'unreachable' });
83
+ return false;
84
+ }
85
+ if (signal.headroom === 'loaded') {
86
+ excluded.push({ device, reason: 'overloaded' });
87
+ return false;
88
+ }
89
+ return true;
90
+ });
91
+ if (eligible.length === 0)
92
+ throw new Error(formatNoWorkerError(excluded, wanted));
93
+ const device = pickBestDevice(eligible, [], { signals });
94
+ return {
95
+ device,
96
+ isLocal: normalizeHost(device) === local,
97
+ candidates: onPlatform.map((key) => ({
98
+ device: key,
99
+ loadPercent: signals.get(key)?.loadPercent,
100
+ headroom: signals.get(key)?.headroom,
101
+ })),
102
+ excluded,
103
+ };
104
+ }
105
+ /** The one no-worker message, naming every candidate and why it was dropped. */
106
+ export function formatNoWorkerError(excluded, platforms) {
107
+ const detail = excluded.length
108
+ ? excluded.map((e) => `${e.device} (${e.reason})`).join(', ')
109
+ : 'none';
110
+ return (`agents: no worker device is available for offloaded work `
111
+ + `[platforms: ${[...platforms].sort().join(', ')}] — excluded: ${detail}. `
112
+ + 'Mark a worker with `agents devices role <name> worker`, or name one explicitly.');
113
+ }
@@ -402,28 +402,65 @@ export interface TmuxWrapContext {
402
402
  noTmuxEnv: boolean;
403
403
  /** This device's `tmux.enabled` config — true opts every eligible launch on this box into the wrap. */
404
404
  configEnabled: boolean;
405
+ /**
406
+ * True when this run was dispatched onto this box over SSH by `--device`
407
+ * (the launcher exports {@link REMOTE_INTERACTIVE_ENV}).
408
+ *
409
+ * A remote interactive agent is a child of the sshd session and holds its
410
+ * controlling TTY, so without the wrap a dropped link SIGHUPs it and the work
411
+ * in flight is gone. Durability is therefore NOT a preference the way
412
+ * `configEnabled` is — `tmux.enabled` is about whether this box's operator
413
+ * likes tmux's mouse/clipboard/scrollback at their own keyboard, which says
414
+ * nothing about whether a run arriving over the network must outlive it.
415
+ * Conflating the two is what left every `--device` agent unsurvivable
416
+ * (RUSH-3125), while lib/hosts/reconnect.ts reconnected on the premise that
417
+ * they were detached.
418
+ */
419
+ remoteDispatch: boolean;
405
420
  /** Whether a tmux binary is on PATH. */
406
421
  tmuxAvailable: boolean;
407
422
  }
423
+ /**
424
+ * What to do with an interactive spawn. Three outcomes, not two: a run that
425
+ * MUST be durable and cannot be is neither "wrap" nor "spawn bare" — it is a
426
+ * launch that should not happen, because a bare remote spawn looks fine right
427
+ * up until the link blinks and the agent dies with it.
428
+ */
429
+ export type TmuxWrapDecision =
430
+ /** Run the agent in a detached tmux session and attach this TTY. */
431
+ {
432
+ kind: 'wrap';
433
+ }
434
+ /** Spawn directly — nothing about this run needs a pane. */
435
+ | {
436
+ kind: 'bare';
437
+ }
438
+ /** Remote-dispatched and tmux is missing on this box: refuse, don't pretend. */
439
+ | {
440
+ kind: 'undurable';
441
+ };
408
442
  /**
409
443
  * Decide whether to run an interactive agent INSIDE a detached tmux session on
410
444
  * the shared socket (then attach the current TTY) instead of a bare spawn.
411
445
  *
412
- * Opting into tmux wrapping gives every interactive agent an exact, unique `%pane` handle so
413
- * `agents sessions --active` can tell co-located agents apart, and lets `agents
414
- * focus` re-attach a live session without forking it. Pure so the gate is unit-
415
- * tested independently of the (side-effecting) spawn.
446
+ * Wrapping serves two independent purposes, and they are gated differently:
447
+ *
448
+ * - **Addressability** (`configEnabled`): a unique `%pane` handle so
449
+ * `agents sessions --active` can tell co-located agents apart and `agents
450
+ * focus` re-attaches without forking. That is a local preference — the
451
+ * operator turns it on once tmux's mouse/clipboard/scrollback suits them.
452
+ * - **Durability** (`remoteDispatch`): a run that arrived over SSH must outlive
453
+ * the SSH client, because a bare remote spawn dies of SIGHUP the moment the
454
+ * link blinks. That is not a preference, so it does not consult
455
+ * `configEnabled` (RUSH-3125 — see {@link TmuxWrapContext.remoteDispatch}).
416
456
  *
417
- * All seven guards must pass:
418
- * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
419
- * - not Windows — no tmux path on win32.
420
- * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
421
- * - not --raw — explicit opt-out.
422
- * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
423
- * - tmux.enabled — this device explicitly opted into addressable tmux panes.
424
- * - tmux installed — otherwise there is nothing to wrap with.
457
+ * The per-run opt-outs bind BOTH: `--raw` / `--no-tmux` / `AGENTS_NO_TMUX=1` are
458
+ * explicit "I want the bare process" requests, and honouring them over the
459
+ * durability rule keeps one escape hatch that always works.
460
+ *
461
+ * Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
425
462
  */
426
- export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
463
+ export declare function resolveTmuxWrap(ctx: TmuxWrapContext): TmuxWrapDecision;
427
464
  /**
428
465
  * Build the shell command that runs an agent inside a tmux pane with the exact
429
466
  * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
@@ -442,6 +479,23 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
442
479
  * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
443
480
  * real values; the stored/informational copy uses the redacted form (RUSH-1758).
444
481
  */
482
+ /**
483
+ * True when `options.sessionId` is an id the HARNESS actually received, and so a
484
+ * real, resumable handle — rather than one the launcher generated for its own
485
+ * bookkeeping and the agent never saw.
486
+ *
487
+ * Mirrors exactly the two branches in buildExecCommand that put the id on the
488
+ * command line: a native resume (any harness with a `resume` spec) and claude's
489
+ * create-with-`--session-id`. Everything else — `agents run codex --session-id X`
490
+ * without `--resume`, or a fresh non-claude launch whose real id only shows up
491
+ * later via the SessionStart hook — leaves the agent with an id of its own
492
+ * choosing, so `options.sessionId` must not be published as if it were that id.
493
+ *
494
+ * Keep in lockstep with buildExecCommand's resume/create branches: a harness
495
+ * that gains a forced-session-id flag must be reflected here in the SAME change,
496
+ * or this reports a genuine id as fabricated.
497
+ */
498
+ export declare function isHarnessKnownSessionId(agent: AgentId, sessionId: string | undefined, resume: boolean | undefined): boolean;
445
499
  export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
446
500
  redactEnvValues?: boolean;
447
501
  envFile?: string;