@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
@@ -29,7 +29,7 @@
29
29
  import * as http from 'http';
30
30
  import { isControlDevice } from '../devices/registry.js';
31
31
  import { loadDevices } from '../devices/registry.js';
32
- import { fleetDialTarget } from '../devices/connect.js';
32
+ import { deviceIdentityArgs, fleetDialTarget } from '../devices/connect.js';
33
33
  import { planFleetTargets } from '../devices/fleet.js';
34
34
  import { assertValidSshTarget, shellQuote, sshExecAsync } from '../ssh-exec.js';
35
35
  import { machineId } from '../session/sync/config.js';
@@ -134,6 +134,7 @@ export function selectLoginTargets(devices, agents, cache, opts = {}) {
134
134
  agent,
135
135
  platform: device.platform,
136
136
  target: fleetDialTarget(device),
137
+ extraSshArgs: deviceIdentityArgs(device),
137
138
  flow,
138
139
  remotable,
139
140
  reason,
@@ -152,7 +153,7 @@ export function selectLoginTargets(devices, agents, cache, opts = {}) {
152
153
  * devices need the askpass shim env, which the PTY env allowlist strips — those
153
154
  * are a follow-up (TODO), surfaced as an error rather than silently mis-driven.
154
155
  */
155
- export function buildRemoteLoginSshCommand(target, flow) {
156
+ export function buildRemoteLoginSshCommand(target, flow, extraSshArgs = []) {
156
157
  assertValidSshTarget(target);
157
158
  // The agent CLIs (kimi/droid/codex/…) are agents-cli shims that live in
158
159
  // ~/.agents/.cache/shims and are only on PATH in an interactive/login shell.
@@ -165,6 +166,7 @@ export function buildRemoteLoginSshCommand(target, flow) {
165
166
  'ssh', '-tt',
166
167
  '-o', 'StrictHostKeyChecking=accept-new',
167
168
  '-o', 'ConnectTimeout=10',
169
+ ...extraSshArgs.map(shellQuote),
168
170
  shellQuote(target),
169
171
  shellQuote(remoteCmd),
170
172
  ].join(' ');
@@ -367,7 +369,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
367
369
  * in the browser; the caller polls the credential file for completion. Testable
368
370
  * against a fake {@link PtyDriver}.
369
371
  */
370
- export async function driveRemoteLogin(target, flow, driver, opts = {}) {
372
+ export async function driveRemoteLogin(target, flow, driver, opts = {}, extraSshArgs = []) {
371
373
  const initialDelayMs = opts.initialDelayMs ?? 4000;
372
374
  const pollMs = opts.pollMs ?? 1000;
373
375
  const timeoutMs = opts.timeoutMs ?? 90000;
@@ -377,7 +379,7 @@ export async function driveRemoteLogin(target, flow, driver, opts = {}) {
377
379
  // `ssh -tt` process it drives) leaks until the sidecar's idle reaper. On the
378
380
  // normal return paths the caller owns stop() via the returned sessionId.
379
381
  try {
380
- await driver.exec(id, buildRemoteLoginSshCommand(target, flow));
382
+ await driver.exec(id, buildRemoteLoginSshCommand(target, flow, extraSshArgs));
381
383
  await sleep(initialDelayMs);
382
384
  if (flow.deviceCodeSelect) {
383
385
  await driver.write(id, flow.deviceCodeSelect);
@@ -431,9 +433,9 @@ export async function detectPending(opts = {}) {
431
433
  * bumping after the human completes the browser step. POSIX `stat` only (the
432
434
  * device-code agents are all POSIX-fleet).
433
435
  */
434
- export async function remoteFileMtime(target, homeRel) {
436
+ export async function remoteFileMtime(target, homeRel, extraSshArgs = []) {
435
437
  const cmd = `stat -c %Y "$HOME/${homeRel}" 2>/dev/null || stat -f %m "$HOME/${homeRel}" 2>/dev/null || echo 0`;
436
- const res = await sshExecAsync(target, cmd, { timeoutMs: 15000, multiplex: true }).catch(() => null);
438
+ const res = await sshExecAsync(target, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs }).catch(() => null);
437
439
  if (!res || res.code !== 0)
438
440
  return 0;
439
441
  const n = parseInt(res.stdout.trim(), 10);
@@ -507,10 +509,10 @@ export async function runFleetLogin(opts = {}) {
507
509
  const k = key(p.device, p.agent);
508
510
  const st = statuses.get(k);
509
511
  st.state = 'driving';
510
- const baseMtime = await remoteFileMtime(p.target, p.flow.successFile).catch(() => 0);
512
+ const baseMtime = await remoteFileMtime(p.target, p.flow.successFile, p.extraSshArgs).catch(() => 0);
511
513
  let sessionId;
512
514
  try {
513
- const r = await driveRemoteLogin(p.target, p.flow, driver, opts.drive);
515
+ const r = await driveRemoteLogin(p.target, p.flow, driver, opts.drive, p.extraSshArgs);
514
516
  sessionId = r.sessionId;
515
517
  if (r.url && r.code) {
516
518
  st.url = r.url;
@@ -539,7 +541,7 @@ export async function runFleetLogin(opts = {}) {
539
541
  st.detail = 'device code expired before authorization';
540
542
  break;
541
543
  }
542
- const mtime = await remoteFileMtime(p.target, p.flow.successFile).catch(() => 0);
544
+ const mtime = await remoteFileMtime(p.target, p.flow.successFile, p.extraSshArgs).catch(() => 0);
543
545
  if (mtime > baseMtime) {
544
546
  st.state = 'authorized';
545
547
  break;
@@ -1,4 +1,3 @@
1
1
  export declare function readGeminiSettings(settingsPath: string): Record<string, unknown>;
2
2
  export declare function writeGeminiSettings(settingsPath: string, settings: Record<string, unknown>): void;
3
3
  export declare function updateGeminiSettings(settingsPath: string, mutate: (settings: Record<string, unknown>) => void): Record<string, unknown>;
4
- export declare function setGeminiAutoUpdateDisabled(settings: Record<string, unknown>): void;
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Generic reader/writer for a `.gemini/…/settings.json`-shaped JSON config.
3
+ *
4
+ * Despite the name, this module is no longer gemini-specific: gemini itself
5
+ * is hard-deprecated and has no live caller left (its own settings writer,
6
+ * `generateGeminiConfig`, was removed with the RUSH-2202 runner.ts fix — a
7
+ * gemini routine is rejected before it ever reaches sandbox prep). Antigravity
8
+ * nests its own `settings.json` under the same `.gemini/antigravity-cli/` tree
9
+ * and shares this exact shape, so `permissions.ts` reuses `updateGeminiSettings`
10
+ * for antigravity's live permission writes. Keep the generic helpers; do not
11
+ * reintroduce gemini-only logic here.
12
+ */
1
13
  import * as fs from 'fs';
2
14
  import * as path from 'path';
3
15
  function isRecord(value) {
@@ -24,10 +36,3 @@ export function updateGeminiSettings(settingsPath, mutate) {
24
36
  writeGeminiSettings(settingsPath, settings);
25
37
  return settings;
26
38
  }
27
- export function setGeminiAutoUpdateDisabled(settings) {
28
- const general = isRecord(settings.general) ? settings.general : {};
29
- settings.general = {
30
- ...general,
31
- enableAutoUpdate: false,
32
- };
33
- }
package/dist/lib/git.d.ts CHANGED
@@ -269,9 +269,15 @@ export declare function displayHomePath(dir: string): string;
269
269
  * Pull changes in an existing repo.
270
270
  * Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
271
271
  *
272
- * Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
273
- * branch reconciles instead of failing with "Need to specify how to reconcile
274
- * divergent branches".
272
+ * Strategy (RUSH-2282):
273
+ * 1. Fetch, then compare HEAD to the resolved tracking ref.
274
+ * 2. Clean behind-only (HEAD is an ancestor of tracking) → `merge --ff-only`
275
+ * against the tracking ref. Never re-enter `git pull` after a bare fetch —
276
+ * multi-entry FETCH_HEAD (concurrent fetch, multi-branch remote) makes
277
+ * `git pull --rebase` die with "Cannot rebase onto multiple branches" even
278
+ * when the checkout is a pure fast-forward.
279
+ * 3. Genuinely diverged (local commits not on tracking) → `rebase` onto the
280
+ * tracking ref (same outcome as {@link syncRepoGit}, without a second pull).
275
281
  */
276
282
  export declare function pullRepo(dir: string): Promise<{
277
283
  success: boolean;
package/dist/lib/git.js CHANGED
@@ -867,9 +867,15 @@ export function displayHomePath(dir) {
867
867
  * Pull changes in an existing repo.
868
868
  * Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
869
869
  *
870
- * Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
871
- * branch reconciles instead of failing with "Need to specify how to reconcile
872
- * divergent branches".
870
+ * Strategy (RUSH-2282):
871
+ * 1. Fetch, then compare HEAD to the resolved tracking ref.
872
+ * 2. Clean behind-only (HEAD is an ancestor of tracking) → `merge --ff-only`
873
+ * against the tracking ref. Never re-enter `git pull` after a bare fetch —
874
+ * multi-entry FETCH_HEAD (concurrent fetch, multi-branch remote) makes
875
+ * `git pull --rebase` die with "Cannot rebase onto multiple branches" even
876
+ * when the checkout is a pure fast-forward.
877
+ * 3. Genuinely diverged (local commits not on tracking) → `rebase` onto the
878
+ * tracking ref (same outcome as {@link syncRepoGit}, without a second pull).
873
879
  */
874
880
  export async function pullRepo(dir) {
875
881
  try {
@@ -933,16 +939,16 @@ export async function pullRepo(dir) {
933
939
  tracking = `origin/${branch}`;
934
940
  }
935
941
  }
936
- // Split the remote-tracking ref (<remote>/<branch>) into its parts and pull
937
- // THOSE. Hardcoding 'origin' while comparing against `tracking` is how a
938
- // branch tracking e.g. upstream/main silently 'succeeded': revparse saw a
939
- // difference, the pull fetched origin/main (already current), and pullRepo
940
- // returned success having moved nothing the same "reported ok, pulled
941
- // nothing" failure this change exists to remove. Branch names may contain
942
- // slashes, so split on the FIRST separator only.
942
+ // Split the remote-tracking ref (<remote>/<branch>) so callers/logs can name
943
+ // the remote. Branch names may contain slashes, so split on the FIRST
944
+ // separator only. The integrate step below uses `tracking` directly (not a
945
+ // second `git pull <remote> <branch>`) so a multi-entry FETCH_HEAD cannot
946
+ // turn a clean fast-forward into "Cannot rebase onto multiple branches".
943
947
  const sep = tracking.indexOf('/');
944
- const remoteName = sep > 0 ? tracking.slice(0, sep) : 'origin';
945
948
  const remoteBranch = sep > 0 ? tracking.slice(sep + 1) : branch;
949
+ // Keep branch-name validation on the ref we would have passed to pull —
950
+ // rejects traversal / flag-smuggling shapes before any integrate command.
951
+ assertValidBranchName(remoteBranch);
946
952
  // Bare fetch: updates every remote, so the revparse below sees a fresh ref
947
953
  // whichever one the branch tracks. Deliberately argument-less — simple-git's
948
954
  // fetchTask only forwards a remote when BOTH remote and branch are passed,
@@ -962,17 +968,27 @@ export async function pullRepo(dir) {
962
968
  branch,
963
969
  };
964
970
  }
971
+ // Behind-only when tracking has commits we lack AND we have none of our own
972
+ // on top. Use rev-list counts — simple-git does not reject on the exit-1
973
+ // that `merge-base --is-ancestor` returns for a non-ancestor, so a try/catch
974
+ // around that call would always report "can ff" (RUSH-2282).
975
+ const aheadCount = parseInt((await git.raw(['rev-list', '--count', `${tracking}..HEAD`])).trim(), 10);
976
+ const behindCount = parseInt((await git.raw(['rev-list', '--count', `HEAD..${tracking}`])).trim(), 10);
977
+ const canFastForward = Number.isFinite(aheadCount) &&
978
+ Number.isFinite(behindCount) &&
979
+ aheadCount === 0 &&
980
+ behindCount > 0;
965
981
  try {
966
- // Rebase, not --ff-only. Fast-forward refuses ANY divergence, conflict or
967
- // not, so a single local commit permanently wedged the pull with nothing
968
- // actually in conflict. Matches syncRepoGit (below) and this function's doc.
969
- //
970
- // Pull the RESOLVED tracking ref, not `branch`: when the local branch has
971
- // no tracking config the block above falls back to origin's default head,
972
- // which may be named differently (local `main` vs origin `master`). Using
973
- // `branch` there asks origin for a ref it does not have.
974
- assertValidBranchName(remoteBranch);
975
- await git.pull(remoteName, remoteBranch, { '--rebase': 'true' });
982
+ if (canFastForward) {
983
+ // Integrate the already-fetched tracking ref. No network, no FETCH_HEAD.
984
+ await git.raw(['merge', '--ff-only', tracking]);
985
+ }
986
+ else {
987
+ // Diverged (or local-only commits). Rebase onto the tracking tip
988
+ // same outcome as `git pull --rebase <remote> <branch>` without
989
+ // re-fetching or consulting FETCH_HEAD.
990
+ await git.raw(['rebase', tracking]);
991
+ }
976
992
  }
977
993
  catch (err) {
978
994
  // Abort so the tree is restored, matching the atomicity --ff-only gave us.
@@ -982,10 +998,11 @@ export async function pullRepo(dir) {
982
998
  // the cause. `agents sync` reaches this path unattended across the fleet,
983
999
  // so a wedged checkout would be worse than the bug this fixes.
984
1000
  await git.raw(['rebase', '--abort']).catch(() => { });
1001
+ const verb = canFastForward ? 'Fast-forward' : 'Rebase';
985
1002
  return {
986
1003
  success: false,
987
1004
  commit: '',
988
- error: `Rebase onto ${tracking} hit a conflict — the pull was rolled back, nothing changed.\n\nResolve the divergence, then pull again:\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}\n\n${err.message}`,
1005
+ error: `${verb} onto ${tracking} failed — the pull was rolled back, nothing changed.\n\nResolve the divergence, then pull again:\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}\n\n${err.message}`,
989
1006
  };
990
1007
  }
991
1008
  installGithooksSymlinks(dir);
@@ -10,8 +10,19 @@ export interface HookProfileRow {
10
10
  cacheStalePct: number;
11
11
  cacheMissPct: number;
12
12
  errorCount: number;
13
- /** Fraction (0-1) of fires with a nonzero exit code. */
13
+ /**
14
+ * Fraction (0-1) of fires with a real crash exit (exit 1 / other nonzero
15
+ * except intentional PreToolUse deny code 2). Exit 2 is blockRate.
16
+ */
14
17
  errorRate?: number;
18
+ /**
19
+ * Count of intentional deny/block exits (PreToolUse exit 2). Deny-by-design
20
+ * guards (ask-user-question-guard, git-guard, plan-html-reminder) use this
21
+ * path — not a crash.
22
+ */
23
+ blockCount: number;
24
+ /** Fraction (0-1) of fires with exit code 2 (intentional deny/block). */
25
+ blockRate?: number;
15
26
  /** Fraction (0-1) of fires that hit their configured timeout. */
16
27
  timeoutRate?: number;
17
28
  /** Project key the row is scoped to (see project-key.ts) — set only when
@@ -72,7 +72,10 @@ export function aggregateHookProfile(events) {
72
72
  const hits = evs.filter(e => e.cache === 'hit').length;
73
73
  const stale = evs.filter(e => e.cache === 'stale-prefetch').length;
74
74
  const misses = evs.filter(e => e.cache === 'miss').length;
75
- const errors = evs.filter(e => typeof e.exit === 'number' && e.exit !== 0).length;
75
+ // Exit classes (Claude/Codex PreToolUse convention): 0 allow, 2 deny/block,
76
+ // 1 / other nonzero = real error. Exit 2 is not a crash.
77
+ const blocks = evs.filter(e => e.exit === 2).length;
78
+ const errors = evs.filter(e => typeof e.exit === 'number' && e.exit !== 0 && e.exit !== 2).length;
76
79
  rows.push({
77
80
  hook,
78
81
  n,
@@ -85,7 +88,9 @@ export function aggregateHookProfile(events) {
85
88
  cacheStalePct: Math.round((stale / n) * 100),
86
89
  cacheMissPct: Math.round((misses / n) * 100),
87
90
  errorCount: errors,
91
+ blockCount: blocks,
88
92
  ...(errors > 0 ? { errorRate: Math.round((errors / n) * 1000) / 1000 } : {}),
93
+ ...(blocks > 0 ? { blockRate: Math.round((blocks / n) * 1000) / 1000 } : {}),
89
94
  // timeoutRate is not derivable here: the daily JSONL a shim writes only
90
95
  // covers fires that reached their own trailing printf — an externally
91
96
  // enforced timeout (the agent harness killing the process) never gets
@@ -217,12 +217,15 @@ export declare function listCentralHooks(): HookEntry[];
217
217
  *
218
218
  * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
219
219
  * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
220
- * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
221
- * routines.ts that one returns milliseconds, has no seconds (`s`) unit, and
222
- * floors at one minute, none of which fit hook timeouts (typically 5–600s).
220
+ * `90s`, `1h`. Suffixed durations longer than 24 hours are rejected as likely
221
+ * typos, while bare seconds stay uncapped for backward compatibility. This
222
+ * intentionally does NOT reuse {@link parseTimeout} from routines.ts that one
223
+ * returns milliseconds, has no seconds (`s`) unit, and floors at one minute,
224
+ * none of which fit hook timeouts (typically 5–600s).
223
225
  *
224
- * Returns the seconds value, or `null` when the input is not a positive number
225
- * or a parseable duration string — the caller decides how to surface that.
226
+ * Returns the seconds value, or `null` when the input is not a positive number,
227
+ * is not parseable, or is a suffixed duration longer than 24 hours — the caller
228
+ * decides how to surface that.
226
229
  */
227
230
  export declare function normalizeHookTimeoutSeconds(value: unknown): number | null;
228
231
  /**
package/dist/lib/hooks.js CHANGED
@@ -1160,17 +1160,21 @@ export function listCentralHooks() {
1160
1160
  }
1161
1161
  return results;
1162
1162
  }
1163
+ const MAX_HOOK_DURATION_SECONDS = 24 * 60 * 60;
1163
1164
  /**
1164
1165
  * Normalize a hook `timeout` from agents.yaml into a whole number of seconds.
1165
1166
  *
1166
1167
  * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
1167
1168
  * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
1168
- * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
1169
- * routines.ts that one returns milliseconds, has no seconds (`s`) unit, and
1170
- * floors at one minute, none of which fit hook timeouts (typically 5–600s).
1169
+ * `90s`, `1h`. Suffixed durations longer than 24 hours are rejected as likely
1170
+ * typos, while bare seconds stay uncapped for backward compatibility. This
1171
+ * intentionally does NOT reuse {@link parseTimeout} from routines.ts that one
1172
+ * returns milliseconds, has no seconds (`s`) unit, and floors at one minute,
1173
+ * none of which fit hook timeouts (typically 5–600s).
1171
1174
  *
1172
- * Returns the seconds value, or `null` when the input is not a positive number
1173
- * or a parseable duration string — the caller decides how to surface that.
1175
+ * Returns the seconds value, or `null` when the input is not a positive number,
1176
+ * is not parseable, or is a suffixed duration longer than 24 hours — the caller
1177
+ * decides how to surface that.
1174
1178
  */
1175
1179
  export function normalizeHookTimeoutSeconds(value) {
1176
1180
  if (typeof value === 'number') {
@@ -1194,7 +1198,7 @@ export function normalizeHookTimeoutSeconds(value) {
1194
1198
  const minutes = Number(m[4] || 0);
1195
1199
  const seconds = Number(m[5] || 0);
1196
1200
  const total = ((weeks * 7 + days) * 24 + hours) * 3600 + minutes * 60 + seconds;
1197
- return total > 0 ? total : null;
1201
+ return total > 0 && total <= MAX_HOOK_DURATION_SECONDS ? total : null;
1198
1202
  }
1199
1203
  return null;
1200
1204
  }
@@ -1606,6 +1610,7 @@ function registerHooksForOpenCode(versionHome, manifest, resolveScript) {
1606
1610
  const pluginSource = `// Generated by agents-cli. Re-run agents sync to update.
1607
1611
  import fs from "node:fs"
1608
1612
  import os from "node:os"
1613
+ import path from "node:path"
1609
1614
 
1610
1615
  const directHooks = ${serializedDirect}
1611
1616
  const lifecycleHooks = ${serializedLifecycle}
@@ -1618,7 +1623,9 @@ function recordTimeoutSample(hook, payload) {
1618
1623
  // printf, so a timed-out fire would otherwise leave ZERO trace in the
1619
1624
  // warehouse. Write the sample ourselves from the side that knows it timed out.
1620
1625
  try {
1621
- fs.mkdirSync(PERF_SPOOL.slice(0, PERF_SPOOL.lastIndexOf("/")), { recursive: true })
1626
+ // path.dirname — not lastIndexOf('/') so Windows backslash spool paths
1627
+ // still resolve to the parent dir (see #1869).
1628
+ fs.mkdirSync(path.dirname(PERF_SPOOL), { recursive: true })
1622
1629
  const line = JSON.stringify({
1623
1630
  ts_ms: Date.now(),
1624
1631
  kind: "hook.fire",
@@ -74,6 +74,15 @@ export declare function remoteRunShellPrelude(agent: string): string;
74
74
  * reliable whole-tree operation for both normal stops and rollback cleanup.
75
75
  */
76
76
  export declare function buildDetachedLaunchCommand(inner: string): string;
77
+ /** Build the detached PowerShell launch protocol used by Windows SSH hosts. */
78
+ export declare function buildWindowsDetachedLaunchCommand(opts: {
79
+ forwardedArgs: string[];
80
+ remoteCwd?: string;
81
+ mirrorCwd?: boolean;
82
+ remoteLog: string;
83
+ remoteExit: string;
84
+ env: Record<string, string>;
85
+ }): string;
77
86
  export interface DispatchResult {
78
87
  task: HostTask;
79
88
  /** Exit code when followed; undefined when detached (--no-follow). */
@@ -92,6 +101,7 @@ export declare function terminateDispatchedTask(task: HostTask): void;
92
101
  * Exit 1 if the group is still alive after TERM/KILL (can't stop it).
93
102
  */
94
103
  export declare function buildStopRemoteCommand(pid: number, remoteExit: string): string;
104
+ export declare function buildWindowsStopRemoteCommand(pid: number, remoteExit: string): string;
95
105
  /**
96
106
  * Stop a running host task from the origin machine (`agents hosts stop <id>`).
97
107
  *
@@ -10,9 +10,9 @@
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
- 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,15 +148,53 @@ 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}`;
159
- const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
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}`;
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}: ` +
162
200
  `${(result.stderr || result.stdout).trim() || 'ssh error'}`);
@@ -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,8 +258,10 @@ 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);
213
- const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
261
+ const command = task.remoteShell === 'powershell'
262
+ ? buildWindowsStopRemoteCommand(task.pid, task.remoteExit)
263
+ : buildStopRemoteCommand(task.pid, task.remoteExit);
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}: ` +
216
267
  `${(result.stderr || result.stdout).trim() || 'ssh error'}`);
@@ -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,11 +307,21 @@ 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,
273
323
  hostKeyOpts: credHostKeyOpts,
324
+ extraSshArgs: hostIdentityArgs(host),
274
325
  });
275
326
  if (res.code !== 0) {
276
327
  throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
@@ -280,6 +331,8 @@ async function launchDetached(host, target, opts) {
280
331
  id,
281
332
  host: host.name,
282
333
  target,
334
+ identityFile: host.identityFile,
335
+ remoteShell,
283
336
  agent: opts.agentLabel,
284
337
  prompt: opts.promptLabel,
285
338
  pid: Number.isFinite(pid) ? pid : undefined,
@@ -311,6 +364,8 @@ async function launchDetached(host, target, opts) {
311
364
  taskId: id,
312
365
  echo: true,
313
366
  timeoutMs: opts.timeoutMs,
367
+ extraSshArgs: hostIdentityArgs(host),
368
+ remoteShell,
314
369
  });
315
370
  // -1 = the follow window closed while the run continues on the host. Leave the
316
371
  // record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
@@ -443,7 +498,9 @@ export function buildInteractiveRunForwardedArgs(opts) {
443
498
  */
444
499
  export async function runInteractiveOnHost(host, opts) {
445
500
  const target = sshTargetFor(host);
446
- 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 });
447
504
  for (const w of warnings)
448
505
  process.stderr.write(`[hosts] warning: ${w}\n`);
449
506
  const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
@@ -464,12 +521,15 @@ export async function runInteractiveOnHost(host, opts) {
464
521
  tty: process.stdin.isTTY,
465
522
  multiplex: !opts.copyCreds,
466
523
  hostKeyOpts: credHostKeyOpts,
524
+ extraSshArgs: hostIdentityArgs(host),
467
525
  });
468
526
  }
469
527
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
470
528
  export async function dispatchToHost(host, opts) {
471
529
  const target = sshTargetFor(host);
472
- 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 });
473
533
  for (const w of warnings)
474
534
  process.stderr.write(`[hosts] warning: ${w}\n`);
475
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.