@phnx-labs/agents-cli 1.20.34 → 1.20.36

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 (291) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +185 -21
  3. package/README.md +13 -5
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer-actions.d.ts +15 -0
  11. package/dist/commands/computer-actions.js +120 -22
  12. package/dist/commands/computer.js +81 -0
  13. package/dist/commands/cost.js +17 -12
  14. package/dist/commands/daemon.js +4 -1
  15. package/dist/commands/doctor.d.ts +1 -0
  16. package/dist/commands/doctor.js +54 -97
  17. package/dist/commands/events.d.ts +16 -0
  18. package/dist/commands/events.js +173 -0
  19. package/dist/commands/exec.d.ts +19 -0
  20. package/dist/commands/exec.js +283 -12
  21. package/dist/commands/hosts.js +37 -29
  22. package/dist/commands/inspect.d.ts +2 -0
  23. package/dist/commands/inspect.js +62 -16
  24. package/dist/commands/lock.d.ts +12 -0
  25. package/dist/commands/lock.js +70 -0
  26. package/dist/commands/logs.d.ts +17 -0
  27. package/dist/commands/logs.js +139 -0
  28. package/dist/commands/message.d.ts +15 -0
  29. package/dist/commands/message.js +56 -0
  30. package/dist/commands/models.d.ts +3 -0
  31. package/dist/commands/models.js +27 -8
  32. package/dist/commands/permissions.js +9 -2
  33. package/dist/commands/repo.d.ts +34 -0
  34. package/dist/commands/repo.js +243 -65
  35. package/dist/commands/resource-view.d.ts +20 -0
  36. package/dist/commands/resource-view.js +90 -28
  37. package/dist/commands/routines.js +101 -5
  38. package/dist/commands/secrets-migrate.js +106 -57
  39. package/dist/commands/secrets.d.ts +31 -18
  40. package/dist/commands/secrets.js +196 -91
  41. package/dist/commands/serve.d.ts +10 -0
  42. package/dist/commands/serve.js +37 -0
  43. package/dist/commands/sessions-inject.d.ts +14 -0
  44. package/dist/commands/sessions-inject.js +111 -0
  45. package/dist/commands/sessions-picker.d.ts +2 -0
  46. package/dist/commands/sessions-picker.js +24 -3
  47. package/dist/commands/sessions-resume.d.ts +2 -0
  48. package/dist/commands/sessions-resume.js +221 -0
  49. package/dist/commands/sessions-tail.d.ts +10 -0
  50. package/dist/commands/sessions-tail.js +11 -0
  51. package/dist/commands/sessions.d.ts +143 -1
  52. package/dist/commands/sessions.js +628 -80
  53. package/dist/commands/setup.js +4 -2
  54. package/dist/commands/sync.d.ts +3 -1
  55. package/dist/commands/sync.js +156 -4
  56. package/dist/commands/teams.js +217 -0
  57. package/dist/commands/versions.js +2 -4
  58. package/dist/commands/view.d.ts +3 -0
  59. package/dist/commands/view.js +19 -8
  60. package/dist/commands/wallet.d.ts +6 -0
  61. package/dist/commands/wallet.js +22 -5
  62. package/dist/commands/watchdog.d.ts +18 -0
  63. package/dist/commands/watchdog.js +238 -0
  64. package/dist/index.js +82 -32
  65. package/dist/lib/agents.js +30 -9
  66. package/dist/lib/audit/log.d.ts +92 -0
  67. package/dist/lib/audit/log.js +177 -0
  68. package/dist/lib/auto-pull.js +2 -1
  69. package/dist/lib/browser/cdp.js +5 -1
  70. package/dist/lib/browser/chrome.d.ts +10 -0
  71. package/dist/lib/browser/chrome.js +18 -7
  72. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  73. package/dist/lib/browser/drivers/ssh.js +77 -16
  74. package/dist/lib/browser/har.d.ts +84 -0
  75. package/dist/lib/browser/har.js +77 -0
  76. package/dist/lib/browser/ipc.d.ts +3 -0
  77. package/dist/lib/browser/ipc.js +37 -12
  78. package/dist/lib/browser/profiles.d.ts +1 -1
  79. package/dist/lib/browser/profiles.js +8 -10
  80. package/dist/lib/browser/refs.d.ts +65 -0
  81. package/dist/lib/browser/refs.js +73 -1
  82. package/dist/lib/browser/runtime-state.js +1 -0
  83. package/dist/lib/browser/service.d.ts +38 -2
  84. package/dist/lib/browser/service.js +183 -23
  85. package/dist/lib/browser/types.d.ts +14 -1
  86. package/dist/lib/budget/live-cloud.d.ts +42 -0
  87. package/dist/lib/budget/live-cloud.js +79 -0
  88. package/dist/lib/budget/live-team.d.ts +31 -0
  89. package/dist/lib/budget/live-team.js +115 -0
  90. package/dist/lib/cloud/codex.js +4 -0
  91. package/dist/lib/cloud/rush.d.ts +12 -1
  92. package/dist/lib/cloud/rush.js +13 -3
  93. package/dist/lib/cloud/types.d.ts +9 -0
  94. package/dist/lib/computer/dispatch.d.ts +8 -0
  95. package/dist/lib/computer/dispatch.js +125 -0
  96. package/dist/lib/computer/loop.d.ts +62 -0
  97. package/dist/lib/computer/loop.js +98 -0
  98. package/dist/lib/computer/model.d.ts +44 -0
  99. package/dist/lib/computer/model.js +157 -0
  100. package/dist/lib/concurrency.d.ts +19 -0
  101. package/dist/lib/concurrency.js +33 -0
  102. package/dist/lib/daemon.d.ts +92 -0
  103. package/dist/lib/daemon.js +222 -18
  104. package/dist/lib/devices/connect.d.ts +3 -2
  105. package/dist/lib/devices/connect.js +5 -3
  106. package/dist/lib/devices/registry.d.ts +7 -0
  107. package/dist/lib/devices/registry.js +24 -0
  108. package/dist/lib/devices/tailscale.js +1 -1
  109. package/dist/lib/drift.d.ts +52 -0
  110. package/dist/lib/drift.js +112 -0
  111. package/dist/lib/events.d.ts +10 -3
  112. package/dist/lib/events.js +101 -24
  113. package/dist/lib/exec.d.ts +17 -0
  114. package/dist/lib/exec.js +100 -12
  115. package/dist/lib/git.d.ts +27 -0
  116. package/dist/lib/git.js +56 -1
  117. package/dist/lib/hooks/cache.d.ts +6 -0
  118. package/dist/lib/hooks/cache.js +54 -12
  119. package/dist/lib/hooks.d.ts +27 -0
  120. package/dist/lib/hooks.js +135 -10
  121. package/dist/lib/hosts/dispatch.d.ts +15 -0
  122. package/dist/lib/hosts/dispatch.js +45 -13
  123. package/dist/lib/hosts/logs.d.ts +16 -0
  124. package/dist/lib/hosts/logs.js +74 -0
  125. package/dist/lib/hosts/option.js +1 -1
  126. package/dist/lib/hosts/passthrough.js +3 -1
  127. package/dist/lib/hosts/progress.d.ts +66 -0
  128. package/dist/lib/hosts/progress.js +125 -17
  129. package/dist/lib/hosts/ready.d.ts +51 -7
  130. package/dist/lib/hosts/ready.js +98 -25
  131. package/dist/lib/hosts/reconcile.d.ts +53 -0
  132. package/dist/lib/hosts/reconcile.js +81 -0
  133. package/dist/lib/hosts/registry.d.ts +19 -2
  134. package/dist/lib/hosts/registry.js +58 -2
  135. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  136. package/dist/lib/hosts/remote-cmd.js +70 -1
  137. package/dist/lib/hosts/remote-os.d.ts +17 -0
  138. package/dist/lib/hosts/remote-os.js +30 -0
  139. package/dist/lib/hosts/session-index.d.ts +34 -0
  140. package/dist/lib/hosts/session-index.js +56 -0
  141. package/dist/lib/hosts/tasks.d.ts +22 -0
  142. package/dist/lib/hosts/tasks.js +29 -0
  143. package/dist/lib/lock.d.ts +93 -0
  144. package/dist/lib/lock.js +207 -0
  145. package/dist/lib/loop.js +16 -1
  146. package/dist/lib/machine-id.d.ts +21 -0
  147. package/dist/lib/machine-id.js +26 -0
  148. package/dist/lib/mailbox-target.d.ts +36 -0
  149. package/dist/lib/mailbox-target.js +45 -0
  150. package/dist/lib/mailbox.d.ts +47 -0
  151. package/dist/lib/mailbox.js +194 -0
  152. package/dist/lib/mcp.d.ts +5 -0
  153. package/dist/lib/mcp.js +24 -8
  154. package/dist/lib/migrate.d.ts +19 -0
  155. package/dist/lib/migrate.js +134 -26
  156. package/dist/lib/overdue.js +3 -0
  157. package/dist/lib/permissions.d.ts +4 -0
  158. package/dist/lib/permissions.js +35 -0
  159. package/dist/lib/picker.d.ts +26 -0
  160. package/dist/lib/picker.js +127 -0
  161. package/dist/lib/platform/exec.d.ts +46 -0
  162. package/dist/lib/platform/exec.js +74 -0
  163. package/dist/lib/platform/process.d.ts +31 -0
  164. package/dist/lib/platform/process.js +34 -1
  165. package/dist/lib/platform/winpath.js +2 -0
  166. package/dist/lib/plugin-marketplace.d.ts +30 -0
  167. package/dist/lib/plugin-marketplace.js +215 -2
  168. package/dist/lib/plugins.d.ts +5 -0
  169. package/dist/lib/plugins.js +61 -10
  170. package/dist/lib/profiles.d.ts +25 -0
  171. package/dist/lib/profiles.js +22 -6
  172. package/dist/lib/pty-client.js +2 -1
  173. package/dist/lib/rotate.d.ts +61 -0
  174. package/dist/lib/rotate.js +52 -0
  175. package/dist/lib/routines.d.ts +40 -2
  176. package/dist/lib/routines.js +66 -8
  177. package/dist/lib/runner.d.ts +11 -2
  178. package/dist/lib/runner.js +49 -7
  179. package/dist/lib/scheduler.js +6 -1
  180. package/dist/lib/secrets/bundles.d.ts +60 -4
  181. package/dist/lib/secrets/bundles.js +131 -12
  182. package/dist/lib/secrets/filestore.d.ts +3 -0
  183. package/dist/lib/secrets/filestore.js +42 -16
  184. package/dist/lib/secrets/index.d.ts +43 -2
  185. package/dist/lib/secrets/index.js +102 -3
  186. package/dist/lib/secrets/mcp.d.ts +93 -0
  187. package/dist/lib/secrets/mcp.js +205 -0
  188. package/dist/lib/secrets/remote.js +12 -5
  189. package/dist/lib/secrets/sync.js +83 -4
  190. package/dist/lib/secrets/windows.js +14 -3
  191. package/dist/lib/serve/data.d.ts +81 -0
  192. package/dist/lib/serve/data.js +91 -0
  193. package/dist/lib/serve/page.d.ts +7 -0
  194. package/dist/lib/serve/page.js +140 -0
  195. package/dist/lib/serve/server.d.ts +46 -0
  196. package/dist/lib/serve/server.js +115 -0
  197. package/dist/lib/session/active.d.ts +61 -0
  198. package/dist/lib/session/active.js +196 -17
  199. package/dist/lib/session/discover.d.ts +37 -0
  200. package/dist/lib/session/discover.js +204 -27
  201. package/dist/lib/session/inject.d.ts +18 -0
  202. package/dist/lib/session/inject.js +21 -0
  203. package/dist/lib/session/parse.d.ts +6 -0
  204. package/dist/lib/session/parse.js +286 -2
  205. package/dist/lib/session/pid-registry.d.ts +22 -0
  206. package/dist/lib/session/pid-registry.js +104 -0
  207. package/dist/lib/session/provenance.d.ts +14 -2
  208. package/dist/lib/session/provenance.js +39 -8
  209. package/dist/lib/session/remote-active.d.ts +26 -0
  210. package/dist/lib/session/remote-active.js +153 -0
  211. package/dist/lib/session/remote-list.d.ts +51 -0
  212. package/dist/lib/session/remote-list.js +213 -0
  213. package/dist/lib/session/remote.d.ts +7 -1
  214. package/dist/lib/session/remote.js +18 -8
  215. package/dist/lib/session/render.js +1 -1
  216. package/dist/lib/session/sync/config.d.ts +1 -7
  217. package/dist/lib/session/sync/config.js +4 -11
  218. package/dist/lib/session/types.d.ts +18 -1
  219. package/dist/lib/session/types.js +1 -1
  220. package/dist/lib/shims.d.ts +44 -6
  221. package/dist/lib/shims.js +143 -57
  222. package/dist/lib/ssh-exec.d.ts +26 -3
  223. package/dist/lib/ssh-exec.js +47 -3
  224. package/dist/lib/ssh-tunnel.d.ts +24 -5
  225. package/dist/lib/ssh-tunnel.js +62 -63
  226. package/dist/lib/staleness/writers/hooks.js +1 -1
  227. package/dist/lib/startup/command-registry.d.ts +8 -0
  228. package/dist/lib/startup/command-registry.js +17 -1
  229. package/dist/lib/state.d.ts +13 -0
  230. package/dist/lib/state.js +103 -9
  231. package/dist/lib/sync-umbrella.d.ts +14 -7
  232. package/dist/lib/sync-umbrella.js +17 -9
  233. package/dist/lib/teams/forEach.d.ts +110 -0
  234. package/dist/lib/teams/forEach.js +186 -0
  235. package/dist/lib/teams/index.d.ts +1 -0
  236. package/dist/lib/teams/index.js +1 -0
  237. package/dist/lib/teams/pr-watch.d.ts +226 -0
  238. package/dist/lib/teams/pr-watch.js +371 -0
  239. package/dist/lib/teams/registry.js +25 -9
  240. package/dist/lib/teams/supervisor.d.ts +14 -1
  241. package/dist/lib/teams/supervisor.js +19 -0
  242. package/dist/lib/teams/worktree.d.ts +9 -0
  243. package/dist/lib/teams/worktree.js +32 -0
  244. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  245. package/dist/lib/terminal/backends/ghostty.js +69 -0
  246. package/dist/lib/terminal/backends/index.d.ts +19 -0
  247. package/dist/lib/terminal/backends/index.js +31 -0
  248. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  249. package/dist/lib/terminal/backends/iterm.js +62 -0
  250. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  251. package/dist/lib/terminal/backends/tmux.js +23 -0
  252. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  253. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  254. package/dist/lib/terminal/engine.d.ts +39 -0
  255. package/dist/lib/terminal/engine.js +54 -0
  256. package/dist/lib/terminal/index.d.ts +17 -0
  257. package/dist/lib/terminal/index.js +8 -0
  258. package/dist/lib/terminal/inject.d.ts +204 -0
  259. package/dist/lib/terminal/inject.js +247 -0
  260. package/dist/lib/terminal/policy.d.ts +11 -0
  261. package/dist/lib/terminal/policy.js +11 -0
  262. package/dist/lib/terminal/quote.d.ts +11 -0
  263. package/dist/lib/terminal/quote.js +13 -0
  264. package/dist/lib/terminal/resolve.d.ts +64 -0
  265. package/dist/lib/terminal/resolve.js +90 -0
  266. package/dist/lib/terminal/shell.d.ts +6 -0
  267. package/dist/lib/terminal/shell.js +23 -0
  268. package/dist/lib/terminal/transport.d.ts +15 -0
  269. package/dist/lib/terminal/transport.js +40 -0
  270. package/dist/lib/terminal/types.d.ts +59 -0
  271. package/dist/lib/terminal/types.js +13 -0
  272. package/dist/lib/triggers/webhook.d.ts +85 -0
  273. package/dist/lib/triggers/webhook.js +141 -0
  274. package/dist/lib/types.d.ts +7 -0
  275. package/dist/lib/versions.d.ts +30 -3
  276. package/dist/lib/versions.js +129 -18
  277. package/dist/lib/watchdog/index.d.ts +3 -0
  278. package/dist/lib/watchdog/index.js +5 -0
  279. package/dist/lib/watchdog/read.d.ts +35 -0
  280. package/dist/lib/watchdog/read.js +149 -0
  281. package/dist/lib/watchdog/runner.d.ts +127 -0
  282. package/dist/lib/watchdog/runner.js +322 -0
  283. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  284. package/dist/lib/watchdog/watchdog.js +166 -0
  285. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  286. package/dist/lib/watchdog/watchdogTail.js +154 -0
  287. package/dist/lib/whats-new.d.ts +9 -0
  288. package/dist/lib/whats-new.js +35 -0
  289. package/dist/lib/workflows.d.ts +166 -0
  290. package/dist/lib/workflows.js +193 -0
  291. package/package.json +5 -4
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared host-task log viewer — the show-or-follow core behind both
3
+ * `agents hosts logs <id>` and the top-level `agents logs <id>`.
4
+ *
5
+ * A running task with follow re-enters the offset-tail (`followHostTask`);
6
+ * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
+ * place so the two commands can never drift.
8
+ */
9
+ import * as fs from 'fs';
10
+ import chalk from 'chalk';
11
+ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
12
+ import { followHostTask } from './progress.js';
13
+ import { reconcileTask } from './reconcile.js';
14
+ import { sshExecRaw } from '../ssh-exec.js';
15
+ /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
+ export async function showHostTaskLog(id, follow) {
17
+ const task = loadTask(id);
18
+ if (!task)
19
+ return { found: false };
20
+ if (follow && task.status === 'running') {
21
+ const code = await followHostTask(task.target, {
22
+ remoteLog: task.remoteLog,
23
+ remoteExit: task.remoteExit,
24
+ taskId: id,
25
+ echo: true,
26
+ });
27
+ // -1 = follow window closed; the run continues on the host (not a failure,
28
+ // so exit 0). Any real code is the finished run — persist the terminal
29
+ // status the killed dispatch follower would have written.
30
+ if (code === -1)
31
+ return { found: true, exitCode: 0 };
32
+ updateTask(id, terminalPatch(code));
33
+ return { found: true, exitCode: code };
34
+ }
35
+ // Non-follow view: heal a still-'running' record from the remote `.exit` so a
36
+ // plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
37
+ // ssh) once the record is already terminal.
38
+ reconcileTask(task);
39
+ try {
40
+ process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
41
+ }
42
+ catch {
43
+ // No local log — task was dispatched with --no-follow. Fetch from the remote
44
+ // on demand and cache locally so subsequent calls are instant.
45
+ const remote = fetchAndCacheRemoteLog(task);
46
+ if (remote !== null) {
47
+ process.stdout.write(remote);
48
+ }
49
+ else {
50
+ process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
51
+ }
52
+ }
53
+ return { found: true, exitCode: 0 };
54
+ }
55
+ /**
56
+ * Fetch a task's remote log over SSH, write it to the local mirror path (for
57
+ * future instant reads), and return its content. Returns null when the host is
58
+ * unreachable or the remote log is empty/absent.
59
+ *
60
+ * `remoteLog` is a $HOME-prefixed path with a safe (hex) basename — intentionally
61
+ * unquoted so the remote shell expands $HOME, matching the contract in reconcile.ts
62
+ * and progress.ts.
63
+ */
64
+ function fetchAndCacheRemoteLog(task) {
65
+ const res = sshExecRaw(task.target, `cat ${task.remoteLog} 2>/dev/null`, { timeoutMs: 30000, multiplex: true });
66
+ if (res.code !== 0 || res.stdout.length === 0)
67
+ return null;
68
+ // The hosts cache dir already exists (saveTask created it) — write is best-effort.
69
+ try {
70
+ fs.writeFileSync(localLogPath(task.id), res.stdout);
71
+ }
72
+ catch { /* best-effort cache */ }
73
+ return res.stdout;
74
+ }
@@ -12,7 +12,7 @@
12
12
  /** Attach the standard `--host` flag family to a command and return it (chainable). */
