@phnx-labs/agents-cli 1.20.35 → 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 (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  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.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -4,10 +4,8 @@ import { checkAllClis } from '../lib/teams/agents.js';
4
4
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError } from '../lib/agents.js';
5
5
  import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
6
6
  import { loadManifest, isStale } from '../lib/staleness/index.js';
7
- import { diffVersionCommands, iterCommandsCapableVersions } from '../lib/commands.js';
8
- import { diffVersionSkills, iterSkillsCapableVersions } from '../lib/skills.js';
9
- import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome } from '../lib/hooks.js';
10
7
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
8
+ import { checkSyncStatus, countOrphans } from '../lib/drift.js';
11
9
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
12
10
  import { listCliStatus } from '../lib/cli-resources.js';
13
11
  import { setHelpSections } from '../lib/help.js';
@@ -17,33 +15,6 @@ import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/
17
15
  import * as fs from 'fs';
18
16
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
19
17
  // ─── overview mode (no target) ────────────────────────────────────────────────
20
- // Lines naming exactly what's out of sync for a version, plugins prioritized:
21
- // each divergent plugin gets its own line with specifics (stale mirror version,
22
- // invalid manifest, or the bundled skills/commands missing from the mirror —
23
- // the system-repo plugin content that matters most). Other kinds collapse to
24
- // compact counts so the readout stays scannable.
25
- function divergenceLines(report) {
26
- const lines = [];
27
- for (const p of report.kinds.plugins) {
28
- if (p.status === 'missing')
29
- lines.push(`plugin ${p.name} — not installed`);
30
- else if (p.status === 'diff')
31
- lines.push(`plugin ${p.name} — ${p.detail ?? 'mirror drifted'}`);
32
- }
33
- for (const kind of ['commands', 'skills', 'hooks', 'rules', 'mcp', 'permissions', 'subagents']) {
34
- const rows = report.kinds[kind];
35
- const miss = rows.filter((r) => r.status === 'missing').length;
36
- const dif = rows.filter((r) => r.status === 'diff').length;
37
- const bits = [];
38
- if (miss)
39
- bits.push(`${miss} missing`);
40
- if (dif)
41
- bits.push(`${dif} drifted`);
42
- if (bits.length)
43
- lines.push(`${kind.padEnd(11)} ${bits.join(' · ')}`);
44
- }
45
- return lines;
46
- }
47
18
  function collapseWhitespace(s) {
48
19
  return s.replace(/\s+/g, ' ').trim();
49
20
  }
@@ -78,65 +49,6 @@ function printWrappedLine(prefix, text) {
78
49
  for (const line of wrapLine(prefix, text))
79
50
  console.log(chalk.gray(line));
80
51
  }
81
- function checkSyncStatus(cwd) {
82
- const rows = [];
83
- // Every installed version, not just the default — a stale NON-default version
84
- // (e.g. one you launched from yesterday) is exactly the rot that silently
85
- // serves outdated/invalid resources and that `--fix` now heals. Hiding it here
86
- // is why that class of bug went unnoticed.
87
- for (const agent of ALL_AGENT_IDS) {
88
- const def = getGlobalDefault(agent);
89
- for (const version of listInstalledVersions(agent)) {
90
- const manifest = loadManifest(agent, version);
91
- const status = !manifest
92
- ? 'never-synced'
93
- : isStale(manifest, agent, version, cwd) ? 'stale' : 'fresh';
94
- const row = { agent, version, status, isDefault: version === def };
95
- if (status === 'stale') {
96
- // Resolve the specifics against non-project layers (the global home is
97
- // never reconciled against per-cwd project resources).
98
- const report = diffVersionResources(agent, version, { cwd, excludeProject: true });
99
- const lines = divergenceLines(report);
100
- if (lines.length)
101
- row.divergence = lines;
102
- }
103
- rows.push(row);
104
- }
105
- }
106
- return rows;
107
- }
108
- function countOrphans() {
109
- const byKey = new Map();
110
- const ensure = (agent, version) => {
111
- const key = `${agent}@${version}`;
112
- let row = byKey.get(key);
113
- if (!row) {
114
- row = { agent, version, commands: 0, skills: 0, hooks: 0 };
115
- byKey.set(key, row);
116
- }
117
- return row;
118
- };
119
- for (const { agent, version } of iterCommandsCapableVersions()) {
120
- const diff = diffVersionCommands(agent, version);
121
- if (diff.orphans.length > 0)
122
- ensure(agent, version).commands = diff.orphans.length;
123
- }
124
- for (const { agent, version } of iterSkillsCapableVersions()) {
125
- const diff = diffVersionSkills(agent, version);
126
- if (diff.orphans.length > 0)
127
- ensure(agent, version).skills = diff.orphans.length;
128
- }
129
- // Orphan hooks are scripts in the version home that no agents.yaml/hooks.yaml
130
- // entry registers — so the registrar never wires them to an event and they
131
- // never fire. (Distinct from the source-diff `diffVersionHooks().orphans`,
132
- // which false-flags valid system-sourced registered hooks.)
133
- for (const { agent, version } of iterHooksCapableVersions()) {
134
- const dead = listUnmanagedHooksInVersionHome(agent, version);
135
- if (dead.length > 0)
136
- ensure(agent, version).hooks = dead.length;
137
- }
138
- return Array.from(byKey.values()).filter((r) => r.commands + r.skills + r.hooks > 0);
139
- }
140
52
  function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
