@phnx-labs/agents-cli 1.22.27 → 1.22.29

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 (89) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +62 -26
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +54 -10
  5. package/dist/commands/hosts.js +4 -3
  6. package/dist/commands/perf.d.ts +10 -0
  7. package/dist/commands/perf.js +14 -6
  8. package/dist/commands/projects.js +24 -10
  9. package/dist/commands/routines.js +31 -4
  10. package/dist/commands/run-account-picker.d.ts +37 -0
  11. package/dist/commands/run-account-picker.js +101 -17
  12. package/dist/commands/secrets.js +86 -2
  13. package/dist/commands/sessions.d.ts +26 -6
  14. package/dist/commands/sessions.js +46 -12
  15. package/dist/commands/teams.js +19 -8
  16. package/dist/lib/activity.js +45 -99
  17. package/dist/lib/claude-account-token.js +38 -11
  18. package/dist/lib/cloud/host.js +1 -0
  19. package/dist/lib/devices/resolve-target.d.ts +1 -0
  20. package/dist/lib/devices/resolve-target.js +9 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/events.js +2 -2
  23. package/dist/lib/git.d.ts +9 -3
  24. package/dist/lib/git.js +39 -22
  25. package/dist/lib/hooks/profile.d.ts +12 -1
  26. package/dist/lib/hooks/profile.js +6 -1
  27. package/dist/lib/hooks.js +4 -1
  28. package/dist/lib/hosts/dispatch.d.ts +10 -0
  29. package/dist/lib/hosts/dispatch.js +78 -22
  30. package/dist/lib/hosts/logs.js +11 -1
  31. package/dist/lib/hosts/passthrough.d.ts +5 -0
  32. package/dist/lib/hosts/passthrough.js +5 -2
  33. package/dist/lib/hosts/progress.d.ts +10 -0
  34. package/dist/lib/hosts/progress.js +37 -5
  35. package/dist/lib/hosts/ready.d.ts +41 -2
  36. package/dist/lib/hosts/ready.js +100 -12
  37. package/dist/lib/hosts/reconcile.d.ts +2 -1
  38. package/dist/lib/hosts/reconcile.js +13 -5
  39. package/dist/lib/hosts/reconnect.js +5 -3
  40. package/dist/lib/hosts/registry.js +1 -0
  41. package/dist/lib/hosts/tasks.d.ts +1 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/menubar/snapshot.d.ts +16 -0
  45. package/dist/lib/menubar/snapshot.js +37 -3
  46. package/dist/lib/models.d.ts +18 -11
  47. package/dist/lib/models.js +19 -15
  48. package/dist/lib/perf/db.js +16 -3
  49. package/dist/lib/perf/types.d.ts +12 -1
  50. package/dist/lib/project-probe.d.ts +22 -3
  51. package/dist/lib/project-probe.js +105 -17
  52. package/dist/lib/project-status.d.ts +9 -0
  53. package/dist/lib/project-status.js +15 -0
  54. package/dist/lib/refresh.js +22 -11
  55. package/dist/lib/rotate.d.ts +22 -0
  56. package/dist/lib/rotate.js +26 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  59. package/dist/lib/secrets/agent.d.ts +19 -3
  60. package/dist/lib/secrets/agent.js +58 -20
  61. package/dist/lib/secrets/audit.d.ts +1 -1
  62. package/dist/lib/secrets/audit.js +2 -0
  63. package/dist/lib/secrets/bundles.d.ts +3 -3
  64. package/dist/lib/secrets/bundles.js +15 -5
  65. package/dist/lib/secrets/filestore.d.ts +2 -0
  66. package/dist/lib/secrets/filestore.js +4 -0
  67. package/dist/lib/secrets/session-store.d.ts +7 -0
  68. package/dist/lib/secrets/session-store.js +21 -0
  69. package/dist/lib/session/active.d.ts +49 -0
  70. package/dist/lib/session/active.js +175 -11
  71. package/dist/lib/session/bash-command.d.ts +16 -0
  72. package/dist/lib/session/bash-command.js +65 -0
  73. package/dist/lib/session/db.d.ts +4 -0
  74. package/dist/lib/session/db.js +32 -15
  75. package/dist/lib/session/remote-bundle.js +1 -1
  76. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  77. package/dist/lib/staleness/detectors/skills.js +17 -2
  78. package/dist/lib/staleness/index.d.ts +10 -1
  79. package/dist/lib/staleness/index.js +28 -3
  80. package/dist/lib/teams/agents.d.ts +1 -0
  81. package/dist/lib/teams/agents.js +38 -11
  82. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  83. package/dist/lib/teams/remoteWorktree.js +29 -27
  84. package/dist/lib/usage.js +18 -10
  85. package/dist/lib/versions.d.ts +1 -0
  86. package/dist/lib/versions.js +40 -22
  87. package/package.json +3 -1
  88. package/dist/lib/exec.bench.d.ts +0 -1
  89. package/dist/lib/exec.bench.js +0 -186
