@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
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { randomUUID } from 'crypto';
12
12
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
13
- import { sshTargetFor } from './types.js';
13
+ import { hostIdentityArgs, sshTargetFor } from './types.js';
14
14
  import { ensureHostReady } from './ready.js';
15
15
  import { remoteShellFor, posixEnvExports } from './remote-cmd.js';
16
16
  import { resolveRemoteOsSync } from './remote-os.js';
@@ -156,7 +156,7 @@ function terminateRemoteLaunch(task) {
156
156
  `sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
157
157
  `elif kill -0 -- -${pid} 2>/dev/null; then exit 1; fi; ` +
158
158
  `rm -f ${task.remoteLog} ${task.remoteExit}`;
159
- const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
159
+ const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true, extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined });
160
160
  if (result.code !== 0) {
161
161
  throw new Error(`Failed to terminate remote task ${task.id} on ${task.host}: ` +
162
162
  `${(result.stderr || result.stdout).trim() || 'ssh error'}`);
@@ -210,7 +210,7 @@ export function stopDispatchedTask(task) {
210
210
  throw new Error(`Cannot stop remote task ${task.id}: launch returned no PID.`);
211
211
  }
212
212
  const command = buildStopRemoteCommand(task.pid, task.remoteExit);
213
- const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
213
+ const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true, extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined });
214
214
  if (result.code !== 0) {
215
215
  throw new Error(`Failed to stop remote task ${task.id} on ${task.host}: ` +
216
216
  `${(result.stderr || result.stdout).trim() || 'ssh error'}`);
@@ -271,6 +271,7 @@ async function launchDetached(host, target, opts) {
271
271
  timeoutMs: 30000,
272
272
  multiplex: !opts.copyCreds,
273
273
  hostKeyOpts: credHostKeyOpts,
274
+ extraSshArgs: hostIdentityArgs(host),
274
275
  });
275
276
  if (res.code !== 0) {
276
277
  throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
@@ -280,6 +281,7 @@ async function launchDetached(host, target, opts) {
280
281
  id,
281
282
  host: host.name,
282
283
  target,
284
+ identityFile: host.identityFile,
283
285
  agent: opts.agentLabel,
284
286
  prompt: opts.promptLabel,
285
287
  pid: Number.isFinite(pid) ? pid : undefined,
@@ -311,6 +313,7 @@ async function launchDetached(host, target, opts) {
311
313
  taskId: id,
312
314
  echo: true,
313
315
  timeoutMs: opts.timeoutMs,
316
+ extraSshArgs: hostIdentityArgs(host),
314
317
  });
315
318
  // -1 = the follow window closed while the run continues on the host. Leave the
316
319
  // record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
@@ -464,6 +467,7 @@ export async function runInteractiveOnHost(host, opts) {
464
467
  tty: process.stdin.isTTY,
465
468
  multiplex: !opts.copyCreds,
466
469
  hostKeyOpts: credHostKeyOpts,
470
+ extraSshArgs: hostIdentityArgs(host),
467
471
  });
468
472
  }
469
473
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
@@ -24,6 +24,28 @@ interface RemoteSpec {
24
24
  /** Flags appended when running non-interactively (no local TTY / `--no-tty`). */
25
25
  nonInteractive?: string[];
26
26
  }
27
+ /**
28
+ * First-class groups that run transparently on a remote via SSH when
29
+ * `--host`/`--device` is present. Keep both canonical names and aliases
30
+ * (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
31
+ *
32
+ * Prefer adding here over per-command SSH code — this is the single choke point.
33
+ *
34
+ * Every key MUST be a real top-level command (a `KNOWN_TOP_LEVEL_COMMANDS`
35
+ * member) — `passthrough.test.ts` asserts it. A key that is not one is dead:
36
+ * the gate in {@link maybeRunOnHost} rejects the name as unknown before this
37
+ * table is consulted, and before that gate existed it SSH'd a command the peer
38
+ * would also reject. `cli`/`packages`/`versions`/`daemon` were exactly that
39
+ * (the commands are `clis`, `registry`/`search`/`install`/`publish`,
40
+ * `add`/`use`/`list`, and none) and were removed.
41
+ */
42
+ export declare const REMOTE_PASSTHROUGH: Record<string, RemoteSpec>;
43
+ /**
44
+ * Commands that register and interpret `--host`/`--device` themselves — must
45
+ * fall through to local commander even when the flag is present. Do not add
46
+ * these to {@link REMOTE_PASSTHROUGH}.
47
+ */
48
+ export declare const OWN_HOST_COMMANDS: Set<string>;
27
49
  /** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
28
50
  export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
29
51
  /** Injectable dependencies for {@link runFleetPassthrough} — used by tests. */
@@ -61,6 +83,29 @@ export declare function runFleetPassthrough(command: string, allArgs: string[],
61
83
  * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
62
84
  */
63
85
  export declare function maybeRunOnHost(command: string, allArgs: string[], opts?: FleetPassthroughOptions): Promise<boolean>;
86
+ /**
87
+ * `--host` passthrough for a **standalone binary** whose command name is fixed by
88
+ * the binary itself (the `browser`/`computer` bins, `dist/browser.js` etc.) rather
89
+ * than being the first argv token.
90
+ *
91
+ * `agents browser … --host <box>` routes through {@link maybeRunOnHost} in
92
+ * index.ts, but the standalone `browser` binary never enters index.ts — so without
93
+ * this it dropped `--host` entirely (commander errored with `unknown option
94
+ * '--host'`). This wires the identical routing: it synthesizes the implicit command
95
+ * token (`browser`) at the front of the argv so the remote invocation builds as
96
+ * `agents browser …`, delegates to {@link maybeRunOnHost}, and returns `true` when
97
+ * it dispatched the run to a remote (the local program must then NOT parse).
98
+ *
99
+ * On a local / self-host fall-through it rewrites `process.argv` to the ORIGINAL
100
+ * args minus the routing flags — never the synthetic command token — so the
101
+ * standalone commander program parses cleanly and never sees an unknown `--host`.
102
+ * When no routing flag is present it leaves `process.argv` untouched (the common
103
+ * case), so an unrelated flag like `--no-tty` is never stripped from a purely local
104
+ * run.
105
+ *
106
+ * @param command the fixed command name the binary stands for (`'browser'`).
107
+ */
108
+ export declare function maybeRunStandaloneOnHost(command: string, opts?: FleetPassthroughOptions): Promise<boolean>;
64
109
  /**
65
110
  * Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
66
111
  * return the exit code. The single place the SSH hop is built, so every remote
@@ -21,7 +21,7 @@ import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
21
21
  import { resolveHost, resolveHostByCap } from './registry.js';
22
22
  import { sshTargetFor } from './types.js';
23
23
  import { dispatchAgentsCommand, withActorEnv } from './dispatch.js';
24
- import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
24
+ import { stripRoutingFlags, buildRemoteAgentsInvocation, stripClixml, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
25
  import { resolveRemoteOsSync } from './remote-os.js';
26
26
  import { machineId } from '../session/sync/config.js';
27
27
  import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
@@ -36,8 +36,16 @@ import { isKnownTopLevelCommand } from '../startup/command-registry.js';
36
36
  * (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
37
37
  *
38
38
  * Prefer adding here over per-command SSH code — this is the single choke point.
39
+ *
40
+ * Every key MUST be a real top-level command (a `KNOWN_TOP_LEVEL_COMMANDS`
41
+ * member) — `passthrough.test.ts` asserts it. A key that is not one is dead:
42
+ * the gate in {@link maybeRunOnHost} rejects the name as unknown before this
43
+ * table is consulted, and before that gate existed it SSH'd a command the peer
44
+ * would also reject. `cli`/`packages`/`versions`/`daemon` were exactly that
45
+ * (the commands are `clis`, `registry`/`search`/`install`/`publish`,
46
+ * `add`/`use`/`list`, and none) and were removed.
39
47
  */
40
- const REMOTE_PASSTHROUGH = {
48
+ export const REMOTE_PASSTHROUGH = {
41
49
  // status / inspect
42
50
  view: {},
43
51
  inspect: {},
@@ -65,10 +73,8 @@ const REMOTE_PASSTHROUGH = {
65
73
  permissions: {},
66
74
  perms: {},
67
75
  mcp: {},
68
- cli: {},
69
76
  subagents: {},
70
77
  workflows: {},
71
- packages: {},
72
78
  models: {},
73
79
  profiles: {},
74
80
  defaults: {},
@@ -89,20 +95,18 @@ const REMOTE_PASSTHROUGH = {
89
95
  lock: {},
90
96
  feedback: {},
91
97
  wallet: {},
92
- daemon: {},
93
98
  pty: {},
94
99
  tmux: {},
95
100
  watchdog: {},
96
101
  factory: {},
97
102
  browser: {},
98
- versions: {},
99
103
  };
100
104
  /**
101
105
  * Commands that register and interpret `--host`/`--device` themselves — must
102
106
  * fall through to local commander even when the flag is present. Do not add
103
107
  * these to {@link REMOTE_PASSTHROUGH}.
104
108
  */
105
- const OWN_HOST_COMMANDS = new Set([
109
+ export const OWN_HOST_COMMANDS = new Set([
106
110
  'run',
107
111
  'exec', // deprecated alias of run
108
112
  'harness', // `--host <agent>` names the host CLI to run under, not a remote device
@@ -186,7 +190,9 @@ function buildFleetForwardedArgs(allArgs) {
186
190
  /** Parse stdout as JSON; on failure return an object describing the error. */
187
191
  function safeJsonParse(stdout) {
188
192
  try {
189
- return JSON.parse(stdout);
193
+ // A Windows device relays its `--json` through PowerShell, which can prefix a
194
+ // CLIXML banner ahead of the payload — strip it before parsing (RUSH-2286).
195
+ return JSON.parse(stripClixml(stdout));
190
196
  }
191
197
  catch {
192
198
  return { parseError: 'invalid JSON', snippet: stdout.trim().slice(0, 200) };
@@ -551,6 +557,51 @@ export async function maybeRunOnHost(command, allArgs, opts) {
551
557
  });
552
558
  return true;
553
559
  }
560
+ /**
561
+ * `--host` passthrough for a **standalone binary** whose command name is fixed by
562
+ * the binary itself (the `browser`/`computer` bins, `dist/browser.js` etc.) rather
563
+ * than being the first argv token.
564
+ *
565
+ * `agents browser … --host <box>` routes through {@link maybeRunOnHost} in
566
+ * index.ts, but the standalone `browser` binary never enters index.ts — so without
567
+ * this it dropped `--host` entirely (commander errored with `unknown option
568
+ * '--host'`). This wires the identical routing: it synthesizes the implicit command
569
+ * token (`browser`) at the front of the argv so the remote invocation builds as
570
+ * `agents browser …`, delegates to {@link maybeRunOnHost}, and returns `true` when
571
+ * it dispatched the run to a remote (the local program must then NOT parse).
572
+ *
573
+ * On a local / self-host fall-through it rewrites `process.argv` to the ORIGINAL
574
+ * args minus the routing flags — never the synthetic command token — so the
575
+ * standalone commander program parses cleanly and never sees an unknown `--host`.
576
+ * When no routing flag is present it leaves `process.argv` untouched (the common
577
+ * case), so an unrelated flag like `--no-tty` is never stripped from a purely local
578
+ * run.
579
+ *
580
+ * @param command the fixed command name the binary stands for (`'browser'`).
581
+ */
582
+ export async function maybeRunStandaloneOnHost(command, opts) {
583
+ const rawArgs = process.argv.slice(2);
584
+ const hasRoutingFlag = flagValue(rawArgs, 'host', 'H') !== undefined ||
585
+ flagValue(rawArgs, 'device') !== undefined ||
586
+ flagValue(rawArgs, 'hosts') !== undefined ||
587
+ flagValue(rawArgs, 'devices') !== undefined;
588
+ // No routing flag → nothing to route or strip. Leave argv alone so a purely
589
+ // local run keeps every flag it passed.
590
+ if (!hasRoutingFlag)
591
+ return false;
592
+ // Keep --help/--version local (docs must work without a reachable host), mirroring
593
+ // index.ts's `helpOrVersionRequested` guard, but still strip the routing flags
594
+ // below so commander doesn't choke on them.
595
+ const helpOrVersion = rawArgs.some((a) => a === '--help' || a === '-h' || a === '--version' || a === '-V');
596
+ if (!helpOrVersion && (await maybeRunOnHost(command, [command, ...rawArgs], opts))) {
597
+ return true;
598
+ }
599
+ // Local / self-host fall-through (maybeRunOnHost may have rewritten process.argv
600
+ // with the synthetic command token). Rebuild argv from the original args minus
601
+ // the routing flags so the standalone program parses cleanly.
602
+ process.argv = [process.argv[0], process.argv[1], ...stripRoutingFlags(rawArgs, STRIP_SPECS)];
603
+ return false;
604
+ }
554
605
  /**
555
606
  * Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
556
607
  * return the exit code. The single place the SSH hop is built, so every remote
@@ -55,6 +55,7 @@ export interface FollowOptions {
55
55
  pollMs?: number;
56
56
  /** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
57
57
  maxPollMs?: number;
58
+ extraSshArgs?: string[];
58
59
  }
59
60
  /**
60
61
  * Build the per-task sentinel that separates the log tail from the exit-file
@@ -124,7 +125,7 @@ export declare function parseStreamingExitFrame(stderr: Buffer, taskId: string):
124
125
  * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
125
126
  * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
126
127
  */
127
- export declare function readRemoteFileId(target: string, remotePath: string): string | null;
128
+ export declare function readRemoteFileId(target: string, remotePath: string, extraSshArgs?: string[]): string | null;
128
129
  /**
129
130
  * True when the local mirror file IS the very file we're tailing — the
130
131
  * localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
@@ -160,8 +160,8 @@ export function parseStreamingExitFrame(stderr, taskId) {
160
160
  * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
161
161
  * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
162
162
  */
163
- export function readRemoteFileId(target, remotePath) {
164
- const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000 });
163
+ export function readRemoteFileId(target, remotePath, extraSshArgs) {
164
+ const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000, extraSshArgs });
165
165
  const id = res.stdout.trim();
166
166
  return id || null;
167
167
  }
@@ -189,7 +189,7 @@ export async function followHostTask(target, opts) {
189
189
  let mirror = true;
190
190
  try {
191
191
  const s = fs.statSync(local);
192
- if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog))) {
192
+ if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog, opts.extraSshArgs))) {
193
193
  mirror = false;
194
194
  }
195
195
  }
@@ -223,6 +223,7 @@ export async function followHostTask(target, opts) {
223
223
  timeoutMs: remaining,
224
224
  signal: abort.signal,
225
225
  multiplex: true,
226
+ extraSshArgs: opts.extraSshArgs,
226
227
  onStdout: (chunk) => { gotOutput = flush(chunk) || gotOutput; },
227
228
  });
228
229
  exitFrame = parseStreamingExitFrame(stream.stderr, opts.taskId);
@@ -48,6 +48,7 @@ function deviceToPoolHost(device) {
48
48
  source: 'inline',
49
49
  address,
50
50
  user: device.user,
51
+ identityFile: device.auth.identityFile,
51
52
  ...(device.platform !== 'unknown' ? { os: device.platform } : {}),
52
53
  enrolled: true,
53
54
  status: statusOf(device),
@@ -31,7 +31,7 @@ export declare function remoteAgentsVersion(target: string, os?: string): string
31
31
  * equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
32
32
  export declare function buildBootstrapCommand(spec: string, os?: string): string;
33
33
  /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
34
- export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string): {
34
+ export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string, extraSshArgs?: string[]): {
35
35
  ok: boolean;
36
36
  output: string;
37
37
  };
@@ -42,6 +42,8 @@ export interface ReadyProbe {
42
42
  version: string | null;
43
43
  /** Raw `agents view`/`list` output, for installed-agent checks. */
44
44
  view: string;
45
+ /** True when the ssh probe timed out before the sentinel arrived. */
46
+ timedOut?: boolean;
45
47
  }
46
48
  /**
47
49
  * Answer every readiness question in ONE ssh round-trip: reachable? (the login
@@ -59,11 +61,13 @@ export interface ReadyProbe {
59
61
  * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
60
62
  */
61
63
  export declare function buildReadyProbeCommand(os?: string): string;
62
- export declare function readyProbe(target: string, os?: string): ReadyProbe;
64
+ export declare function readyProbe(target: string, os?: string, extraSshArgs?: string[]): ReadyProbe;
63
65
  /** Pure parser for `readyProbe` output (unit-tested without ssh). */
64
66
  export declare function parseReadyProbe(stdout: string): ReadyProbe;
65
67
  /** True if `view` output lists the named agent (word-boundary, case-insensitive). */
66
68
  export declare function viewHasAgent(view: string, agent: string): boolean;
69
+ /** Read the requested harness's sign-in verdict from `agents view --json`. */
70
+ export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
67
71
  export interface EnsureReadyOptions {
68
72
  agent: string;
69
73
  /** Throw instead of warn when the agent isn't installed remotely. */
@@ -9,7 +9,7 @@ import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
  import { sshExec, shellQuote } from '../ssh-exec.js';
12
- import { sshTargetFor } from './types.js';
12
+ import { hostIdentityArgs, sshTargetFor } from './types.js';
13
13
  import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
14
14
  import { resolveRemoteOsSync } from './remote-os.js';
15
15
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
@@ -87,9 +87,9 @@ export function buildBootstrapCommand(spec, os) {
87
87
  return `bash -lc ${shellQuote(script)}`;
88
88
  }
89
89
  /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
90
- export function bootstrapAgentsCli(target, version, os) {
90
+ export function bootstrapAgentsCli(target, version, os, extraSshArgs) {
91
91
  const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
92
- const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000 });
92
+ const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000, extraSshArgs });
93
93
  return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
94
94
  }
95
95
  /** Sentinel splitting the version output from the agent listing in one probe. */
@@ -113,15 +113,19 @@ export function buildReadyProbeCommand(os) {
113
113
  if (remoteShellFor(os) === 'powershell') {
114
114
  const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
115
115
  `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
116
- `agents view 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
116
+ `agents view --json 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
117
117
  return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
118
118
  }