13
13
  export function addHostOption(cmd) {
14
14
  return cmd
15
- .option('-H, --host <name>', 'Run this command on a registered host (or user@host) over SSH instead of locally. See `agents hosts`.')
15
+ .option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
16
16
  .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
17
17
  .option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
18
18
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
@@ -22,6 +22,7 @@ import { resolveHost, resolveHostByCap } from './registry.js';
22
22
  import { sshTargetFor } from './types.js';
23
23
  import { dispatchAgentsCommand } from './dispatch.js';
24
24
  import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
+ import { resolveRemoteOsSync } from './remote-os.js';
25
26
  import { machineId } from '../session/sync/config.js';
26
27
  const REMOTE_PASSTHROUGH = {
27
28
  view: {},
@@ -32,6 +33,7 @@ const REMOTE_PASSTHROUGH = {
32
33
  list: {},
33
34
  sync: { nonInteractive: ['--yes'] },
34
35
  teams: {},
36
+ message: {},
35
37
  };
36
38
  /** `--no-tty` is stripped like the routing flags but carries no value. */
37
39
  const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
@@ -130,7 +132,7 @@ export async function maybeRunOnHost(command, allArgs) {
130
132
  }
131
133
  return true;
132
134
  }
133
- const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd);
135
+ const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, resolveRemoteOsSync(host.name));
134
136
  const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