@@ -12,7 +12,7 @@ import { randomUUID } from 'crypto';
12
12
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
13
13
  import { hostIdentityArgs, sshTargetFor } from './types.js';
14
14
  import { ensureHostReady } from './ready.js';
15
- import { remoteShellFor, posixEnvExports } from './remote-cmd.js';
15
+ import { encodePowershell, powershellQuote, remoteShellFor, posixEnvExports } from './remote-cmd.js';
16
16
  import { resolveRemoteOsSync } from './remote-os.js';
17
17
  import { resolveActor, actorEnv } from '../actor.js';
18
18
  import { saveTask, updateTask, terminalPatch } from './tasks.js';
@@ -148,14 +148,52 @@ export function buildDetachedLaunchCommand(inner) {
148
148
  ].join(' ');
149
149
  return `bash -lc ${shellQuote(`node -e ${shellQuote(nodeScript)}`)}`;
150
150
  }
151
+ function windowsRemotePath(path) {
152
+ return path.startsWith('$HOME/')
153
+ ? `(Join-Path $HOME ${powershellQuote(path.slice('$HOME/'.length))})`
154
+ : powershellQuote(path);
155
+ }
156
+ /** Build the detached PowerShell launch protocol used by Windows SSH hosts. */
157
+ export function buildWindowsDetachedLaunchCommand(opts) {
158
+ const log = windowsRemotePath(opts.remoteLog);
159
+ const exit = windowsRemotePath(opts.remoteExit);
160
+ const env = Object.entries(opts.env).map(([key, value]) => `$env:${key} = ${powershellQuote(value)}`);
161
+ const cwd = opts.remoteCwd
162
+ ? opts.mirrorCwd
163
+ ? `try { Set-Location -LiteralPath ${powershellQuote(opts.remoteCwd)} } catch { Set-Location -LiteralPath $HOME }`
164
+ : `Set-Location -LiteralPath ${powershellQuote(opts.remoteCwd)}`
165
+ : '';
166
+ const inner = [
167
+ `$ProgressPreference = 'SilentlyContinue'`,
168
+ `$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'`,
169
+ ...env,
170
+ cwd,
171
+ `$log = ${log}`,
172
+ `$exit = ${exit}`,
173
+ `$code = 1`,
174
+ `try { & ${['agents', ...opts.forwardedArgs].map(powershellQuote).join(' ')} *> $log; if ($null -ne $LASTEXITCODE) { $code = $LASTEXITCODE } } catch { $_ | Out-File -LiteralPath $log -Append; $code = 1 } finally { Set-Content -LiteralPath $exit -Value $code -NoNewline -Encoding ascii }`,
175
+ ].filter(Boolean).join('; ');
176
+ const encodedInner = encodePowershell(inner);
177
+ const outer = [
178
+ `$dir = Join-Path $HOME '.agents/.cache/hosts'`,
179
+ `New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
180
+ `Remove-Item -LiteralPath ${exit} -Force -ErrorAction SilentlyContinue`,
181
+ `$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powershellQuote(`powershell.exe -NoProfile -EncodedCommand ${encodedInner}`)} }`,
182
+ `if ($result.ReturnValue -ne 0) { throw \"Win32_Process.Create failed with code $($result.ReturnValue)\" }`,
183
+ `Write-Output $result.ProcessId`,
184
+ ].join('; ');
185
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(outer)}`;
186
+ }
151
187
  function terminateRemoteLaunch(task) {
152
188
  if (!task.pid)
153
189
  throw new Error(`Cannot terminate remote task ${task.id}: launch returned no PID.`);
154
190
  const pid = task.pid;
155
- const command = `if kill -TERM -- -${pid} 2>/dev/null; then ` +
156
- `sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
157
- `elif kill -0 -- -${pid} 2>/dev/null; then exit 1; fi; ` +
158
- `rm -f ${task.remoteLog} ${task.remoteExit}`;
191
+ const command = task.remoteShell === 'powershell'
192
+ ? `powershell -NoProfile -EncodedCommand ${encodePowershell(`Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath ${windowsRemotePath(task.remoteLog)}, ${windowsRemotePath(task.remoteExit)} -Force -ErrorAction SilentlyContinue`)}`
193
+ : `if kill -TERM -- -${pid} 2>/dev/null; then ` +
194
+ `sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
195
+ `elif kill -0 -- -${pid} 2>/dev/null; then exit 1; fi; ` +
196
+ `rm -f ${task.remoteLog} ${task.remoteExit}`;
159
197
  const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true, extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined });
160
198
  if (result.code !== 0) {
161
199
  throw new Error(`Failed to terminate remote task ${task.id} on ${task.host}: ` +
@@ -194,6 +232,17 @@ export function buildStopRemoteCommand(pid, remoteExit) {
194
232
  `else echo 143 > ${remoteExit}; echo GONE; fi; ` +
195
233
  `fi`);
196
234
  }
235
+ export function buildWindowsStopRemoteCommand(pid, remoteExit) {
236
+ if (!Number.isInteger(pid) || pid <= 0)
237
+ throw new Error(`Invalid remote task pid: ${pid}`);
238
+ const exit = windowsRemotePath(remoteExit);
239
+ const script = `$process = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; ` +
240
+ `function Get-DescendantProcessIds([int]$ParentId) { $children = Get-CimInstance Win32_Process -Filter \"ParentProcessId = $ParentId\"; foreach ($child in $children) { Get-DescendantProcessIds $child.ProcessId; $child.ProcessId } }; ` +
241
+ `if ($process) { $descendants = @(Get-DescendantProcessIds ${pid}); foreach ($childId in $descendants) { Stop-Process -Id $childId -Force -ErrorAction SilentlyContinue }; Stop-Process -Id ${pid} -Force; Set-Content -LiteralPath ${exit} -Value 143 -NoNewline -Encoding ascii; Write-Output 'SIGNALED' } ` +
242
+ `elseif (Test-Path -LiteralPath ${exit}) { $code = (Get-Content -LiteralPath ${exit} -Raw).Trim(); if ($code) { Write-Output (\"ALREADY $code\") } else { Set-Content -LiteralPath ${exit} -Value 143 -NoNewline -Encoding ascii; Write-Output 'GONE' } } ` +
243
+ `else { Set-Content -LiteralPath ${exit} -Value 143 -NoNewline -Encoding ascii; Write-Output 'GONE' }`;
244
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
245
+ }
197
246
  /**
198
247
  * Stop a running host task from the origin machine (`agents hosts stop <id>`).
199
248
  *
@@ -209,7 +258,9 @@ export function stopDispatchedTask(task) {
209
258
  if (!task.pid) {
210
259
  throw new Error(`Cannot stop remote task ${task.id}: launch returned no PID.`);
211
260
  }
212
- const command = buildStopRemoteCommand(task.pid, task.remoteExit);
261
+ const command = task.remoteShell === 'powershell'
262
+ ? buildWindowsStopRemoteCommand(task.pid, task.remoteExit)
263
+ : buildStopRemoteCommand(task.pid, task.remoteExit);
213
264
  const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true, extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined });
214
265
  if (result.code !== 0) {
215
266
  throw new Error(`Failed to stop remote task ${task.id} on ${task.host}: ` +
@@ -230,21 +281,11 @@ export function stopDispatchedTask(task) {
230
281
  * and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
231
282
  * so the nohup/exit-file/offset-tail machinery lives in exactly one place.
232
283
  *
233
- * Windows remotes are refused up front: the detached launch is only half the
234
- * contract the follow/reconcile layer (`progress.ts`/`reconcile.ts`) offset-
235
- * tails the log with POSIX `tail -c`/`printf`/`cat`/`stat`, which do not exist
236
- * in cmd.exe/PowerShell. Shipping the launch alone would leave `run --host
237
- * <windows>` dispatching but hanging on follow forever, so we fail fast with an
238
- * actionable message instead. Read-only `--host` commands (view/sessions/…) run
239
- * a single round-trip with no follow protocol and DO work against Windows.
284
+ * POSIX hosts use a detached bash process group; Windows hosts use a hidden
285
+ * detached PowerShell process and the same durable log/exit-file protocol.
240
286
  */