119
119
  const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
120
- `agents view 2>/dev/null || agents list 2>/dev/null`;
120
+ `agents view --json 2>/dev/null || agents list 2>/dev/null`;
121
121
  return `bash -lc ${shellQuote(script)}`;
122
122
  }
123
- export function readyProbe(target, os) {
124
- const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000 });
123
+ export function readyProbe(target, os, extraSshArgs) {
124
+ // Disable multiplexing: a stale control socket can hang the local ssh client
125
+ // until the timeout fires, just like sshExecAsync does for the same reason.
126
+ const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false, extraSshArgs });
127
+ if (r.timedOut)
128
+ return { reachable: false, version: null, view: '', timedOut: true };
125
129
  return parseReadyProbe(r.stdout);
126
130
  }
127
131
  /** Pure parser for `readyProbe` output (unit-tested without ssh). */
@@ -136,6 +140,22 @@ export function parseReadyProbe(stdout) {
136
140
  export function viewHasAgent(view, agent) {
137
141
  return new RegExp(`\\b${agent}\\b`, 'i').test(view);
138
142
  }
143
+ /** Read the requested harness's sign-in verdict from `agents view --json`. */
144
+ export function viewAgentSignedIn(view, agent) {
145
+ try {
146
+ const rows = JSON.parse(view);
147
+ const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
148
+ if (!row)
149
+ return undefined;
150
+ const verdicts = (row.versions ?? [])
151
+ .map((version) => version.signedIn)
152
+ .filter((value) => typeof value === 'boolean');
153
+ return verdicts.length === 0 ? undefined : verdicts.some(Boolean);
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ }
139
159
  /**
140
160
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
141
161
  * actionable message otherwise. Agent-not-installed is a warning by default (the
@@ -145,7 +165,12 @@ export function viewHasAgent(view, agent) {
145
165
  */
146
166
  export function ensureHostReady(host, opts) {
147
167
  const target = sshTargetFor(host);
148
- const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name));
168
+ const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name), hostIdentityArgs(host));
169
+ if (probe.timedOut) {
170
+ throw new Error(`Host "${host.name}" (${target}) did not respond in time — the SSH probe timed out after 20 seconds. ` +
171
+ `The host may be slow to start a login shell (nvm/sdkman init, cold node startup). ` +
172
+ `Retry, or run \`agents ssh ${host.name} agents view\` to confirm manually.`);
173
+ }
149
174
  if (!probe.reachable) {
150
175
  throw new Error(`Host "${host.name}" (${target}) is not reachable over SSH. Check it's online and key auth works.`);
151
176
  }
