@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
@@ -10,9 +10,11 @@ import chalk from 'chalk';
10
10
  import { setHelpSections } from '../lib/help.js';
11
11
  import { parseLoopInterval } from '../lib/loop.js';
12
12
  import { AGENTS } from '../lib/agents.js';
13
+ import { recordDispatchedRun } from '../lib/audit/log.js';
13
14
  import * as fs from 'fs';
14
15
  import * as path from 'path';
15
16
  import * as os from 'os';
17
+ import { randomUUID } from 'crypto';
16
18
  /** Type guard that narrows a string to a known AgentId. */
17
19
  function isValidAgent(agent) {
18
20
  return agent in AGENTS;
@@ -101,6 +103,100 @@ export function loopExitCode(stoppedBy) {
101
103
  return 1;
102
104
  }
103
105
  }
106
+ /**
107
+ * Drive a workflow's declarative `for_each:` fan-out end-to-end (issue #343).
108
+ *
109
+ * Runs the producer, expands one stage teammate per produced item (respecting
110
+ * `max_items` / `DEFAULT_FOR_EACH_CAP`, surfacing any truncation — never a
111
+ * silent cap), stages them into a fresh team via the existing `runForEach`
112
+ * bridge, then drives the teams supervisor until the DAG drains. When a verify
113
+ * panel is declared, tallies each item's `keep_if` gate from the skeptics'
114
+ * terminal status and logs which items survived.
115
+ *
116
+ * Reuses the teams substrate wholesale — no new orchestration engine. Returns
117
+ * the process exit code (0 on drain, 1 on producer failure / non-drain).
118
+ */
119
+ export async function runWorkflowForEach(spec, opts) {
120
+ const [{ produceItems, runForEach, tallyForEach }, { expandForEach, DEFAULT_FOR_EACH_CAP }, { AgentManager }, { createTeam }, { runSupervisor }, { ALL_AGENT_IDS }] = await Promise.all([
121
+ import('../lib/teams/forEach.js'),
122
+ import('../lib/workflows.js'),
123
+ import('../lib/teams/agents.js'),
124
+ import('../lib/teams/registry.js'),
125
+ import('../lib/teams/supervisor.js'),
126
+ import('../lib/agents.js'),
127
+ ]);
128
+ // The teams substrate spawns teammates as a real harness. When `agent:` names
129
+ // a known harness id, honor it; otherwise fall back to claude (the workflow
130
+ // harness) so a subagent-style name in `agent:` still runs.
131
+ const isHarness = (a) => ALL_AGENT_IDS.includes(a);
132
+ const runSpec = {
133
+ ...spec,
134
+ agent: isHarness(spec.agent) ? spec.agent : 'claude',
135
+ ...(spec.verify
136
+ ? { verify: { ...spec.verify, agent: isHarness(spec.verify.agent) ? spec.verify.agent : 'claude' } }
137
+ : {}),
138
+ };
139
+ if (runSpec.agent !== spec.agent) {
140
+ process.stderr.write(chalk.gray(`[for_each] '${spec.agent}' is not a harness id — staging stage teammates as claude\n`));
141
+ }
142
+ // 1. Producer: run the shell command (or resolve itemsRef) into the item list.
143
+ let items;
144
+ try {
145
+ items = await produceItems(runSpec, { cwd: opts.cwd });
146
+ }
147
+ catch (err) {
148
+ console.error(chalk.red(`[for_each] producer failed: ${err.message}`));
149
+ return 1;
150
+ }
151
+ if (items.length === 0) {
152
+ process.stderr.write(chalk.yellow('[for_each] producer emitted no items — nothing to fan out.\n'));
153
+ return 0;
154
+ }
155
+ // 2. Cap accounting (surfaced, never silent — acceptance criterion in #343).
156
+ const cap = runSpec.max_items ?? DEFAULT_FOR_EACH_CAP;
157
+ const { truncated } = expandForEach(runSpec, items);
158
+ if (truncated > 0) {
159
+ process.stderr.write(chalk.yellow(`[for_each] producer emitted ${items.length} items; capping at ${cap} (${truncated} dropped). Raise \`max_items\` to fan out more.\n`));
160
+ }
161
+ process.stderr.write(chalk.gray(`[for_each] ${Math.min(items.length, cap)} stage teammate(s) from ${items.length} produced item(s)\n`));
162
+ // 3. Stage the expanded teammates into a fresh team via the existing bridge.
163
+ const team = `foreach-${opts.workflowName.replace(/[^a-zA-Z0-9_-]/g, '-')}-${Date.now().toString(36)}`;
164
+ await createTeam(team, { description: `for_each fan-out from workflow '${opts.workflowName}'` });
165
+ const mgr = new AgentManager();
166
+ const { teammates } = await runForEach(mgr, team, runSpec, items, {
167
+ cwd: opts.cwd,
168
+ effort: opts.effort,
169
+ concurrency: runSpec.concurrency,
170
+ });
171
+ // 4. Drive the supervisor until the DAG drains (the same loop `teams start
172
+ // --watch` uses; it absorbs the staged teammates via rescanFromDisk).
173
+ const result = await runSupervisor(mgr, {
174
+ team,
175
+ onWave: (s) => {
176
+ const ts = s.timestamp.slice(11, 19);
177
+ process.stderr.write(`[${ts}] [for_each] wave ${s.wave} launched=${s.launched.length} running=${s.running} pending=${s.pending} done=${s.completed} failed=${s.failed}\n`);
178
+ },
179
+ });
180
+ // 5. keep_if tally: a skeptic that COMPLETED is a keep vote; a failed skeptic
181
+ // votes to drop. Gate each item and report which survived.
182
+ if (runSpec.verify) {
183
+ const loaded = await mgr.listByTask(team);
184
+ const statusByName = new Map(loaded.map((a) => [a.name, a.status]));
185
+ const verdicts = tallyForEach(teammates, (v) => statusByName.get(v.name) === 'completed');
186
+ const kept = verdicts.filter((v) => v.kept);
187
+ process.stderr.write(chalk.gray(`[for_each] keep_if=${runSpec.verify.keep_if}: kept ${kept.length}/${verdicts.length} item(s)\n`));
188
+ for (const v of verdicts) {
189
+ const tag = v.kept ? chalk.green('keep') : chalk.red('drop');
190
+ process.stderr.write(chalk.gray(` [${tag}] ${v.item} (${v.votes.filter(Boolean).length}/${v.votes.length} votes)\n`));
191
+ }
192
+ }
193
+ if (result.stoppedBy === 'drained') {
194
+ process.stderr.write(chalk.green(`[for_each] drained in ${Math.floor(result.elapsed_ms / 1000)}s (${result.waves} waves). Team: ${team}\n`));
195
+ return 0;
196
+ }
197
+ process.stderr.write(chalk.yellow(`[for_each] stopped by ${result.stoppedBy} after ${result.waves} waves. Team: ${team}\n`));
198
+ return 1;
199
+ }
104
200
  /** Register the `agents run <agent> [prompt]` command. */