135
137
  if (code === 255) {
136
138
  console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
@@ -5,6 +5,13 @@
5
5
  * a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
6
6
  * a dropped connection resumes from the saved offset) and finish when `.exit`
7
7
  * appears. Rich transcript-parser rendering is a fast-follow.
8
+ *
9
+ * Efficiency: each cycle is a SINGLE ssh round-trip that returns the new log
10
+ * bytes, a per-task sentinel, then the exit-file contents — half the process +
11
+ * handshake cost of the old tail-then-cat pair. It rides the default control
12
+ * socket (multiplex on) that the launch opened, and eases the poll interval off
13
+ * toward `maxPollMs` while the job is idle, so a quiet long-running follow no
14
+ * longer spawns thousands of ssh processes per hour on the laptop.
8
15
  */
9
16
  export interface FollowOptions {
10
17
  remoteLog: string;
@@ -15,7 +22,66 @@ export interface FollowOptions {
15
22
  echo?: boolean;
16
23
  /** Overall wall-clock cap; returns -1 on timeout. */
17
24
  timeoutMs?: number;
25
+ /** Fast poll interval while output is flowing (default 1500ms). */
18
26
  pollMs?: number;
27
+ /** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
28
+ maxPollMs?: number;
19
29
  }
30
+ /**
31
+ * Build the per-task sentinel that separates the log tail from the exit-file
32
+ * contents in one combined fetch. The task id (8 hex chars) makes collision with
33
+ * the agent's own output effectively impossible; callers still split on the LAST
34
+ * occurrence so a token echoed into the log can never be mistaken for the real
35
+ * trailing marker.
36
+ */
37
+ export declare function exitMarker(taskId: string): string;
38
+ /**
39
+ * Split a combined fetch (`<log bytes><marker><exit>`) back into its parts at the
40
+ * BYTE level. Splits on the LAST marker occurrence, so even if the agent's own
41
+ * output happened to echo the token, the real trailing sentinel still wins.
42
+ * Returns null when the marker is absent (a transient fetch miss — the remote
43
+ * shell never ran our printf), telling the caller to retry without advancing.
44
+ *
45
+ * Byte-level (not string) is load-bearing: `logChunk.length` is the EXACT number
46
+ * of log bytes consumed on the wire, which the follow loop adds to its offset. A
47
+ * string split would first UTF-8-decode, turning any multibyte char split at the
48
+ * `tail -c` boundary into a U+FFFD whose re-encoded length ≠ the wire bytes,
49
+ * drifting the offset (see followHostTask). `consumed` is returned explicitly for
50
+ * clarity; it always equals `logChunk.length`.
51
+ */
52
+ export declare function splitProgressBytes(buf: Buffer, taskId: string): {
53
+ logChunk: Buffer;
54
+ exit: Buffer;
55
+ consumed: number;
56
+ } | null;
57
+ /**
58
+ * One round-trip: new log bytes since `offset`, the sentinel, then the exit
59
+ * file. Returns null on a transient fetch miss (ssh error / marker absent) so
60
+ * the caller simply retries next cycle without advancing the offset.
61
+ *
62
+ * `remoteLog`/`remoteExit` are $HOME-prefixed paths with safe (hex) basenames —
63
+ * intentionally unquoted so the remote shell expands $HOME.
64
+ */
65
+ export declare function fetchProgress(target: string, opts: {
66
+ remoteLog: string;
67
+ remoteExit: string;
68
+ taskId: string;
69
+ offset: number;
70
+ }): {
71
+ logChunk: Buffer;
72
+ exit: string;
73
+ } | null;
74
+ /**
75
+ * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
76
+ * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
77
+ */
78
+ export declare function readRemoteFileId(target: string, remotePath: string): string | null;
79
+ /**
80
+ * True when the local mirror file IS the very file we're tailing — the
81
+ * localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
82
+ * resolve to the same inode. Appending our read bytes back into it would feed the
83
+ * tail and multiply the log, so the caller must skip the mirror write.
84
+ */
85
+ export declare function mirrorAliasesSource(localId: string | null, remoteId: string | null): boolean;
20
86
  /** Tail the remote log to stdout until the run finishes; return its exit code. */
21
87
  export declare function followHostTask(target: string, opts: FollowOptions): Promise<number>;
@@ -5,45 +5,153 @@
5
5
  * a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
6
6
  * a dropped connection resumes from the saved offset) and finish when `.exit`
7
7
  * appears. Rich transcript-parser rendering is a fast-follow.
8
+ *
9
+ * Efficiency: each cycle is a SINGLE ssh round-trip that returns the new log
10
+ * bytes, a per-task sentinel, then the exit-file contents — half the process +
11
+ * handshake cost of the old tail-then-cat pair. It rides the default control
12
+ * socket (multiplex on) that the launch opened, and eases the poll interval off
13
+ * toward `maxPollMs` while the job is idle, so a quiet long-running follow no
14
+ * longer spawns thousands of ssh processes per hour on the laptop.
8
15
  */
9
16
  import * as fs from 'fs';
10
- import { sshExec } from '../ssh-exec.js';
17
+ import { sshExec, sshExecRaw } from '../ssh-exec.js';
11
18
  import { localLogPath } from './tasks.js';
12
19
  function sleep(ms) {
13
20
  return new Promise((resolve) => setTimeout(resolve, ms));
14
21
  }
22
+ /**
23
+ * Build the per-task sentinel that separates the log tail from the exit-file
24
+ * contents in one combined fetch. The task id (8 hex chars) makes collision with
25
+ * the agent's own output effectively impossible; callers still split on the LAST
26
+ * occurrence so a token echoed into the log can never be mistaken for the real
27
+ * trailing marker.
28
+ */
29
+ export function exitMarker(taskId) {
30
+ return `\n@@AGENTS_HOST_EXIT_${taskId}@@\n`;
31
+ }
32
+ /**
33
+ * Split a combined fetch (`<log bytes><marker><exit>`) back into its parts at the
34
+ * BYTE level. Splits on the LAST marker occurrence, so even if the agent's own
35
+ * output happened to echo the token, the real trailing sentinel still wins.
36
+ * Returns null when the marker is absent (a transient fetch miss — the remote
37
+ * shell never ran our printf), telling the caller to retry without advancing.
38
+ *
39
+ * Byte-level (not string) is load-bearing: `logChunk.length` is the EXACT number
40
+ * of log bytes consumed on the wire, which the follow loop adds to its offset. A
41
+ * string split would first UTF-8-decode, turning any multibyte char split at the
42
+ * `tail -c` boundary into a U+FFFD whose re-encoded length ≠ the wire bytes,
43
+ * drifting the offset (see followHostTask). `consumed` is returned explicitly for
44
+ * clarity; it always equals `logChunk.length`.
45
+ */
46
+ export function splitProgressBytes(buf, taskId) {
47
+ const marker = Buffer.from(exitMarker(taskId), 'utf8'); // ASCII-only, unambiguous
48
+ const idx = buf.lastIndexOf(marker);
49
+ if (idx === -1)
50
+ return null;
51
+ return {
52
+ logChunk: buf.subarray(0, idx),
53
+ exit: buf.subarray(idx + marker.length),
54
+ consumed: idx,
55
+ };
56
+ }
57
+ /**
58
+ * One round-trip: new log bytes since `offset`, the sentinel, then the exit
59
+ * file. Returns null on a transient fetch miss (ssh error / marker absent) so
60
+ * the caller simply retries next cycle without advancing the offset.
61
+ *
62
+ * `remoteLog`/`remoteExit` are $HOME-prefixed paths with safe (hex) basenames —
63
+ * intentionally unquoted so the remote shell expands $HOME.
64
+ */
65
+ export function fetchProgress(target, opts) {
66
+ // Derive the printf format from the SAME exitMarker the parser splits on, so
67
+ // the emitted sentinel and the one we look for can never desync. The marker's
68
+ // only escape-sensitive bytes are its newlines (→ `\n`); it carries no `%`,
69
+ // single-quote, or other printf/shell-special chars (task id is hex).
70
+ const printfArg = exitMarker(opts.taskId).replace(/\n/g, '\\n');
71
+ const remote = `tail -c +${opts.offset + 1} ${opts.remoteLog} 2>/dev/null; ` +
72
+ `printf '${printfArg}'; ` +
73
+ `cat ${opts.remoteExit} 2>/dev/null`;
74
+ // Raw bytes (no UTF-8 decode): the log tail must be counted and re-emitted
75
+ // byte-for-byte so a multibyte char split at the `tail -c` boundary neither
76
+ // drifts the offset nor renders as U+FFFD. The exit code is pure ASCII → safe
77
+ // to decode to a string for the caller's `.trim()`.
78
+ const res = sshExecRaw(target, remote, { timeoutMs: 20000 });
79
+ const parts = splitProgressBytes(res.stdout, opts.taskId);
80
+ if (!parts)
81
+ return null;
82
+ return { logChunk: parts.logChunk, exit: parts.exit.toString('utf8') };
83
+ }
84
+ /**
85
+ * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
86
+ * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
87
+ */
88
+ export function readRemoteFileId(target, remotePath) {
89
+ const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000 });
90
+ const id = res.stdout.trim();
91
+ return id || null;
92
+ }
93
+ /**
94
+ * True when the local mirror file IS the very file we're tailing — the
95
+ * localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
96
+ * resolve to the same inode. Appending our read bytes back into it would feed the
97
+ * tail and multiply the log, so the caller must skip the mirror write.
98
+ */
99
+ export function mirrorAliasesSource(localId, remoteId) {
100
+ return localId !== null && remoteId !== null && localId === remoteId;
101
+ }
15
102
  /** Tail the remote log to stdout until the run finishes; return its exit code. */
