@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  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/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -8,6 +8,7 @@
8
8
  * canonical definition; `commands/secrets.ts` re-exports it.
9
9
  */
10
10
  import { spawn, spawnSync } from 'child_process';
11
+ import { StringDecoder } from 'string_decoder';
11
12
  import * as fs from 'fs';
12
13
  import * as path from 'path';
13
14
  import { getCacheDir } from './state.js';
@@ -46,6 +47,38 @@ export const SSH_OPTS = [
46
47
  '-o', 'ServerAliveInterval=15',
47
48
  '-o', 'ServerAliveCountMax=3',
48
49
  ];
50
+ /**
51
+ * Hard ceiling on the stdout one peer may return before its capture is aborted.
52
+ * A cross-machine fan-out streams every peer's `agents … --json` into memory in
53
+ * parallel, so an unbounded buffer means one runaway peer (a corrupt or
54
+ * pathologically large payload) can exhaust the caller's heap and take the whole
55
+ * sweep down with it — RUSH-2065 observed ~170MB retained across a single
56
+ * `--active` gather. 16 MiB is far above any legitimate JSON listing yet small
57
+ * enough that N peers in flight stay bounded. A peer that overflows is treated as
58
+ * unreachable rather than trusted with partial output.
59
+ */
60
+ export const REMOTE_STDOUT_MAX_BYTES = 16 * 1024 * 1024;
61
+ /**
62
+ * Accumulate SSH stdout as UTF-8 without corrupting a multi-byte code point that
63
+ * a chunk boundary splits — the streaming `on('data')` callbacks hand us raw
64
+ * `Buffer`s, and a naive `buf.toString()` per chunk mangles any character the
65
+ * kernel cut in half. `StringDecoder` holds the trailing partial bytes until the
66
+ * next chunk completes them.
67
+ */
68
+ export class RemoteUtf8Accumulator {
69
+ decoder = new StringDecoder('utf8');
70
+ value = '';
71
+ write(chunk) {
72
+ this.value += this.decoder.write(chunk);
73
+ }
74
+ end() {
75
+ this.value += this.decoder.end();
76
+ return this.value;
77
+ }
78
+ current() {
79
+ return this.value;
80
+ }
81
+ }
49
82
  /**
50
83
  * OpenSSH connection-multiplexing options. The first connection to a host opens
51
84
  * a control socket; subsequent connections (even from a *separate* `agents`
@@ -117,7 +150,11 @@ export function sshExec(target, remoteCmd, opts = {}) {
117
150
  stdio: ['pipe', 'pipe', 'pipe'],
118
151
  windowsHide: true,
119
152
  });
120
- const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
153
+ // Node's spawnSync with a `timeout` option kills the child via SIGTERM and sets
154
+ // res.signal (e.g. 'SIGTERM') — it does NOT set res.error.code = 'ETIMEDOUT'.
155
+ // Check both so the detection fires whether the timeout is signalled or errored.
156
+ const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT') ||
157
+ (!!opts.timeoutMs && res.signal !== null);
121
158
  return {
122
159
  code: typeof res.status === 'number' ? res.status : null,
123
160
  stdout: res.stdout ?? '',
@@ -206,7 +243,9 @@ export function sshExecRaw(target, remoteCmd, opts = {}) {
206
243
  stdio: ['pipe', 'pipe', 'pipe'],
207
244
  windowsHide: true,
208
245
  });
209
- const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
246
+ // Same fix as sshExec: spawnSync sets res.signal, not error.code = 'ETIMEDOUT'.
247
+ const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT') ||
248
+ (!!opts.timeoutMs && res.signal !== null);
210
249
  return {
211
250
  code: typeof res.status === 'number' ? res.status : null,
212
251
  stdout: res.stdout ?? Buffer.alloc(0),
@@ -286,7 +325,7 @@ export function sshStream(target, remoteCmd, opts = {}) {
286
325
  assertValidSshTarget(target);
287
326
  const mux = opts.multiplex === false ? [] : controlOpts();
288
327
  const tty = opts.tty ? ['-tt'] : [];
289
- const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...tty, target, remoteCmd];
328
+ const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...(opts.extraSshArgs ?? []), ...tty, target, remoteCmd];
290
329
  const res = spawnSync('ssh', args, { stdio: 'inherit' });
291
330
  if (typeof res.status === 'number')
292
331
  return res.status;
@@ -27,13 +27,14 @@ export interface StartTunnelOptions {
27
27
  * of one CDP session and kills it on cleanup.
28
28
  */