241
287
  async function launchDetached(host, target, opts) {
242
- if (remoteShellFor(resolveRemoteOsSync(host.name)) === 'powershell') {
243
- throw new Error(`Detached dispatch to Windows host "${host.name}" is not supported yet — the run ` +
244
- `follow/reconcile layer is POSIX-only (offset-tails the remote log with tail/cat/stat). ` +
245
- `Read-only --host commands (view, sessions, usage, cost, doctor, list, teams) do work ` +
246
- `against Windows.`);
247
- }
288
+ const remoteShell = remoteShellFor(host.os ?? resolveRemoteOsSync(host.name));
248
289
  const id = randomUUID().slice(0, 8);
249
290
  const remoteLog = `${REMOTE_DIR}/${id}.log`;
250
291
  const remoteExit = `${REMOTE_DIR}/${id}.exit`;
@@ -266,7 +307,16 @@ async function launchDetached(host, target, opts) {
266
307
  const credHostKeyOpts = opts.copyCreds ? hostKeyCheckingOpts(true) : undefined;
267
308
  // Outer: ensure dir, launch the login-shell wrapper as a new process-group
268
309
  // leader, and print that leader PID.
269
- const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
310
+ const launch = remoteShell === 'powershell'
311
+ ? buildWindowsDetachedLaunchCommand({
312
+ forwardedArgs: opts.forwardedArgs,
313
+ remoteCwd: opts.remoteCwd,
314
+ mirrorCwd: opts.mirrorCwd,
315
+ remoteLog,
316
+ remoteExit,
317
+ env: withActorEnv(opts.agentLabel === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {}),
318
+ })
319
+ : `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
270
320
  const res = sshExec(target, launch, {
271
321
  timeoutMs: 30000,
272
322
  multiplex: !opts.copyCreds,
@@ -282,6 +332,7 @@ async function launchDetached(host, target, opts) {
282
332
  host: host.name,
283
333
  target,
284
334
  identityFile: host.identityFile,
335
+ remoteShell,
285
336
  agent: opts.agentLabel,
286
337
  prompt: opts.promptLabel,
287
338
  pid: Number.isFinite(pid) ? pid : undefined,
@@ -314,6 +365,7 @@ async function launchDetached(host, target, opts) {
314
365
  echo: true,
315
366
  timeoutMs: opts.timeoutMs,
316
367
  extraSshArgs: hostIdentityArgs(host),
368
+ remoteShell,
317
369
  });
318
370
  // -1 = the follow window closed while the run continues on the host. Leave the
319
371
  // record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
@@ -446,7 +498,9 @@ export function buildInteractiveRunForwardedArgs(opts) {
446
498
  */
447
499
  export async function runInteractiveOnHost(host, opts) {
448
500
  const target = sshTargetFor(host);
449
- const { warnings } = ensureHostReady(host, { agent: opts.agent });
501
+ // Concrete version pins fail loud here (RUSH-2313) so we never open a TTY
502
+ // to a box that cannot run the pin.
503
+ const { warnings } = ensureHostReady(host, { agent: opts.agent, version: opts.version });
450
504
  for (const w of warnings)
451
505
  process.stderr.write(`[hosts] warning: ${w}\n`);
452
506
  const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
@@ -473,7 +527,9 @@ export async function runInteractiveOnHost(host, opts) {
473
527
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
474
528
  export async function dispatchToHost(host, opts) {
475
529
  const target = sshTargetFor(host);
476
- const { warnings } = ensureHostReady(host, { agent: opts.agent });
530
+ // Concrete agent@version pins fail loud before we print "Dispatched" and
531
+ // leave a dead remote log (RUSH-2313). Aliases stay remote-resolved.
532
+ const { warnings } = ensureHostReady(host, { agent: opts.agent, version: opts.version });
477
533
  for (const w of warnings)
478
534
  process.stderr.write(`[hosts] warning: ${w}\n`);
479
535
  return launchDetached(host, target, {
@@ -16,6 +16,7 @@ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
16
16
  import { followHostTask } from './progress.js';
17
17
  import { reconcileTask } from './reconcile.js';
18
18
  import { sshExecRaw } from '../ssh-exec.js';
19
+ import { encodePowershell } from './remote-cmd.js';
19
20
  /** Lines of raw combined-stdout to show in the concise (non-`full`) view. */
20
21
  const HOST_LOG_TAIL_LINES = 40;
21
22
  /**
@@ -32,6 +33,8 @@ export async function showHostTaskLog(id, follow, full = false) {
32
33
  remoteExit: task.remoteExit,
33
34
  taskId: id,
34
35
  echo: true,
36
+ remoteShell: task.remoteShell,
37
+ extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : [],
35
38
  });
36
39
  // -1 = follow window closed; the run continues on the host (not a failure,
37
40
  // so exit 0). Any real code is the finished run — persist the terminal
@@ -106,7 +109,14 @@ export function tailLines(text, n) {
106
109
  * and progress.ts.
107
110
  */
108
111
  function fetchAndCacheRemoteLog(task) {
109
- const res = sshExecRaw(task.target, `cat ${task.remoteLog} 2>/dev/null`, { timeoutMs: 30000, multiplex: true });
112
+ const command = task.remoteShell === 'powershell'
113
+ ? `powershell -NoProfile -EncodedCommand ${encodePowershell(`$path = Join-Path $HOME '${task.remoteLog.replace(/^\$HOME\//, '').replace(/'/g, "''")}'; if (Test-Path -LiteralPath $path) { $bytes = [IO.File]::ReadAllBytes($path); [Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length) }`)}`
114
+ : `cat ${task.remoteLog} 2>/dev/null`;
115
+ const res = sshExecRaw(task.target, command, {
116
+ timeoutMs: 30000,
117
+ multiplex: true,
118
+ extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
119
+ });
110
120
  if (res.code !== 0 || res.stdout.length === 0)
111
121
  return null;
112
122
  // The hosts cache dir already exists (saveTask created it) — write is best-effort.
@@ -126,4 +126,9 @@ export declare function streamAgentsOnHost(host: Host, forwardedArgs: string[],
126
126
  remoteOs?: string;
127
127
  target?: string;
128
128
  }): number;
129
+ export declare function passthroughSshOptions(host: Host, interactive: boolean): {
130
+ tty: boolean;
131
+ multiplex: true;
132
+ extraSshArgs: string[];
133
+ };
129
134
  export {};
@@ -19,7 +19,7 @@
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
24
  import { stripRoutingFlags, buildRemoteAgentsInvocation, stripClixml, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
25
  import { resolveRemoteOsSync } from './remote-os.js';
@@ -620,10 +620,13 @@ export function streamAgentsOnHost(host, forwardedArgs, opts = {}) {
620
620
  const remoteOs = opts.remoteOs ?? resolveRemoteOsSync(host.name);
621
621
  const env = withActorEnv({ ...opts.extraEnv, AGENTS_FLEET_REMOTE: '1' });
622
622
  const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, opts.remoteCwd, remoteOs, env);
623
- const code = sshStream(target, remoteCmd, { tty: !!opts.interactive, multiplex: true });
623
+ const code = sshStream(target, remoteCmd, passthroughSshOptions(host, !!opts.interactive));
624
624
  if (code === 255) {
625
625
  console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
626
626
  chalk.gray(' Check: agents hosts check ' + host.name));
627
627
  }
628
628
  return code;
629
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;
@@ -56,6 +57,7 @@ export interface FollowOptions {
56
57
  /** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
57
58
  maxPollMs?: number;
58
59
  extraSshArgs?: string[];
60
+ remoteShell?: 'posix' | 'powershell';
59
61
  }
60
62
  /**
61
63
  * Build the per-task sentinel that separates the log tail from the exit-file
@@ -97,10 +99,18 @@ export declare function fetchProgress(target: string, opts: {
97
99
  remoteExit: string;
98
100
  taskId: string;
99
101
  offset: number;
102
+ extraSshArgs?: string[];
103
+ remoteShell?: 'posix' | 'powershell';
100
104
  }): {
101
105
  logChunk: Buffer;
102
106
  exit: string;
103
107
  } | null;
108
+ export declare function buildWindowsProgressCommand(opts: {
109
+ remoteLog: string;
110
+ remoteExit: string;
111
+ taskId: string;
112
+ offset: number;
113
+ }): string;
104
114
  /**
105
115
  * Remote shell for the persistent follow stream.
106
116
  *
@@ -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
  *
@@ -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) {
@@ -18,14 +18,14 @@ 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. */
@@ -66,17 +66,56 @@ export declare function readyProbe(target: string, os?: string, extraSshArgs?: s
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;
69
91
  /** Read the requested harness's sign-in verdict from `agents view --json`. */
70
92
  export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
71
93
  export interface EnsureReadyOptions {
72
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;
73
102
  /** Throw instead of warn when the agent isn't installed remotely. */
74
103
  requireAgent?: boolean;
75
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
+ };
76
113
  /**
77
114
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
78
115
  * actionable message otherwise. Agent-not-installed is a warning by default (the
79
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.
80
119
  *
81
120
  * One ssh round-trip (`readyProbe`) covers all three checks.
82
121
  */
@@ -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();
@@ -140,6 +140,73 @@ 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
+ }
143
210
  /** Read the requested harness's sign-in verdict from `agents view --json`. */
144
211
  export function viewAgentSignedIn(view, agent) {
145
212
  try {
@@ -156,10 +223,38 @@ export function viewAgentSignedIn(view, agent) {
156
223
  return undefined;
157
224
  }
158
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
+ }
159
252
  /**
160
253
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
161
254
  * actionable message otherwise. Agent-not-installed is a warning by default (the
162
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.
163
258
  *
164
259
  * One ssh round-trip (`readyProbe`) covers all three checks.
165
260
  */
@@ -177,12 +272,5 @@ export function ensureHostReady(host, opts) {
177
272
  if (!probe.version) {
178
273
  throw new Error(`agents-cli is not installed on "${host.name}". Enroll it first: agents hosts add ${host.name} (bootstraps agents-cli).`);
179
274
  }
180
- const warnings = [];
181
- if (!viewHasAgent(probe.view, opts.agent)) {
182
- const msg = `Agent "${opts.agent}" may not be installed on "${host.name}" (remote \`agents add ${opts.agent}\` to install).`;
183
- if (opts.requireAgent)
184
- throw new Error(msg);
185
- warnings.push(msg);
186
- }
187
- return { warnings };
275
+ return evaluateHostAgentInstall(probe.view, opts, host.name);
188
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, identityFile?: string): 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;
@@ -12,6 +12,7 @@
12
12
  */
13
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,11 @@ 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, identityFile) {
39
- return classifyExit(sshExec(target, `cat ${remoteExit} 2>/dev/null`, {
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, {
40
44
  timeoutMs,
41
45
  multiplex: true,
42
46
  extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
@@ -50,7 +54,7 @@ export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFil
50
54
  export function reconcileTask(task) {
51
55
  if (task.status !== 'running')
52
56
  return task;
53
- const st = readRemoteExit(task.target, task.remoteExit, 6000, task.identityFile);
57
+ const st = readRemoteExit(task.target, task.remoteExit, 6000, task.identityFile, task.remoteShell);
54
58
  if (st.state !== 'done')
55
59
  return task;
56
60
  return updateTask(task.id, terminalPatch(st.code)) ?? task;
@@ -71,7 +75,8 @@ export function reconcileRunningTasks(tasks) {
71
75
  const patched = new Map();
72
76
  for (const t of running) {
73
77
  if (!reachable.has(t.target)) {
74
- const probe = sshExec(t.target, 'true', {
78
+ const probeCommand = reachabilityProbeCommand(t.remoteShell);
79
+ const probe = sshExec(t.target, probeCommand, {
75
80
  timeoutMs: 6000,
76
81
  extraSshArgs: t.identityFile ? ['-i', t.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
77
82
  });
@@ -79,7 +84,7 @@ export function reconcileRunningTasks(tasks) {
79
84
  }
80
85
  if (!reachable.get(t.target))
81
86
  continue; // host down → leave running
82
- const st = readRemoteExit(t.target, t.remoteExit, 6000, t.identityFile);
87
+ const st = readRemoteExit(t.target, t.remoteExit, 6000, t.identityFile, t.remoteShell);
83
88
  if (st.state === 'done') {
84
89
  const updated = updateTask(t.id, terminalPatch(st.code));
85
90
  if (updated)
@@ -88,3 +93,6 @@ export function reconcileRunningTasks(tasks) {
88
93
  }
89
94
  return tasks.map((t) => patched.get(t.id) ?? t);
90
95
  }
96
+ export function reachabilityProbeCommand(remoteShell) {
97
+ return remoteShell === 'powershell' ? 'powershell -NoProfile -Command "exit 0"' : 'true';
98
+ }