16
103
  export async function followHostTask(target, opts) {
17
- const pollMs = opts.pollMs ?? 1500;
104
+ const fastMs = opts.pollMs ?? 1500;
105
+ const maxMs = Math.max(opts.maxPollMs ?? fastMs * 4, 4000);
18
106
  const deadline = Date.now() + (opts.timeoutMs ?? 3600_000);
19
107
  const local = localLogPath(opts.taskId);
20
108
  let offset = 0;
21
- const drain = () => {
22
- // remoteLog is a $HOME-prefixed path with a safe (hex) basename intentionally
23
- // unquoted so the remote shell expands $HOME.
24
- const chunk = sshExec(target, `tail -c +${offset + 1} ${opts.remoteLog} 2>/dev/null`, { timeoutMs: 20000 });
25
- if (chunk.stdout) {
26
- if (opts.echo)
27
- process.stdout.write(chunk.stdout);
109
+ let waitMs = fastMs;
110
+ // localhost-as-host guard: when the local mirror and the remote log are the
111
+ // same physical file, appending our read bytes back would feed the tail and
112
+ // multiply the log (a plain `--host localhost` follow otherwise tripled it).
113
+ // Detect via file identity and echo-only in that case.
114
+ let mirror = true;
115
+ try {
116
+ const s = fs.statSync(local);
117
+ if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog))) {
118
+ mirror = false;
119
+ }
120
+ }
121
+ catch { /* mirror absent or unstattable → distinct file, keep mirroring */ }
122
+ const flush = (logChunk) => {
123
+ if (logChunk.length === 0)
124
+ return false;
125
+ if (opts.echo)
126
+ process.stdout.write(logChunk);
127
+ if (mirror) {
28
128
  try {
29
- fs.appendFileSync(local, chunk.stdout);
129
+ fs.appendFileSync(local, logChunk);
30
130
  }
31
131
  catch { /* best-effort */ }
32
- offset += Buffer.byteLength(chunk.stdout, 'utf8');
33
132
  }
133
+ offset += logChunk.length; // exact wire bytes — no re-encode drift
134
+ return true;
34
135
  };