29
29
  detached?: boolean;
30
+ extraSshArgs?: string[];
30
31
  }
31
32
  /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
32
33
  *
33
34
  * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
34
35
  * so the tunnel inherits the same options — crucially the keepalive, which lets
35
36
  * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
36
- export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number): string[];
37
+ export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number, extraSshArgs?: string[]): string[];
37
38
  /**
38
39
  * Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
39
40
  *
@@ -151,7 +152,7 @@ export declare function scpRemotePath(remotePath: string): string;
151
152
  * assert the real binary copy path keeps BatchMode and does not route bytes
152
153
  * through a PowerShell decoder.
153
154
  */
154
- export declare function buildScpArgs(target: string, remotePath: string, filePath: string): string[];
155
+ export declare function buildScpArgs(target: string, remotePath: string, filePath: string, extraSshArgs?: string[]): string[];
155
156
  /**
156
157
  * `setup --host`: push the exe, then register + start the LOGON task. Remote
157
158
  * PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening
@@ -28,7 +28,7 @@ import { sshExec, SSH_OPTS, assertValidSshTarget } from './ssh-exec.js';
28
28
  import { backgroundSpawnOptions } from './platform/process.js';
29
29
  import { encodePowerShell } from './browser/drivers/ssh.js';
30
30
  import { getDevice } from './devices/registry.js';
31
- import { sshTargetFor } from './devices/connect.js';
31
+ import { deviceIdentityArgs, sshTargetFor } from './devices/connect.js';
32
32
  import { hostNameFor } from './devices/ssh-config.js';
33
33
  import { getCacheDir } from './state.js';
34
34
  import { getCliVersion } from './version.js';
@@ -38,12 +38,13 @@ import { openComputerClient, resolveTcpEndpoint } from './computer-rpc.js';
38
38
  * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
39
39
  * so the tunnel inherits the same options — crucially the keepalive, which lets
40
40
  * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
41
- export function buildTunnelArgs(user, host, localPort, remotePort) {
41
+ export function buildTunnelArgs(user, host, localPort, remotePort, extraSshArgs = []) {
42
42
  return [
43
43
  '-L',
44
44
  `${localPort}:127.0.0.1:${remotePort}`,
45
45
  `${user}@${host}`,
46
46
  '-N',
47
+ ...extraSshArgs,
47
48
  ...SSH_OPTS,
48
49
  ];
49
50
  }
@@ -69,7 +70,7 @@ export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
69
70
  reject(err);
70
71
  return;
71
72
  }
72
- const args = buildTunnelArgs(user, host, localPort, remotePort);
73
+ const args = buildTunnelArgs(user, host, localPort, remotePort, opts.extraSshArgs);
73
74
  const tunnel = spawn('ssh', args, {
74
75
  stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
75
76
  ...(opts.detached ? backgroundSpawnOptions() : { detached: false, windowsHide: true }),
@@ -360,16 +361,16 @@ export function scpRemotePath(remotePath) {
360
361
  * assert the real binary copy path keeps BatchMode and does not route bytes
361
362
  * through a PowerShell decoder.
362
363
  */
