@phnx-labs/agents-cli 1.22.24 → 1.22.26

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 (143) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +31 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/browser.js +35 -0
  7. package/dist/commands/cloud.js +9 -5
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +108 -9
  10. package/dist/commands/exec.js +19 -16
  11. package/dist/commands/feed.d.ts +5 -0
  12. package/dist/commands/feed.js +21 -1
  13. package/dist/commands/focus.js +2 -2
  14. package/dist/commands/insights.d.ts +25 -19
  15. package/dist/commands/insights.js +107 -33
  16. package/dist/commands/menubar.js +8 -0
  17. package/dist/commands/reconnect.d.ts +46 -0
  18. package/dist/commands/reconnect.js +109 -0
  19. package/dist/commands/routines.d.ts +3 -0
  20. package/dist/commands/routines.js +72 -49
  21. package/dist/commands/run-cloud.js +1 -1
  22. package/dist/commands/secrets.d.ts +2 -8
  23. package/dist/commands/secrets.js +29 -105
  24. package/dist/commands/sessions-browser.d.ts +1 -1
  25. package/dist/commands/sessions-browser.js +27 -7
  26. package/dist/commands/sessions-resume.js +3 -2
  27. package/dist/commands/sessions.d.ts +13 -1
  28. package/dist/commands/sessions.js +28 -2
  29. package/dist/commands/setup-secrets.d.ts +1 -0
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/setup-watchdog.js +5 -10
  32. package/dist/commands/setup.d.ts +26 -3
  33. package/dist/commands/setup.js +106 -47
  34. package/dist/commands/teams.d.ts +6 -0
  35. package/dist/commands/teams.js +46 -2
  36. package/dist/commands/trends.d.ts +8 -0
  37. package/dist/commands/trends.js +10 -156
  38. package/dist/commands/watchdog.d.ts +3 -4
  39. package/dist/commands/watchdog.js +26 -66
  40. package/dist/index.js +37 -2
  41. package/dist/lib/agents.d.ts +11 -0
  42. package/dist/lib/agents.js +155 -23
  43. package/dist/lib/analytics/dashboard.d.ts +10 -6
  44. package/dist/lib/analytics/dashboard.js +6 -4
  45. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  46. package/dist/lib/analytics/mix-commands.js +229 -0
  47. package/dist/lib/analytics/recipes.d.ts +19 -14
  48. package/dist/lib/analytics/recipes.js +4 -2
  49. package/dist/lib/browser/ipc.d.ts +26 -0
  50. package/dist/lib/browser/ipc.js +139 -24
  51. package/dist/lib/browser/profiles.d.ts +11 -0
  52. package/dist/lib/browser/profiles.js +1 -1
  53. package/dist/lib/browser/stream.d.ts +14 -0
  54. package/dist/lib/browser/stream.js +71 -0
  55. package/dist/lib/channels/owner-sink.d.ts +27 -0
  56. package/dist/lib/channels/owner-sink.js +93 -0
  57. package/dist/lib/cloud/cursor.d.ts +79 -0
  58. package/dist/lib/cloud/cursor.js +228 -0
  59. package/dist/lib/cloud/registry.js +2 -0
  60. package/dist/lib/cloud/types.d.ts +7 -2
  61. package/dist/lib/cloud/types.js +14 -0
  62. package/dist/lib/crabbox/cli.d.ts +2 -2
  63. package/dist/lib/crabbox/config.d.ts +7 -8
  64. package/dist/lib/crabbox/config.js +14 -14
  65. package/dist/lib/crabbox/lease.d.ts +11 -4
  66. package/dist/lib/crabbox/lease.js +40 -8
  67. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  68. package/dist/lib/crabbox/setup-copy.js +17 -1
  69. package/dist/lib/daemon.js +27 -1
  70. package/dist/lib/device-config.js +7 -0
  71. package/dist/lib/devices/doctor-findings.d.ts +13 -1
  72. package/dist/lib/devices/doctor-findings.js +73 -2
  73. package/dist/lib/events.d.ts +9 -0
  74. package/dist/lib/events.js +58 -0
  75. package/dist/lib/exec.d.ts +3 -3
  76. package/dist/lib/exec.js +24 -10
  77. package/dist/lib/feed-outcome.d.ts +3 -0
  78. package/dist/lib/feed-outcome.js +18 -10
  79. package/dist/lib/feed.d.ts +4 -0
  80. package/dist/lib/fleet/apply.d.ts +59 -3
  81. package/dist/lib/fleet/apply.js +183 -6
  82. package/dist/lib/fleet/types.d.ts +21 -2
  83. package/dist/lib/hooks/cache.js +15 -0
  84. package/dist/lib/hosts/passthrough.d.ts +44 -0
  85. package/dist/lib/hosts/passthrough.js +84 -12
  86. package/dist/lib/hosts/ready.d.ts +2 -0
  87. package/dist/lib/hosts/ready.js +10 -1
  88. package/dist/lib/hosts/reconnect.d.ts +14 -12
  89. package/dist/lib/hosts/reconnect.js +41 -40
  90. package/dist/lib/mcp.js +5 -1
  91. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  92. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  93. package/dist/lib/menubar/snapshot.d.ts +15 -0
  94. package/dist/lib/menubar/snapshot.js +40 -0
  95. package/dist/lib/plugins.js +13 -1
  96. package/dist/lib/resources/mcp.js +3 -0
  97. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  98. package/dist/lib/routine-process-cleanup.js +73 -0
  99. package/dist/lib/routines.js +14 -2
  100. package/dist/lib/runner.d.ts +0 -3
  101. package/dist/lib/runner.js +6 -19
  102. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  103. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  104. package/dist/lib/secrets/bundles.js +1 -20
  105. package/dist/lib/secrets/filestore.d.ts +2 -0
  106. package/dist/lib/secrets/filestore.js +13 -0
  107. package/dist/lib/secrets/push.d.ts +94 -0
  108. package/dist/lib/secrets/push.js +145 -0
  109. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  110. package/dist/lib/secrets/rc-hygiene.js +14 -1
  111. package/dist/lib/secrets/reaper.d.ts +15 -1
  112. package/dist/lib/secrets/reaper.js +30 -3
  113. package/dist/lib/session/active.d.ts +4 -0
  114. package/dist/lib/session/db.d.ts +21 -3
  115. package/dist/lib/session/db.js +229 -15
  116. package/dist/lib/session/discover.d.ts +1 -0
  117. package/dist/lib/session/discover.js +115 -19
  118. package/dist/lib/session/insights.d.ts +18 -0
  119. package/dist/lib/session/insights.js +143 -1
  120. package/dist/lib/session/remote-list.d.ts +2 -0
  121. package/dist/lib/session/remote-list.js +1 -0
  122. package/dist/lib/session/session-cache.d.ts +4 -4
  123. package/dist/lib/session/session-cache.js +4 -4
  124. package/dist/lib/session/tool-index.js +133 -22
  125. package/dist/lib/session/tool-store.d.ts +26 -2
  126. package/dist/lib/session/tool-store.js +36 -17
  127. package/dist/lib/shims.js +21 -1
  128. package/dist/lib/signin-badge.js +2 -0
  129. package/dist/lib/ssh-exec.js +8 -2
  130. package/dist/lib/startup/command-registry.d.ts +16 -0
  131. package/dist/lib/startup/command-registry.js +46 -0
  132. package/dist/lib/teams/agents.d.ts +13 -0
  133. package/dist/lib/teams/agents.js +76 -8
  134. package/dist/lib/teams/parsers.d.ts +1 -1
  135. package/dist/lib/teams/placement-probe.d.ts +21 -0
  136. package/dist/lib/teams/placement-probe.js +135 -0
  137. package/dist/lib/teams/scheduler.d.ts +74 -1
  138. package/dist/lib/teams/scheduler.js +187 -10
  139. package/dist/lib/types.d.ts +1 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -152,21 +152,22 @@ export function groupBlocksByOutcome(blocks) {
152
152
  const groups = [];
153
153
  for (const { outcome, blocks: members } of byKey.values()) {
154
154
  const mailboxes = new Set(members.map((b) => b.mailboxId));
155
- let open = 0;
156
- let answered = 0;
157
- let parked = 0;
155
+ const states = new Map();
158
156
  for (const b of members) {
159
- if (b.parkedAt)
160
- parked += 1;
161
- else if (b.answer || b.defaultedAt || b.continuedAt)
162
- answered += 1;
163
- else
164
- open += 1;
157
+ const next = b.parkedAt
158
+ ? 'parked'
159
+ : b.answer || b.defaultedAt || b.continuedAt ? 'answered' : 'open';
160
+ const current = states.get(b.mailboxId);
161
+ // One agent occupies one state. Its most actionable block wins.
162
+ if (!current || next === 'open' || (next === 'parked' && current === 'answered')) {
163
+ states.set(b.mailboxId, next);
164
+ }
165
165
  }
166
+ const count = (state) => [...states.values()].filter((value) => value === state).length;
166
167
  groups.push({
167
168
  outcome,
168
169
  blocks: members,
169
- counts: { agents: mailboxes.size, open, answered, parked },
170
+ counts: { agents: mailboxes.size, open: count('open'), answered: count('answered'), parked: count('parked') },
170
171
  });
171
172
  }
172
173
  groups.sort((a, b) => {
@@ -222,6 +223,13 @@ export function enrichBlockFromSession(block, hint) {
222
223
  next.worktreeSlug = hint.worktreeSlug;
223
224
  if (!next.project && hint.project)
224
225
  next.project = hint.project;
226
+ if (hint.host && hint.host !== 'terminal' && next.runtime === 'terminal')
227
+ next.runtime = hint.host;
228
+ if (hint.origin === 'routine') {
229
+ next.origin = 'routine';
230
+ if (hint.routineName)
231
+ next.routineName = hint.routineName;
232
+ }
225
233
  return next;
226
234
  }
227
235
  /**
@@ -36,6 +36,10 @@ export interface OpenBlock {
36
36
  mailboxId: string;
37
37
  host: string;
38
38
  runtime: string;
39
+ /** Indexed launch origin, added at read time when the live session is known. */
40
+ origin?: 'cli' | 'routine';
41
+ /** Routine definition name when origin is `routine`. */
42
+ routineName?: string;
39
43
  /** Project/repo name this block belongs to (derived from cwd, worktree-aware). */
40
44
  project?: string;
41
45
  ts: string;
@@ -8,6 +8,7 @@
8
8
  * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
9
  */
10
10
  import type { DeviceProfile } from '../devices/registry.js';
11
+ import { type RemoteBackend } from '../secrets/push.js';
11
12
  import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
12
13
  /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
13
14
  export declare function agentIdOf(spec: string): string;
@@ -54,21 +55,76 @@ export interface DiffContext {
54
55
  /** agents-cli version the source is on — the fleet target version. */
55
56
  targetCliVersion: string;
56
57
  sourceAuth: SourceAuth;
57
- /** Secrets-bundle names the profile declares. Values are keychain-local and
58
- * can't be pushed, so each reachable device surfaces them as a manual recreate
59
- * (`needs-secret`) — informational, never an executed mutation. */
58
+ /** Secrets-bundle names the profile declares. */
60
59
  secretsBundles?: string[];
60
+ /** `--provision-secrets`. OFF by default: pushing a bundle moves credential
61
+ * VALUES to another machine, so it is opted into per invocation and never
62
+ * defaulted from the shared `agents.yaml` (RUSH-1968). */
63
+ provisionSecrets?: boolean;
64
+ /** Is this device's host key pinned? Injected so `decideSecretPush` stays pure
65
+ * and its refusals are testable against real known_hosts fixtures with no
66
+ * network. Absent = treated as unpinned, i.e. refuse. */
67
+ isHostPinned?: (device: string) => boolean;
68
+ /** `--force`: push a declared bundle even when the device already has it. */
69
+ forceSecrets?: boolean;
61
70
  }
62
71
  /** Pure: desired vs probed -> per-device diff + flat action list. */
63
72
  export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
73
+ /** Why a declared bundle is or is not pushed to one device. */
74
+ export interface SecretPushDecision {
75
+ push: boolean;
76
+ /** Where it would land on the remote. Only meaningful when `push`. */
77
+ backend: RemoteBackend;
78
+ /** Set when `push` is false — rendered as the `needs-secret` reminder. */
79
+ reason: string;
80
+ }
81
+ /**
82
+ * Decide whether `fleet apply` may push one declared bundle to one device.
83
+ *
84
+ * PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
85
+ * every branch is unit-testable with no live fleet. Three gates, in order, and
86
+ * each REFUSAL still yields a `needs-secret` reminder rather than silence:
87
+ *
88
+ * 1. `--provision-secrets` must be set. Off by default, and deliberately a
89
+ * flag rather than an `agents.yaml` field: the manifest is shared, so a
90
+ * file-level default means someone else's `apply -y` ships credential
91
+ * values without deciding to (RUSH-1968's shape of accident).
92
+ * 2. The device must be reachable — nothing to push to otherwise.
93
+ * 3. The host key must be PINNED. This moves credential values to another
94
+ * machine, so it reuses the same bar `agents exec --copy-creds` already
95
+ * sets (EXEC-34): an unpinned device earns its pin through a normal
96
+ * `agents ssh <device>` first.
97
+ *
98
+ * Backend follows the platform, and this is the load-bearing default of the
99
+ * whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
100
+ * box has no keychain (`lib/secrets/linux.ts`), and the file store there
101
+ * auto-provisions its OWN machine-local key — so each box ends up with an
102
+ * unshared at-rest key and NO passphrase is forwarded. That is the direct
103
+ * alternative to the fleet-wide shared secret this ticket exists to remove.
104
+ */
105
+ export declare function decideSecretPush(bundle: string, desired: DeviceDesired, probe: DeviceProbe, ctx: DiffContext): SecretPushDecision;
64
106
  export interface ProbeOptions {
65
107
  /** Also fetch per-agent installed versions (one extra `agents view --json`
66
108
  * round-trip). Enable only when the plan has a version-pinned spec. */
67
109
  withVersions?: boolean;
110
+ /** Also fetch which secrets bundles the device already has (one extra
111
+ * `agents secrets list --json`). Enable only when the manifest declares
112
+ * bundles and provisioning is on — same cost discipline as `withVersions`. */
113
+ withSecrets?: boolean;
68
114
  }
69
115
  /** Probe one device: reachability + agents-cli version + installed agent ids
70
116
  * (and, when `withVersions`, the installed version strings per agent). */
71
117
  export declare function probeDevice(device: DeviceProfile, opts?: ProbeOptions): DeviceProbe;
118
+ /**
119
+ * Narrow a remote `secrets list --json` payload to `name -> updated_at`.
120
+ *
121
+ * Exported and pure so the parse is unit-tested against real payload shapes with
122
+ * no live fleet. Returns `{}` rather than throwing on anything unexpected: the
123
+ * remote runs its own agents-cli version, and a parse failure must degrade to
124
+ * "unknown, so push" — never to "present, so skip", which would silently leave a
125
+ * device unprovisioned.
126
+ */
127
+ export declare function parseRemoteBundles(stdout: string): Record<string, string>;
72
128
  export interface ApplyStep {
73
129
  kind: FleetAction['kind'];
74
130
  ok: boolean;
@@ -8,6 +8,7 @@
8
8
  * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
9
  */
10
10
  import * as os from 'os';
11
+ import { pushBundleToHost } from '../secrets/push.js';
11
12
  import { sshTargetFor } from '../devices/connect.js';
12
13
  import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
13
14
  import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
@@ -167,14 +168,33 @@ export function diffFleet(desired, probes, ctx) {
167
168
  }
168
169
  }
169
170
  }
170
- // secrets surfaced, never pushed (values are keychain-local). Declared
171
- // once at the manifest level, so every reachable device gets the same
172
- // manual-recreate reminder. Not an executable action (see idempotence
173
- // check in commands/apply.ts, which excludes needs-* kinds).
171
+ // secrets. Declared once at the manifest level, so every reachable device
172
+ // is considered. Historically this was ALWAYS a manual reminder "surfaced,
173
+ // never pushed" and that gap is a direct cause of RUSH-1968: an operator
174
+ // who needed secrets on a worker box had no supported path, so they
175
+ // hand-exported the file store's master key across the fleet instead.
176
+ //
177
+ // It is now pushable, but only deliberately. `--provision-secrets` is off by
178
+ // default and is a FLAG, not a manifest field: `agents.yaml` is shared, and
179
+ // a file-level default would mean someone else's `apply -y` silently ships
180
+ // credential values — the same shape of accident this ticket is about.
181
+ // Everything the gate refuses stays a `needs-secret` reminder, so nothing
182
+ // is ever silently skipped.
174
183
  if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
175
184
  for (const bundle of ctx.secretsBundles) {
176
- secretsNeeded.push(bundle);
177
- rowActions.push({ device: d.device, kind: 'needs-secret', detail: `recreate secrets bundle '${bundle}' (\`agents secrets create ${bundle}\`)` });
185
+ const decision = decideSecretPush(bundle, d, probe, ctx);
186
+ if (decision.push) {
187
+ rowActions.push({
188
+ device: d.device,
189
+ kind: 'push-secret',
190
+ bundle,
191
+ detail: `push secrets bundle '${bundle}' (${decision.backend} backend)`,
192
+ });
193
+ }
194
+ else {
195
+ secretsNeeded.push(bundle);
196
+ rowActions.push({ device: d.device, kind: 'needs-secret', bundle, detail: decision.reason });
197
+ }
178
198
  }
179
199
  }
180
200
  }
