@phnx-labs/agents-cli 1.22.26 → 1.22.28

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 (183) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +80 -27
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +71 -26
  8. package/dist/commands/hosts.js +4 -3
  9. package/dist/commands/insights.js +39 -5
  10. package/dist/commands/output.js +100 -25
  11. package/dist/commands/perf.d.ts +10 -0
  12. package/dist/commands/perf.js +14 -6
  13. package/dist/commands/projects.js +24 -10
  14. package/dist/commands/resume.d.ts +11 -0
  15. package/dist/commands/resume.js +51 -0
  16. package/dist/commands/routines.js +31 -4
  17. package/dist/commands/run-account-picker.d.ts +37 -0
  18. package/dist/commands/run-account-picker.js +101 -17
  19. package/dist/commands/secrets.js +86 -2
  20. package/dist/commands/sessions-picker.js +12 -7
  21. package/dist/commands/sessions.d.ts +73 -7
  22. package/dist/commands/sessions.js +391 -49
  23. package/dist/commands/ssh.js +184 -11
  24. package/dist/commands/teams.js +22 -11
  25. package/dist/index.js +16 -20
  26. package/dist/lib/activity.js +45 -99
  27. package/dist/lib/auth-health.d.ts +47 -3
  28. package/dist/lib/auth-health.js +74 -15
  29. package/dist/lib/bench/index.d.ts +4 -0
  30. package/dist/lib/bench/index.js +4 -0
  31. package/dist/lib/bench/runner.d.ts +16 -0
  32. package/dist/lib/bench/runner.js +111 -0
  33. package/dist/lib/bench/schema.d.ts +5 -0
  34. package/dist/lib/bench/schema.js +91 -0
  35. package/dist/lib/bench/storage.d.ts +5 -0
  36. package/dist/lib/bench/storage.js +32 -0
  37. package/dist/lib/bench/types.d.ts +40 -0
  38. package/dist/lib/bench/types.js +1 -0
  39. package/dist/lib/claude-account-token.d.ts +2 -0
  40. package/dist/lib/claude-account-token.js +56 -3
  41. package/dist/lib/cloud/host.js +1 -0
  42. package/dist/lib/crabbox/cli.d.ts +2 -0
  43. package/dist/lib/crabbox/cli.js +2 -0
  44. package/dist/lib/crabbox/lease.js +7 -1
  45. package/dist/lib/daemon.js +20 -0
  46. package/dist/lib/devices/connect.d.ts +2 -0
  47. package/dist/lib/devices/connect.js +7 -0
  48. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  49. package/dist/lib/devices/doctor-findings.js +15 -1
  50. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  51. package/dist/lib/devices/harness-inventory.js +0 -0
  52. package/dist/lib/devices/registry.d.ts +2 -0
  53. package/dist/lib/devices/resolve-target.d.ts +1 -0
  54. package/dist/lib/devices/resolve-target.js +9 -2
  55. package/dist/lib/devices/ssh-config.js +3 -0
  56. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  57. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  58. package/dist/lib/events.d.ts +1 -1
  59. package/dist/lib/events.js +2 -2
  60. package/dist/lib/exec.js +18 -8
  61. package/dist/lib/fleet/apply.js +9 -7
  62. package/dist/lib/fleet/remote-login.d.ts +4 -3
  63. package/dist/lib/fleet/remote-login.js +11 -9
  64. package/dist/lib/gemini-settings.d.ts +0 -1
  65. package/dist/lib/gemini-settings.js +12 -7
  66. package/dist/lib/git.d.ts +9 -3
  67. package/dist/lib/git.js +39 -22
  68. package/dist/lib/hooks/profile.d.ts +12 -1
  69. package/dist/lib/hooks/profile.js +6 -1
  70. package/dist/lib/hooks.d.ts +8 -5
  71. package/dist/lib/hooks.js +14 -7
  72. package/dist/lib/hosts/dispatch.d.ts +10 -0
  73. package/dist/lib/hosts/dispatch.js +85 -25
  74. package/dist/lib/hosts/logs.js +11 -1
  75. package/dist/lib/hosts/passthrough.d.ts +27 -0
  76. package/dist/lib/hosts/passthrough.js +19 -10
  77. package/dist/lib/hosts/progress.d.ts +12 -1
  78. package/dist/lib/hosts/progress.js +41 -8
  79. package/dist/lib/hosts/providers/devices.js +1 -0
  80. package/dist/lib/hosts/ready.d.ts +45 -4
  81. package/dist/lib/hosts/ready.js +124 -20
  82. package/dist/lib/hosts/reconcile.d.ts +2 -1
  83. package/dist/lib/hosts/reconcile.js +24 -7
  84. package/dist/lib/hosts/reconnect.d.ts +45 -12
  85. package/dist/lib/hosts/reconnect.js +94 -36
  86. package/dist/lib/hosts/registry.d.ts +2 -2
  87. package/dist/lib/hosts/registry.js +4 -5
  88. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  89. package/dist/lib/hosts/remote-cmd.js +29 -0
  90. package/dist/lib/hosts/tasks.d.ts +3 -0
  91. package/dist/lib/hosts/types.d.ts +1 -0
  92. package/dist/lib/hosts/types.js +3 -0
  93. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  94. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  95. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  96. package/dist/lib/menubar/install-menubar.js +20 -9
  97. package/dist/lib/menubar/snapshot.js +15 -2
  98. package/dist/lib/models.d.ts +42 -6
  99. package/dist/lib/models.js +55 -153
  100. package/dist/lib/perf/db.js +16 -3
  101. package/dist/lib/perf/types.d.ts +12 -1
  102. package/dist/lib/pricing/cost.d.ts +9 -0
  103. package/dist/lib/pricing/cost.js +24 -0
  104. package/dist/lib/pricing/index.d.ts +1 -1
  105. package/dist/lib/pricing/index.js +1 -1
  106. package/dist/lib/project-probe.d.ts +22 -3
  107. package/dist/lib/project-probe.js +105 -17
  108. package/dist/lib/project-status.d.ts +9 -0
  109. package/dist/lib/project-status.js +15 -0
  110. package/dist/lib/redact.js +8 -3
  111. package/dist/lib/refresh.js +22 -11
  112. package/dist/lib/remote-agents-json.d.ts +32 -0
  113. package/dist/lib/remote-agents-json.js +47 -16
  114. package/dist/lib/resource-profiles.js +1 -2
  115. package/dist/lib/rotate.d.ts +22 -0
  116. package/dist/lib/rotate.js +26 -0
  117. package/dist/lib/routine-notify-owner.d.ts +102 -0
  118. package/dist/lib/routine-notify-owner.js +232 -0
  119. package/dist/lib/routines.d.ts +10 -0
  120. package/dist/lib/runner.d.ts +9 -1
  121. package/dist/lib/runner.js +182 -17
  122. package/dist/lib/sandbox.d.ts +0 -2
  123. package/dist/lib/sandbox.js +2 -19
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  126. package/dist/lib/secrets/agent.d.ts +19 -3
  127. package/dist/lib/secrets/agent.js +61 -22
  128. package/dist/lib/secrets/audit.d.ts +1 -1
  129. package/dist/lib/secrets/audit.js +2 -0
  130. package/dist/lib/secrets/bundles.d.ts +3 -3
  131. package/dist/lib/secrets/bundles.js +15 -5
  132. package/dist/lib/secrets/filestore.d.ts +2 -0
  133. package/dist/lib/secrets/filestore.js +4 -0
  134. package/dist/lib/secrets/lease.d.ts +25 -0
  135. package/dist/lib/secrets/lease.js +44 -0
  136. package/dist/lib/secrets/session-store.d.ts +7 -0
  137. package/dist/lib/secrets/session-store.js +21 -0
  138. package/dist/lib/self-update.d.ts +20 -5
  139. package/dist/lib/self-update.js +93 -16
  140. package/dist/lib/session/active.d.ts +49 -0
  141. package/dist/lib/session/active.js +175 -11
  142. package/dist/lib/session/bash-command.d.ts +16 -0
  143. package/dist/lib/session/bash-command.js +65 -0
  144. package/dist/lib/session/db.d.ts +23 -2
  145. package/dist/lib/session/db.js +86 -20
  146. package/dist/lib/session/discover.d.ts +21 -1
  147. package/dist/lib/session/discover.js +124 -32
  148. package/dist/lib/session/insights.d.ts +19 -0
  149. package/dist/lib/session/insights.js +78 -10
  150. package/dist/lib/session/recovery.d.ts +17 -1
  151. package/dist/lib/session/recovery.js +111 -4
  152. package/dist/lib/session/remote-bundle.js +1 -1
  153. package/dist/lib/session/remote-list.d.ts +10 -9
  154. package/dist/lib/session/remote-list.js +25 -23
  155. package/dist/lib/session/resume-owner.d.ts +55 -0
  156. package/dist/lib/session/resume-owner.js +69 -0
  157. package/dist/lib/session/team-filter.d.ts +65 -0
  158. package/dist/lib/session/team-filter.js +98 -3
  159. package/dist/lib/session/types.d.ts +33 -0
  160. package/dist/lib/smart-launch.d.ts +31 -5
  161. package/dist/lib/smart-launch.js +43 -7
  162. package/dist/lib/ssh-exec.d.ts +27 -0
  163. package/dist/lib/ssh-exec.js +34 -1
  164. package/dist/lib/ssh-tunnel.d.ts +3 -2
  165. package/dist/lib/ssh-tunnel.js +25 -16
  166. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  167. package/dist/lib/staleness/detectors/skills.js +17 -2
  168. package/dist/lib/staleness/index.d.ts +10 -1
  169. package/dist/lib/staleness/index.js +28 -3
  170. package/dist/lib/startup/command-registry.d.ts +1 -0
  171. package/dist/lib/startup/command-registry.js +2 -0
  172. package/dist/lib/teams/agents.d.ts +1 -0
  173. package/dist/lib/teams/agents.js +38 -11
  174. package/dist/lib/teams/placement-probe.d.ts +1 -1
  175. package/dist/lib/teams/placement-probe.js +24 -24
  176. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  177. package/dist/lib/teams/remoteWorktree.js +29 -27
  178. package/dist/lib/tmux/session.d.ts +8 -0
  179. package/dist/lib/tmux/session.js +22 -0
  180. package/dist/lib/types.d.ts +2 -0
  181. package/dist/lib/versions.d.ts +1 -0
  182. package/dist/lib/versions.js +40 -22
  183. package/package.json +3 -1