363
- export function buildScpArgs(target, remotePath, filePath) {
364
- return [...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
364
+ export function buildScpArgs(target, remotePath, filePath, extraSshArgs = []) {
365
+ return [...extraSshArgs, ...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
365
366
  }
366
367
  /**
367
368
  * Copy a local file to the remote destination with scp. This is a binary
368
369
  * transfer; no base64 transform runs on either side.
369
370
  */
370
- function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
371
+ function copyFileOverScp(target, remotePath, filePath, extraSshArgs = [], timeoutMs = 600_000) {
371
372
  return new Promise((resolve, reject) => {
372
- const child = spawn('scp', buildScpArgs(target, remotePath, filePath), {
373
+ const child = spawn('scp', buildScpArgs(target, remotePath, filePath, extraSshArgs), {
373
374
  stdio: ['ignore', 'ignore', 'pipe'],
374
375
  });
375
376
  let stderr = '';
@@ -397,11 +398,12 @@ function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
397
398
  * scp transfer. Throws with the remote stderr on any failure.
398
399
  */
399
400
  export async function setupRemoteHelper(name) {
400
- const { target } = await resolveRemoteDevice(name);
401
+ const { device, target } = await resolveRemoteDevice(name);
402
+ const identityArgs = deviceIdentityArgs(device);
401
403
  // Local build output, else the checksum-verified GitHub release asset for
402
404
  // this CLI version. Throws naming the tag it checked when neither exists.
403
405
  const exe = await ensureWinHelperExe();
404
- const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000 });
406
+ const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000, extraSshArgs: identityArgs });
405
407
  if (prep.code !== 0) {
406
408
  throw new Error(`preparing helper exe path on '${name}' failed (exit ${prep.code ?? 'null'}): ${prep.stderr.trim() || prep.stdout.trim()}`);
407
409
  }
@@ -409,12 +411,12 @@ export async function setupRemoteHelper(name) {
409
411
  if (!remotePath) {
410
412
  throw new Error(`preparing helper exe path on '${name}' did not return a destination path`);
411
413
  }
412
- const push = await copyFileOverScp(target, remotePath, exe);
414
+ const push = await copyFileOverScp(target, remotePath, exe, identityArgs);
413
415
  if (push.code !== 0) {
414
416
  throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
415
417
  }
416
418
  const expectedBytes = fs.statSync(exe).size;
417
- const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000 });
419
+ const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
418
420
  if (verify.code !== 0) {
419
421
  throw new Error(`verifying helper exe on '${name}' failed (exit ${verify.code ?? 'null'}): ${verify.stderr.trim() || verify.stdout.trim()}`);
420
422
  }
@@ -423,7 +425,7 @@ export async function setupRemoteHelper(name) {
423
425
  // refuses to start without a token, so a token-less (open-to-any-local-process)
424
426
  // daemon can no longer be stood up through the CLI.
425
427
  const token = generateToken();
426
- const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000 });
428
+ const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
427
429
  if (tokWrite.code !== 0) {
428
430
  throw new Error(`writing helper token on '${name}' failed (exit ${tokWrite.code ?? 'null'}): ${tokWrite.stderr.trim() || tokWrite.stdout.trim()}`);
429
431
  }
@@ -434,6 +436,7 @@ export async function setupRemoteHelper(name) {
434
436
  // Register + start the LOGON task, pointing it at the token file.
435
437
  const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME, remoteTokenPath)), {
436
438
  timeoutMs: 60_000,
439
+ extraSshArgs: identityArgs,
437
440
  });
438
441
  if (reg.code !== 0) {
439
442
  throw new Error(`registering scheduled task on '${name}' failed (exit ${reg.code ?? 'null'}): ${reg.stderr.trim() || reg.stdout.trim()}`);
@@ -460,10 +463,13 @@ export function pickFreePort() {
460
463
  * the state (and leaves the tunnel running in the background).
461
464
  */
462
465
  export async function startRemoteTunnel(name) {
463
- const { target, user, host } = await resolveRemoteDevice(name);
466
+ const { device, target, user, host } = await resolveRemoteDevice(name);
464
467
  const remotePort = REMOTE_HELPER_PORT;
465
468
  const localPort = await pickFreePort();
466
- const tunnel = await startSSHTunnel(user, host, localPort, remotePort, { detached: true });
469
+ const tunnel = await startSSHTunnel(user, host, localPort, remotePort, {
470
+ detached: true,
471
+ extraSshArgs: deviceIdentityArgs(device),
472
+ });
467
473
  const tunnelPid = tunnel.pid ?? 0;
468
474
  // Verify the daemon answers through the tunnel before we record it. This is
469
475
  // the real end-to-end check: tunnel up + daemon listening + RPC round-trips.
@@ -543,8 +549,11 @@ export async function stopRemoteHelper(name) {
543
549
  }
544
550
  let taskRemoved = false;
545
551
  try {
546
- const { target } = await resolveRemoteDevice(name);
547
- const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), { timeoutMs: 60_000 });
552
+ const { device, target } = await resolveRemoteDevice(name);
553
+ const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), {
554
+ timeoutMs: 60_000,
555
+ extraSshArgs: deviceIdentityArgs(device),
556
+ });
548
557
  taskRemoved = res.code === 0;
549
558
  }