@@ -183,6 +203,66 @@ export function diffFleet(desired, probes, ctx) {
183
203
  }
184
204
  return { devices, actions };
185
205
  }
206
+ /**
207
+ * Decide whether `fleet apply` may push one declared bundle to one device.
208
+ *
209
+ * PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
210
+ * every branch is unit-testable with no live fleet. Three gates, in order, and
211
+ * each REFUSAL still yields a `needs-secret` reminder rather than silence:
212
+ *
213
+ * 1. `--provision-secrets` must be set. Off by default, and deliberately a
214
+ * flag rather than an `agents.yaml` field: the manifest is shared, so a
215
+ * file-level default means someone else's `apply -y` ships credential
216
+ * values without deciding to (RUSH-1968's shape of accident).
217
+ * 2. The device must be reachable — nothing to push to otherwise.
218
+ * 3. The host key must be PINNED. This moves credential values to another
219
+ * machine, so it reuses the same bar `agents exec --copy-creds` already
220
+ * sets (EXEC-34): an unpinned device earns its pin through a normal
221
+ * `agents ssh <device>` first.
222
+ *
223
+ * Backend follows the platform, and this is the load-bearing default of the
224
+ * whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
225
+ * box has no keychain (`lib/secrets/linux.ts`), and the file store there
226
+ * auto-provisions its OWN machine-local key — so each box ends up with an
227
+ * unshared at-rest key and NO passphrase is forwarded. That is the direct
228
+ * alternative to the fleet-wide shared secret this ticket exists to remove.
229
+ */
230
+ export function decideSecretPush(bundle, desired, probe, ctx) {
231
+ const device = desired.device;
232
+ const backend = probe.platform === 'linux' ? 'file' : 'keychain';
233
+ const manual = `recreate secrets bundle '${bundle}' (\`agents ssh ${device} -- secrets create ${bundle}\`)`;
234
+ if (!ctx.provisionSecrets) {
235
+ return { push: false, backend, reason: `${manual} — or re-run with --provision-secrets to push it` };
236
+ }
237
+ if (!probe.reachable) {
238
+ return { push: false, backend, reason: manual };
239
+ }
240
+ // Already there? Skip — otherwise every `apply` re-resolves the bundle, and a
241
+ // resolve can prompt for Touch ID, so a converged fleet would nag on every run.
242
+ //
243
+ // Known limitation, stated rather than hidden: this compares PRESENCE (and
244
+ // carries `updated_at` for a future content check). It is a timestamp
245
+ // heuristic, not a content hash — a bundle whose VALUES changed locally still
246
+ // reads as present. `--force` is the way to overwrite regardless.
247
+ // hasOwnProperty, NOT `in`: `in` walks the prototype chain, so a bundle named
248
+ // `toString` / `constructor` / `valueOf` would read as present on an EMPTY map
249
+ // and be silently skipped — leaving that device unprovisioned, the worse of the
250
+ // two errors this gate can make.
251
+ if (!ctx.forceSecrets && probe.remoteBundles
252
+ && Object.prototype.hasOwnProperty.call(probe.remoteBundles, bundle)) {
253
+ return { push: false, backend, reason: `secrets bundle '${bundle}' already present on ${device} — pass --force to overwrite` };
254
+ }
255
+ if (!ctx.isHostPinned?.(device)) {
256
+ // Same bar as `exec --copy-creds`: never ship credential values to a host
257
+ // whose key we have not pinned.
258
+ return {
259
+ push: false,
260
+ backend,
261
+ reason: `${manual} — host key not pinned; run \`agents ssh ${device}\` once to pin it, then re-apply`,
262
+ };
263
+ }
264
+ return { push: true, backend, reason: '' };
265
+ }
186
266
  // ---- execution (real SSH; verified end-to-end, not unit-mocked) ----
