@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
@@ -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. */
@@ -104,4 +126,9 @@ export declare function streamAgentsOnHost(host: Host, forwardedArgs: string[],
104
126
  remoteOs?: string;
105
127
  target?: string;
106
128
  }): number;
129
+ export declare function passthroughSshOptions(host: Host, interactive: boolean): {
130
+ tty: boolean;
131
+ multiplex: true;
132
+ extraSshArgs: string[];
133
+ };
107
134
  export {};
@@ -19,9 +19,9 @@
19
19
  import chalk from 'chalk';
20
20
  import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
21
21
  import { resolveHost, resolveHostByCap } from './registry.js';
22
- import { sshTargetFor } from './types.js';
22
+ import { sshTargetFor, hostIdentityArgs } 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) };
@@ -614,10 +620,13 @@ export function streamAgentsOnHost(host, forwardedArgs, opts = {}) {
614
620
  const remoteOs = opts.remoteOs ?? resolveRemoteOsSync(host.name);
615
621
  const env = withActorEnv({ ...opts.extraEnv, AGENTS_FLEET_REMOTE: '1' });
616
622
  const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, opts.remoteCwd, remoteOs, env);
617
- const code = sshStream(target, remoteCmd, { tty: !!opts.interactive, multiplex: true });
623
+ const code = sshStream(target, remoteCmd, passthroughSshOptions(host, !!opts.interactive));
618
624
  if (code === 255) {
619
625
  console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
620
626
  chalk.gray(' Check: agents hosts check ' + host.name));
621
627
  }
622
628
  return code;
623
629
  }
