@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
@@ -12,7 +12,8 @@ import { buildLocalFindings, fleetDivergenceToFindings, signInToFindings, render
12
12
  import { getCliVersion } from '../lib/version.js';
13
13
  import { resolveHost } from '../lib/hosts/registry.js';
14
14
  import { sshExecAsync } from '../lib/ssh-exec.js';
15
- import { sshTargetFor } from '../lib/hosts/types.js';
15
+ import { hostIdentityArgs, sshTargetFor } from '../lib/hosts/types.js';
16
+ import { deviceIdentityArgs } from '../lib/devices/connect.js';
16
17
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
17
18
  import { findAmbiguousDevicePins } from '../lib/routines.js';
18
19
  import chalk from 'chalk';
@@ -33,6 +34,7 @@ import { listCliStatus, listCliStatusAsync } from '../lib/cli-resources.js';
33
34
  import { setHelpSections } from '../lib/help.js';
34
35
  import { heal, healChangedAnything } from '../lib/heal.js';
35
36
  import { getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
37
+ import { auditWindowsSshEnrollment, diagnoseWindowsSshFailure } from '../lib/devices/windows-ssh-enrollment.js';
36
38
  import { scanUserRcFiles, masterPassphraseInEnv } from '../lib/secrets/rc-hygiene.js';
37
39
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
38
40
  import { readRepoBehindMarkers } from '../lib/auto-pull.js';
@@ -83,14 +85,14 @@ function printWrappedLine(prefix, text) {
83
85
  console.log(chalk.gray(line));
84
86
  }
85
87
  /**
86
- * Narrow a remote `findings` array to the secret-hygiene rows, or `[]`.
88
+ * Narrow a remote `findings` array to rows only that device can observe.
87
89
  *
88
90
  * Deliberately NOT "forward every remote finding". The aggregator already
89
91
  * rebuilds a remote's sign-in rows from its inventory and its divergence rows
90
92
  * from the comparator, so forwarding wholesale would double them; and pulling a
91
93
  * remote's orphan/drift rows into a fleet readout is a much larger UX change
92
- * than this fix. The two secret kinds are the ones that are BOTH unrecomputable
93
- * centrally and security-relevant, which is exactly why they were being lost.
94
+ * than this fix. These kinds are unrecomputable centrally: shell/process secret
95
+ * hygiene and the Windows host's effective OpenSSH key path/content/ACL.
94
96
  *
95
97
  * **The remote contributes exactly one thing: the KIND.** Severity, message and
96
98
  * remediation are all generated HERE. That is not defensiveness for its own
@@ -111,13 +113,15 @@ function printWrappedLine(prefix, text) {
111
113
  * file and line. Run `agents doctor` on that box for the specifics — the
112
114
  * message says so.
113
115
  */
114
- const REMOTE_FORWARDED_KINDS = ['rc-secret-export', 'env-secret-export'];
116
+ const REMOTE_FORWARDED_KINDS = ['rc-secret-export', 'env-secret-export', 'ssh-key-enrollment'];
115
117
  /** Canonical, locally-authored text for a forwarded kind. Never the remote's. */
116
118
  const REMOTE_SECRET_MESSAGE = {
117
119
  'rc-secret-export': 'a credential-shaped export was found in this box\'s shell rc files'
118
120
  + ' — run `agents doctor` there for the file and line',
119
121
  'env-secret-export': 'AGENTS_SECRETS_PASSPHRASE is set in this box\'s process environment'
120
122
  + ' — run `agents doctor` there for detail',
123
+ 'ssh-key-enrollment': 'Windows OpenSSH key enrollment is invalid'
124
+ + ' — run `agents doctor` on this box for the effective path or ACL failure',
121
125
  };
122
126
  export function asRemoteSecretFindings(raw, device) {
123
127
  if (!Array.isArray(raw))
@@ -158,11 +162,12 @@ async function resolveFleetTargets(opts) {
158
162
  name: deviceProfile.name,
159
163
  sshTarget: deviceProfile.name,
160
164
  os: deviceProfile.platform !== 'unknown' ? deviceProfile.platform : undefined,
165
+ extraSshArgs: deviceIdentityArgs(deviceProfile),
161
166
  }];
162
167
  }
163
168
  const host = await resolveHost(singleName);
164
169
  if (host) {
165
- return [{ name: singleName, sshTarget: sshTargetFor(host), os: host.os }];
170
+ return [{ name: singleName, sshTarget: sshTargetFor(host), os: host.os, extraSshArgs: hostIdentityArgs(host) }];
166
171
  }
167
172
  console.error(chalk.red(`Unknown host or device '${singleName}'.`));
168
173
  process.exit(1);
@@ -180,6 +185,7 @@ async function resolveFleetTargets(opts) {
180
185
  name: d.name,
181
186
  sshTarget: d.name,
182
187
  os: d.platform !== 'unknown' ? d.platform : undefined,
188
+ extraSshArgs: deviceIdentityArgs(d),
183
189
  }));