187
267
  function osHint(platform) {
188
268
  return platform === 'windows' ? 'windows' : undefined;
@@ -225,6 +305,15 @@ export function probeDevice(device, opts) {
225
305
  if (vres.code === 0)
226
306
  installedVersions = parseInstalledVersions(vres.stdout);
227
307
  }
308
+ let remoteBundles;
309
+ if (opts?.withSecrets) {
310
+ // Metadata only — `secrets list --json` returns names + timestamps and never
311
+ // values, which is why this is safe to run across the fleet.
312
+ const listCmd = buildRemoteAgentsInvocation(['secrets', 'list', '--json'], undefined, hint, remoteEnv(device.platform));
313
+ const lres = sshExec(target, listCmd, { timeoutMs: 30000, multiplex: true });
314
+ if (lres.code === 0)
315
+ remoteBundles = parseRemoteBundles(lres.stdout);
316
+ }
228
317
  return {
229
318
  device: device.name,
230
319
  reachable: true,
@@ -232,8 +321,52 @@ export function probeDevice(device, opts) {
232
321
  cliVersion: ready.version ?? undefined,
233
322
  installedAgents: installed,
234
323
  installedVersions,
324
+ remoteBundles,
235
325
  };
236
326
  }
327
+ /**
328
+ * Narrow a remote `secrets list --json` payload to `name -> updated_at`.
329
+ *
330
+ * Exported and pure so the parse is unit-tested against real payload shapes with
331
+ * no live fleet. Returns `{}` rather than throwing on anything unexpected: the
332
+ * remote runs its own agents-cli version, and a parse failure must degrade to
333
+ * "unknown, so push" — never to "present, so skip", which would silently leave a
334
+ * device unprovisioned.
335
+ */
336
+ export function parseRemoteBundles(stdout) {
337
+ try {
338
+ const parsed = JSON.parse(stdout);
339
+ const rows = Array.isArray(parsed)
340
+ ? parsed
341
+ : Array.isArray(parsed?.bundles)
342
+ ? parsed.bundles
343
+ : [];
344
+ // Null-prototype: a remote-supplied name is used as a KEY here, so `{}` would
345
+ // let `__proto__` hit the prototype setter instead of becoming an own
346
+ // property (and then read back as absent). It also means the presence check
347
+ // cannot see inherited names.
348
+ const out = Object.create(null);
349
+ for (const row of rows) {
350
+ if (!row || typeof row !== 'object')
351
+ continue;
352
+ const r = row;
353
+ const name = typeof r.name === 'string' ? r.name : undefined;
354
+ if (!name)
355
+ continue;
356
+ // `updatedAt` is the real field name in `secrets list --json` — verified
357
+ // against a live payload, not assumed. `updated_at` is accepted too so an
358
+ // older remote is not silently recorded with an empty timestamp.
359
+ const ts = typeof r.updatedAt === 'string' ? r.updatedAt
360
+ : typeof r.updated_at === 'string' ? r.updated_at
361
+ : '';
362
+ out[name] = ts;
363
+ }
364
+ return out;
365
+ }
366
+ catch {
367
+ return {};
368
+ }
369
+ }
237
370
  /** Execute one device's planned actions in order. Real SSH — no mocks. */
238
371
  export function reconcileDevice(row, device, ctx) {
239
372
  if (!row.probe.reachable) {
@@ -290,6 +423,50 @@ export function reconcileDevice(row, device, ctx) {
290
423
  steps.push({ kind: 'push-login', ok: r.code === 0, detail: `propagate login: ${pushAgents.join(', ')}` });
291
424
  ok = ok && r.code === 0;
292
425
  }
426
+ // 5. secrets provisioning — LAST, and deliberately so. It is the most
427
+ // sensitive mutation apply performs (credential VALUES crossing to another
428
+ // machine), so every lower-risk step above is already recorded before we
429
+ // touch it: a failure here never obscures what did land.
430
+ //
431
+ // Resolve ONCE per device even for several bundles is not possible (a resolve
432
+ // is per bundle), but each bundle resolves once and pushes once — the read can
433
+ // prompt, so it must not repeat.
434
+ const pushSecrets = row.actions.filter((a) => a.kind === 'push-secret');
435
+ for (const action of pushSecrets) {
436
+ const bundle = action.bundle;
437
+ if (!bundle) {
438
+ // A push-secret action without a bundle name is a planner bug, not a
439
+ // recoverable state — fail loud rather than push nothing and report ok.
440
+ steps.push({ kind: 'push-secret', ok: false, detail: 'push-secret action carried no bundle name' });
441
+ ok = false;
442
+ continue;
443
+ }
444
+ const backend = device.platform === 'linux' ? 'file' : 'keychain';
445
+ try {
446
+ const out = pushBundleToHost(bundle, target, {
447
+ remoteBackend: backend,
448
+ operation: `fleet apply ${row.device}`,
449
+ // No passphrase, ever, from this path. On the file backend the remote
450
+ // auto-provisions its OWN machine-local key, which is the entire point:
451
+ // each box gets an unshared at-rest key instead of the fleet-wide shared
452
+ // secret RUSH-1968 is about.
453
+ });
454
+ steps.push({
455
+ kind: 'push-secret',
456
+ ok: out.ok,
457
+ detail: out.ok
458
+ ? `secrets '${bundle}' -> ${row.device} (${backend}): ${out.message}`
459
+ : `secrets '${bundle}' -> ${row.device}: ${out.message}`,
460
+ });
461
+ ok = ok && out.ok;
462
+ }
463
+ catch (e) {
464
+ // A local resolve failure (locked store, missing bundle, multi-line value)
465
+ // is reported per bundle rather than aborting the whole device.
466
+ steps.push({ kind: 'push-secret', ok: false, detail: `secrets '${bundle}': ${e.message}` });
467
+ ok = false;
468
+ }
469
+ }
293
470
  // Surface blocked logins as (non-fatal) informational steps.
294
471
  for (const blocked of row.loginBlocked) {
295
472
  steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
@@ -95,13 +95,28 @@ export interface DeviceProbe {
95
95
  * undefined, version-pinned specs fall back to id-level presence.
96
96
  */
97
97
  installedVersions?: Record<string, string[]>;
98
+ /**
99
+ * Secrets bundles already present on the device: name -> `updated_at` (or ''
100
+ * when the remote reports none). Only populated when the manifest declares
101
+ * bundles AND `--provision-secrets` is set — a fleet that uses no bundles
102
+ * never pays for the extra round trip.
103
+ *
104
+ * METADATA ONLY. `agents secrets list --json` returns names and timestamps and
105
+ * explicitly never values, which is what makes this probe safe to run.
106
+ */
107
+ remoteBundles?: Record<string, string>;
98
108
  /** Reason string when `reachable` is false or the probe partially failed. */
99
109
  note?: string;
100
110
  }
101
111
  /** One planned action against a device, in a single reconcile dimension. */
102
112
  export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
103
- /** Secrets bundles the profile declares but that can't be pushed (values are
104
- * keychain-local) surfaced as a manual recreate, like `needs-login`. */
113
+ /** Push a declared secrets bundle to the device over SSH. Opt-in only
114
+ * (`--provision-secrets`) and gated on a pinned host key, because this moves
115
+ * credential VALUES to another machine (RUSH-1968). */
116
+ | 'push-secret'
117
+ /** A declared secrets bundle that could NOT be pushed — the flag is off, the
118
+ * host key isn't pinned, or the bundle is already current. Surfaced as a manual
119
+ * recreate, like `needs-login`. */
105
120
  | 'needs-secret';
106
121
  export interface FleetAction {
107
122
  device: string;
@@ -111,6 +126,10 @@ export interface FleetAction {
111
126
  /** Full agent spec for `add-agent` (e.g. `claude@2.1.170`) so the plan can show
112
127
  * the exact version being installed; equals the id for a bare/latest spec. */
113
128
  spec?: string;
129
+ /** Bundle name for `push-secret` / `needs-secret`, so the executor pushes the
130
+ * bundle the planner decided on rather than re-deriving it from the detail
131
+ * string. */
132
+ bundle?: string;
114
133
  /** Human, one-line description of the action. */
115
134
  detail: string;
116
135
  }
@@ -505,6 +505,21 @@ if [ "$CACHE_STATUS" = miss ]; then
505
505
  if [ "$_in_backoff" -eq 0 ]; then
506
506
  # Fix 1: lockdir — only one background refresh runs at a time.
507
507
  LOCK_DIR="$CACHE_FILE.bg.lck"
508
+ # Fix 4 (RUSH-2259): reclaim a stale lock. The lock is released by the bg
509
+ # subshell's EXIT trap, but a hard kill (SIGKILL, OOM, reboot) skips the
510
+ # trap and orphans the dir — after which every future mkdir fails and bg
511
+ # refresh stops FOREVER while stale cache is served indefinitely. The dir
512
+ # mtime is fixed at mkdir time (the refresh writes elsewhere), so its age
513
+ # is the time the lock has been held; treat a lock older than LOCK_TTL_SEC
514
+ # as abandoned and remove it before locking. LOCK_TTL_SEC is set well above
515
+ # any real hook runtime so a live refresh is never reclaimed out from under.
516
+ LOCK_TTL_SEC=300
517
+ if [ -d "$LOCK_DIR" ]; then
518
+ _lock_mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$LOCK_DIR" 2>/dev/null || echo 0)
519
+ _lock_now_s=$(date +%s)
520
+ _lock_age=$((_lock_now_s - \${_lock_mtime:-0}))
521
+ [ "$_lock_age" -ge "$LOCK_TTL_SEC" ] && rm -rf "$LOCK_DIR" 2>/dev/null
522
+ fi
508
523
  if mkdir "$LOCK_DIR" 2>/dev/null; then
509
524
  tmp="$CACHE_FILE.new.$$"
510
525
  # Fix 3: background subshell captures and logs its own real exit code.
@@ -16,6 +16,7 @@
16
16
  * table or, when `--host`/`--device` is present, exits with a clear
17
17
  * "not supported" message — never commander's raw `unknown option`.
18
18
  */
19
+ import { type Host } from './types.js';
19
20
  import { type DeviceProfile, type DeviceRegistry } from '../devices/registry.js';
20
21
  import { runLocalCommand, runOnDevice } from '../devices/fleet.js';
21
22
  /** Per-command remote behaviour. Absence from this map = not host-routable here. */
@@ -60,4 +61,47 @@ export declare function runFleetPassthrough(command: string, allArgs: string[],
60
61
  * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
61
62
  */
62
63
  export declare function maybeRunOnHost(command: string, allArgs: string[], opts?: FleetPassthroughOptions): Promise<boolean>;
64
+ /**
65
+ * `--host` passthrough for a **standalone binary** whose command name is fixed by
66
+ * the binary itself (the `browser`/`computer` bins, `dist/browser.js` etc.) rather
67
+ * than being the first argv token.
68
+ *
69
+ * `agents browser … --host <box>` routes through {@link maybeRunOnHost} in
70
+ * index.ts, but the standalone `browser` binary never enters index.ts — so without
71
+ * this it dropped `--host` entirely (commander errored with `unknown option
72
+ * '--host'`). This wires the identical routing: it synthesizes the implicit command
73
+ * token (`browser`) at the front of the argv so the remote invocation builds as
74
+ * `agents browser …`, delegates to {@link maybeRunOnHost}, and returns `true` when
75
+ * it dispatched the run to a remote (the local program must then NOT parse).
76
+ *
77
+ * On a local / self-host fall-through it rewrites `process.argv` to the ORIGINAL
78
+ * args minus the routing flags — never the synthetic command token — so the
79
+ * standalone commander program parses cleanly and never sees an unknown `--host`.
80
+ * When no routing flag is present it leaves `process.argv` untouched (the common
81
+ * case), so an unrelated flag like `--no-tty` is never stripped from a purely local
82
+ * run.
83
+ *
84
+ * @param command the fixed command name the binary stands for (`'browser'`).
85
+ */
86
+ export declare function maybeRunStandaloneOnHost(command: string, opts?: FleetPassthroughOptions): Promise<boolean>;
87
+ /**
88
+ * Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
89
+ * return the exit code. The single place the SSH hop is built, so every remote
90
+ * `agents` invocation carries identical env semantics.
91
+ *
92
+ * Forwards actor provenance (`AGENTS_ACTOR`/`GIT_` vars) across the hop, merged UNDER
93
+ * `extraEnv` so a caller-supplied PATH still wins — without this the remote
94
+ * re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
95
+ * (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
96
+ * AGENTS_FLEET_REMOTE marks this as a fleet-dispatched run so the far side can
97
+ * gate consent-sensitive actions — the browser consent gate
98
+ * (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
99
+ */
100
+ export declare function streamAgentsOnHost(host: Host, forwardedArgs: string[], opts?: {
101
+ remoteCwd?: string;
102
+ interactive?: boolean;
103
+ extraEnv?: Record<string, string>;
104
+ remoteOs?: string;
105
+ target?: string;
106
+ }): number;
63
107
  export {};
@@ -29,6 +29,7 @@ import { loadDevices } from '../devices/registry.js';
29
29
  import { isSelfHost } from '../devices/self-host.js';
30
30
  import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
31
31
  import { platformGroupLabel } from '../devices/health-report.js';
32
+ import { isKnownTopLevelCommand } from '../startup/command-registry.js';
32
33
  /**
33
34
  * First-class groups that run transparently on a remote via SSH when
34
35
  * `--host`/`--device` is present. Keep both canonical names and aliases
@@ -436,6 +437,15 @@ export async function maybeRunOnHost(command, allArgs, opts) {
436
437
  if (!isAll && command !== 'routines')
437
438
  return false;
438
439
  }
440
+ // A command that does not exist is an unknown-command error, not a routing
441
+ // error. The router runs BEFORE commander parses, so without this gate a typo
442
+ // (`agents session resume --host box`) was answered with "does not support
443
+ // --host/--device" — a true statement about a command the user never typed,
444
+ // and the exact opposite of the truth for the `sessions` they meant, which
445
+ // does support it. Fall through so commander reports `unknown command` (and
446
+ // its did-you-mean). RUSH-2022.
447
+ if (!isKnownTopLevelCommand(command))
448
+ return false;
439
449
  const spec = REMOTE_PASSTHROUGH[command];
440
450
  if (!spec) {
441
451
  // Flag was accepted (no raw commander "unknown option") but this group has
@@ -532,20 +542,82 @@ export async function maybeRunOnHost(command, allArgs, opts) {
532
542
  const doctorPath = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
533
543
  ? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
534
544
  : undefined;
535
- // Forward actor provenance (AGENTS_ACTOR*/GIT_*) across the SSH hop, merged
536
- // UNDER the doctor PATH so that PATH still wins — without this the remote
537
- // re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
538
- // (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
539
- // AGENTS_FLEET_REMOTE marks this as a fleet-dispatched `--host` run so the far
540
- // side can gate consent-sensitive actions — the browser consent gate
541
- // (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
542
- const env = withActorEnv({ ...doctorPath, AGENTS_FLEET_REMOTE: '1' });
543
- const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
544
- const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
545
+ process.exitCode = streamAgentsOnHost(host, forwarded, {
546
+ remoteCwd,
547
+ interactive,
548
+ extraEnv: doctorPath,
549
+ remoteOs,
550
+ target,
551
+ });
552
+ return true;
553
+ }
554
+ /**
555
+ * `--host` passthrough for a **standalone binary** whose command name is fixed by
556
+ * the binary itself (the `browser`/`computer` bins, `dist/browser.js` etc.) rather
557
+ * than being the first argv token.
558
+ *
559
+ * `agents browser … --host <box>` routes through {@link maybeRunOnHost} in
560
+ * index.ts, but the standalone `browser` binary never enters index.ts — so without
561
+ * this it dropped `--host` entirely (commander errored with `unknown option
562
+ * '--host'`). This wires the identical routing: it synthesizes the implicit command
563
+ * token (`browser`) at the front of the argv so the remote invocation builds as
564
+ * `agents browser …`, delegates to {@link maybeRunOnHost}, and returns `true` when
565
+ * it dispatched the run to a remote (the local program must then NOT parse).
566
+ *
567
+ * On a local / self-host fall-through it rewrites `process.argv` to the ORIGINAL
568
+ * args minus the routing flags — never the synthetic command token — so the
569
+ * standalone commander program parses cleanly and never sees an unknown `--host`.
570
+ * When no routing flag is present it leaves `process.argv` untouched (the common
571
+ * case), so an unrelated flag like `--no-tty` is never stripped from a purely local
572
+ * run.
573
+ *
574
+ * @param command the fixed command name the binary stands for (`'browser'`).
575
+ */
576
+ export async function maybeRunStandaloneOnHost(command, opts) {
577
+ const rawArgs = process.argv.slice(2);
578
+ const hasRoutingFlag = flagValue(rawArgs, 'host', 'H') !== undefined ||
579
+ flagValue(rawArgs, 'device') !== undefined ||
580
+ flagValue(rawArgs, 'hosts') !== undefined ||
581
+ flagValue(rawArgs, 'devices') !== undefined;
582
+ // No routing flag → nothing to route or strip. Leave argv alone so a purely
583
+ // local run keeps every flag it passed.
584
+ if (!hasRoutingFlag)
585
+ return false;
586
+ // Keep --help/--version local (docs must work without a reachable host), mirroring
587
+ // index.ts's `helpOrVersionRequested` guard, but still strip the routing flags
588
+ // below so commander doesn't choke on them.
589
+ const helpOrVersion = rawArgs.some((a) => a === '--help' || a === '-h' || a === '--version' || a === '-V');
590
+ if (!helpOrVersion && (await maybeRunOnHost(command, [command, ...rawArgs], opts))) {
591
+ return true;
592
+ }
593
+ // Local / self-host fall-through (maybeRunOnHost may have rewritten process.argv
594
+ // with the synthetic command token). Rebuild argv from the original args minus
595
+ // the routing flags so the standalone program parses cleanly.
596
+ process.argv = [process.argv[0], process.argv[1], ...stripRoutingFlags(rawArgs, STRIP_SPECS)];
597
+ return false;
598
+ }
599
+ /**
600
+ * Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
601
+ * return the exit code. The single place the SSH hop is built, so every remote
602
+ * `agents` invocation carries identical env semantics.
603
+ *
604
+ * Forwards actor provenance (`AGENTS_ACTOR`/`GIT_` vars) across the hop, merged UNDER
605
+ * `extraEnv` so a caller-supplied PATH still wins — without this the remote
606
+ * re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
607
+ * (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
608
+ * AGENTS_FLEET_REMOTE marks this as a fleet-dispatched run so the far side can
609
+ * gate consent-sensitive actions — the browser consent gate
610
+ * (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
611
+ */
612
+ export function streamAgentsOnHost(host, forwardedArgs, opts = {}) {
613
+ const target = opts.target ?? sshTargetFor(host);
614
+ const remoteOs = opts.remoteOs ?? resolveRemoteOsSync(host.name);
615
+ const env = withActorEnv({ ...opts.extraEnv, AGENTS_FLEET_REMOTE: '1' });
616
+ const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, opts.remoteCwd, remoteOs, env);
617
+ const code = sshStream(target, remoteCmd, { tty: !!opts.interactive, multiplex: true });
545
618
  if (code === 255) {
546
619
  console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
547
620
  chalk.gray(' Check: agents hosts check ' + host.name));
548
621
  }
549
- process.exitCode = code;
550
- return true;
622
+ return code;
551
623
  }
@@ -42,6 +42,8 @@ export interface ReadyProbe {
42
42
  version: string | null;
43
43
  /** Raw `agents view`/`list` output, for installed-agent checks. */
44
44
  view: string;
45
+ /** True when the ssh probe timed out before the sentinel arrived. */
46
+ timedOut?: boolean;
45
47
  }
46
48
  /**
47
49
  * Answer every readiness question in ONE ssh round-trip: reachable? (the login