550
559
  catch {
@@ -47,6 +47,7 @@ export declare const loadMonitors: ModuleLoader;
47
47
  export declare const loadProjects: ModuleLoader;
48
48
  export declare const loadRun: ModuleLoader;
49
49
  export declare const loadResume: ModuleLoader;
50
+ export declare const loadReconnect: ModuleLoader;
50
51
  export declare const loadFork: ModuleLoader;
51
52
  export declare const loadDefaults: ModuleLoader;
52
53
  export declare const loadSet: ModuleLoader;
@@ -75,6 +76,7 @@ export declare const loadUsage: ModuleLoader;
75
76
  export declare const loadCost: ModuleLoader;
76
77
  export declare const loadInsights: ModuleLoader;
77
78
  export declare const loadPerf: ModuleLoader;
79
+ export declare const loadBench: ModuleLoader;
78
80
  export declare const loadTrends: ModuleLoader;
79
81
  export declare const loadOutput: ModuleLoader;
80
82
  export declare const loadBudget: ModuleLoader;
@@ -25,6 +25,7 @@ export const loadMonitors = async () => (await import('../../commands/monitors.j
25
25
  export const loadProjects = async () => (await import('../../commands/projects.js')).registerProjectsCommands;
26
26
  export const loadRun = async () => (await import('../../commands/exec.js')).registerRunCommand;
27
27
  export const loadResume = async () => (await import('../../commands/resume.js')).registerResumeCommand;
28
+ export const loadReconnect = async () => (await import('../../commands/reconnect.js')).registerReconnectCommand;
28
29
  export const loadFork = async () => (await import('../../commands/fork.js')).registerForkCommand;
29
30
  export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
30
31
  export const loadSet = async () => (await import('../../commands/set.js')).registerSetCommand;
@@ -53,6 +54,8 @@ export const loadUsage = async () => (await import('../../commands/usage.js')).r
53
54
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
54
55
  export const loadInsights = async () => (await import('../../commands/insights.js')).registerInsightsCommand;
55
56
  export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
57
+ export const loadBench = async () => (await import('../../commands/bench.js')).registerBenchCommand;
58
+ // Thin deprecated alias of `agents insights mix` — no second mix implementation.
56
59
  export const loadTrends = async () => (await import('../../commands/trends.js')).registerTrendsCommand;
57
60
  export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
58
61
  export const loadBudget = async () => (await import('../../commands/budget.js')).registerBudgetCommand;
@@ -98,6 +101,7 @@ export const loadHumans = async () => (await import('../../commands/humans.js'))
98
101
  export const LAZY_COMMAND_NAMES = new Set([
99
102
  'sessions',
100
103
  'resume',
104
+ 'reconnect',
101
105
  'roster',
102
106
  'teams',
103
107
  'cloud',
@@ -156,6 +160,7 @@ export const COMMAND_LOADERS = {
156
160
  projects: [loadProjects],
157
161
  run: [loadRun],
158
162
  resume: [loadResume],
163
+ reconnect: [loadReconnect],
159
164
  fork: [loadFork],
160
165
  defaults: [loadDefaults],
161
166
  set: [loadSet],
@@ -187,6 +192,7 @@ export const COMMAND_LOADERS = {
187
192
  cost: [loadCost],
188
193
  insights: [loadInsights],
189
194
  perf: [loadPerf],
195
+ bench: [loadBench],
190
196
  trends: [loadTrends],
191
197
  output: [loadOutput],
192
198
  budget: [loadBudget],
@@ -1,5 +1,6 @@
1
1
  import { captureProcessStartTime } from '../platform/index.js';
2
2
  import { AgentType } from './parsers.js';
3
+ import { NoViableDeviceError } from './scheduler.js';
3
4
  /**
4
5
  * Compute the Lowest Common Ancestor (LCA) of multiple file paths.
5
6
  * Returns the deepest common directory shared by all paths.
@@ -405,6 +406,18 @@ export declare class AgentManager {
405
406
  * teammate schedules identically no matter how it was fired.
406
407
  */
407
408
  private maybeSchedulePlacement;
409
+ /** Human label of a teammate's agent for the placement fail-loud message. */
410
+ private placementAgentLabel;
411
+ /**
412
+ * Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
413
+ * confirm each distinct agent among the pending, unpinned, pooled teammates
414
+ * has at least one viable device. Returns the first {@link NoViableDeviceError}
415
+ * so the command can fail loud BEFORE launching a wave, rather than silently
416
+ * leaving teammates stranded pending. A poolless team (no `--devices`) or a
417
+ * probe that could not gather positive evidence returns null — fail-loud fires
418
+ * only on proof that no device can run the agent, never on a probe miss.
419
+ */
420
+ preflightPlacement(taskName: string): Promise<NoViableDeviceError | null>;
408
421
  /**
409
422
  * One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
410
423
  * The supervisor calls this each wave BEFORE listByTask() so the per-teammate
@@ -34,7 +34,8 @@ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
34
34
  import { pullRemoteLogDelta, REMOTE_MIRROR_MAX_BYTES } from '../hosts/progress.js';
35
35
  import { createRemoteWorktree, ensureRemoteRepo } from './remoteWorktree.js';
36
36
  import { getTeam } from './registry.js';
37
- import { resolvePlacement, cappedDevices } from './scheduler.js';
37
+ import { resolvePlacement, classifyExclusions, NoViableDeviceError } from './scheduler.js';
38
+ import { probePoolSignals } from './placement-probe.js';
38
39
  import { readMaxConcurrentCaps } from '../device-config.js';
39
40
  import chalk from 'chalk';
40
41
  let lastMemoryWarnAt = 0;
@@ -1756,7 +1757,7 @@ export class AgentManager {
1756
1757
  * (immediate add-launch) and startReady() (staged launch) so an unpinned pool
1757
1758
  * teammate schedules identically no matter how it was fired.
1758
1759
  */
1759
- async maybeSchedulePlacement(agent, taskName) {
1760
+ async maybeSchedulePlacement(agent, taskName, opts = {}) {
1760
1761
  if (agent.hostName || agent.cloudProvider)
1761
1762
  return;
1762
1763
  const teamMeta = await getTeam(taskName);
@@ -1765,15 +1766,79 @@ export class AgentManager {
1765
1766
  const roster = await this.listByTask(taskName);
1766
1767
  const pool = teamMeta.devices ?? [];
1767
1768
  const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
1768
- if (maxConcurrent) {
1769
- for (const c of cappedDevices(pool, roster, maxConcurrent)) {
1770
- console.error(chalk.dim(`[placement] '${c.device}' excluded from auto-pick at its agents.max-concurrent cap (${c.running}/${c.cap} running)`));
1769
+ // On the start path (opts.probe), gather live signals so the pick is health-,
1770
+ // harness-, and load-aware (RUSH-2002); the add path stays the cap-only
1771
+ // roster count so `teams add` never blocks on an SSH fan-out. Cached per
1772
+ // (pool, agent), so a wave placing many teammates probes the pool once.
1773
+ const signals = opts.probe && pool.length > 0
1774
+ ? await probePoolSignals(pool, agent.agentType, { now: Date.now() })
1775
+ : undefined;
1776
+ const placeOpts = {
1777
+ maxConcurrent,
1778
+ signals,
1779
+ agentLabel: this.placementAgentLabel(agent),
1780
+ };
1781
+ if (signals) {
1782
+ for (const e of classifyExclusions(pool, roster, placeOpts).excluded) {
1783
+ const why = e.reason === 'capped'
1784
+ ? `at its agents.max-concurrent cap (${e.detail} running)`
1785
+ : e.reason === 'not-installed'
1786
+ ? `does not have ${this.placementAgentLabel(agent)} installed`
1787
+ : e.reason;
1788
+ console.error(chalk.dim(`[placement] '${e.device}' excluded from auto-pick — ${why}`));
1771
1789
  }
1772
1790
  }
1773
- const { device } = resolvePlacement(teamMeta, null, roster, { maxConcurrent });
1791
+ const { device } = resolvePlacement(teamMeta, null, roster, placeOpts);
1774
1792
  if (device)
1775
1793
  await this.resolveScheduledPlacement(agent, device, taskName);
1776
1794
  }
1795
+ /** Human label of a teammate's agent for the placement fail-loud message. */
1796
+ placementAgentLabel(agent) {
1797
+ return agent.version ? `${agent.agentType}@${agent.version}` : String(agent.agentType);
1798
+ }
1799
+ /**
1800
+ * Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
1801
+ * confirm each distinct agent among the pending, unpinned, pooled teammates
1802
+ * has at least one viable device. Returns the first {@link NoViableDeviceError}
1803
+ * so the command can fail loud BEFORE launching a wave, rather than silently
1804
+ * leaving teammates stranded pending. A poolless team (no `--devices`) or a
1805
+ * probe that could not gather positive evidence returns null — fail-loud fires
1806
+ * only on proof that no device can run the agent, never on a probe miss.
1807
+ */
1808
+ async preflightPlacement(taskName) {
1809
+ await this.initialize();
1810
+ const teamMeta = await getTeam(taskName);
1811
+ const pool = teamMeta?.devices ?? [];
1812
+ if (!teamMeta || pool.length === 0)
1813
+ return null;
1814
+ const roster = await this.listByTask(taskName);
1815
+ const pending = roster.filter((a) => a.status === AgentStatus.PENDING && !a.hostName && !a.cloudProvider);
1816
+ if (pending.length === 0)
1817
+ return null;
1818
+ const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
1819
+ // One representative teammate per distinct agent type — the signal + gate is
1820
+ // per harness, not per teammate.
1821
+ const byAgent = new Map();
1822
+ for (const a of pending)
1823
+ if (!byAgent.has(a.agentType))
1824
+ byAgent.set(a.agentType, a);
1825
+ for (const [, rep] of byAgent) {
1826
+ const signals = await probePoolSignals(pool, rep.agentType, { now: Date.now() });
1827
+ try {
1828
+ resolvePlacement(teamMeta, null, roster, {
1829
+ maxConcurrent,
1830
+ signals,
1831
+ agentLabel: this.placementAgentLabel(rep),
1832
+ });
1833
+ }
1834
+ catch (err) {
1835
+ if (err instanceof NoViableDeviceError)
1836
+ return err;
1837
+ throw err;
1838
+ }
1839
+ }
1840
+ return null;
1841
+ }
1777
1842
  /**
1778
1843
  * One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
1779
1844
  * The supervisor calls this each wave BEFORE listByTask() so the per-teammate
@@ -1866,9 +1931,12 @@ export class AgentManager {
1866
1931
  // immediate-add and staged paths agree. A null pick keeps hostName null →
1867
1932
  // local spawn, unchanged. Cloud teammates never schedule.
1868
1933
  try {
1869
- await this.maybeSchedulePlacement(agent, taskName);
1934
+ await this.maybeSchedulePlacement(agent, taskName, { probe: true });
1870
1935
  }
1871
1936
  catch (err) {
1937
+ // A NoViableDeviceError means the pool cannot host this teammate right
1938
+ // now — leave it PENDING (never a silent local fallback) and surface the
1939
+ // reason; a transient device loss is retried next wave.
1872
1940
  console.error(`Could not schedule ${agent.agentId} onto the team pool:`, err);
1873
1941
  continue;
1874
1942
  }
@@ -0,0 +1,21 @@
1
+ import { type AgentType } from './agents.js';
2
+ import type { DevicePlacementSignal } from './scheduler.js';
3
+ /** Per-remote readiness probe budget — matches the health probe's short window
4
+ * (`agents view` on a warm box is sub-second; a wedged one degrades to unknown). */
5
+ export declare const READY_PROBE_TIMEOUT_MS = 8000;
6
+ /** How long a probed pool snapshot is reused within a `teams start` wave. Short
7
+ * enough that a device coming online / going overloaded is seen next wave, long
8
+ * enough that placing a wave of teammates does not re-fan-out per teammate. */
9
+ export declare const SIGNAL_TTL_MS = 15000;
10
+ /** Clear the probe cache — for tests and after a device-registry change. */
11
+ export declare function clearPlacementSignalCache(): void;
12
+ /**
13
+ * Probe every device in the team pool and return a name→signal map for the pure
14
+ * placement pick. Devices with no data at all are omitted (the pick then neither
15
+ * excludes nor prefers them). Never throws — a probe failure degrades to a
16
+ * missing/partial signal.
17
+ */
18
+ export declare function probePoolSignals(pool: string[], agent?: AgentType, opts?: {
19
+ force?: boolean;
20
+ now?: number;
21
+ }): Promise<Map<string, DevicePlacementSignal>>;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Live pool probing for the teams placement scheduler (RUSH-2002).
3
+ *
4
+ * Gathers one {@link DevicePlacementSignal} per pool device so the pure pick in
5
+ * {@link ./scheduler} can filter unreachable / overloaded / not-installed
6
+ * devices and rank the rest. Two concerns, both best-effort:
7
+ *
8
+ * - reachability + headroom + load ← {@link probeFleetStats} (one parallel
9
+ * SSH fan-out over the pool; the local box is measured directly).
10
+ * - requested agent installed + signed in (when known) ← a one-shot readiness
11
+ * probe per remote device ({@link buildReadyProbeCommand} → `agents view`),
12
+ * the local box via {@link checkCliAvailable}/{@link checkCliSignedIn}.
13
+ *
14
+ * The result is cached briefly per (pool, agent-or-any) so a `teams start` wave that
15
+ * places N teammates probes the pool ONCE, not N times — the roster-count part
16
+ * of the rank stays live (the pure pick recounts the roster each call), only the
17
+ * SSH-measured load/harness snapshot is reused within the TTL.
18
+ *
19
+ * All SSH here is via `execFile` (async, bounded) so the whole pool is probed in
20
+ * parallel; a slow or wedged box degrades to "no signal" instead of blocking the
21
+ * launch. The readiness payload is `agents view --json`, so remote and local
22
+ * candidates carry the same installed/sign-in verdict.
23
+ */
24
+ import { execFile } from 'child_process';
25
+ import { probeFleetStats, headroom } from '../devices/health.js';
26
+ import { loadDevicesSync } from '../devices/registry.js';
27
+ import { buildSshInvocation, writeAskpassShim } from '../devices/connect.js';
28
+ import { buildReadyProbeCommand, parseReadyProbe, viewAgentSignedIn, viewHasAgent, } from '../hosts/ready.js';
29
+ import { localMachineId } from '../session/origin-machine.js';
30
+ import { normalizeHost } from '../machine-id.js';
31
+ import { checkCliAvailable, checkCliSignedIn } from './agents.js';
32
+ /** Per-remote readiness probe budget — matches the health probe's short window
33
+ * (`agents view` on a warm box is sub-second; a wedged one degrades to unknown). */
34
+ export const READY_PROBE_TIMEOUT_MS = 8_000;
35
+ /** How long a probed pool snapshot is reused within a `teams start` wave. Short
36
+ * enough that a device coming online / going overloaded is seen next wave, long
37
+ * enough that placing a wave of teammates does not re-fan-out per teammate. */
38
+ export const SIGNAL_TTL_MS = 15_000;
39
+ const cache = new Map();
40
+ function cacheKey(pool, agent) {
41
+ return `${agent ?? 'any-agent'}::${[...pool].map(normalizeHost).sort().join(',')}`;
42
+ }
43
+ /** Clear the probe cache — for tests and after a device-registry change. */
44
+ export function clearPlacementSignalCache() {
45
+ cache.clear();
46
+ }
47
+ /**
48
+ * Whether the requested agent is installed on a REMOTE device, via one SSH
49
+ * readiness probe. `undefined` when the probe could not answer (ssh/login
50
+ * failure) — reachability is then left to {@link probeFleetStats}; `false` when
51
+ * the box answered but agents-cli or the agent is absent (a genuine can't-run).
52
+ */
53
+ function probeRemoteReadiness(device, agent) {
54
+ let args;
55
+ let env;
56
+ try {
57
+ const shim = writeAskpassShim();
58
+ const cmd = buildReadyProbeCommand(device.shell === 'powershell' ? 'windows' : undefined);
59
+ // agentOnly: a read-only probe must never force a foreground Touch ID sheet
60
+ // on a password-auth device (mirrors probeDeviceStats in devices/health).
61
+ ({ args, env } = buildSshInvocation(device, [cmd], shim, {}, { agentOnly: true }));
62
+ }
63
+ catch {
64
+ return Promise.resolve({ installed: undefined, signedIn: undefined });
65
+ }
66
+ return new Promise((resolve) => {
67
+ execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: READY_PROBE_TIMEOUT_MS }, (err, stdout) => {
68
+ if (err || !stdout)
69
+ return resolve({ installed: undefined, signedIn: undefined });
70
+ const probe = parseReadyProbe(stdout);
71
+ if (!probe.reachable)
72
+ return resolve({ installed: undefined, signedIn: undefined });
73
+ if (!probe.version)
74
+ return resolve({ installed: false, signedIn: false });
75
+ const installed = viewHasAgent(probe.view, agent);
76
+ resolve({ installed, signedIn: installed ? viewAgentSignedIn(probe.view, agent) : false });
77
+ });
78
+ });
79
+ }
80
+ /**
81
+ * Probe every device in the team pool and return a name→signal map for the pure
82
+ * placement pick. Devices with no data at all are omitted (the pick then neither
83
+ * excludes nor prefers them). Never throws — a probe failure degrades to a
84
+ * missing/partial signal.
85
+ */
86
+ export async function probePoolSignals(pool, agent, opts = {}) {
87
+ const now = opts.now ?? Date.now();
88
+ const key = cacheKey(pool, agent);
89
+ const cached = cache.get(key);
90
+ if (!opts.force && cached && now - cached.at < SIGNAL_TTL_MS)
91
+ return cached.signals;
92
+ const reg = loadDevicesSync();
93
+ const self = normalizeHost(localMachineId());
94
+ const lookup = (name) => reg[name] ?? reg[normalizeHost(name)];
95
+ const profiles = [];
96
+ const seen = new Set();
97
+ for (const name of pool) {
98
+ const d = lookup(name);
99
+ if (d && !seen.has(d.name)) {
100
+ profiles.push(d);
101
+ seen.add(d.name);
102
+ }
103
+ }
104
+ const selfProfile = profiles.find((d) => normalizeHost(d.name) === self);
105
+ const stats = await probeFleetStats(profiles, { selfName: selfProfile?.name });
106
+ const installed = new Map(agent
107
+ ? await Promise.all(profiles.map(async (d) => {
108
+ const isSelf = normalizeHost(d.name) === self;
109
+ if (isSelf) {
110
+ const inst = checkCliAvailable(agent)[0];
111
+ const signedIn = inst ? await checkCliSignedIn(agent) : undefined;
112
+ return [d.name, { installed: inst, signedIn }];
113
+ }
114
+ return [d.name, await probeRemoteReadiness(d, agent)];
115
+ }))
116
+ : []);
117
+ const signals = new Map();
118
+ for (const name of pool) {
119
+ const d = lookup(name);
120
+ const s = d ? stats.get(d.name) : undefined;
121
+ const inst = d ? installed.get(d.name) : undefined;
122
+ if (!s && !inst)
123
+ continue; // fully unknown device — leave it out of the map
124
+ signals.set(name, {
125
+ reachable: s?.reachable,
126
+ headroom: s ? headroom(s) : undefined,
127
+ loadPercent: s?.loadPercent,
128
+ memPercent: s?.memPercent,
129
+ installed: inst?.installed,
130
+ signedIn: inst?.signedIn,
131
+ });
132
+ }
133
+ cache.set(key, { at: now, signals });
134
+ return signals;
135
+ }