184
190
  }
185
191
  async function probeFleetTarget(target) {
@@ -190,12 +196,14 @@ async function probeFleetTarget(target) {
190
196
  // has it via the install profile, and our single-quote escaping would
191
197
  // prevent $HOME expansion there, so skip the bootstrap on Windows.
192
198
  isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
193
- const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
199
+ const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs: target.extraSshArgs });
194
200
  if (res.code !== 0) {
195
201
  return {
196
202
  name: target.name,
197
203
  online: false,
198
- error: res.timedOut ? 'timed out' : (res.stderr || `exit ${res.code ?? 'unknown'}`),
204
+ error: isWin
205
+ ? diagnoseWindowsSshFailure(res.stderr, res.timedOut)
206
+ : res.timedOut ? 'timed out' : (res.stderr || `exit ${res.code ?? 'unknown'}`),
199
207
  agents: {},
200
208
  };
201
209
  }
@@ -238,7 +246,11 @@ export const FLEET_INVENTORY_TIMEOUT_MS = 180_000;
238
246
  async function probeFleetInventory(target) {
239
247
  const isWin = /^win/i.test((target.os ?? '').trim());
240
248
  const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
241
- const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: FLEET_INVENTORY_TIMEOUT_MS, multiplex: true });
249
+ const res = await sshExecAsync(target.sshTarget, remoteCmd, {
250
+ timeoutMs: FLEET_INVENTORY_TIMEOUT_MS,
251
+ multiplex: true,
252
+ extraSshArgs: target.extraSshArgs,
253
+ });
242
254
  if (res.code !== 0)
243
255
  return null;
244
256
  try {
@@ -404,6 +416,7 @@ async function runDevicesDoctor(opts) {
404
416
  execPolicy: process.platform === 'win32'
405
417
  ? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
406
418
  : undefined,
419
+ windowsSshEnrollment: auditWindowsSshEnrollment(),
407
420
  isolatedVersions: localReports
408
421
  .filter((rep) => isVersionIsolated(rep.agent, rep.version))
409
422
  .map((rep) => `${rep.agent}@${rep.version}`),
@@ -1182,7 +1195,7 @@ function checkPayload(device, drift) {
1182
1195
  async function probeDeviceCheck(target) {
1183
1196
  const isWin = /^win/i.test((target.platform ?? '').trim());
1184
1197
  const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--check', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
1185
- const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
1198
+ const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs: target.extraSshArgs });
1186
1199
  if (res.code !== 0 && !res.stdout.trim()) {
1187
1200
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
1188
1201
  }
@@ -1211,6 +1224,7 @@ async function runDevicesCheck(opts, cwd) {
1211
1224
  platform: t.device.platform,
1212
1225
  skip: t.skip,
1213
1226
  dialTarget: fleetDialTarget(t.device),
1227
+ extraSshArgs: deviceIdentityArgs(t.device),
1214
1228
  }));