141
53
  console.log(chalk.bold('Agent CLIs'));
142
54
  // Show the fleet you actually run — agents that are ready in PATH, plus any
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import chalk from 'chalk';
16
16
  import * as fs from 'fs';
17
- import { query, LOGS_PATH } from '../lib/events.js';
17
+ import { query, getLogsPath } from '../lib/events.js';
18
18
  /** Parse `--since`: relative offsets (30s/5m/2h/7d/4w) or an ISO/absolute date. */
19
19
  function parseSince(s) {
20
20
  const m = s.match(/^(\d+)([smhdw])$/);
@@ -116,7 +116,7 @@ Examples:
116
116
  // query() returns newest-first; print oldest-first so a tail reads naturally.
117
117
  for (const r of records.slice().reverse())
118
118
  console.log(renderRow(r));
119
- console.log(chalk.gray(`\n${records.length} event(s). Log: ${LOGS_PATH}`));
119
+ console.log(chalk.gray(`\n${records.length} event(s). Log: ${getLogsPath()}`));
120
120
  });
121
121
  }
122
122
  /** commander repeatable-option collector. */
@@ -126,7 +126,7 @@ function collect(value, previous) {
126
126
  /** Tail today's event file, printing new lines as they land. */
127
127
  async function followLog() {
128
128
  const today = new Date();
129
- const file = `${LOGS_PATH}/events-${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}.jsonl`;
129
+ const file = `${getLogsPath()}/events-${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}.jsonl`;
130
130
  let offset = 0;
131
131
  try {
132
132
  offset = fs.statSync(file).size;
@@ -6,6 +6,7 @@
6
6
  * injection, and multi-agent fallback chains for rate-limit resilience.
7
7
  */
8
8
  import { type Command } from 'commander';
9
+ import type { ExecEffort } from '../lib/exec.js';
9
10
  /**
10
11
  * Build the LoopConfig the driver consumes from CLI flags and/or a workflow's
11
12
  * `loop:` frontmatter block (issue #332). Returns undefined when neither source
@@ -26,5 +27,23 @@ export declare function buildLoopConfig(flags: {
26
27
  }, workflowLoop?: import('../lib/workflows.js').LoopConfigRaw): import('../lib/loop.js').LoopConfig | undefined;
27
28
  /** Map a loop stop reason to a process exit code. condition-met/max are clean exits. */
28
29
  export declare function loopExitCode(stoppedBy: import('../lib/loop.js').LoopStoppedBy): number;
30
+ /**
31
+ * Drive a workflow's declarative `for_each:` fan-out end-to-end (issue #343).
32
+ *
33
+ * Runs the producer, expands one stage teammate per produced item (respecting
34
+ * `max_items` / `DEFAULT_FOR_EACH_CAP`, surfacing any truncation — never a
35
+ * silent cap), stages them into a fresh team via the existing `runForEach`
36
+ * bridge, then drives the teams supervisor until the DAG drains. When a verify
37
+ * panel is declared, tallies each item's `keep_if` gate from the skeptics'
38
+ * terminal status and logs which items survived.
39
+ *
40
+ * Reuses the teams substrate wholesale — no new orchestration engine. Returns
41
+ * the process exit code (0 on drain, 1 on producer failure / non-drain).
42
+ */
43
+ export declare function runWorkflowForEach(spec: import('../lib/workflows.js').ForEachSpec, opts: {
44
+ workflowName: string;
45
+ cwd: string;
46
+ effort?: ExecEffort;
47
+ }): Promise<number>;
29
48
  /** Register the `agents run <agent> [prompt]` command. */
30
49
  export declare function registerRunCommand(program: Command): void;
@@ -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,14 +385,31 @@ 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);
@@ -382,9 +502,20 @@ export function registerRunCommand(program) {
382
502
  sessionId: cp.sessionId,
383
503
  });
384
504
  process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens\n`));
385
- 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);
386
517
  }
387
- 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([
388
519
  import('../lib/exec.js'),
389
520
  import('../lib/agents.js'),
390
521
  import('../lib/profiles.js'),
@@ -405,13 +536,23 @@ export function registerRunCommand(program) {
405
536
  let version = rawVersion || undefined;
406
537
  let profileEnv;
407
538
  let fromProfile = false;
539
+ let profileFallbackModel;
408
540
  let workflowModel;
409
541
  // WORKFLOW.md capability scoping, translated to Claude headless flags below.
410
542
  let workflowToolsRestrict;
411
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 = [];
412
548
  // WORKFLOW.md `loop:` block (issue #332). When a workflow declares it,
413
549
  // `agents run <workflow>` honors the loop without a --loop flag.
414
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;
415
556
  const cwd = options.cwd ?? process.cwd();
416
557
  if (isValidAgent(rawAgent)) {
417
558
  agent = rawAgent;
@@ -427,6 +568,7 @@ export function registerRunCommand(program) {
427
568
  if (!version)
428
569
  version = resolved.version;
429
570
  profileEnv = resolved.env;
571
+ profileFallbackModel = resolved.fallbackModel;
430
572
  fromProfile = true;
431
573
  process.stderr.write(chalk.gray(`Resolved profile '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
432
574
  }