105
201
  export function registerRunCommand(program) {
106
202
  const runCmd = program
@@ -112,6 +208,8 @@ export function registerRunCommand(program) {
112
208
  .option('--env <key=value>', 'Pass environment variable to the agent (repeatable, e.g., --env DEBUG=1 --env API_KEY=xyz)', (val, prev) => [...prev, val], [])
113
209
  .option('--secrets <bundle>', 'Inject a secrets bundle (repeatable). Values resolve from macOS Keychain at run time. See `agents secrets`.', (val, prev) => [...prev, val], [])
114
210
  .option('--no-auto-secrets', 'Skip auto-injection of secrets declared by a workflow\'s frontmatter `secrets:` field. Has no effect on bare-agent runs.')
211
+ .option('--secrets-keys <keys>', 'Inject only this comma-separated subset of keys from --secrets bundles (e.g. KEY1,KEY2). Missing keys are an error. Applies to all --secrets bundles on this run.')
212
+ .option('--allow-expired', 'Inject secrets even if their expiry date has passed (overrides the pre-run expiry abort).')
115
213
  .option('--cwd <dir>', 'Working directory for the agent (defaults to current directory)')
116
214
  .option('--add-dir <dir>', 'Grant access to an additional directory outside the project (Claude only, repeatable)', (val, prev) => [...prev, val], [])
117
215
  .option('--json', 'Stream events as JSON lines (for parsing by other tools)')
@@ -133,7 +231,8 @@ export function registerRunCommand(program) {
133
231
  .option('--budget <tokens>', 'Loop token hard-cap: stop once cumulative tokens reach this (stoppedBy: budget), enforced outside the agent. Loop only.')
134
232
  .option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
135
233
  .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
136
- .option('--host <name>', 'Offload this run onto a registered agent host over SSH instead of running locally. See `agents hosts`.')
234
+ .option('--host <name>', 'Offload this run onto another machine over SSH instead of running locally — a device, a registered agent host, or user@host. See `agents devices` / `agents hosts`.')
235
+ .option('--device <name>', 'Alias of --host: offload this run onto a registered device (from `agents devices`).')
137
236
  .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
138
237
  .option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
139
238
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.')
@@ -248,10 +347,10 @@ export function registerRunCommand(program) {
248
347
  }
249
348
  // --host/--on/--computer: offload this run onto a registered agent host
250
349
  // over SSH instead of running locally. The three flags are aliases.
251
- const hostGiven = [options.host, options.on, options.computer].filter((v) => !!v);
350
+ const hostGiven = [options.host, options.device, options.on, options.computer].filter((v) => !!v);
252
351
  if (hostGiven.length > 0) {
253
352
  if (new Set(hostGiven).size > 1) {
254
- console.error(chalk.red('Conflicting --host/--on/--computer values — pass just one.'));
353
+ console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
255
354
  process.exit(1);
256
355
  }
257
356
  const hostName = hostGiven[0];
@@ -261,6 +360,10 @@ export function registerRunCommand(program) {
261
360
  }
262
361
  const { resolveHost, resolveHostByCap } = await import('../lib/hosts/registry.js');
263
362
  const { dispatchToHost } = await import('../lib/hosts/dispatch.js');
363
+ const { registerHostSession } = await import('../lib/hosts/session-index.js');
364
+ // A password-auth device throws DeviceOffloadUnsupportedError here; it's
365
+ // printed cleanly by the top-level catch in index.ts (covers every
366
+ // resolveHost caller), so it never falls through to capability routing.
264
367
  let host = await resolveHost(hostName);
265
368
  if (!host) {
266
369
  // Not a host name — try capability routing (e.g. --host gpu). A
@@ -282,19 +385,41 @@ export function registerRunCommand(program) {
282
385
  process.exit(1);
283
386
  }
284
387
  try {
285
- const { exitCode } = await dispatchToHost(host, {
286
- agent: agentSpec.split('@')[0],
388
+ const runAgent = agentSpec.split('@')[0];
389
+ // `--resume [id]`: commander yields the string id, or `true` when the
390
+ // flag is passed bare. A bare resume needs the interactive picker,
391
+ // which can't run over a detached remote dispatch — only forward a
392
+ // concrete id.
393
+ const resumeId = typeof options.resume === 'string' ? options.resume : undefined;
394
+ // Mirror the local path (lib/exec.ts): only Claude accepts a forced
395
+ // `--session-id`. Generating it here lets us register the run in the
396
+ // local index and makes it resumable by that id. On resume the remote
397
+ // session keeps its existing id — don't mint a new one.
398
+ const hostSessionId = runAgent === 'claude' && !resumeId ? randomUUID() : undefined;
399
+ const { task, exitCode } = await dispatchToHost(host, {
400
+ agent: runAgent,
287
401
  prompt,
288
402
  mode: options.mode,
289
403
  model: options.model,
290
404
  remoteCwd: options.remoteCwd,
405
+ sessionId: hostSessionId,
406
+ resume: resumeId,
291
407
  follow: options.follow !== false,
292
408
  });
409
+ // Register the dispatched run in the LOCAL session index so it shows
410
+ // up in `agents sessions` and resolves by id, even though its
411
+ // transcript lives on the host. No-op when no session id was captured.
412
+ registerHostSession(task, { cwd: process.cwd(), prompt });
293
413
  if (options.follow === false) {
294
414
  console.log(chalk.green(`Dispatched to ${host.name}.`) + chalk.gray(' Track: agents hosts ps · Follow: agents hosts logs <id> -f'));
295
415
  process.exit(0);
296
416
  }
297
- process.exit(exitCode === undefined || exitCode === -1 ? 1 : exitCode);
417
+ // -1 = the follow window closed but the run continues on the host (the
418
+ // reattach hint is already printed). That's a detach, not a failure —
419
+ // exit 0. Any real remote code passes through.
420
+ if (exitCode === -1)
421
+ process.exit(0);
422
+ process.exit(exitCode ?? 1);
298
423
  }
299
424
  catch (err) {
300
425
  console.error(chalk.red(err.message));
@@ -377,9 +502,20 @@ export function registerRunCommand(program) {
377
502
  sessionId: cp.sessionId,
378
503
  });
379
504
  process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens\n`));
380
- process.exit(loopExitCode(result.stoppedBy));
505
+ // Governance chokepoint (#347): --resume-checkpoint short-circuits normal
506
+ // dispatch and exits here — record its one audit entry with the agent,
507
+ // version, and cwd reconstructed from the checkpoint.
508
+ const resumeExit = loopExitCode(result.stoppedBy);
509
+ recordDispatchedRun({
510
+ agent: cp.agent,
511
+ version: cp.version ?? 'unknown',
512
+ mode: resumeExec.mode ?? 'auto',
513
+ cwd: resumeExec.cwd ?? process.cwd(),
514
+ exitCode: resumeExit,
515
+ });
516
+ process.exit(resumeExit);
381
517
  }
382
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
518
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
383
519
  import('../lib/exec.js'),
384
520
  import('../lib/agents.js'),
385
521
  import('../lib/profiles.js'),
@@ -400,13 +536,23 @@ export function registerRunCommand(program) {
400
536
  let version = rawVersion || undefined;
401
537
  let profileEnv;
402
538
  let fromProfile = false;
539
+ let profileFallbackModel;
403
540
  let workflowModel;
404
541
  // WORKFLOW.md capability scoping, translated to Claude headless flags below.
405
542
  let workflowToolsRestrict;
406
543
  let workflowMcpConfigPath;
544
+ // Full paths of workflow subagent files THIS run copied into the shared
545
+ // per-agent agents dir. Torn down after the run to restore the shared dir
546
+ // (issue #401), mirroring cleanupWorkflowMcpConfig for the mcp-config.
547
+ const workflowSubagentTargets = [];
407
548
  // WORKFLOW.md `loop:` block (issue #332). When a workflow declares it,
408
549
  // `agents run <workflow>` honors the loop without a --loop flag.
409
550
  let workflowLoop;
551
+ // WORKFLOW.md `for_each:` block (issue #343). When a workflow declares it,
552
+ // `agents run <workflow>` runs the producer, expands one stage teammate per
553
+ // produced item, and drives the teams supervisor to drain — no `teams`
554
+ // subcommands needed.
555
+ let workflowForEach;
410
556
  const cwd = options.cwd ?? process.cwd();
411
557
  if (isValidAgent(rawAgent)) {
412
558
  agent = rawAgent;
@@ -422,6 +568,7 @@ export function registerRunCommand(program) {
422
568
  if (!version)
423
569
  version = resolved.version;
424
570
  profileEnv = resolved.env;
571
+ profileFallbackModel = resolved.fallbackModel;
425
572
  fromProfile = true;
426
573
  process.stderr.write(chalk.gray(`Resolved profile '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
427
574
  }
@@ -443,6 +590,7 @@ export function registerRunCommand(program) {
443
590
  workflowModel = workflowFrontmatter.model.trim();
444
591
  }
445
592
  workflowLoop = workflowFrontmatter?.loop;
593
+ workflowForEach = workflowFrontmatter?.forEach;
446
594
  const resolvedVersion = resolveVersionAlias('claude', version);
447
595
  const versionHome = getVersionHomePath('claude', resolvedVersion ?? getGlobalDefault('claude') ?? '');
448
596
  const claudeAgentsDir = path.join(versionHome, '.claude', 'agents');
@@ -464,6 +612,15 @@ export function registerRunCommand(program) {
464
612
  const allFiles = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.md'));
465
613
  const { allowedStems, missing } = resolveAllowedSubagents(allFiles, allowedAgents);
466
614
  const allowStemSet = new Set(allowedStems);
615
+ // Fail-closed prune (issue #401, follow-up to #324). A prior
616
+ // unrestricted run may have left workflow subagent files that THIS
617
+ // scoped run does not permit; they linger in the shared dir and stay
618
+ // dispatchable. Remove those no-longer-permitted workflow-managed
619
+ // files BEFORE writing the allowed set — never a user's own subagent.
620
+ const pruned = pruneStaleWorkflowSubagents(claudeAgentsDir, allFiles, allowedStems);
621
+ if (pruned.length > 0) {
622
+ process.stderr.write(chalk.gray(`[workflow] pruned ${pruned.length} stale workflow subagent(s) from shared dir: ${pruned.join(', ')}\n`));
623
+ }
467
624
  let copied = 0;
468
625
  let skipped = 0;
469
626
  for (const file of allFiles) {
@@ -472,7 +629,9 @@ export function registerRunCommand(program) {
472
629
  skipped++;
473
630
  continue;
474
631
  }
475
- fs.copyFileSync(path.join(subagentsDir, file), path.join(claudeAgentsDir, file));
632
+ const dest = path.join(claudeAgentsDir, file);
633
+ fs.copyFileSync(path.join(subagentsDir, file), dest);
634
+ workflowSubagentTargets.push(dest);
476
635
  copied++;
477
636
  }
478
637
  if (allowedAgents !== undefined) {
@@ -717,6 +876,10 @@ export function registerRunCommand(program) {
717
876
  }
718
877
  const configuredStrategy = getConfiguredRunStrategy(agent, cwd);
719
878
  const explicitStrategy = options.strategy ? normalizeRunStrategy(options.strategy) : null;
879
+ // Captured from resolveRunVersion below so mid-run rate-limit failover can
880
+ // synthesize a same-agent fallback chain from the other healthy accounts
881
+ // (issue #348). Stays null unless a non-pinned strategy actually rotated.
882
+ let rotationResult = null;
720
883
  if (options.strategy && !explicitStrategy) {
721
884
  console.error(chalk.red(`Invalid strategy: ${options.strategy}. Use ${RUN_STRATEGIES.join(', ')}.`));
722
885
  process.exit(1);
@@ -743,6 +906,7 @@ export function registerRunCommand(program) {
743
906
  const resolved = await resolveRunVersion(agent, strategy, cwd);
744
907
  if (resolved.version) {
745
908
  version = resolved.version;
909
+ rotationResult = resolved.rotation;
746
910
  if (resolved.rotation && !options.quiet) {
747
911
  const banner = formatRotationBanner(resolved.rotation, strategy);
748
912
  process.stderr.write(chalk.gray(banner + '\n'));
@@ -831,11 +995,19 @@ export function registerRunCommand(program) {
831
995
  // Resolve --secrets bundles in flag order. Later bundles override earlier
832
996
  // ones. Any resolution failure (missing keychain item, blocked exec ref)
833
997
  // aborts before spawn so the agent never sees a partial env.
998
+ const secretsKeysSubset = options.secretsKeys
999
+ ? options.secretsKeys.split(',').map((k) => k.trim()).filter(Boolean)
1000
+ : undefined;
834
1001
  let secretsEnv = {};
835
1002
  for (const bundleRef of options.secrets) {
836
1003
  try {
837
1004
  const { bundle: bundleName, host } = splitBundleRef(bundleRef);
838
1005
  if (host) {
1006
+ // Least-privilege flags (--secrets-keys / --allow-expired) do not
1007
+ // yet cross the SSH resolver — silently applying them would inject
1008
+ // the full remote env or an expired key. Fail loud so the user
1009
+ // can drop the flag or resolve locally instead.
1010
+ assertRemoteBundleFlagsUnsupported(bundleName, host, { keys: secretsKeysSubset, allowExpired: options.allowExpired }, { keysFlag: '--secrets-keys', allowExpiredFlag: '--allow-expired' });
839
1011
  // Remote bundle (`bundle@host`): resolve over SSH and inject
840
1012
  // ephemerally — values never touch this machine's keychain or disk.
841
1013
  const target = await resolveSshTarget(host);
@@ -844,7 +1016,11 @@ export function registerRunCommand(program) {
844
1016
  secretsEnv = { ...secretsEnv, ...bundleEnv };
845
1017
  }
846
1018
  else {
847
- const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, { caller: `agent ${agent}` });
1019
+ const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, {
1020
+ caller: `agent ${agent}`,
1021
+ keys: secretsKeysSubset,
1022
+ allowExpired: options.allowExpired,
1023
+ });
848
1024
  const entries = describeBundle(bundle);
849
1025
  const counts = {};
850
1026
  for (const e of entries) {
@@ -940,6 +1116,50 @@ export function registerRunCommand(program) {
940
1116
  fallback.push({ agent: fbAgent, version: resolveVersionAlias(fbAgent, fbVersion || undefined) });
941
1117
  }
942
1118
  }
1119
+ // Profile-declared same-host model swap (issue #325). Inserted BEFORE any
1120
+ // user --fallback entries so a rate limit first tries the cheaper/backup
1121
+ // model on the same provider (auth + base URL preserved via envOverride);
1122
+ // only if THAT still rate-limits do we cascade to a different agent CLI.
1123
+ // Requires a prompt for the same reason --fallback does — headless-only.
1124
+ if (fromProfile && profileFallbackModel && prompt !== undefined && !options.interactive) {
1125
+ fallback.unshift({
1126
+ agent,
1127
+ version,
1128
+ envOverride: { [profileFallbackModel.envKey]: profileFallbackModel.model },
1129
+ });
1130
+ }
1131
+ // Mid-run rate-limit failover (issue #348). When a pre-flight rotation
1132
+ // picked an account and there are OTHER healthy accounts for the same
1133
+ // agent, synthesize a same-agent fallback chain from them so a 429 mid-run
1134
+ // re-dispatches on the next healthy account via the SAME runWithFallback
1135
+ // path (continuing the session via /continue). Because this injects into
1136
+ // the same `fallback` array `--fallback` uses, it must only arm for run
1137
+ // shapes that accept a fallback chain — shouldArmRotationFailover excludes
1138
+ // acp/loop/resume-checkpoint (which reject a non-empty fallback below),
1139
+ // interactive/no-prompt runs, and runs that already have an explicit or
1140
+ // profile fallback. Pinned/single-account runs stay unchanged because
1141
+ // rotationResult is null or rotationFailoverChain returns []. version is
1142
+ // set here because rotationResult is only populated when resolveRunVersion
1143
+ // picked one.
1144
+ if (shouldArmRotationFailover({
1145
+ hasRotation: !!rotationResult,
1146
+ hasVersion: !!version,
1147
+ hasPrompt: prompt !== undefined,
1148
+ explicitFallback: fallback.length > 0,
1149
+ interactive: !!options.interactive,
1150
+ acp: !!options.acp,
1151
+ loop: !!options.loop,
1152
+ resumeCheckpoint: !!options.resumeCheckpoint,
1153
+ })) {
1154
+ const failover = rotationFailoverChain(rotationResult, version);
1155
+ if (failover.length > 0) {
1156
+ fallback.push(...failover);
1157
+ if (!options.quiet) {
1158
+ const accounts = failover.map(f => `${f.agent}@${f.version}`).join(', ');
1159
+ process.stderr.write(chalk.gray(`[agents] rate-limit failover armed: ${accounts}\n`));
1160
+ }
1161
+ }
1162
+ }
943
1163
  if (options.acp) {
944
1164
  if (prompt === undefined) {
945
1165
  console.error(chalk.red('--acp requires a prompt. ACP is a programmatic protocol; interactive TUI sessions still use the native CLI.'));
@@ -963,6 +1183,9 @@ export function registerRunCommand(program) {
963
1183
  mode,
964
1184
  json: options.json ?? false,
965
1185
  });
1186
+ // Governance chokepoint (#347): the --acp path exits here, bypassing
1187
+ // the normal finalize below — record its one audit entry.
1188
+ recordDispatchedRun({ agent, version: defaultVersion ?? 'unknown', mode, cwd, exitCode });
966
1189
  process.exit(exitCode);
967
1190
  }
968
1191
  catch (err) {
@@ -1044,6 +1267,36 @@ export function registerRunCommand(program) {
1044
1267
  // best-effort: nothing actionable if the temp dir is already gone.
1045
1268
  }
1046
1269
  };
1270
+ // Restore the shared per-agent agents dir after the run (issue #401):
1271
+ // remove the workflow subagent files THIS run copied in, so a scoped
1272
+ // workflow never leaves definitions behind for the next, unrelated run to
1273
+ // inherit. Mirrors cleanupWorkflowMcpConfig — tear down only what we made.
1274
+ const cleanupWorkflowSubagents = () => {
1275
+ for (const target of workflowSubagentTargets) {
1276
+ try {
1277
+ fs.rmSync(target, { force: true });
1278
+ }
1279
+ catch {
1280
+ // best-effort: nothing actionable if the file is already gone.
1281
+ }
1282
+ }
1283
+ };
1284
+ // for_each dispatch (issue #343). A workflow that declares `for_each:` is a
1285
+ // declarative dynamic fan-out, not a single-agent run: execute the producer,
1286
+ // expand one stage teammate per produced item (+ optional verify panel),
1287
+ // stage them into a team, then drive the supervisor until the DAG drains.
1288
+ // This is mutually exclusive with the single-agent loop/fallback paths — the
1289
+ // fan-out IS the run.
1290
+ if (workflowForEach) {
1291
+ cleanupWorkflowMcpConfig();
1292
+ cleanupWorkflowSubagents();
1293
+ const exitCode = await runWorkflowForEach(workflowForEach, {
1294
+ workflowName: rawAgent,
1295
+ cwd,
1296
+ effort: options.effort,
1297
+ });
1298
+ process.exit(exitCode);
1299
+ }
1047
1300
  // Loop dispatch (issue #332). Active when --loop is passed OR a workflow
1048
1301
  // declares a `loop:` block. The loop path runs AFTER the #346 pre-flight
1049
1302
  // gate above (which fired once) — the loop's token budget is an ADDITIONAL
@@ -1083,29 +1336,47 @@ export function registerRunCommand(program) {
1083
1336
  version,
1084
1337
  });
1085
1338
  cleanupWorkflowMcpConfig();
1339
+ cleanupWorkflowSubagents();
1086
1340
  process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens (checkpoint: ${path.join(runDir, 'checkpoint.json')})\n`));
1087
- process.exit(loopExitCode(result.stoppedBy));
1341
+ // Governance chokepoint (#347): the --loop path exits here, bypassing
1342
+ // the normal finalize below — record its one audit entry.
1343
+ const loopExit = loopExitCode(result.stoppedBy);
1344
+ recordDispatchedRun({ agent, version: defaultVersion ?? 'unknown', mode, cwd, exitCode: loopExit });
1345
+ process.exit(loopExit);
1088
1346
  }
1089
1347
  catch (err) {
1090
1348
  cleanupWorkflowMcpConfig();
1349
+ cleanupWorkflowSubagents();
1091
1350
  console.error(chalk.red(`Loop failed for ${agent}: ${err.message}`));
1092
1351
  process.exit(1);
1093
1352
  }
1094
1353
  }
1095
1354
  try {
1096
1355
  let exitCode;
1356
+ let ranAgent = agent;
1357
+ let ranVersion = defaultVersion;
1097
1358
  if (fallback.length > 0) {
1098
1359
  // fallback requires a prompt — enforced above, narrow the type here.
1099
- exitCode = await runWithFallback({ ...execOptions, prompt: prompt, fallback });
1360
+ // The sink reports which chain entry actually executed (may differ from
1361
+ // the primary after a rate-limit handoff) so the audit record is honest.
1362
+ const sink = {};
1363
+ exitCode = await runWithFallback({ ...execOptions, prompt: prompt, fallback, dispatchSink: sink });
1364
+ ranAgent = sink.agent ?? agent;
1365
+ ranVersion = sink.version ?? defaultVersion;
1100
1366
  }
1101
1367
  else {
1102
1368
  exitCode = await execAgent(execOptions);
1103
1369
  }
1104
1370
  cleanupWorkflowMcpConfig();
1371
+ cleanupWorkflowSubagents();
1372
+ // Governance chokepoint (#347): every dispatched run finalizes here.
1373
+ // ONE tamper-evident audit record per run — non-fatal by contract.
1374
+ recordDispatchedRun({ agent: ranAgent, version: ranVersion ?? 'unknown', mode, cwd, exitCode });
1105
1375
  process.exit(exitCode);
1106
1376
  }
1107
1377
  catch (err) {
1108
1378
  cleanupWorkflowMcpConfig();
1379
+ cleanupWorkflowSubagents();
1109
1380
  console.error(chalk.red(`Failed to execute ${agent}: ${err.message}`));
1110
1381
  process.exit(1);
1111
1382
  }
@@ -7,15 +7,17 @@
7
7
  * hosts. Dispatch itself is `agents run --host <name>` (see commands/exec.ts).
8
8
  */
9
9
  import chalk from 'chalk';
10
+ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
10
11
  import { checkbox, confirm } from '@inquirer/prompts';
11
12
  import { assertValidSshTarget } from '../lib/ssh-exec.js';
12
13
  import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
13
14
  import { sshTargetFor } from '../lib/hosts/types.js';
14
15
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
15
16
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
16
- import { listTasks, loadTask, localLogPath } from '../lib/hosts/tasks.js';
17
- import { followHostTask } from '../lib/hosts/progress.js';
18
- import * as fs from 'fs';
17
+ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
+ import { listTasks } from '../lib/hosts/tasks.js';
19
+ import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
20
+ import { showHostTaskLog } from '../lib/hosts/logs.js';
19
21
  /** Parse `user@host` or `host` into its pieces. */
20
22
  function parseTarget(target) {
21
23
  const at = target.indexOf('@');
@@ -23,20 +25,25 @@ function parseTarget(target) {
23
25
  return { address: target };
24
26
  return { user: target.slice(0, at), address: target.slice(at + 1) };
25
27
  }
26
- /** Bootstrap/verify agents-cli on a freshly-enrolled host (best-effort, prompts). */
27
- async function maybeBootstrap(target, hostName) {
28
- const probe = probeHost(target);
28
+ /**
29
+ * Bootstrap/verify agents-cli on a freshly-enrolled host (best-effort, prompts).
30
+ * Callers that just probed the host pass the result through to avoid a second,
31
+ * redundant `uname` round-trip; otherwise we probe here.
32
+ */
33
+ async function maybeBootstrap(target, hostName, probe = probeHost(target, resolveRemoteOsSync(hostName))) {
29
34
  if (!probe.reachable) {
30
35
  console.log(chalk.yellow(` Not reachable over SSH yet — skipping bootstrap. Fix key auth, then: agents hosts check ${hostName}`));
31
36
  return;
32
37
  }
33
- const remoteVer = remoteAgentsVersion(target);
38
+ // Prefer the OS the probe just observed; fall back to the registry hint.
39
+ const os = probe.os ?? resolveRemoteOsSync(hostName);
40
+ const remoteVer = remoteAgentsVersion(target, os);
34
41
  const localVer = localCliVersion();
35
42
  if (!remoteVer) {
36
43
  const ok = await confirm({ message: ` agents-cli not found on ${hostName}. Install ${localVer ? `v${localVer}` : 'latest'} now?`, default: true });
37
44
  if (ok) {
38
45
  console.log(chalk.gray(' Installing agents-cli on the host…'));
39
- const r = bootstrapAgentsCli(target, localVer);
46
+ const r = bootstrapAgentsCli(target, localVer, os);
40
47
  console.log(r.ok ? chalk.green(' Installed.') : chalk.red(` Install failed:\n${r.output}`));
41
48
  }
42
49
  return;
@@ -45,7 +52,7 @@ async function maybeBootstrap(target, hostName) {
45
52
  if (localVer && remoteClean !== localVer) {
46
53
  const ok = await confirm({ message: ` ${hostName} has agents-cli ${remoteClean}, you have ${localVer}. Upgrade to match?`, default: false });
47
54
  if (ok) {
48
- const r = bootstrapAgentsCli(target, localVer);
55
+ const r = bootstrapAgentsCli(target, localVer, os);
49
56
  console.log(r.ok ? chalk.green(' Upgraded.') : chalk.red(` Upgrade failed:\n${r.output}`));
50
57
  }
51
58
  }
@@ -75,7 +82,7 @@ async function doAdd(name, target, opts) {
75
82
  await registerHost({ name: c, provider: 'local', source, ...(source === 'inline' ? { address: c } : {}), os: probe.os, caps: opts.cap });
76
83
  console.log(chalk.green(`Enrolled ${c}`) + chalk.gray(` (${source}${probe.os ? `, ${probe.os}` : ''})`));
77
84
  if (opts.enroll !== false)
78
- await maybeBootstrap(c, c);
85
+ await maybeBootstrap(c, c, probe);
79
86
  }
80
87
  return;
81
88
  }
@@ -107,7 +114,7 @@ async function doAdd(name, target, opts) {
107
114
  await registerHost(spec);
108
115
  console.log(chalk.green(`Enrolled ${name}`) + chalk.gray(` (${spec.source}${spec.os ? `, ${spec.os}` : ''}${spec.caps?.length ? `, caps: ${spec.caps.join(',')}` : ''})`));
109
116
  if (opts.enroll !== false)
110
- await maybeBootstrap(sshTarget, name);
117
+ await maybeBootstrap(sshTarget, name, probe);
111
118
  }
112
119
  async function doList(json) {
113
120
  const hosts = await listAllHosts();
@@ -122,8 +129,11 @@ async function doList(json) {
122
129
  console.log(chalk.bold('NAME').padEnd(20) + chalk.bold('SOURCE').padEnd(13) + chalk.bold('TARGET').padEnd(28) + chalk.bold('CAPS'));
123
130
  for (const h of hosts) {
124
131
  const tgt = h.source === 'ssh-config' ? chalk.gray('(ssh-config)') : `${h.user ? h.user + '@' : ''}${h.address ?? ''}`;
132
+ // Cap TARGET at its column so a long user@host can't shove CAPS out of alignment.
133
+ const tgtW = stringWidth(tgt);
134
+ const tgtCol = tgtW > 28 ? truncateToWidth(tgt, 28) : tgt + ' '.repeat(28 - tgtW);
125
135
  const mark = h.enrolled ? '' : chalk.gray(' ·available');
126
- console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgt.padEnd(28) + (h.caps?.join(',') ?? '') + mark);
136
+ console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgtCol + (h.caps?.join(',') ?? '') + mark);
127
137
  }
128
138
  }
129
139
  async function doCheck(name) {
@@ -134,15 +144,16 @@ async function doCheck(name) {
134
144
  return;
135
145
  }
136
146
  const target = sshTargetFor(host);
147
+ const os = host.os ?? resolveRemoteOsSync(name);
137
148
  process.stdout.write(`Probing ${chalk.cyan(name)} (${target})… `);
138
- const probe = probeHost(target);
149
+ const probe = probeHost(target, os);
139
150
  if (!probe.reachable) {
140
151
  console.log(chalk.red('unreachable'));
141
152
  process.exitCode = 1;
142
153
  return;
143
154
  }
144
155
  console.log(chalk.green('reachable') + chalk.gray(probe.os ? ` · ${probe.os}` : ''));
145
- const ver = remoteAgentsVersion(target);
156
+ const ver = remoteAgentsVersion(target, probe.os ?? os);
146
157
  console.log(` agents-cli: ${ver ? chalk.green(ver) : chalk.yellow('not installed')}`);
147
158
  }
148
159
  async function doRemove(name) {
@@ -155,7 +166,10 @@ async function doRemove(name) {
155
166
  console.log(chalk.green(`Removed ${name}`));
156
167
  }
157
168
  async function doPs(json) {
158
- const tasks = listTasks();
169
+ // Heal any 'running' record whose local follower died (dropped connection,
170
+ // laptop sleep) against the remote `.exit` before listing, so `ps` never shows
171
+ // a finished run stuck at 'running'.
172
+ const tasks = reconcileRunningTasks(listTasks());
159
173
  if (json) {
160
174
  console.log(JSON.stringify(tasks, null, 2));
161
175
  return;
@@ -164,30 +178,24 @@ async function doPs(json) {
164
178
  console.log(chalk.gray('No host tasks yet. Dispatch one: agents run <agent> "<task>" --host <name>'));
165
179
  return;
166
180
  }
181
+ const cols = terminalWidth();
167
182
  console.log(chalk.bold('ID').padEnd(11) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
168
183
  for (const t of tasks) {
169
184
  const status = t.status === 'completed' ? chalk.green(t.status) : t.status === 'failed' ? chalk.red(t.status) : chalk.yellow(t.status);
170
- console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + t.prompt.slice(0, 50));
185
+ // Prompt fills the remaining width instead of a fixed 50-char byte slice (98-char rows).
186
+ const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 10 + 11)));
187
+ console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
171
188
  }
172
189
  }
173
190
  async function doLogs(id, follow) {
174
- const task = loadTask(id);
175
- if (!task) {
191
+ const res = await showHostTaskLog(id, follow);
192
+ if (!res.found) {
176
193
  console.log(chalk.red(`Unknown task "${id}".`));
177
194
  process.exitCode = 1;
178
195
  return;
179
196
  }
180
- if (follow && task.status === 'running') {
181
- const code = await followHostTask(task.target, { remoteLog: task.remoteLog, remoteExit: task.remoteExit, taskId: id, echo: true });
182
- process.exitCode = code === -1 ? 1 : code;
183
- return;
184
- }
185
- try {
186
- process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
187
- }
188
- catch {
189
- console.log(chalk.gray('(no local log captured for this task)'));
190
- }
197
+ if (res.exitCode !== undefined)
198
+ process.exitCode = res.exitCode;
191
199
  }
192
200
  /** Register the `agents hosts` command tree. */
193
201
  export function registerHostsCommand(program) {