@@ -35,7 +35,7 @@ export declare function classifyExit(res: Pick<SshExecResult, 'code' | 'stdout'
35
35
  * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
36
  * remote shell expands $HOME (same contract as progress.ts's fetch).
37
37
  */
38
- export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number): RemoteExitState;
38
+ export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string): RemoteExitState;
39
39
  /**
40
40
  * Heal one record. Terminal records are immutable (and never re-probed); a
41
41
  * `running` record is resolved to completed/failed only when the remote `.exit`
@@ -10,7 +10,7 @@
10
10
  * record. We only ever CONFIRM completion; an unreachable host or an absent
11
11
  * `.exit` leaves the record `running` (we never guess failure).
12
12
  */
13
- import { sshExec, sshReachable } from '../ssh-exec.js';
13
+ import { sshExec } from '../ssh-exec.js';
14
14
  import { updateTask, terminalPatch } from './tasks.js';
15
15
  /**
16
16
  * Classify a `cat <remoteExit>` result into a remote run state. Pure: all the
@@ -35,8 +35,12 @@ export function classifyExit(res) {
35
35
  * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
36
  * remote shell expands $HOME (same contract as progress.ts's fetch).
37
37
  */
38
- export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
39
- return classifyExit(sshExec(target, `cat ${remoteExit} 2>/dev/null`, { timeoutMs, multiplex: true }));
38
+ export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFile) {
39
+ return classifyExit(sshExec(target, `cat ${remoteExit} 2>/dev/null`, {
40
+ timeoutMs,
41
+ multiplex: true,
42
+ extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
43
+ }));
40
44
  }