35
136
  for (;;) {
36
- drain();
37
- const exit = sshExec(target, `cat ${opts.remoteExit} 2>/dev/null`, { timeoutMs: 12000 });
38
- if (exit.code === 0 && exit.stdout.trim() !== '') {
39
- drain(); // final flush
40
- const code = parseInt(exit.stdout.trim(), 10);
137
+ const r = fetchProgress(target, { remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset });
138
+ const gotOutput = r ? flush(r.logChunk) : false;
139
+ if (r && r.exit.trim() !== '') {
140
+ // Finished — one final fetch catches bytes written between our tail and
141
+ // the exit file appearing.
142
+ const tail = fetchProgress(target, { remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset });
143
+ if (tail)
144
+ flush(tail.logChunk);
145
+ const code = parseInt(r.exit.trim(), 10);
41
146
  return Number.isFinite(code) ? code : 0;
42
147
  }
43
148
  if (Date.now() > deadline) {
44
149
  process.stderr.write('\n[hosts] follow timed out; the run continues on the host. Reattach with: agents hosts logs ' + opts.taskId + ' -f\n');
45
150
  return -1;
46
151
  }
47
- await sleep(pollMs);
152
+ // Fast while output flows; ease toward maxMs when idle so a quiet job isn't
153
+ // polled needlessly. New output snaps the cadence back to fast.
154
+ waitMs = gotOutput ? fastMs : Math.min(maxMs, Math.round(waitMs * 1.5));
155
+ await sleep(waitMs);
48
156
  }
49
157
  }