630
+ export function passthroughSshOptions(host, interactive) {
631
+ return { tty: interactive, multiplex: true, extraSshArgs: hostIdentityArgs(host) };
632
+ }
@@ -38,6 +38,7 @@ export declare const REMOTE_MIRROR_MAX_BYTES: number;
38
38
  export declare function pullRemoteLogDelta(target: string, opts: {
39
39
  remoteLog: string;
40
40
  offset: number;
41
+ extraSshArgs?: string[];
41
42
  }): {
42
43
  bytes: Buffer;
43
44
  newOffset: number;
@@ -55,6 +56,8 @@ export interface FollowOptions {
55
56
  pollMs?: number;
56
57
  /** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
57
58
  maxPollMs?: number;
59
+ extraSshArgs?: string[];
60
+ remoteShell?: 'posix' | 'powershell';
58
61
  }
59
62
  /**
60
63
  * Build the per-task sentinel that separates the log tail from the exit-file
@@ -96,10 +99,18 @@ export declare function fetchProgress(target: string, opts: {
96
99
  remoteExit: string;
97
100
  taskId: string;
98
101
  offset: number;
102
+ extraSshArgs?: string[];
103
+ remoteShell?: 'posix' | 'powershell';
99
104
  }): {
100
105
  logChunk: Buffer;
101
106
  exit: string;
102
107
  } | null;
108
+ export declare function buildWindowsProgressCommand(opts: {
109
+ remoteLog: string;
110
+ remoteExit: string;
111
+ taskId: string;
112
+ offset: number;
113
+ }): string;
103
114
  /**
104
115
  * Remote shell for the persistent follow stream.
105
116
  *
@@ -124,7 +135,7 @@ export declare function parseStreamingExitFrame(stderr: Buffer, taskId: string):
124
135
  * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
125
136
  * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
126
137
  */
127
- export declare function readRemoteFileId(target: string, remotePath: string): string | null;
138
+ export declare function readRemoteFileId(target: string, remotePath: string, extraSshArgs?: string[]): string | null;
128
139
  /**
129
140
  * True when the local mirror file IS the very file we're tailing — the
130
141
  * localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import * as fs from 'fs';
15
15
  import { sshExec, sshExecRaw, sshExecRawStream } from '../ssh-exec.js';
16
+ import { encodePowershell } from './remote-cmd.js';
16
17
  import { localLogPath } from './tasks.js';
17
18
  function sleep(ms) {
18
19
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -47,7 +48,7 @@ export function pullRemoteLogDelta(target, opts) {
47
48
  // single-quote it into a literal `$HOME`, and the tail would find nothing). The
48
49
  // hex basename is injection-safe, matching fetchProgress below.
49
50
  const remote = `tail -c +${opts.offset + 1} ${opts.remoteLog} 2>/dev/null`;
50
- const res = sshExecRaw(target, remote, { timeoutMs: 20000, multiplex: true });
51
+ const res = sshExecRaw(target, remote, { timeoutMs: 20000, multiplex: true, extraSshArgs: opts.extraSshArgs });
51
52
  if (res.code === null)
52
53
  return null; // ssh itself failed / timed out
53
54
  return { bytes: res.stdout, newOffset: opts.offset + res.stdout.length };
@@ -102,19 +103,35 @@ export function fetchProgress(target, opts) {
102
103
  // only escape-sensitive bytes are its newlines (→ `\n`); it carries no `%`,
103
104
  // single-quote, or other printf/shell-special chars (task id is hex).
104
105
  const printfArg = exitMarker(opts.taskId).replace(/\n/g, '\\n');
105
- const remote = `tail -c +${opts.offset + 1} ${opts.remoteLog} 2>/dev/null; ` +
106
- `printf '${printfArg}'; ` +
107
- `cat ${opts.remoteExit} 2>/dev/null`;
106
+ const remote = opts.remoteShell === 'powershell'
107
+ ? buildWindowsProgressCommand(opts)
108
+ : `tail -c +${opts.offset + 1} ${opts.remoteLog} 2>/dev/null; printf '${printfArg}'; cat ${opts.remoteExit} 2>/dev/null`;
108
109
  // Raw bytes (no UTF-8 decode): the log tail must be counted and re-emitted
109
110
  // byte-for-byte so a multibyte char split at the `tail -c` boundary neither
110
111
  // drifts the offset nor renders as U+FFFD. The exit code is pure ASCII → safe
111
112
  // to decode to a string for the caller's `.trim()`.
112
- const res = sshExecRaw(target, remote, { timeoutMs: 20000 });
113
+ const res = sshExecRaw(target, remote, { timeoutMs: 20000, extraSshArgs: opts.extraSshArgs });
113
114
  const parts = splitProgressBytes(res.stdout, opts.taskId);
114
115
  if (!parts)
115
116
  return null;
116
117
  return { logChunk: parts.logChunk, exit: parts.exit.toString('utf8') };
117
118
  }
119
+ export function buildWindowsProgressCommand(opts) {
120
+ const windowsPath = (value) => value.startsWith('$HOME/')
121
+ ? `(Join-Path $HOME '${value.slice('$HOME/'.length).replace(/'/g, "''")}')`
122
+ : `'${value.replace(/'/g, "''")}'`;
123
+ const markerBase64 = Buffer.from(exitMarker(opts.taskId), 'utf8').toString('base64');
124
+ const script = [
125
+ `$out = [Console]::OpenStandardOutput()`,
126
+ `$log = ${windowsPath(opts.remoteLog)}`,
127
+ `$exit = ${windowsPath(opts.remoteExit)}`,
128
+ `if (Test-Path -LiteralPath $log) { $bytes = [IO.File]::ReadAllBytes($log); if ($bytes.Length -gt ${opts.offset}) { $out.Write($bytes, ${opts.offset}, $bytes.Length - ${opts.offset}) } }`,
129
+ `$marker = [Convert]::FromBase64String('${markerBase64}')`,
130
+ `$out.Write($marker, 0, $marker.Length)`,
131
+ `if (Test-Path -LiteralPath $exit) { $bytes = [IO.File]::ReadAllBytes($exit); $out.Write($bytes, 0, $bytes.Length) }`,
132
+ ].join('; ');
133
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
134
+ }
118
135
  /**
119
136
  * Remote shell for the persistent follow stream.
120
137
  *
@@ -160,8 +177,8 @@ export function parseStreamingExitFrame(stderr, taskId) {
160
177
  * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
161
178
  * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
162
179
  */
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 });
180
+ export function readRemoteFileId(target, remotePath, extraSshArgs) {
181
+ const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000, extraSshArgs });
165
182
  const id = res.stdout.trim();