41
45
  /**
42
46
  * Heal one record. Terminal records are immutable (and never re-probed); a
@@ -46,7 +50,7 @@ export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
46
50
  export function reconcileTask(task) {
47
51
  if (task.status !== 'running')
48
52
  return task;
49
- const st = readRemoteExit(task.target, task.remoteExit);
53
+ const st = readRemoteExit(task.target, task.remoteExit, 6000, task.identityFile);
50
54
  if (st.state !== 'done')
51
55
  return task;
52
56
  return updateTask(task.id, terminalPatch(st.code)) ?? task;
@@ -66,11 +70,16 @@ export function reconcileRunningTasks(tasks) {
66
70
  const reachable = new Map();
67
71
  const patched = new Map();
68
72
  for (const t of running) {
69
- if (!reachable.has(t.target))
70
- reachable.set(t.target, sshReachable(t.target, 6000));
73
+ if (!reachable.has(t.target)) {
74
+ const probe = sshExec(t.target, 'true', {
75
+ timeoutMs: 6000,
76
+ extraSshArgs: t.identityFile ? ['-i', t.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
77
+ });
78
+ reachable.set(t.target, probe.code === 0);
79
+ }
71
80
  if (!reachable.get(t.target))
72
81
  continue; // host down → leave running
73
- const st = readRemoteExit(t.target, t.remoteExit);
82
+ const st = readRemoteExit(t.target, t.remoteExit, 6000, t.identityFile);
74
83
  if (st.state === 'done') {
75
84
  const updated = updateTask(t.id, terminalPatch(st.code));
76
85
  if (updated)
@@ -6,13 +6,23 @@ export declare const SSH_CONN_FAILURE = 255;
6
6
  * {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
7
7
  * transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
8
8
  export declare const REMOTE_EXIT_255_REMAPPED = 254;
9
- /** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
10
- * {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
11
- * refills the budget, so a long session that blinks all day reconnects every time
12
- * only consecutive UNREACHABLE attempts exhaust it. */
9
+ /** Consecutive unproductive reattaches before giving up. Backoff is capped at
10
+ * {@link MAX_BACKOFF_MS}. A reattach that reconnected and HELD (then dropped again)
11
+ * refills the budget, so a long session that blinks all day reconnects every time
12
+ * an attempt that never reached the host, or reached it and died back inside
13
+ * {@link MIN_HOLD_MS}, counts against it. */
13
14
  export declare const MAX_ATTEMPTS = 6;