1215
1229
  const remote = await fanOutDevices(remoteTargets, probeDeviceCheck);
1216
1230
  const devices = [local];
@@ -554,7 +554,7 @@ export function registerRunCommand(program) {
554
554
  .option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
555
555
  .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
556
556
  .option('--where <spec>', 'Where this run\'s body executes (one placement door): local | device:<name> | auto | lease[:backend] | cloud[:provider]. Expands to --host/--lease/--cloud. Do not combine with those flags. See docs/00-concepts.md#placement.')
557
- .option('--host <name>', 'Offload this run onto another machine over SSH — a device name, registered host, or user@host. Pass "auto" to pick from 14d usage affinity (most-used online device has highest probability). Same as --where device:<name>. See `agents devices`.')
557
+ .option('--host <name>', 'Offload this run onto another machine over SSH — a device name, registered host, or user@host. Pass "auto" to pick the least-loaded reachable device where the requested agent is installed and signed in, keeping the run local when no remote is better. Same as --where device:<name>. See `agents devices`.')
558
558
  .option('--device <name>', 'Alias of --host. Pass "auto" for affinity-based device pick (same as --where auto).')
559
559
  .option('--remote-cwd <dir>', "Explicit host working directory for --host runs, used VERBATIM (overrides --cwd; usually --cwd suffices — it re-roots a local-home path onto the remote home). Pass a single-quoted '$HOME/…' or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you).")
560
560
  .option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
@@ -947,13 +947,18 @@ export function registerRunCommand(program) {
947
947
  if (!resolvedResumeSource && runAutoDefaultsToAffinity(options))
948
948
  options.device = 'auto';
949
949
  }
950
- // --device auto / --host auto (and deprecated --smart): affinity-pick host.
950
+ // --device auto / --host auto (and deprecated --smart): live fleet pick.
951
951
  // Harness is always the agent the user typed — never auto-picked.
952
952
  // Affinity failure degrades to local (does not kill the run).
953
953
  {
954
954
  const { applyDeviceAutoToOptions } = await import('../lib/smart-launch.js');
955
- const result = applyDeviceAutoToOptions(options, {
955
+ const result = await applyDeviceAutoToOptions(options, {
956
956
  accountPickerRequested,
957
+ // `run auto` selects its harness after placement, so do not filter
958
+ // candidates against an arbitrary proxy harness at this stage.
959
+ agent: normalizedAgentSpec.split('@')[0] === RUN_AUTO_KEYWORD
960
+ ? undefined
961
+ : (resolveAgentName(normalizedAgentSpec.split('@')[0]) ?? undefined),
957
962
  });
958
963
  if (!options.quiet && result.deprecationSmart) {
959
964
  process.stderr.write(chalk.yellow('[agents] --smart is deprecated; use --device auto\n'));
@@ -964,7 +969,7 @@ export function registerRunCommand(program) {
964
969
  if (!options.quiet && result.banner) {
965
970
  const { hostLabel, deviceHint, acctNote } = result.banner;
966
971
  process.stderr.write(chalk.gray(`[agents] device=auto → ${hostLabel}` +
967
- (deviceHint ? ` (affinity ${deviceHint})` : '') +
972
+ (deviceHint ? ` (load ${deviceHint})` : '') +
968
973
  ` · ${acctNote}\n`));
969
974
  }
970
975
  }
@@ -1790,7 +1795,7 @@ export function registerRunCommand(program) {
1790
1795
  await warnUnpushedWork(resumeExec.cwd ?? process.cwd());
1791
1796
  process.exit(resumeExit);
1792
1797
  }
1793
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, implicitModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveHostSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
1798
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, implicitModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveHostSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError, signInRecoverableCandidates }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
1794
1799
  import('../lib/exec.js'),
1795
1800
  import('../lib/agents.js'),
1796
1801
  import('../lib/profiles.js'),
@@ -2268,22 +2273,18 @@ export function registerRunCommand(program) {
2268
2273
  process.exit(1);
2269
2274
  }
2270
2275
  version = resolvedRecoveryTarget.version;
2271
- const canResumeNatively = resolvedRecoveryTarget.mode === 'native';
2272
- if (canResumeNatively) {
2276
+ if (resolvedRecoveryTarget.mode === 'native') {
2273
2277
  version = session.version;
2274
2278
  resumeNative = true;
2275
2279
  resumeSessionId = session.id;
2276
- // Native `--resume` (claude/codex) resolves the transcript relative to the
2277
- // working directory (projects/<cwd-hash>/). The session may have been started
2278
- // in a different directory than we're standing in now — most importantly when a
2279
- // routine daemon fires `agents run --resume` from its own cwd. Spawn from the
2280
- // session's ORIGIN cwd so the resume actually finds it; otherwise the agent
2281
- // exits "No conversation found with session ID". Honor an explicit --cwd only if
2282
- // the caller passed one (they're overriding on purpose).
2283
- if (!options.cwd && session.cwd)
2284
- options.cwd = session.cwd;
2280
+ // The centralized recovery decision proves the transcript belongs to
2281
+ // this exact isolated home and resolves any harness-specific launch cwd.
2282
+ // Claude's indexed `session.cwd` is the first user-turn cwd, which may
2283
+ // differ from the earlier cwd that selected projects/<cwd-key>.
2284
+ if (!options.cwd && resolvedRecoveryTarget.cwd)
2285
+ options.cwd = resolvedRecoveryTarget.cwd;
2285
2286
  if (!options.quiet)
2286
- process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''}${!options.cwd || options.cwd === session.cwd ? ` in ${session.cwd ?? cwd}` : ''}\n`));
2287
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''} in ${options.cwd ?? cwd}\n`));
2287
2288
  }
2288
2289
  else {
2289
2290
  // Tier-2: launch fresh with a /continue <id> first message; the agent
@@ -2301,6 +2302,9 @@ export function registerRunCommand(program) {
2301
2302
  // synthesize a same-agent fallback chain from the other healthy accounts
2302
2303
  // (issue #348). Stays null unless a non-pinned strategy actually rotated.
2303
2304
  let rotationResult = null;
2305
+ // Set when the zero-healthy path already announced a deliberate
2306
+ // launch-to-sign-in, so the login preflight below does not repeat it.
2307
+ let signInLaunch = false;
2304
2308
  if (options.strategy && !explicitStrategy) {
2305
2309
  console.error(chalk.red(`Invalid strategy: ${options.strategy}. Use ${RUN_STRATEGIES.join(', ')}.`));
2306
2310
  process.exit(1);
@@ -2327,15 +2331,53 @@ export function registerRunCommand(program) {
2327
2331
  try {
2328
2332
  const resolved = await resolveRunVersion(agent, strategy, cwd);
2329
2333
  if (resolved.exhausted) {
2330
- // Fail loud (RUSH-2132): the old behavior warned "found no
2331
- // usable version; falling back to defaults" and launched the
2332
- // pinned default anyway — the exact move that loops a rotate
2333
- // into an exhausted account. The message text is a contract
2334
- // the Factory watchdog tail-detects; do not reword it.
2335
- console.error(chalk.red(formatNoHealthyAccountError(agent, strategy, resolved.exhausted)));
2336
- process.exit(1);
2334
+ // Zero healthy accounts splits two ways, and conflating them is what
2335
+ // stranded a logged-out harness with no way in at all (RUSH-2334):
2336
+ //
2337
+ // - THROTTLED (rate_limited / out_of_credits) -> fail loud (RUSH-2132).
2338
+ // The old behavior warned "found no usable version; falling back to
2339
+ // defaults" and launched the pinned default anyway — the exact move
2340
+ // that loops a rotate into an exhausted account. Only a window reset
2341
+ // clears it. The message text is a contract the Factory watchdog
2342
+ // tail-detects; do not reword it.
2343
+ // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2344
+ // because the harness's own TUI is the login surface. So on a TTY we
2345
+ // carry the user into that login instead of erroring. Exiting here
2346
+ // made `agents run <agent>`, `agents run <agent>@`, and `agents use`
2347
+ // all dead-end with no reachable way to authenticate.
2348
+ const recoverable = signInRecoverableCandidates(resolved.exhausted);
2349
+ const { signInLaunchDecision } = await import('./run-account-picker.js');
2350
+ const decision = signInLaunchDecision({
2351
+ recoverable: recoverable.length,
2352
+ tty: isInteractiveTerminal(),
2353
+ json: options.json === true,
2354
+ });
2355
+ if (decision === 'launch') {
2356
+ const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2357
+ const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2358
+ // A cancelled prompt launches nothing — same contract as the
2359
+ // trailing-@ account picker above.
2360
+ if (!signInVersion)
2361
+ return;
2362
+ version = signInVersion;
2363
+ // We just told the user this account is logged out and why we're
2364
+ // launching it, so suppress the downstream login preflight — it
2365
+ // would print a second, near-identical "looks logged out" warning.
2366
+ signInLaunch = true;
2367
+ }
2368
+ else {
2369
+ console.error(chalk.red(formatNoHealthyAccountError(agent, strategy, resolved.exhausted)));
2370
+ if (recoverable.length > 0) {
2371
+ // Off a TTY nobody can complete a login, so we still exit — but
2372
+ // name the actual fix rather than only offering --strategy pinned,
2373
+ // which would just pin the same unauthenticated account.
2374
+ const { loginHint } = await import('../lib/signin-badge.js');
2375
+ console.error(chalk.gray(`To sign in: ${loginHint(agent)} — or run \`agents run ${agent}\` from a terminal.`));
2376
+ }
2377
+ process.exit(1);
2378
+ }
2337
2379
  }
2338
- if (resolved.version) {
2380
+ else if (resolved.version) {
2339
2381
  version = resolved.version;
2340
2382
  rotationResult = resolved.rotation;
2341
2383
  if (resolved.rotation && !options.quiet) {
@@ -2427,7 +2469,10 @@ export function registerRunCommand(program) {
2427
2469
  json: options.json,
2428
2470
  quiet: options.quiet,
2429
2471
  authCheckDisabled: options.authCheck === false || process.env.AGENTS_NO_AUTH_CHECK === '1',
2430
- rotated: !!rotationResult || accountPickerRequested,
2472
+ // `signInLaunch` means the zero-healthy path already reported this exact
2473
+ // account as logged out and named the login command, so re-probing here
2474
+ // only prints a second, near-identical warning.
2475
+ rotated: !!rotationResult || accountPickerRequested || signInLaunch,
2431
2476
  });
2432
2477
  if (preflight) {
2433
2478
  try {
@@ -13,7 +13,7 @@ import { isInteractiveTerminal } from './utils.js';
13
13
  import { assertValidSshTarget } from '../lib/ssh-exec.js';
14
14
  import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
15
15
  import { getDevice } from '../lib/devices/registry.js';
16
- import { sshTargetFor } from '../lib/hosts/types.js';
16
+ import { hostIdentityArgs, sshTargetFor } from '../lib/hosts/types.js';
17
17
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
18
18
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
19
19
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
@@ -190,14 +190,15 @@ async function doCheck(name) {
190
190
  const target = sshTargetFor(host);
191
191
  const os = host.os ?? resolveRemoteOsSync(name);
192
192
  process.stdout.write(`Probing ${chalk.cyan(name)} (${target})… `);
193
- const probe = probeHost(target, os);
193
+ const identityArgs = hostIdentityArgs(host);
194
+ const probe = probeHost(target, os, identityArgs);
194
195
  if (!probe.reachable) {
195
196
  console.log(chalk.red('unreachable'));
196
197
  process.exitCode = 1;
197
198
  return;
198
199
  }
199
200
  console.log(chalk.green('reachable') + chalk.gray(probe.os ? ` · ${probe.os}` : ''));
200
- const ver = remoteAgentsVersion(target, probe.os ?? os);
201
+ const ver = remoteAgentsVersion(target, probe.os ?? os, identityArgs);
201
202
  console.log(` agents-cli: ${ver ? chalk.green(ver) : chalk.yellow('not installed')}`);
202
203
  }
203
204
  async function doRemove(name) {
@@ -217,16 +217,27 @@ function renderReport(groups, dim, meta, actions, harnesses) {
217
217
  return;
218
218
  }
219
219
  // Per-group table — the headline, and the thing no sibling command produces.
220
+ // Includes silent-stall counts so harness/account laziness is visible without --json.
220
221
  out.push('');
221
222
  out.push(chalk.bold(`By ${dim}`));
222
- const labelW = Math.min(Math.max(...groups.map((g) => stringWidth(g.label)), 5), Math.max(20, terminalWidth() - 46));
223
+ const labelW = Math.min(Math.max(...groups.map((g) => stringWidth(g.label)), 5), Math.max(16, terminalWidth() - 58));
223
224
  const sessW = Math.max(...groups.map((g) => String(g.sessions).length), 3);
225
+ const stallOf = (g) => Object.entries(g.facets.frictionSignals)
226
+ .filter(([k]) => k.startsWith('silent stall:'))
227
+ .reduce((n, [, c]) => n + c, 0);
228
+ const resumeOf = (g) => g.facets.correctionSignals['resume after silent stall'] ?? 0;
229
+ const stallW = Math.max(...groups.map((g) => String(stallOf(g)).length), 5);
230
+ out.push(chalk.gray(` ${padToWidth('', labelW)} ${''.padStart(sessW)} ` +
231
+ `${''.padStart(9)} ${''.padStart(8)} ${'stalls'.padStart(stallW)} resume`));
224
232
  for (const g of groups) {
225
233
  const cost = g.costUsd > 0 ? formatUsd(g.costUsd) : '—';
226
234
  const dur = g.durationMs > 0 ? formatDuration(g.durationMs) : '—';
235
+ const stalls = stallOf(g);
236
+ const resumes = resumeOf(g);
227
237
  out.push(` ${padToWidth(truncateToWidth(g.label, labelW), labelW)} ` +
228
238
  `${chalk.gray(String(g.sessions).padStart(sessW))} ${chalk.gray('sess')} ` +
229
- `${chalk.green(padToWidth(cost, 9))} ${chalk.gray(dur)}`);
239
+ `${chalk.green(padToWidth(cost, 9))} ${chalk.gray(padToWidth(dur, 8))} ` +
240
+ `${chalk.cyan(String(stalls).padStart(stallW))} ${chalk.cyan(String(resumes))}`);
230
241
  }
231
242
  // Everything below is the whole scope folded together; per-group detail is in --json.
232
243
  const all = newFacetAccumulator();
@@ -235,16 +246,32 @@ function renderReport(groups, dim, meta, actions, harnesses) {
235
246
  renderCounts('Top tools', topEntries(all.toolCounts, 8), out);
236
247
  renderCounts('Languages', topEntries(all.languages, 6), out);
237
248
  renderCounts('Models', topEntries(all.models, 6), out);
249
+ renderCounts('Silent stalls by model', topEntries(all.silentStallsByModel ?? {}, 8), out);
238
250
  // Friction — the section that earns the command.
239
251
  const gaps = all.responseGaps;
252
+ const silentStalls = Object.entries(all.frictionSignals)
253
+ .filter(([k]) => k.startsWith('silent stall:'))
254
+ .reduce((n, [, c]) => n + c, 0);
255
+ const resumeNudges = all.correctionSignals['resume after silent stall'] ?? 0;
240
256
  out.push('');
241
257
  out.push(chalk.bold('Friction'));
242
258
  out.push(` ${padToWidth('interruptions', 18)} ${chalk.cyan(String(all.interruptions))}` +
243
259
  chalk.gray(' turns you cut short'));
244
260
  out.push(` ${padToWidth('tool errors', 18)} ${chalk.cyan(String(all.errorCount))}`);
245
261
  if (gaps.length > 0) {
246
- out.push(` ${padToWidth('your reply time', 18)} ` +
247
- chalk.cyan(`p50 ${Math.round(percentile(gaps, 50))}s`) + chalk.gray(` · p90 ${Math.round(percentile(gaps, 90))}s`));
262
+ // Same timestamps as silent stalls; this line is the distribution. Silent
263
+ // stalls (below) are the agent-attributed long gaps after the model stopped.
264
+ out.push(` ${padToWidth('gap until next msg', 18)} ` +
265
+ chalk.cyan(`p50 ${Math.round(percentile(gaps, 50))}s`) + chalk.gray(` · p90 ${Math.round(percentile(gaps, 90))}s`) +
266
+ chalk.gray(' after assistant last spoke'));
267
+ }
268
+ if (silentStalls > 0) {
269
+ out.push(` ${padToWidth('silent stalls', 18)} ${chalk.cyan(String(silentStalls))}` +
270
+ chalk.gray(' agent idle ≥5m until you resumed (also in By ' + dim + ' table)'));
271
+ }
272
+ if (resumeNudges > 0) {
273
+ out.push(` ${padToWidth('resume nudges', 18)} ${chalk.cyan(String(resumeNudges))}` +
274
+ chalk.gray(' "continue"/"keep going" after a silent stall'));
248
275
  }
249
276
  const errs = topEntries(all.errorCategories, 6);
250
277
  if (errs.length > 0) {
@@ -321,7 +348,7 @@ function renderReport(groups, dim, meta, actions, harnesses) {
321
348
  out.push(chalk.yellow(` ${meta.unreadable} transcripts could not be read; their behaviour is missing from these totals.`));
322
349
  }
323
350
  if (all.gapsOverCeiling > 0) {
324
- out.push(chalk.gray(` ${all.gapsOverCeiling} reply gaps over an hour excluded from the percentiles.`));
351
+ out.push(chalk.gray(` ${all.gapsOverCeiling} gaps over an hour excluded from p50/p90 (still counted as silent stall: 1h+ when the assistant last spoke).`));
325
352
  }
326
353
  out.push('');
327
354
  out.push(chalk.gray(' `agents insights --by project` to see it per repo'));
@@ -341,6 +368,13 @@ async function renderNarrative(payload) {
341
368
  '2. What is costing you — split into the assistant\'s fault vs your own workflow.',
342
369
  '3. Quick wins — concrete, tied to a number in the data.',
343
370
  '4. Worth trying — one more ambitious workflow change.',
371
+ '',
372
+ 'Silent stalls (required): if frictionSignals contain "silent stall: …" or',
373
+ 'correctionSignals contain "resume after silent stall" / "continue / keep going",',
374
+ 'call that out explicitly in section 2 or 3 with the counts. Those mean the model',
375
+ 'stopped mid-session and sat idle until the human pinged it (timestamps: last',
376
+ 'assistant event → next user message ≥ 5 minutes). Do not reframe them as the',
377
+ 'user being slow unless the data only shows short reply gaps.',
344
378
  'Be specific and cite the numbers. No preamble, no flattery, no bullet padding.',
345
379
  '',
346
380
  JSON.stringify(payload),