@@ -448,6 +590,7 @@ export function registerRunCommand(program) {
448
590
  workflowModel = workflowFrontmatter.model.trim();
449
591
  }
450
592
  workflowLoop = workflowFrontmatter?.loop;
593
+ workflowForEach = workflowFrontmatter?.forEach;
451
594
  const resolvedVersion = resolveVersionAlias('claude', version);
452
595
  const versionHome = getVersionHomePath('claude', resolvedVersion ?? getGlobalDefault('claude') ?? '');
453
596
  const claudeAgentsDir = path.join(versionHome, '.claude', 'agents');
@@ -469,6 +612,15 @@ export function registerRunCommand(program) {
469
612
  const allFiles = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.md'));
470
613
  const { allowedStems, missing } = resolveAllowedSubagents(allFiles, allowedAgents);
471
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
+ }
472
624
  let copied = 0;
473
625
  let skipped = 0;
474
626
  for (const file of allFiles) {
@@ -477,7 +629,9 @@ export function registerRunCommand(program) {
477
629
  skipped++;
478
630
  continue;
479
631
  }
480
- 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);
481
635
  copied++;
482
636
  }
483
637
  if (allowedAgents !== undefined) {
@@ -722,6 +876,10 @@ export function registerRunCommand(program) {
722
876
  }
723
877
  const configuredStrategy = getConfiguredRunStrategy(agent, cwd);
724
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;
725
883
  if (options.strategy && !explicitStrategy) {
726
884
  console.error(chalk.red(`Invalid strategy: ${options.strategy}. Use ${RUN_STRATEGIES.join(', ')}.`));
727
885
  process.exit(1);
@@ -748,6 +906,7 @@ export function registerRunCommand(program) {
748
906
  const resolved = await resolveRunVersion(agent, strategy, cwd);
749
907
  if (resolved.version) {
750
908
  version = resolved.version;
909
+ rotationResult = resolved.rotation;
751
910
  if (resolved.rotation && !options.quiet) {
752
911
  const banner = formatRotationBanner(resolved.rotation, strategy);
753
912
  process.stderr.write(chalk.gray(banner + '\n'));
@@ -836,11 +995,19 @@ export function registerRunCommand(program) {
836
995
  // Resolve --secrets bundles in flag order. Later bundles override earlier
837
996
  // ones. Any resolution failure (missing keychain item, blocked exec ref)
838
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;
839
1001
  let secretsEnv = {};
840
1002
  for (const bundleRef of options.secrets) {
841
1003
  try {
842
1004
  const { bundle: bundleName, host } = splitBundleRef(bundleRef);
843
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' });
844
1011
  // Remote bundle (`bundle@host`): resolve over SSH and inject
845
1012
  // ephemerally — values never touch this machine's keychain or disk.
846
1013
  const target = await resolveSshTarget(host);
@@ -849,7 +1016,11 @@ export function registerRunCommand(program) {
849
1016
  secretsEnv = { ...secretsEnv, ...bundleEnv };
850
1017
  }
851
1018
  else {
852
- 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
+ });
853
1024
  const entries = describeBundle(bundle);
854
1025
  const counts = {};
855
1026
  for (const e of entries) {
@@ -945,6 +1116,50 @@ export function registerRunCommand(program) {
945
1116
  fallback.push({ agent: fbAgent, version: resolveVersionAlias(fbAgent, fbVersion || undefined) });
946
1117
  }
947
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
+ }
948
1163
  if (options.acp) {
949
1164
  if (prompt === undefined) {
950
1165
  console.error(chalk.red('--acp requires a prompt. ACP is a programmatic protocol; interactive TUI sessions still use the native CLI.'));
@@ -968,6 +1183,9 @@ export function registerRunCommand(program) {
968
1183
  mode,
969
1184
  json: options.json ?? false,
970
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 });
971
1189
  process.exit(exitCode);
972
1190
  }
973
1191
  catch (err) {
@@ -1049,6 +1267,36 @@ export function registerRunCommand(program) {
1049
1267
  // best-effort: nothing actionable if the temp dir is already gone.
1050
1268
  }
1051
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
+ }
1052
1300
  // Loop dispatch (issue #332). Active when --loop is passed OR a workflow
1053
1301
  // declares a `loop:` block. The loop path runs AFTER the #346 pre-flight
1054
1302
  // gate above (which fired once) — the loop's token budget is an ADDITIONAL
@@ -1088,29 +1336,47 @@ export function registerRunCommand(program) {
1088
1336
  version,
1089
1337
  });
1090
1338
  cleanupWorkflowMcpConfig();
1339
+ cleanupWorkflowSubagents();
1091
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`));
1092
- 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);
1093
1346
  }
1094
1347
  catch (err) {
1095
1348
  cleanupWorkflowMcpConfig();
1349
+ cleanupWorkflowSubagents();
1096
1350
  console.error(chalk.red(`Loop failed for ${agent}: ${err.message}`));
1097
1351
  process.exit(1);
1098
1352
  }
1099
1353
  }
1100
1354
  try {
1101
1355
  let exitCode;
1356
+ let ranAgent = agent;
1357
+ let ranVersion = defaultVersion;
1102
1358
  if (fallback.length > 0) {
1103
1359
  // fallback requires a prompt — enforced above, narrow the type here.
1104
- 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;
1105
1366
  }
1106
1367
  else {
1107
1368
  exitCode = await execAgent(execOptions);
1108
1369
  }
1109
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 });
1110
1375
  process.exit(exitCode);
1111
1376
  }
1112
1377
  catch (err) {
1113
1378
  cleanupWorkflowMcpConfig();
1379
+ cleanupWorkflowSubagents();
1114
1380
  console.error(chalk.red(`Failed to execute ${agent}: ${err.message}`));
1115
1381
  process.exit(1);
1116
1382
  }