@@ -8,20 +8,62 @@
8
8
  import type { Host } from './types.js';
9
9
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
10
10
  export declare function localCliVersion(): string | null;
11
- /** uname over ssh reachable + OS string. */
12
- export declare function probeHost(target: string): {
11
+ /** ssh command that confirms reachability and echoes the OS. POSIX boxes answer
12
+ * `uname -s`; a Windows target has no `uname` (ssh lands in cmd.exe/PowerShell),
13
+ * so it runs a tiny PowerShell probe. Pure/exported so both branches are
14
+ * unit-testable without ssh. */
15
+ export declare function buildProbeCommand(os?: string): string;
16
+ /**
17
+ * Reachability + OS probe over ssh. `os` is the caller's hint (device-registry
18
+ * platform / enrolled `HostEntry.os`); when it marks the host Windows we take
19
+ * the PowerShell path and report that known platform, otherwise POSIX + uname.
20
+ */
21
+ export declare function probeHost(target: string, os?: string): {
13
22
  reachable: boolean;
14
23
  os?: string;
15
24
  };
16
- /** Remote agents-cli version (login shell for PATH), or null if not installed. */
17
- export declare function remoteAgentsVersion(target: string): string | null;
18
- /** True if the named agent CLI is installed on the remote (best-effort). */
19
- export declare function remoteAgentInstalled(target: string, agent: string): boolean;
25
+ /** ssh command that prints the remote agents-cli version. Pure/exported. */
26
+ export declare function buildRemoteVersionCommand(os?: string): string;
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;
29
+ /** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
30
+ * PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
31
+ * equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
32
+ export declare function buildBootstrapCommand(spec: string, os?: string): string;
20
33
  /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
21
- export declare function bootstrapAgentsCli(target: string, version: string | null): {
34
+ export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string): {
22
35
  ok: boolean;
23
36
  output: string;
24
37
  };
38
+ export interface ReadyProbe {
39
+ /** ssh connected and the remote login shell ran (our sentinel came back). */
40
+ reachable: boolean;
41
+ /** Remote agents-cli version (no leading `v`), or null if not installed. */
42
+ version: string | null;
43
+ /** Raw `agents view`/`list` output, for installed-agent checks. */
44
+ view: string;
45
+ }
46
+ /**
47
+ * Answer every readiness question in ONE ssh round-trip: reachable? (the login
48
+ * shell ran and echoed our sentinel), agents-cli version, and the installed-agent
49
+ * listing. This replaces three sequential probes (`true` + `agents --version` +
50
+ * `agents view`) — 3 handshakes collapse to 1. Reachability keys off the sentinel
51
+ * rather than the exit code, so a command that ran-but-failed is never mistaken
52
+ * for a dead connection (only ssh's own failure drops the sentinel).
53
+ */
54
+ /**
55
+ * The one-shot readiness command (version + sentinel + agent listing). The
56
+ * Windows branch emits the sentinel with `Write-Output` and branches on
57
+ * `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
58
+ * substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
59
+ * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
60
+ */
61
+ export declare function buildReadyProbeCommand(os?: string): string;
62
+ export declare function readyProbe(target: string, os?: string): ReadyProbe;
63
+ /** Pure parser for `readyProbe` output (unit-tested without ssh). */
64
+ export declare function parseReadyProbe(stdout: string): ReadyProbe;
65
+ /** True if `view` output lists the named agent (word-boundary, case-insensitive). */
66
+ export declare function viewHasAgent(view: string, agent: string): boolean;
25
67
  export interface EnsureReadyOptions {
26
68
  agent: string;
27
69
  /** Throw instead of warn when the agent isn't installed remotely. */
@@ -31,6 +73,8 @@ export interface EnsureReadyOptions {
31
73
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
32
74
  * actionable message otherwise. Agent-not-installed is a warning by default (the
33
75
  * remote `agents run` will surface it); pass requireAgent to make it fatal.
76
+ *
77
+ * One ssh round-trip (`readyProbe`) covers all three checks.
34
78
  */
35
79
  export declare function ensureHostReady(host: Host, opts: EnsureReadyOptions): {
36
80
  warnings: string[];
@@ -8,8 +8,10 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
- import { sshExec, sshReachable, shellQuote } from '../ssh-exec.js';
11
+ import { sshExec, shellQuote } from '../ssh-exec.js';
12
12
  import { sshTargetFor } from './types.js';
13
+ import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote } from './remote-cmd.js';
14
+ import { resolveRemoteOsSync } from './remote-os.js';
13
15
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
14
16
  export function localCliVersion() {
15
17
  let dir = path.dirname(fileURLToPath(import.meta.url));
@@ -30,55 +32,126 @@ export function localCliVersion() {
30
32
  }
31
33
  return null;
32
34
  }
33
- /** uname over ssh reachable + OS string. */
34
- export function probeHost(target) {
35
- const r = sshExec(target, 'uname -s 2>/dev/null || echo unknown', { timeoutMs: 12000 });
35
+ /** ssh command that confirms reachability and echoes the OS. POSIX boxes answer
36
+ * `uname -s`; a Windows target has no `uname` (ssh lands in cmd.exe/PowerShell),
37
+ * so it runs a tiny PowerShell probe. Pure/exported so both branches are
38
+ * unit-testable without ssh. */
39
+ export function buildProbeCommand(os) {
40
+ if (remoteShellFor(os) === 'powershell') {
41
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell('[System.Environment]::OSVersion.Platform.ToString()')}`;
42
+ }
43
+ return 'uname -s 2>/dev/null || echo unknown';
44
+ }
45
+ /**
46
+ * Reachability + OS probe over ssh. `os` is the caller's hint (device-registry
47
+ * platform / enrolled `HostEntry.os`); when it marks the host Windows we take
48
+ * the PowerShell path and report that known platform, otherwise POSIX + uname.
49
+ */
50
+ export function probeHost(target, os) {
51
+ const r = sshExec(target, buildProbeCommand(os), { timeoutMs: 12000 });
36
52
  if (r.code !== 0)
37
53
  return { reachable: false };
38
- const os = r.stdout.trim();
39
- return { reachable: true, os: os && os !== 'unknown' ? os : undefined };
54
+ if (remoteShellFor(os) === 'powershell')
55
+ return { reachable: true, os };
56
+ const uname = r.stdout.trim();
57
+ return { reachable: true, os: uname && uname !== 'unknown' ? uname : undefined };
58
+ }
59
+ /** ssh command that prints the remote agents-cli version. Pure/exported. */
60
+ export function buildRemoteVersionCommand(os) {
61
+ return remoteShellFor(os) === 'powershell'
62
+ ? buildWindowsAgentsCommand({ args: ['--version'] })
63
+ : 'bash -lc "agents --version 2>/dev/null"';
40
64
  }
41
- /** Remote agents-cli version (login shell for PATH), or null if not installed. */
42
- export function remoteAgentsVersion(target) {
43
- const r = sshExec(target, 'bash -lc "agents --version 2>/dev/null"', { timeoutMs: 20000 });
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 });
44
68
  if (r.code !== 0)
45
69
  return null;
46
70
  const v = r.stdout.trim();
47
71
  return v || null;
48
72
  }
49
- /** True if the named agent CLI is installed on the remote (best-effort). */
50
- export function remoteAgentInstalled(target, agent) {
51
- // `agents view` (formerly `agents list`) prints capitalized display names
52
- // (e.g. "Claude"), so match case-insensitively.
53
- const r = sshExec(target, `bash -lc "agents view 2>/dev/null || agents list 2>/dev/null"`, { timeoutMs: 20000 });
54
- if (r.code !== 0)
55
- return false;
56
- return new RegExp(`\\b${agent}\\b`, 'i').test(r.stdout);
57
- }
58
- /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
59
- export function bootstrapAgentsCli(target, version) {
60
- const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
73
+ /** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
74
+ * PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
75
+ * equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
76
+ export function buildBootstrapCommand(spec, os) {
77
+ if (remoteShellFor(os) === 'powershell') {
78
+ const script = `npm install -g ${powershellQuote(spec)} 2>&1 | Select-Object -Last 3; ` +
79
+ `if (-not (Test-Path "$HOME/.agents/.system")) { agents setup 2>&1 | Select-Object -Last 3 }; ` +
80
+ `agents --version`;
81
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
82
+ }
61
83
  const script = `npm install -g ${shellQuote(spec)} 2>&1 | tail -3; ` +
62
84
  `if [ ! -d ~/.agents/.system ]; then agents setup 2>&1 | tail -3 || true; fi; ` +
63
85
  `agents --version`;
64
- const r = sshExec(target, `bash -lc ${shellQuote(script)}`, { timeoutMs: 300000 });
86
+ return `bash -lc ${shellQuote(script)}`;
87
+ }
88
+ /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
89
+ export function bootstrapAgentsCli(target, version, os) {
90
+ const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
91
+ const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000 });
65
92
  return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
66
93
  }
94
+ /** Sentinel splitting the version output from the agent listing in one probe. */
95
+ const READY_MARKER = '@@AGENTS_READY@@';
96
+ /**
97
+ * Answer every readiness question in ONE ssh round-trip: reachable? (the login
98
+ * shell ran and echoed our sentinel), agents-cli version, and the installed-agent
99
+ * listing. This replaces three sequential probes (`true` + `agents --version` +
100
+ * `agents view`) — 3 handshakes collapse to 1. Reachability keys off the sentinel
101
+ * rather than the exit code, so a command that ran-but-failed is never mistaken
102
+ * for a dead connection (only ssh's own failure drops the sentinel).
103
+ */
104
+ /**
105
+ * The one-shot readiness command (version + sentinel + agent listing). The
106
+ * Windows branch emits the sentinel with `Write-Output` and branches on
107
+ * `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
108
+ * substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
109
+ * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
110
+ */
111
+ export function buildReadyProbeCommand(os) {
112
+ if (remoteShellFor(os) === 'powershell') {
113
+ const script = `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
114
+ `agents view 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
115
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
116
+ }
117
+ const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
118
+ `agents view 2>/dev/null || agents list 2>/dev/null`;
119
+ return `bash -lc ${shellQuote(script)}`;
120
+ }
121
+ export function readyProbe(target, os) {
122
+ const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000 });
123
+ return parseReadyProbe(r.stdout);
124
+ }
125
+ /** Pure parser for `readyProbe` output (unit-tested without ssh). */
126
+ export function parseReadyProbe(stdout) {
127
+ const idx = stdout.indexOf(READY_MARKER);
128
+ if (idx === -1)
129
+ return { reachable: false, version: null, view: '' };
130
+ const version = stdout.slice(0, idx).trim().replace(/^v/, '') || null;
131
+ return { reachable: true, version, view: stdout.slice(idx + READY_MARKER.length) };
132
+ }
133
+ /** True if `view` output lists the named agent (word-boundary, case-insensitive). */
134
+ export function viewHasAgent(view, agent) {
135
+ return new RegExp(`\\b${agent}\\b`, 'i').test(view);
136
+ }
67
137
  /**
68
138
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
69
139
  * actionable message otherwise. Agent-not-installed is a warning by default (the
70
140
  * remote `agents run` will surface it); pass requireAgent to make it fatal.
141
+ *
142
+ * One ssh round-trip (`readyProbe`) covers all three checks.
71
143
  */
72
144
  export function ensureHostReady(host, opts) {
73
145
  const target = sshTargetFor(host);
74
- if (!sshReachable(target)) {
146
+ const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name));
147
+ if (!probe.reachable) {
75
148
  throw new Error(`Host "${host.name}" (${target}) is not reachable over SSH. Check it's online and key auth works.`);
76
149
  }
77
- if (!remoteAgentsVersion(target)) {
150
+ if (!probe.version) {
78
151
  throw new Error(`agents-cli is not installed on "${host.name}". Enroll it first: agents hosts add ${host.name} (bootstraps agents-cli).`);
79
152
  }
80
153
  const warnings = [];
81
- if (!remoteAgentInstalled(target, opts.agent)) {
154
+ if (!viewHasAgent(probe.view, opts.agent)) {
82
155
  const msg = `Agent "${opts.agent}" may not be installed on "${host.name}" (remote \`agents add ${opts.agent}\` to install).`;
83
156
  if (opts.requireAgent)
84
157
  throw new Error(msg);