15
+ /** How long a reattach must hold the remote pane before it counts as a genuine
16
+ * reconnection that refills the budget. Measured on the interactive attach ALONE
17
+ * — the preflight probe has already returned by then, so this is not the connect
18
+ * timing the file header rules out. 10s is comfortably longer than any attach that
19
+ * dies during TTY negotiation and far shorter than a session the user is working
20
+ * in; a link that drops the user out inside 10s on every attempt is one this loop
21
+ * should stop retrying, not one it should keep re-entering. */
22
+ export declare const MIN_HOLD_MS = 10000;
14
23
  export interface ReconnectState {
15
- /** Consecutive failed-to-connect reattaches since the last genuine reconnection. */
24
+ /** Consecutive unproductive reattaches since the last genuine reconnection — one
25
+ * that reached the host and held for {@link MIN_HOLD_MS}. */
16
26
  attempt: number;
17
27
  }
18
28
  export interface ReconnectOutcome {
@@ -20,8 +30,13 @@ export interface ReconnectOutcome {
20
30
  code: number;
21
31
  /** Whether the ssh handshake for this attempt actually completed. The initial run
22
32
  * and any reattach whose preflight probe succeeded are `connected`; a reattach
23
- * that couldn't reach the host is not. Drives the budget refill (see file head). */
33
+ * that couldn't reach the host is not. Half of the budget refill (see file head). */
24
34
  connected: boolean;
35
+ /** Wall-clock ms the interactive attach ran for, timed from after the preflight
36
+ * probe returned. `0` when the attempt never reached the host. The other half of
37
+ * the refill: it must be at least {@link MIN_HOLD_MS} to count as a genuine
38
+ * reconnection rather than a link that drops the user straight back out. */
39
+ heldMs: number;
25
40
  }
26
41
  export type ReconnectDecision = {
27
42
  action: 'stop';
@@ -34,6 +49,14 @@ export type ReconnectDecision = {
34
49
  export declare function initialReconnectState(): ReconnectState;
35
50
  /** Exponential backoff capped at {@link MAX_BACKOFF_MS}: 2s, 4s, 8s, 16s, 30s… */
36
51
  export declare function backoffMs(attempt: number): number;
52
+ /**
53
+ * Did this attempt genuinely put the user back into the agent? Only such an attempt
54
+ * refills the retry budget — it must have reached the host AND held the pane for at
55
+ * least {@link MIN_HOLD_MS}. An attach that reached the host and died right back is
56
+ * a flapping link, not a reconnection, and counts against the budget like an
57
+ * unreachable host (agents-cli#1884; see the file header).
58
+ */
59
+ export declare function refillsBudget(outcome: ReconnectOutcome): boolean;
37
60
  /**
38
61
  * Decide what to do after a run/re-attach returned `outcome`. Pure — the only
39
62
  * input is the prior state and the outcome, the only output is the next action.
@@ -41,15 +64,25 @@ export declare function backoffMs(attempt: number): number;
41
64
  * - a non-255 code means the remote command spoke for itself (clean detach = 0,
42
65
  * agent exit / no live session = non-zero) → stop and surface that code.
43
66
  * - a 255 means the link dropped → retry, unless the budget is spent.
44
- * - a 255 from an attempt that DID connect (a genuine reconnection that then
45
- * dropped) refills the budget first; a 255 that never connected counts against
46
- * it, so a host that stays unreachable gives up after MAX_ATTEMPTS.
67
+ * - a 255 from an attempt that reconnected AND held ({@link refillsBudget})
68
+ * refills the budget first; every other 255 counts against it, so a host that
69
+ * stays unreachable — and a link that keeps dropping the attach immediately
70
+ * both give up after MAX_ATTEMPTS.
47
71
  */
48
72
  export declare function reconnectStep(state: ReconnectState, outcome: ReconnectOutcome): ReconnectDecision;
49
73
  /** Human-readable notice shown before each reconnect wait. "13 seconds", not "12.8s". */
50
74
  export declare function reconnectNotice(sessionId: string, host: string, attempt: number, waitMs: number): string;
51
- /** Notice shown once the retry budget is spent. */
75
+ /** Notice shown once the retry budget is spent on a host that stayed UNREACHABLE.
76
+ * Hands back the one verb that re-enters the terminal — attach the live pane if it
77
+ * survived, else resume. */
52
78
  export declare function exhaustedNotice(sessionId: string, host: string): string;
79
+ /** Notice shown when the budget is spent the OTHER way: the last reattach reached
80
+ * the host and the connection dropped again within {@link MIN_HOLD_MS}. Saying
81
+ * "couldn't reconnect" there would be false — it did reconnect and could not stay
82
+ * — and the user needs to know the link, not the host, is the problem. It claims
83
+ * no count of successful reconnections: the budget can also be spent by a run of
84
+ * unreachable attempts followed by one that reconnected and dropped straight out. */
85
+ export declare function unstableNotice(sessionId: string, host: string): string;
53
86
  /** Notice shown when a reattach stops on a remapped remote-side exit
54
87
  * ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
55
88
  * on for its own reasons, not the ssh transport dropping; see
@@ -75,13 +108,14 @@ export declare function remoteExitNotice(sessionId: string, host: string): strin
75
108
  */
76
109
  export declare function wrapRemoteExitCode(cmd: string): string;
77
110
  /**
78
- * The remote command a reattach runs — the peer's own reconnect verb
79
- * (`agents sessions focus <id> --local --attach-only`), wrapped by
80
- * {@link wrapRemoteExitCode} so a stray remote-origin 255 (from this command,
81
- * whatever produces it — see the file header) can never masquerade as a
82
- * network drop. Split out from {@link reattachRemoteSession} so it is
83
- * unit-tested without SSH mirrors `remoteAgentsJsonCommand` in
84
- * lib/remote-agents-json.ts.
111
+ * The remote command a reattach runs — the peer's own recovery verb
112
+ * (`agents sessions focus <id> --local`), wrapped by {@link wrapRemoteExitCode}
113
+ * so a stray remote-origin 255 (from this command, whatever produces it — see the
114
+ * file header) can never masquerade as a network drop. No `--attach-only`: focus
115
+ * joins the live pane when it survived, else RESUMES the session in place, so a
116
+ * reattach landing after the pane died recovers the agent instead of dead-ending
117
+ * (RUSH-2085). Split out from {@link reattachRemoteSession} so it is unit-tested
118
+ * without SSH — mirrors `remoteAgentsJsonCommand` in lib/remote-agents-json.ts.
85
119
  */
86
120
  export declare function reattachRemoteCommand(sessionId: string): string;
87
121
  /**
@@ -89,10 +123,11 @@ export declare function reattachRemoteCommand(sessionId: string): string;
89
123
  * `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
90
124
  * first establishes whether the host is actually reachable this attempt — that
91
125
  * `connected` bit, not the call duration, is what the retry policy keys on. Only on
92
- * a reachable host do we run the interactive attach (which carries no credentials —
93
- * the agent already runs on the peer — so it rides the normal transport). Returns
94
- * the ssh exit code (255 = dropped again / unreachable; 0 = clean detach; other =
95
- * session ended) plus whether this attempt connected.
126
+ * a reachable host do we run the interactive attach-or-resume (which carries no
127
+ * credentials — the agent already runs on the peer — so it rides the normal
128
+ * transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
129
+ * clean detach; other = session ended), whether this attempt connected, and how
130
+ * long the attach held — the two inputs {@link refillsBudget} decides on.
96
131
  */
97
132
  export declare function reattachRemoteSession(host: Host, sessionId: string): ReconnectOutcome;
98
133
  export interface ReconnectLoopOpts {