166
183
  return id || null;
167
184
  }
@@ -189,7 +206,7 @@ export async function followHostTask(target, opts) {
189
206
  let mirror = true;
190
207
  try {
191
208
  const s = fs.statSync(local);
192
- if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog))) {
209
+ if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog, opts.extraSshArgs))) {
193
210
  mirror = false;
194
211
  }
195
212
  }
@@ -208,6 +225,21 @@ export async function followHostTask(target, opts) {
208
225
  offset += logChunk.length; // exact wire bytes — no re-encode drift
209
226
  return true;
210
227
  };
228
+ if (opts.remoteShell === 'powershell') {
229
+ for (;;) {
230
+ if (Date.now() >= deadline)
231
+ return -1;
232
+ const fetched = fetchProgress(target, { ...opts, offset, remoteShell: 'powershell' });
233
+ if (fetched) {
234
+ flush(fetched.logChunk);
235
+ const code = parseInt(fetched.exit.trim(), 10);
236
+ if (Number.isFinite(code))
237
+ return code;
238
+ }
239
+ await sleep(waitMs);
240
+ waitMs = Math.min(maxMs, Math.round(waitMs * 1.5));
241
+ }
242
+ }
211
243
  for (;;) {
212
244
  const remaining = deadline - Date.now();
213
245
  if (remaining <= 0) {
@@ -223,6 +255,7 @@ export async function followHostTask(target, opts) {
223
255
  timeoutMs: remaining,
224
256
  signal: abort.signal,
225
257
  multiplex: true,
258
+ extraSshArgs: opts.extraSshArgs,
226
259
  onStdout: (chunk) => { gotOutput = flush(chunk) || gotOutput; },
227
260
  });
228
261
  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),
@@ -18,20 +18,20 @@ export declare function buildProbeCommand(os?: string): string;
18
18
  * platform / enrolled `HostEntry.os`); when it marks the host Windows we take
19
19
  * the PowerShell path and report that known platform, otherwise POSIX + uname.
20
20
  */