@@ -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 {
@@ -1,3 +1,5 @@
1
1
  import type { AgentId } from '../../types.js';
2
2
  import type { ResourceDetector } from './types.js';
3
+ /** Exported for unit tests (RUSH-2320 #2). Production callers: list() below. */
4
+ export declare function skillDirsMatch(src: string, dest: string): boolean;
3
5
  export declare const skillsDetectors: Partial<Record<AgentId, ResourceDetector>>;
@@ -9,7 +9,8 @@ import { capableAgents } from '../../capabilities.js';
9
9
  import { resolveSkillSource } from '../writers/sources.js';
10
10
  import { lazyAgentMap } from '../writers/lazy-map.js';
11
11
  const SKILL_COPY_IGNORE = new Set(['.DS_Store', '.git', '.gitignore', '.venv', '__pycache__', 'node_modules']);
12
- function skillDirsMatch(src, dest) {
12
+ /** Exported for unit tests (RUSH-2320 #2). Production callers: list() below. */
13
+ export function skillDirsMatch(src, dest) {
13
14
  const entries = fs.readdirSync(src, { withFileTypes: true });
14
15
  for (const entry of entries) {
15
16
  if (entry.isSymbolicLink())
@@ -25,7 +26,21 @@ function skillDirsMatch(src, dest) {
25
26
  return false;
26
27
  }
27
28
  else {
28
- if (!fs.existsSync(destPath))
29
+ // Stat-first (RUSH-2320 #2): size mismatch is a definitive miss with no
30
+ // content reads. Do NOT treat equal mtimes as equal content — src and
31
+ // dest are different trees (version home vs source), and copyFileSync
32
+ // does not preserve mtime, so mtime equality is accidental and unsafe.
33
+ // Content compare only when sizes match.
34
+ let srcStat;
35
+ let destStat;
36
+ try {
37
+ srcStat = fs.statSync(srcPath);
38
+ destStat = fs.statSync(destPath);
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ if (srcStat.size !== destStat.size)
29
44
  return false;
30
45
  if (fs.readFileSync(srcPath, 'utf-8') !== fs.readFileSync(destPath, 'utf-8'))
31
46
  return false;
@@ -16,7 +16,16 @@ export type { SyncManifest } from './types.js';
16
16
  export { MANIFEST_VERSION } from './types.js';
17
17
  export declare function loadManifest(agent: AgentId, version: string): SyncManifest | null;
18
18
  export declare function saveManifest(agent: AgentId, version: string, manifest: SyncManifest): void;
19
- export declare function buildManifest(agent: AgentId, version: string, cwd: string): SyncManifest;
19
+ /**
20
+ * Snapshot current resource fingerprints for `agent@version`.
21
+ *
22
+ * When `previous` is supplied (the still-loaded post-guard manifest), any
23
+ * entry whose stored path/mtime/size still match via the checker's `isFresh`
24
+ * is carried forward without re-hashing. Measured: full rebuild ~716 ms →
25
+ * carry-forward near the 5.67 ms `isStale` cost on an unchanged tree
26
+ * (RUSH-2320 #3).
27
+ */
28
+ export declare function buildManifest(agent: AgentId, version: string, cwd: string, previous?: SyncManifest | null): SyncManifest;
20
29
  /**
21
30
  * True when any tracked resource has drifted from the stored manifest.
22
31
  * Walks every resource type in turn and returns true at the first miss —
@@ -69,7 +69,16 @@ export function saveManifest(agent, version, manifest) {
69
69
  catch { /* ignore */ }
70
70
  }
71
71
  }
72
- export function buildManifest(agent, version, cwd) {
72
+ /**
73
+ * Snapshot current resource fingerprints for `agent@version`.
74
+ *
75
+ * When `previous` is supplied (the still-loaded post-guard manifest), any
76
+ * entry whose stored path/mtime/size still match via the checker's `isFresh`
77
+ * is carried forward without re-hashing. Measured: full rebuild ~716 ms →
78
+ * carry-forward near the 5.67 ms `isStale` cost on an unchanged tree
79
+ * (RUSH-2320 #3).
80
+ */
81
+ export function buildManifest(agent, version, cwd, previous) {
73
82
  const manifest = {
74
83
  v: MANIFEST_VERSION,
75
84
  syncedAt: new Date().toISOString(),
@@ -85,14 +94,30 @@ export function buildManifest(agent, version, cwd) {
85
94
  };
86
95
  for (const { checker, field } of STANDARD_CHECKERS) {
87
96
  const target = manifest[field];
97
+ const prevMap = (previous?.[field] ?? {});
88
98
  for (const name of checker.listNames(cwd)) {
99
+ const prev = prevMap[name];
100
+ if (prev !== undefined && checker.isFresh(name, prev, cwd)) {
101
+ target[name] = prev;
102
+ continue;
103
+ }
89
104
  const entry = checker.build(name, cwd);
90
105
  if (entry !== null)
91
106
  target[name] = entry;
92
107
  }
93
108
  }
94
- manifest.rules = buildRules(agent, version, cwd);
95
- manifest.permissions = buildPermissions();
109
+ if (previous?.rules && !isRulesStale(previous.rules, agent, version, cwd)) {
110
+ manifest.rules = previous.rules;
111
+ }
112
+ else {
113
+ manifest.rules = buildRules(agent, version, cwd);
114
+ }
115
+ if (previous?.permissions && !isPermissionsStale(previous.permissions)) {
116
+ manifest.permissions = previous.permissions;
117
+ }
118
+ else {
119
+ manifest.permissions = buildPermissions();
120
+ }
96
121
  return manifest;
97
122
  }
98
123
  /**
@@ -76,6 +76,7 @@ export declare const loadUsage: ModuleLoader;
76
76
  export declare const loadCost: ModuleLoader;
77
77
  export declare const loadInsights: ModuleLoader;
78
78
  export declare const loadPerf: ModuleLoader;
79
+ export declare const loadBench: ModuleLoader;
79
80
  export declare const loadTrends: ModuleLoader;
80
81
  export declare const loadOutput: ModuleLoader;
81
82
  export declare const loadBudget: ModuleLoader;
@@ -54,6 +54,7 @@ export const loadUsage = async () => (await import('../../commands/usage.js')).r
54
54
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
55
55
  export const loadInsights = async () => (await import('../../commands/insights.js')).registerInsightsCommand;
56
56
  export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
57
+ export const loadBench = async () => (await import('../../commands/bench.js')).registerBenchCommand;
57
58
  // Thin deprecated alias of `agents insights mix` — no second mix implementation.
58
59
  export const loadTrends = async () => (await import('../../commands/trends.js')).registerTrendsCommand;
59
60
  export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
@@ -191,6 +192,7 @@ export const COMMAND_LOADERS = {
191
192
  cost: [loadCost],
192
193
  insights: [loadInsights],
193
194
  perf: [loadPerf],
195
+ bench: [loadBench],
194
196
  trends: [loadTrends],
195
197
  output: [loadOutput],
196
198
  budget: [loadBudget],
@@ -156,6 +156,7 @@ export declare class AgentProcess {
156
156
  worktreePath: string | null;
157
157
  hostName: string | null;
158
158
  hostTarget: string | null;
159
+ hostIdentityFile: string | null;
159
160
  repoPath: string | null;
160
161
  remotePid: number | null;
161
162
  remoteLog: string | null;
@@ -493,6 +493,7 @@ export class AgentProcess {
493
493
  // (remotePid/remoteLog/remoteExit) — so the giant constructor stays untouched.
494
494
  hostName = null;
495
495
  hostTarget = null;
496
+ hostIdentityFile = null;
496
497
  repoPath = null;
497
498
  remotePid = null;
498
499
  remoteLog = null;
@@ -692,6 +693,7 @@ export class AgentProcess {
692
693
  const delta = pullRemoteLogDelta(this.hostTarget, {
693
694
  remoteLog: this.remoteLog,
694
695
  offset: this.remoteLogOffset,
696
+ extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
695
697
  });
696
698
  if (delta && delta.bytes.length > 0) {
697
699
  const stdoutPath = await this.getStdoutPath();
@@ -717,6 +719,7 @@ export class AgentProcess {
717
719
  const res = sshExec(this.hostTarget, `cat ${this.remoteExit} 2>/dev/null`, {
718
720
  timeoutMs: 8000,
719
721
  multiplex: true,
722
+ extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
720
723
  });
721
724
  exit = res.code === 0 && res.stdout.trim() !== '' ? res.stdout.trim() : null;
722
725
  }
@@ -898,6 +901,7 @@ export class AgentProcess {
898
901
  worktree_path: this.worktreePath,
899
902
  host_name: this.hostName,
900
903
  host_target: this.hostTarget,
904
+ host_identity_file: this.hostIdentityFile,
901
905
  repo_path: this.repoPath,
902
906
  remote_pid: this.remotePid,
903
907
  remote_log: this.remoteLog,
@@ -952,6 +956,7 @@ export class AgentProcess {
952
956
  // constructor signature stays fixed. Null on every pre-existing teammate.
953
957
  agent.hostName = meta.host_name || null;
954
958
  agent.hostTarget = meta.host_target || null;
959
+ agent.hostIdentityFile = meta.host_identity_file || null;
955
960
  agent.repoPath = meta.repo_path || null;
956
961
  agent.remotePid = typeof meta.remote_pid === 'number' ? meta.remote_pid : null;
957
962
  agent.remoteLog = meta.remote_log || null;
@@ -980,7 +985,11 @@ export class AgentProcess {
980
985
  // interpolate UNQUOTED so `$HOME` expands (shellQuote would defeat it).
981
986
  const probe = `test -f ${this.remoteExit} && echo DEAD || ` +
982
987
  `(kill -0 ${this.remotePid} 2>/dev/null && echo ALIVE || echo DEAD)`;
983
- const res = sshExec(this.hostTarget, probe, { timeoutMs: 8000, multiplex: true });
988
+ const res = sshExec(this.hostTarget, probe, {
989
+ timeoutMs: 8000,
990
+ multiplex: true,
991
+ extraSshArgs: this.hostIdentityFile ? ['-i', this.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
992
+ });
984
993
  if (res.code === null)
985
994
  return true; // transient ssh failure — don't reap early
986
995
  return res.stdout.trim().endsWith('ALIVE');
@@ -1640,10 +1649,16 @@ export class AgentManager {
1640
1649
  if (!host) {
1641
1650
  throw new Error(`Cannot launch remote teammate ${agent.agentId}: device "${agent.hostName}" no longer resolves.`);
1642
1651
  }
1643
- // Ensure agents-cli is present + version-matched on the host; surface (not
1644
- // fail on) an agent-not-installed warning like dispatch.ts does.
1652
+ agent.hostIdentityFile = host.identityFile ?? null;
1653
+ // Ensure agents-cli is present + the pin is installed on the host. A bare
1654
+ // agent name still warns (like dispatch.ts); a concrete agent.version pin
1655
+ // fails loud so the teammate never reports launched against a missing pin
1656
+ // (RUSH-2313).
1645
1657
  try {
1646
- const { warnings } = ensureHostReady(host, { agent: agent.agentType });
1658
+ const { warnings } = ensureHostReady(host, {
1659
+ agent: agent.agentType,
1660
+ version: agent.version ?? undefined,
1661
+ });
1647
1662
  for (const w of warnings)
1648
1663
  process.stderr.write(`[teams] warning: ${w}\n`);
1649
1664
  }
@@ -1661,7 +1676,9 @@ export class AgentManager {
1661
1676
  remoteCwd = agent.worktreePath;
1662
1677
  }
1663
1678
  else {
1664
- const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1679
+ const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName, {
1680
+ extraSshArgs: agent.hostIdentityFile ? ['-i', agent.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
1681
+ });
1665
1682
  agent.worktreePath = worktreePath;
1666
1683
  remoteCwd = worktreePath;
1667
1684
  }
@@ -1743,9 +1760,12 @@ export class AgentManager {
1743
1760
  }
1744
1761
  const target = sshTargetFor(host);
1745
1762
  const teamMeta = await getTeam(taskName);
1746
- const repoRoot = ensureRemoteRepo(target, teamMeta?.repo ?? '', taskName);
1763
+ const repoRoot = ensureRemoteRepo(target, teamMeta?.repo ?? '', taskName, {
1764
+ extraSshArgs: host.identityFile ? ['-i', host.identityFile, '-o', 'IdentitiesOnly=yes'] : [],
1765
+ });
1747
1766
  agent.hostName = host.name;
1748
1767
  agent.hostTarget = target;
1768
+ agent.hostIdentityFile = host.identityFile ?? null;
1749
1769
  agent.repoPath = repoRoot;
1750
1770
  await agent.saveMeta();
1751
1771
  }
@@ -1864,11 +1884,12 @@ export class AgentManager {
1864
1884
  return;
1865
1885
  const byTarget = new Map();
1866
1886
  for (const a of teammates) {
1867
- const arr = byTarget.get(a.hostTarget) || [];
1868
- arr.push(a);
1869
- byTarget.set(a.hostTarget, arr);
1887
+ const key = `${a.hostTarget}\0${a.hostIdentityFile ?? ''}`;
1888
+ const group = byTarget.get(key) || { target: a.hostTarget, agents: [] };
1889
+ group.agents.push(a);
1890
+ byTarget.set(key, group);
1870
1891
  }
1871
- for (const [target, agents] of byTarget) {
1892
+ for (const { target, agents } of byTarget.values()) {
1872
1893
  // Emit one line per teammate: "<agentId> ALIVE|DEAD <exitOrEmpty>". A single
1873
1894
  // round-trip over the multiplexed socket, regardless of teammate count.
1874
1895
  const parts = agents.map((a) => {
@@ -1883,7 +1904,12 @@ export class AgentManager {
1883
1904
  `elif kill -0 ${a.remotePid} 2>/dev/null; then printf 'ALIVE\\n'; ` +
1884
1905
  `else printf 'DEAD\\n'; fi`);
1885
1906
  });
1886
- const res = sshExec(target, parts.join('; '), { timeoutMs: 12000, multiplex: true });
1907
+ const identityFile = agents[0]?.hostIdentityFile;
1908
+ const res = sshExec(target, parts.join('; '), {
1909
+ timeoutMs: 12000,
1910
+ multiplex: true,
1911
+ extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : [],
1912
+ });
1887
1913
  if (res.code === null)
1888
1914
  continue; // transient ssh failure — skip this wave, no snapshot
1889
1915
  const snapshots = new Map();
@@ -2155,6 +2181,7 @@ export class AgentManager {
2155
2181
  sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null`, {
2156
2182
  timeoutMs: 10000,
2157
2183
  multiplex: true,
2184
+ extraSshArgs: agent.hostIdentityFile ? ['-i', agent.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [],
2158
2185
  });
2159
2186
  }
2160
2187
  catch {
@@ -15,7 +15,7 @@ export declare function clearPlacementSignalCache(): void;
15
15
  * excludes nor prefers them). Never throws — a probe failure degrades to a
16
16
  * missing/partial signal.
17
17
  */
18
- export declare function probePoolSignals(pool: string[], agent: AgentType, opts?: {
18
+ export declare function probePoolSignals(pool: string[], agent?: AgentType, opts?: {
19
19
  force?: boolean;
20
20
  now?: number;
21
21
  }): Promise<Map<string, DevicePlacementSignal>>;
@@ -7,26 +7,25 @@
7
7
  *
8
8
  * - reachability + headroom + load ← {@link probeFleetStats} (one parallel
9
9
  * SSH fan-out over the pool; the local box is measured directly).
10
- * - requested agent installed (+ signed in, locally) ← a one-shot readiness
10
+ * - requested agent installed + signed in (when known) ← a one-shot readiness
11
11
  * probe per remote device ({@link buildReadyProbeCommand} → `agents view`),
12
12
  * the local box via {@link checkCliAvailable}/{@link checkCliSignedIn}.
13
13
  *
14
- * The result is cached briefly per (pool, agent) so a `teams start` wave that
14
+ * The result is cached briefly per (pool, agent-or-any) so a `teams start` wave that
15
15
  * places N teammates probes the pool ONCE, not N times — the roster-count part
16
16
  * of the rank stays live (the pure pick recounts the roster each call), only the
17
17
  * SSH-measured load/harness snapshot is reused within the TTL.
18
18
  *
19
19
  * All SSH here is via `execFile` (async, bounded) so the whole pool is probed in
20
20
  * parallel; a slow or wedged box degrades to "no signal" instead of blocking the
21
- * launch. Remote sign-in is deliberately left unknown sign-in detection is
22
- * unreliable over SSH (see {@link checkCliSignedIn}); it ranks the local box
23
- * only, and the install gate (not sign-in) is what drives the fail-loud.
21
+ * launch. The readiness payload is `agents view --json`, so remote and local
22
+ * candidates carry the same installed/sign-in verdict.
24
23
  */
25
24
  import { execFile } from 'child_process';
26
25
  import { probeFleetStats, headroom } from '../devices/health.js';
27
26
  import { loadDevicesSync } from '../devices/registry.js';
28
27
  import { buildSshInvocation, writeAskpassShim } from '../devices/connect.js';
29
- import { buildReadyProbeCommand, parseReadyProbe, viewHasAgent } from '../hosts/ready.js';
28
+ import { buildReadyProbeCommand, parseReadyProbe, viewAgentSignedIn, viewHasAgent, } from '../hosts/ready.js';
30
29
  import { localMachineId } from '../session/origin-machine.js';
31
30
  import { normalizeHost } from '../machine-id.js';
32
31
  import { checkCliAvailable, checkCliSignedIn } from './agents.js';
@@ -39,7 +38,7 @@ export const READY_PROBE_TIMEOUT_MS = 8_000;
39
38
  export const SIGNAL_TTL_MS = 15_000;
40
39
  const cache = new Map();
41
40
  function cacheKey(pool, agent) {
42
- return `${agent}::${[...pool].map(normalizeHost).sort().join(',')}`;
41
+ return `${agent ?? 'any-agent'}::${[...pool].map(normalizeHost).sort().join(',')}`;
43
42
  }
44
43
  /** Clear the probe cache — for tests and after a device-registry change. */
45
44
  export function clearPlacementSignalCache() {
@@ -51,7 +50,7 @@ export function clearPlacementSignalCache() {
51
50
  * failure) — reachability is then left to {@link probeFleetStats}; `false` when
52
51
  * the box answered but agents-cli or the agent is absent (a genuine can't-run).
53
52
  */
54
- function probeRemoteInstalled(device, agent) {
53
+ function probeRemoteReadiness(device, agent) {
55
54
  let args;
56
55
  let env;
57
56
  try {
@@ -62,18 +61,19 @@ function probeRemoteInstalled(device, agent) {
62
61
  ({ args, env } = buildSshInvocation(device, [cmd], shim, {}, { agentOnly: true }));
63
62
  }
64
63
  catch {
65
- return Promise.resolve(undefined);
64
+ return Promise.resolve({ installed: undefined, signedIn: undefined });
66
65
  }
67
66
  return new Promise((resolve) => {
68
67
  execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: READY_PROBE_TIMEOUT_MS }, (err, stdout) => {
69
68
  if (err || !stdout)
70
- return resolve(undefined);
69
+ return resolve({ installed: undefined, signedIn: undefined });
71
70
  const probe = parseReadyProbe(stdout);
72
71
  if (!probe.reachable)
73
- return resolve(undefined); // ssh/login issue unknown
72
+ return resolve({ installed: undefined, signedIn: undefined });
74
73
  if (!probe.version)
75
- return resolve(false); // agents-cli missing cannot run
76
- resolve(viewHasAgent(probe.view, agent));
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
77
  });
78
78
  });
79
79
  }
@@ -103,17 +103,17 @@ export async function probePoolSignals(pool, agent, opts = {}) {
103
103
  }
104
104
  const selfProfile = profiles.find((d) => normalizeHost(d.name) === self);
105
105
  const stats = await probeFleetStats(profiles, { selfName: selfProfile?.name });
106
- const installed = new Map(await Promise.all(profiles.map(async (d) => {
107
- const isSelf = normalizeHost(d.name) === self;
108
- if (isSelf) {
109
- const inst = checkCliAvailable(agent)[0];
110
- const signedIn = inst ? await checkCliSignedIn(agent) : undefined;
111
- return [d.name, { installed: inst, signedIn }];
112
- }
113
- // Remote sign-in stays unknown (see the module docblock); the install
114
- // gate is what drives the fail-loud.
115
- return [d.name, { installed: await probeRemoteInstalled(d, agent), signedIn: undefined }];
116
- })));
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
117
  const signals = new Map();
118
118
  for (const name of pool) {
119
119
  const d = lookup(name);
@@ -1,3 +1,6 @@
1
+ interface RemoteSshOptions {
2
+ extraSshArgs?: string[];
3
+ }
1
4
  /**
2
5
  * Render a path for safe interpolation into a REMOTE shell, expanding a leading
3
6
  * `~`/`~/` to `"$HOME"` so it resolves on the host. A plain `shellQuote('~/x')`
@@ -13,15 +16,15 @@ export declare function remotePathExpr(p: string): string;
13
16
  * downstream remote command (launch `cd`, worktree create, polling) works from an
14
17
  * absolute path with no tilde-expansion hazard.
15
18
  */
16
- export declare function resolveRemoteRepoRoot(target: string, repoPath: string): string | null;
19
+ export declare function resolveRemoteRepoRoot(target: string, repoPath: string, opts?: RemoteSshOptions): string | null;
17
20
  /** True when `repoPath` is a git working tree on the host. */
18
- export declare function isRemoteGitRepo(target: string, repoPath: string): boolean;
21
+ export declare function isRemoteGitRepo(target: string, repoPath: string, opts?: RemoteSshOptions): boolean;
19
22
  /**
20
23
  * Resolve the host repo's default branch name (origin/HEAD → `main`/`master`/…).
21
24
  * Falls back to `main` when origin/HEAD isn't set, matching the local recipe's
22
25
  * `remote set-head` step.
23
26
  */
24
- export declare function remoteDefaultBranch(target: string, repoPath: string): string;
27
+ export declare function remoteDefaultBranch(target: string, repoPath: string, opts?: RemoteSshOptions): string;
25
28
  /**
26
29
  * Create a worktree on the host for a teammate, branched off the freshly-fetched
27
30
  * default branch. Returns the absolute worktree path on the host.
@@ -30,7 +33,7 @@ export declare function remoteDefaultBranch(target: string, repoPath: string): s
30
33
  * `worktree add -b … origin/<default>` so a stale checkout can't fork the
31
34
  * teammate off old code.
32
35
  */
33
- export declare function createRemoteWorktree(target: string, repoPath: string, worktreeName: string): string;
36
+ export declare function createRemoteWorktree(target: string, repoPath: string, worktreeName: string, opts?: RemoteSshOptions): string;
34
37
  /**
35
38
  * Ensure the team's repo is present on the host and return its absolute git
36
39
  * root. The canonical location is `~/.agents/repos/<slug>` (slug = sanitized
@@ -45,12 +48,13 @@ export declare function createRemoteWorktree(target: string, repoPath: string, w
45
48
  * expands `$HOME` — single-quoting the tilde would defeat expansion, the same
46
49
  * hazard dispatch.ts avoids.
47
50
  */
48
- export declare function ensureRemoteRepo(target: string, repo: string, slug: string): string;
51
+ export declare function ensureRemoteRepo(target: string, repo: string, slug: string, opts?: RemoteSshOptions): string;
49
52
  /** True when the host worktree has uncommitted changes (staged or unstaged). */
50
- export declare function remoteWorktreeDirty(target: string, worktreePath: string): boolean;
53
+ export declare function remoteWorktreeDirty(target: string, worktreePath: string, opts?: RemoteSshOptions): boolean;
51
54
  /**
52
55
  * Remove a host worktree and (optionally) its branch. Best-effort: prunes on a
53
56
  * "not a working tree" error and ignores a missing branch, matching local
54
57
  * `removeWorktree` semantics so cleanup never throws on a half-gone worktree.
55
58
  */
56
- export declare function removeRemoteWorktree(target: string, repoPath: string, worktreeName: string, deleteBranch?: boolean): void;
59
+ export declare function removeRemoteWorktree(target: string, repoPath: string, worktreeName: string, deleteBranch?: boolean, opts?: RemoteSshOptions): void;
60
+ export {};