21
- export declare function probeHost(target: string, os?: string): {
21
+ export declare function probeHost(target: string, os?: string, extraSshArgs?: string[]): {
22
22
  reachable: boolean;
23
23
  os?: string;
24
24
  };
25
25
  /** ssh command that prints the remote agents-cli version. Pure/exported. */
26
26
  export declare function buildRemoteVersionCommand(os?: string): string;
27
27
  /** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
28
- export declare function remoteAgentsVersion(target: string, os?: string): string | null;
28
+ export declare function remoteAgentsVersion(target: string, os?: string, extraSshArgs?: string[]): string | null;
29
29
  /** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
30
30
  * PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
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
  };
@@ -61,20 +61,61 @@ export interface ReadyProbe {
61
61
  * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
62
62
  */
63
63
  export declare function buildReadyProbeCommand(os?: string): string;
64
- export declare function readyProbe(target: string, os?: string): ReadyProbe;
64
+ export declare function readyProbe(target: string, os?: string, extraSshArgs?: string[]): ReadyProbe;
65
65
  /** Pure parser for `readyProbe` output (unit-tested without ssh). */
66
66
  export declare function parseReadyProbe(stdout: string): ReadyProbe;
67
67
  /** True if `view` output lists the named agent (word-boundary, case-insensitive). */
68
68
  export declare function viewHasAgent(view: string, agent: string): boolean;
69
+ /**
70
+ * True when `version` is a concrete pin the remote must already have installed
71
+ * (e.g. `0.145.0`, `2.1.207`). Aliases (`latest` / `oldest` / `pinned` /
72
+ * `default` / `all` / `any`) resolve against the remote's own install set and
73
+ * are not preflight-checked here.
74
+ */
75
+ export declare function isConcreteVersionPin(version?: string | null): boolean;
76
+ /**
77
+ * Installed version strings for `agent` from `agents view --json` output.
78
+ * Returns `[]` when the agent row is absent, `undefined` when `view` is not
79
+ * JSON (text `agents list` fallback) so callers can degrade.
80
+ */
81
+ export declare function viewAgentVersions(view: string, agent: string): string[] | undefined;
82
+ /**
83
+ * Whether a concrete `agent@version` is installed according to remote view
84
+ * output. JSON path is authoritative; text listing falls back to a whole-token
85
+ * version match. `undefined` only when the text path has the agent but no
86
+ * version token to confirm against (callers treat that as unverified).
87
+ */
88
+ export declare function viewHasAgentVersion(view: string, agent: string, version: string): boolean | undefined;
89
+ /** Actionable fail-loud message for a missing remote pin (RUSH-2313). */
90
+ export declare function missingPinnedVersionMessage(hostName: string, agent: string, version: string, installed?: string[]): string;
91
+ /** Read the requested harness's sign-in verdict from `agents view --json`. */
92
+ export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
69
93
  export interface EnsureReadyOptions {
70
94
  agent: string;
95
+ /**
96
+ * Explicit version pin (e.g. `"0.145.0"`). Concrete pins fail loud when the
97
+ * remote does not have that version installed so a detached `--no-follow`
98
+ * dispatch never reports "Dispatched" for a pin the box cannot run
99
+ * (RUSH-2313). Aliases (`latest` / …) are left for the remote CLI to resolve.
100
+ */
101
+ version?: string;
71
102
  /** Throw instead of warn when the agent isn't installed remotely. */
72
103
  requireAgent?: boolean;
73
104
  }
105
+ /**
106
+ * Pure readiness verdict for the agent/version half of {@link ensureHostReady}
107
+ * (unit-tested without SSH). Returns warnings for soft agent-missing cases, or
108
+ * throws (via the caller's `throw new Error`) when a concrete pin is missing.
109
+ */
110
+ export declare function evaluateHostAgentInstall(view: string, opts: EnsureReadyOptions, hostName: string): {
111
+ warnings: string[];
112
+ };
74
113
  /**
75
114
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
76
115
  * actionable message otherwise. Agent-not-installed is a warning by default (the
77
116
  * remote `agents run` will surface it); pass requireAgent to make it fatal.
117
+ * A concrete `version` pin always fails loud when that version is absent
118
+ * (RUSH-2313) — never a silent "Dispatched" for a pin the box cannot run.
78
119
  *
79
120
  * One ssh round-trip (`readyProbe`) covers all three checks.
80
121
  */
@@ -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. */
@@ -47,8 +47,8 @@ export function buildProbeCommand(os) {
47
47
  * platform / enrolled `HostEntry.os`); when it marks the host Windows we take
48
48
  * the PowerShell path and report that known platform, otherwise POSIX + uname.
49
49
  */
50
- export function probeHost(target, os) {
51
- const r = sshExec(target, buildProbeCommand(os), { timeoutMs: 12000 });
50
+ export function probeHost(target, os, extraSshArgs = []) {
51
+ const r = sshExec(target, buildProbeCommand(os), { timeoutMs: 12000, extraSshArgs });
52
52
  if (r.code !== 0)
53
53
  return { reachable: false };
54
54
  if (remoteShellFor(os) === 'powershell')
@@ -63,8 +63,8 @@ export function buildRemoteVersionCommand(os) {
63
63
  : 'bash -lc "agents --version 2>/dev/null"';
64
64
  }
65
65
  /** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
66
- export function remoteAgentsVersion(target, os) {
67
- const r = sshExec(target, buildRemoteVersionCommand(os), { timeoutMs: 20000 });
66
+ export function remoteAgentsVersion(target, os, extraSshArgs = []) {
67
+ const r = sshExec(target, buildRemoteVersionCommand(os), { timeoutMs: 20000, extraSshArgs });
68
68
  if (r.code !== 0)
69
69
  return null;
70
70
  const v = r.stdout.trim();
@@ -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,17 +113,17 @@ 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) {
123
+ export function readyProbe(target, os, extraSshArgs) {
124
124
  // Disable multiplexing: a stale control socket can hang the local ssh client
125
125
  // until the timeout fires, just like sshExecAsync does for the same reason.
126
- const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false });
126
+ const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false, extraSshArgs });
127
127
  if (r.timedOut)
128
128
  return { reachable: false, version: null, view: '', timedOut: true };
129
129
  return parseReadyProbe(r.stdout);
@@ -140,16 +140,127 @@ export function parseReadyProbe(stdout) {
140
140
  export function viewHasAgent(view, agent) {
141
141
  return new RegExp(`\\b${agent}\\b`, 'i').test(view);
142
142
  }
143
+ /**
144
+ * True when `version` is a concrete pin the remote must already have installed
145
+ * (e.g. `0.145.0`, `2.1.207`). Aliases (`latest` / `oldest` / `pinned` /
146
+ * `default` / `all` / `any`) resolve against the remote's own install set and
147
+ * are not preflight-checked here.
148
+ */
149
+ export function isConcreteVersionPin(version) {
150
+ if (!version)
151
+ return false;
152
+ const v = version.trim();
153
+ if (!v)
154
+ return false;
155
+ // Keep in lockstep with agent-spec AGENT_QUALIFIERS (+ `any` filter token).
156
+ if (['latest', 'oldest', 'pinned', 'default', 'all', 'any'].includes(v.toLowerCase()))
157
+ return false;
158
+ // Same shape agent-spec accepts for an exact pin (no path traversal / spaces).
159
+ return /^(?!.*\.\.)[A-Za-z0-9._+-]{1,64}$/.test(v);
160
+ }
161
+ /**
162
+ * Installed version strings for `agent` from `agents view --json` output.
163
+ * Returns `[]` when the agent row is absent, `undefined` when `view` is not
164
+ * JSON (text `agents list` fallback) so callers can degrade.
165
+ */
166
+ export function viewAgentVersions(view, agent) {
167
+ try {
168
+ const rows = JSON.parse(view);
169
+ if (!Array.isArray(rows))
170
+ return undefined;
171
+ const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
172
+ if (!row)
173
+ return [];
174
+ return (row.versions ?? [])
175
+ .map((entry) => entry.version)
176
+ .filter((v) => typeof v === 'string' && v.length > 0);
177
+ }
178
+ catch {
179
+ return undefined;
180
+ }
181
+ }
182
+ /**
183
+ * Whether a concrete `agent@version` is installed according to remote view
184
+ * output. JSON path is authoritative; text listing falls back to a whole-token
185
+ * version match. `undefined` only when the text path has the agent but no
186
+ * version token to confirm against (callers treat that as unverified).
187
+ */
188
+ export function viewHasAgentVersion(view, agent, version) {
189
+ const versions = viewAgentVersions(view, agent);
190
+ if (versions !== undefined)
191
+ return versions.includes(version);
192
+ if (!viewHasAgent(view, agent))
193
+ return false;
194
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
195
+ if (new RegExp(`\\b${escaped}\\b`).test(view))
196
+ return true;
197
+ return undefined;
198
+ }
199
+ /** Actionable fail-loud message for a missing remote pin (RUSH-2313). */
200
+ export function missingPinnedVersionMessage(hostName, agent, version, installed) {
201
+ let installedHint = '';
202
+ if (installed) {
203
+ installedHint = installed.length > 0
204
+ ? ` Installed on that box: ${installed.join(', ')}.`
205
+ : ` Agent "${agent}" is not installed there.`;
206
+ }
207
+ return (`Pinned ${agent}@${version} is not installed on "${hostName}".${installedHint} ` +
208
+ `Install it on that box: agents ssh ${hostName} -- agents add ${agent}@${version}`);
209
+ }
210
+ /** Read the requested harness's sign-in verdict from `agents view --json`. */
211
+ export function viewAgentSignedIn(view, agent) {
212
+ try {
213
+ const rows = JSON.parse(view);
214
+ const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
215
+ if (!row)
216
+ return undefined;
217
+ const verdicts = (row.versions ?? [])
218
+ .map((version) => version.signedIn)
219
+ .filter((value) => typeof value === 'boolean');
220
+ return verdicts.length === 0 ? undefined : verdicts.some(Boolean);
221
+ }
222
+ catch {
223
+ return undefined;
224
+ }
225
+ }
226
+ /**
227
+ * Pure readiness verdict for the agent/version half of {@link ensureHostReady}
228
+ * (unit-tested without SSH). Returns warnings for soft agent-missing cases, or
229
+ * throws (via the caller's `throw new Error`) when a concrete pin is missing.
230
+ */
231
+ export function evaluateHostAgentInstall(view, opts, hostName) {
232
+ const warnings = [];
233
+ if (isConcreteVersionPin(opts.version)) {
234
+ const version = opts.version.trim();
235
+ const installed = viewAgentVersions(view, opts.agent);
236
+ const has = viewHasAgentVersion(view, opts.agent, version);
237
+ if (has === true)
238
+ return { warnings };
239
+ // Missing or unverifiable pin: fail loud. Detached host dispatch used to
240
+ // print "Dispatched" and only die in the remote log ("codex@0.145.0 is not
241
+ // installed") — whole fleet drains no-op'd under that silent success.
242
+ throw new Error(missingPinnedVersionMessage(hostName, opts.agent, version, installed));
243
+ }
244
+ if (!viewHasAgent(view, opts.agent)) {
245
+ const msg = `Agent "${opts.agent}" may not be installed on "${hostName}" (remote \`agents add ${opts.agent}\` to install).`;
246
+ if (opts.requireAgent)
247
+ throw new Error(msg);
248
+ warnings.push(msg);
249
+ }
250
+ return { warnings };
251
+ }
143
252
  /**
144
253
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
145
254
  * actionable message otherwise. Agent-not-installed is a warning by default (the
146
255
  * remote `agents run` will surface it); pass requireAgent to make it fatal.
256
+ * A concrete `version` pin always fails loud when that version is absent
257
+ * (RUSH-2313) — never a silent "Dispatched" for a pin the box cannot run.
147
258
  *
148
259
  * One ssh round-trip (`readyProbe`) covers all three checks.
149
260
  */
150
261
  export function ensureHostReady(host, opts) {
151
262
  const target = sshTargetFor(host);
152
- const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name));
263
+ const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name), hostIdentityArgs(host));
153
264
  if (probe.timedOut) {
154
265
  throw new Error(`Host "${host.name}" (${target}) did not respond in time — the SSH probe timed out after 20 seconds. ` +
155
266
  `The host may be slow to start a login shell (nvm/sdkman init, cold node startup). ` +
@@ -161,12 +272,5 @@ export function ensureHostReady(host, opts) {
161
272
  if (!probe.version) {
162
273
  throw new Error(`agents-cli is not installed on "${host.name}". Enroll it first: agents hosts add ${host.name} (bootstraps agents-cli).`);
163
274
  }
164
- const warnings = [];
165
- if (!viewHasAgent(probe.view, opts.agent)) {
166
- const msg = `Agent "${opts.agent}" may not be installed on "${host.name}" (remote \`agents add ${opts.agent}\` to install).`;
167
- if (opts.requireAgent)
168
- throw new Error(msg);
169
- warnings.push(msg);
170
- }
171
- return { warnings };
275
+ return evaluateHostAgentInstall(probe.view, opts, host.name);
172
276
  }
@@ -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, remoteShell?: 'posix' | 'powershell'): 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`
@@ -51,3 +51,4 @@ export declare function reconcileTask(task: HostTask): HostTask;
51
51
  * (async) ssh path is deliberately out of scope.
52
52
  */
53
53
  export declare function reconcileRunningTasks(tasks: HostTask[]): HostTask[];
54
+ export declare function reachabilityProbeCommand(remoteShell?: 'posix' | 'powershell'): string;
@@ -10,8 +10,9 @@
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
+ import { encodePowershell } from './remote-cmd.js';
15
16
  /**
16
17
  * Classify a `cat <remoteExit>` result into a remote run state. Pure: all the
17
18
  * bug-prone branching (ssh-failure vs absent vs empty vs coded) lives here so it
@@ -35,8 +36,15 @@ export function classifyExit(res) {
35
36
  * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
37
  * remote shell expands $HOME (same contract as progress.ts's fetch).
37
38
  */
38
- export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
39
- return classifyExit(sshExec(target, `cat ${remoteExit} 2>/dev/null`, { timeoutMs, multiplex: true }));
39
+ export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFile, remoteShell = 'posix') {
40
+ const command = remoteShell === 'powershell'
41
+ ? `powershell -NoProfile -EncodedCommand ${encodePowershell(`$path = Join-Path $HOME '${remoteExit.replace(/^\$HOME\//, '').replace(/'/g, "''")}'; if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path -Raw }`)}`
42
+ : `cat ${remoteExit} 2>/dev/null`;
43
+ return classifyExit(sshExec(target, command, {
44
+ timeoutMs,
45
+ multiplex: true,
46
+ extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
47
+ }));
40
48
  }
41
49
  /**
42
50
  * Heal one record. Terminal records are immutable (and never re-probed); a
@@ -46,7 +54,7 @@ export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
46
54
  export function reconcileTask(task) {
47
55
  if (task.status !== 'running')
48
56
  return task;
49
- const st = readRemoteExit(task.target, task.remoteExit);
57
+ const st = readRemoteExit(task.target, task.remoteExit, 6000, task.identityFile, task.remoteShell);
50
58
  if (st.state !== 'done')
51
59
  return task;
52
60
  return updateTask(task.id, terminalPatch(st.code)) ?? task;
@@ -66,11 +74,17 @@ export function reconcileRunningTasks(tasks) {
66
74
  const reachable = new Map();
67
75
  const patched = new Map();
68
76
  for (const t of running) {
69
- if (!reachable.has(t.target))
70
- reachable.set(t.target, sshReachable(t.target, 6000));
77
+ if (!reachable.has(t.target)) {
78
+ const probeCommand = reachabilityProbeCommand(t.remoteShell);
79
+ const probe = sshExec(t.target, probeCommand, {
80
+ timeoutMs: 6000,
81
+ extraSshArgs: t.identityFile ? ['-i', t.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
82
+ });
83
+ reachable.set(t.target, probe.code === 0);
84
+ }
71
85
  if (!reachable.get(t.target))
72
86
  continue; // host down → leave running
73
- const st = readRemoteExit(t.target, t.remoteExit);
87
+ const st = readRemoteExit(t.target, t.remoteExit, 6000, t.identityFile, t.remoteShell);
74
88
  if (st.state === 'done') {
75
89
  const updated = updateTask(t.id, terminalPatch(st.code));
76
90
  if (updated)
@@ -79,3 +93,6 @@ export function reconcileRunningTasks(tasks) {
79
93
  }
80
94
  return tasks.map((t) => patched.get(t.id) ?? t);
81
95
  }
96
+ export function reachabilityProbeCommand(remoteShell) {
97
+ return remoteShell === 'powershell' ? 'powershell -NoProfile -Command "exit 0"' : 'true';
98
+ }