@workflow-manager/runner 0.4.0 → 0.5.0

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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  The core ideas:
6
6
 
7
7
  - **Workflow definitions.** A workflow is a list of steps with stable keys, objectives, and `dependsOn` edges (the engine topologically orders them). Each step is one of three kinds: `task` (delegated to an agent), `approval` (a human checkpoint), or `system`. Steps can carry retry policies, timeouts, and validation rules (`human`, `external`, or `none`).
8
- - **Agent adapters.** Each task step picks an adapter that does the actual work: `pi-agent` (the default — drives the `pi` coding agent CLI with a built prompt plus `input.json`/`output.json` envelopes), `claude-code` (pipes a built prompt into the `claude` CLI), `opencode`, `codex` (not yet implemented, mock-routed), and `mock` (deterministic simulator for tests/authoring). Steps can declare skills, MCP endpoints, system prompts, and a model under `taskSpec.init`, which get injected into the agent's prompt or input file.
8
+ - **Agent adapters.** Each task step picks an adapter that does the actual work: `pi-agent` (the default — drives the `pi` coding agent CLI with a built prompt plus `input.json`/`output.json` envelopes), `acp` (connects to any [Agent Client Protocol](https://agentclientprotocol.com) agent over JSON-RPC/stdio), and `mock` (deterministic simulator for tests/authoring). The `claude-code`, `opencode`, and `codex` keys are ACP presets — their original bespoke executors are deprecated behind `taskSpec.payload.legacyExecutor`. Steps can declare skills, MCP endpoints, system prompts, and a model under `taskSpec.init`, which get injected into the agent's prompt, input file, or ACP session.
9
9
  - **The envelope protocol.** Every step execution returns a structured result: an execution status (`SUCCESS`, `FAILED`, `QA_REJECTED`, `YIELD_EXTERNAL`) plus a QA routing action (`PROCEED`, `RETRY_CURRENT`, `ROLLBACK_PREVIOUS`, `RESTART_ALL`). That's what lets the engine retry a step, roll back to the previous one, or restart the whole run based on what the agent reported.
10
10
  - **Human-in-the-loop control.** While a run is active, wfm starts a local HTTP attach API (token-protected, with SSE event streaming), so a waiting step can be resolved either in the terminal prompt or from another shell with `wfm approve` / `wfm resume` / `wfm cancel`.
11
11
  - **A registry.** `wfm publish` / `pull` / `search` / `auth` talk to a Supabase-backed remote registry (the `apps/remote-registry` web app in this repo) for sharing workflows, with skills bundled and SHA-256 verified. Runs also emit opt-in telemetry there.
@@ -0,0 +1,16 @@
1
+ import type { InputEnvelope, OutputEnvelope, StepDefinition, StepExecutionHooks, WorkflowDefinition } from "./types.js";
2
+ interface ResolvedAcpCommand {
3
+ command: string;
4
+ args: string[];
5
+ }
6
+ export declare function normalizeTimeout(value: unknown, fallbackMs?: number): number;
7
+ /**
8
+ * Resolves which ACP agent command to launch for a step. Precedence:
9
+ * payload.acpCommand → WFM_ACP_COMMAND env → preset for payload.acpAgent → preset
10
+ * for the adapter key (claude-code / opencode). Returns null when nothing resolves
11
+ * (e.g. bare `acp` or `codex` without configuration), which keeps the step on mock.
12
+ */
13
+ export declare function resolveAcpCommand(step: StepDefinition, env: NodeJS.ProcessEnv): ResolvedAcpCommand | null;
14
+ export declare function shouldUseRealAcp(step: StepDefinition): boolean;
15
+ export declare function executeAcpStep(step: StepDefinition, input: InputEnvelope, attempt: number, workflow?: WorkflowDefinition, workflowFilePath?: string, hooks?: StepExecutionHooks): Promise<OutputEnvelope>;
16
+ export {};
@@ -0,0 +1,311 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream, } from "@zed-industries/agent-client-protocol";
5
+ import { resolveTaskAdapter } from "./adapters.js";
6
+ import { resolveSkill } from "./skillResolver.js";
7
+ // Best-effort presets so claude-code / opencode / a named agent route through ACP
8
+ // without explicit commands. All are overridable via payload.acpCommand or WFM_ACP_COMMAND.
9
+ const ACP_COMMAND_PRESETS = {
10
+ "claude-code": { command: "claude-code-acp", args: [] },
11
+ opencode: { command: "opencode", args: ["acp"] },
12
+ gemini: { command: "gemini", args: ["--experimental-acp"] },
13
+ };
14
+ const READ_ONLY_TOOL_KINDS = new Set(["read", "search", "fetch", "think"]);
15
+ function asRecord(value) {
16
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
17
+ }
18
+ // node:stream's Writable.toWeb / Readable.toWeb are not implemented in Bun, so we
19
+ // bridge the child process stdio to Web streams ourselves (portable across runtimes).
20
+ function writableToWeb(writable) {
21
+ return new WritableStream({
22
+ write(chunk) {
23
+ return new Promise((resolve, reject) => {
24
+ writable.write(chunk, (err) => (err ? reject(err) : resolve()));
25
+ });
26
+ },
27
+ close() {
28
+ return new Promise((resolve) => writable.end(() => resolve()));
29
+ },
30
+ abort() {
31
+ writable.destroy();
32
+ },
33
+ });
34
+ }
35
+ function readableToWeb(readable) {
36
+ return new ReadableStream({
37
+ start(controller) {
38
+ readable.on("data", (chunk) => {
39
+ controller.enqueue(typeof chunk === "string" ? new TextEncoder().encode(chunk) : Uint8Array.from(chunk));
40
+ });
41
+ readable.on("end", () => {
42
+ try {
43
+ controller.close();
44
+ }
45
+ catch {
46
+ // already closed
47
+ }
48
+ });
49
+ readable.on("error", (err) => controller.error(err));
50
+ },
51
+ cancel() {
52
+ readable.destroy();
53
+ },
54
+ });
55
+ }
56
+ export function normalizeTimeout(value, fallbackMs = 600000) {
57
+ const timeout = Number(value ?? fallbackMs);
58
+ if (!Number.isFinite(timeout) || timeout <= 0)
59
+ return fallbackMs;
60
+ return Math.floor(timeout);
61
+ }
62
+ function stringArg(value) {
63
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
64
+ }
65
+ /**
66
+ * Resolves which ACP agent command to launch for a step. Precedence:
67
+ * payload.acpCommand → WFM_ACP_COMMAND env → preset for payload.acpAgent → preset
68
+ * for the adapter key (claude-code / opencode). Returns null when nothing resolves
69
+ * (e.g. bare `acp` or `codex` without configuration), which keeps the step on mock.
70
+ */
71
+ export function resolveAcpCommand(step, env) {
72
+ const payload = asRecord(step.taskSpec?.payload);
73
+ const payloadArgs = Array.isArray(payload.acpArgs) ? payload.acpArgs.map((arg) => String(arg)) : [];
74
+ const explicit = stringArg(payload.acpCommand) ?? stringArg(env.WFM_ACP_COMMAND);
75
+ if (explicit) {
76
+ return { command: explicit, args: payloadArgs };
77
+ }
78
+ const presetKey = stringArg(payload.acpAgent) ?? resolveTaskAdapter(step.taskSpec?.adapterKey);
79
+ const preset = ACP_COMMAND_PRESETS[presetKey];
80
+ if (preset) {
81
+ return { command: preset.command, args: payloadArgs.length > 0 ? payloadArgs : [...preset.args] };
82
+ }
83
+ return null;
84
+ }
85
+ export function shouldUseRealAcp(step) {
86
+ const payload = asRecord(step.taskSpec?.payload);
87
+ return payload.useRealAdapter === true && resolveAcpCommand(step, process.env) !== null;
88
+ }
89
+ function permissionPolicy(step) {
90
+ const value = asRecord(step.taskSpec?.payload).acpPermissions;
91
+ return value === "deny" || value === "reads-only" ? value : "allow";
92
+ }
93
+ function decidePermission(req, policy) {
94
+ const allow = req.options.find((option) => option.kind === "allow_once") ??
95
+ req.options.find((option) => option.kind === "allow_always");
96
+ const reject = req.options.find((option) => option.kind === "reject_once") ??
97
+ req.options.find((option) => option.kind === "reject_always");
98
+ let chooseAllow;
99
+ if (policy === "allow") {
100
+ chooseAllow = true;
101
+ }
102
+ else if (policy === "deny") {
103
+ chooseAllow = false;
104
+ }
105
+ else {
106
+ const kind = req.toolCall.kind ?? undefined;
107
+ chooseAllow = kind ? READ_ONLY_TOOL_KINDS.has(kind) : false;
108
+ }
109
+ const chosen = chooseAllow ? (allow ?? req.options[0]) : reject;
110
+ if (!chosen) {
111
+ return { outcome: { outcome: "cancelled" } };
112
+ }
113
+ return { outcome: { outcome: "selected", optionId: chosen.optionId } };
114
+ }
115
+ function buildMcpServers(endpoints) {
116
+ const servers = [];
117
+ const skipped = [];
118
+ for (const endpoint of endpoints) {
119
+ if (/^https?:\/\//i.test(endpoint)) {
120
+ servers.push({ type: "http", name: endpoint, url: endpoint, headers: [] });
121
+ }
122
+ else {
123
+ skipped.push(endpoint);
124
+ }
125
+ }
126
+ return { servers, skipped };
127
+ }
128
+ function composePrompt(step, input, workflow, workflowFilePath) {
129
+ const payload = asRecord(step.taskSpec?.payload);
130
+ if (typeof payload.prompt === "string" && payload.prompt.trim()) {
131
+ return payload.prompt;
132
+ }
133
+ const parts = [];
134
+ const systemPrompts = input.priming_configuration.system_prompts;
135
+ if (systemPrompts.length > 0) {
136
+ parts.push(systemPrompts.join("\n"));
137
+ }
138
+ const skills = input.priming_configuration.required_skills;
139
+ if (skills.length > 0) {
140
+ const unresolved = [];
141
+ for (const name of skills) {
142
+ const resolved = workflow && workflowFilePath ? resolveSkill(name, workflow, workflowFilePath) : null;
143
+ if (resolved) {
144
+ parts.push(resolved.content);
145
+ }
146
+ else {
147
+ unresolved.push(name);
148
+ }
149
+ }
150
+ if (unresolved.length > 0) {
151
+ parts.push(`Apply the following skills: ${unresolved.join(", ")}`);
152
+ }
153
+ }
154
+ const globalState = input.global_context.global_state;
155
+ const inputLines = Object.entries(globalState)
156
+ .filter(([, value]) => typeof value === "string" || typeof value === "number")
157
+ .map(([key, value]) => `${key}: ${String(value).replace(/[\n\r]/g, " ")}`);
158
+ if (inputLines.length > 0) {
159
+ parts.push(`Input:\n${inputLines.join("\n")}`);
160
+ }
161
+ parts.push(input.step_context.step_objective);
162
+ const previous = input.step_context.previous_output;
163
+ if (Object.keys(previous).length > 0) {
164
+ parts.push(`Previous step output:\n${JSON.stringify(previous, null, 2)}`);
165
+ }
166
+ const context = input.priming_configuration.context;
167
+ if (typeof context === "string" && context.trim()) {
168
+ parts.push(`Context:\n${context}`);
169
+ }
170
+ else if (context && typeof context === "object") {
171
+ const serialized = JSON.stringify(context, null, 2);
172
+ if (serialized !== "{}") {
173
+ parts.push(`Context:\n${serialized}`);
174
+ }
175
+ }
176
+ return parts.join("\n\n");
177
+ }
178
+ function mapStopReason(stopReason) {
179
+ switch (stopReason) {
180
+ case "end_turn":
181
+ return { status: "SUCCESS", action: "PROCEED", reason: "" };
182
+ case "refusal":
183
+ return { status: "QA_REJECTED", action: "RETRY_CURRENT", reason: "agent refused the turn" };
184
+ case "max_tokens":
185
+ return { status: "FAILED", action: "RETRY_CURRENT", reason: "agent stopped at max tokens" };
186
+ case "max_turn_requests":
187
+ return { status: "FAILED", action: "RETRY_CURRENT", reason: "agent stopped at max turn requests" };
188
+ case "cancelled":
189
+ return { status: "FAILED", action: "PROCEED", reason: "turn cancelled" };
190
+ default:
191
+ return { status: "FAILED", action: "PROCEED", reason: `unknown stop reason: ${stopReason}` };
192
+ }
193
+ }
194
+ export function executeAcpStep(step, input, attempt, workflow, workflowFilePath, hooks) {
195
+ const startedAt = Date.now();
196
+ const adapter = resolveTaskAdapter(step.taskSpec?.adapterKey);
197
+ const payload = asRecord(step.taskSpec?.payload);
198
+ const timeoutMs = normalizeTimeout(payload.timeoutMs);
199
+ const resolved = resolveAcpCommand(step, process.env);
200
+ const makeResult = (status, reason, extra = {}, action = "PROCEED") => ({
201
+ step_id: step.key,
202
+ execution_status: status,
203
+ qa_routing: { action, feedback_reason: reason },
204
+ mutated_payload: {
205
+ stepKey: step.key,
206
+ attempt,
207
+ adapter,
208
+ command: resolved?.command,
209
+ args: resolved?.args,
210
+ ...extra,
211
+ },
212
+ metadata: {
213
+ execution_time_ms: Date.now() - startedAt,
214
+ external_intervention_required: false,
215
+ },
216
+ });
217
+ if (!resolved) {
218
+ return Promise.resolve(makeResult("FAILED", "no ACP agent command could be resolved for this step"));
219
+ }
220
+ const cwd = stringArg(payload.cwd) ?? (workflowFilePath ? path.dirname(path.resolve(workflowFilePath)) : process.cwd());
221
+ const policy = permissionPolicy(step);
222
+ const { servers: mcpServers, skipped } = buildMcpServers(input.priming_configuration.mcp_endpoints);
223
+ if (skipped.length > 0) {
224
+ hooks?.onStderr?.(`[acp] ignoring non-http MCP endpoints (need structured config): ${skipped.join(", ")}\n`);
225
+ }
226
+ let child;
227
+ try {
228
+ child = spawn(resolved.command, resolved.args, { stdio: ["pipe", "pipe", "pipe"], cwd, env: process.env });
229
+ }
230
+ catch (err) {
231
+ return Promise.resolve(makeResult("FAILED", `failed to spawn ${resolved.command}: ${err.message}`));
232
+ }
233
+ hooks?.onStarted?.({ command: resolved.command, args: resolved.args, cwd, timeoutMs });
234
+ child.stderr?.setEncoding("utf-8");
235
+ child.stderr?.on("data", (chunk) => hooks?.onStderr?.(chunk));
236
+ // A ChildProcess "error" (e.g. ENOENT for a missing agent binary) is emitted
237
+ // asynchronously and would crash the process if unhandled.
238
+ const childError = new Promise((_resolve, reject) => {
239
+ child.on("error", (err) => reject(new Error(`agent process error: ${err.message}`)));
240
+ });
241
+ let agentText = "";
242
+ let sessionId;
243
+ let timer;
244
+ const client = {
245
+ async sessionUpdate(params) {
246
+ const update = params.update;
247
+ if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
248
+ agentText += update.content.text;
249
+ hooks?.onStdout?.(update.content.text);
250
+ }
251
+ else if (update.sessionUpdate === "tool_call") {
252
+ hooks?.onStdout?.(`\n[acp tool] ${update.title}\n`);
253
+ }
254
+ },
255
+ async requestPermission(params) {
256
+ return decidePermission(params, policy);
257
+ },
258
+ async readTextFile(params) {
259
+ return { content: fs.readFileSync(params.path, "utf-8") };
260
+ },
261
+ async writeTextFile(params) {
262
+ fs.mkdirSync(path.dirname(params.path), { recursive: true });
263
+ fs.writeFileSync(params.path, params.content, "utf-8");
264
+ return {};
265
+ },
266
+ };
267
+ let conn;
268
+ try {
269
+ const stream = ndJsonStream(writableToWeb(child.stdin), readableToWeb(child.stdout));
270
+ conn = new ClientSideConnection(() => client, stream);
271
+ }
272
+ catch (err) {
273
+ child.kill("SIGKILL");
274
+ return Promise.resolve(makeResult("FAILED", `failed to open ACP connection: ${err.message}`));
275
+ }
276
+ const runTurn = async () => {
277
+ await conn.initialize({
278
+ protocolVersion: PROTOCOL_VERSION,
279
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
280
+ });
281
+ const authMethod = stringArg(payload.acpAuthMethod) ?? stringArg(process.env.WFM_ACP_AUTH_METHOD);
282
+ if (authMethod) {
283
+ await conn.authenticate({ methodId: authMethod });
284
+ }
285
+ const session = await conn.newSession({ cwd, mcpServers });
286
+ sessionId = session.sessionId;
287
+ const prompt = [{ type: "text", text: composePrompt(step, input, workflow, workflowFilePath) }];
288
+ const response = await conn.prompt({ sessionId: session.sessionId, prompt });
289
+ const mapped = mapStopReason(response.stopReason);
290
+ return makeResult(mapped.status, mapped.reason, { stopReason: response.stopReason, output: agentText.trim() }, mapped.action);
291
+ };
292
+ const timeout = new Promise((resolve) => {
293
+ timer = setTimeout(() => {
294
+ if (sessionId) {
295
+ conn.cancel({ sessionId }).catch(() => undefined);
296
+ }
297
+ resolve(makeResult("FAILED", `timed out after ${timeoutMs}ms`, { stopReason: "timeout", output: agentText.trim() }));
298
+ }, timeoutMs);
299
+ });
300
+ return Promise.race([runTurn(), timeout, childError])
301
+ .catch((err) => makeResult("FAILED", `ACP turn failed: ${err.message}`, { output: agentText.trim() }))
302
+ .then((result) => {
303
+ hooks?.onFinished?.({ executionStatus: result.execution_status });
304
+ return result;
305
+ })
306
+ .finally(() => {
307
+ if (timer)
308
+ clearTimeout(timer);
309
+ child.kill("SIGTERM");
310
+ });
311
+ }
package/dist/adapters.js CHANGED
@@ -5,6 +5,7 @@ export const SUPPORTED_ADAPTERS = [
5
5
  "opencode",
6
6
  "codex",
7
7
  "claude-code",
8
+ "acp",
8
9
  ];
9
10
  export function resolveTaskAdapter(adapter) {
10
11
  return adapter ?? DEFAULT_TASK_ADAPTER;
package/dist/engine.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createInterface } from "node:readline";
3
+ import { executeAcpStep, shouldUseRealAcp } from "./acpExecutor.js";
3
4
  import { resolveTaskAdapter } from "./adapters.js";
4
5
  import { EventLog } from "./events.js";
5
6
  import { executeClaudeCodeStep, shouldUseRealClaudeCode } from "./claudeCodeExecutor.js";
6
7
  import { executeMockStep } from "./mockExecutor.js";
7
8
  import { executeOpencodeStep, shouldUseRealOpencode } from "./opencodeExecutor.js";
8
9
  import { executePiAgentStep } from "./piAgentExecutor.js";
9
- import { validateRuntimeRequirements } from "./runtimePreflight.js";
10
+ import { adapterMockFallbackReason, validateRuntimeRequirements } from "./runtimePreflight.js";
10
11
  function nodeType(step) {
11
12
  if (step.kind === "approval")
12
13
  return "HUMAN";
@@ -296,12 +297,25 @@ async function executeStep(step, input, attempt, workflow, workflowFilePath, hoo
296
297
  if (adapterKey === "pi-agent") {
297
298
  return executePiAgentStep(step, input, attempt, workflow, workflowFilePath, hooks);
298
299
  }
299
- if (adapterKey === "opencode" && shouldUseRealOpencode(step)) {
300
+ // Legacy escape hatch: the bespoke claude-code / opencode subprocess executors are
301
+ // deprecated in favor of ACP, but stay reachable via payload.legacyExecutor so existing
302
+ // real-integration users are not stranded.
303
+ const legacyExecutor = step.taskSpec?.payload?.legacyExecutor === true;
304
+ if (legacyExecutor && adapterKey === "opencode" && shouldUseRealOpencode(step)) {
300
305
  return executeOpencodeStep(step, input, attempt, hooks);
301
306
  }
302
- if (adapterKey === "claude-code" && shouldUseRealClaudeCode(step)) {
307
+ if (legacyExecutor && adapterKey === "claude-code" && shouldUseRealClaudeCode(step)) {
303
308
  return executeClaudeCodeStep(step, input, attempt, workflow, workflowFilePath, hooks);
304
309
  }
310
+ // All non-pi agents run through ACP.
311
+ const acpRoutable = adapterKey === "acp" || adapterKey === "claude-code" || adapterKey === "opencode" || adapterKey === "codex";
312
+ if (acpRoutable && shouldUseRealAcp(step)) {
313
+ return executeAcpStep(step, input, attempt, workflow, workflowFilePath, hooks);
314
+ }
315
+ const fallbackReason = adapterMockFallbackReason(step);
316
+ if (fallbackReason) {
317
+ hooks?.onStderr?.(`[wfm] ${step.key}: ${fallbackReason}\n`);
318
+ }
305
319
  return executeMockStep(step, input, attempt, hooks);
306
320
  }
307
321
  export async function runWorkflow(definition, options) {
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { MAN_PAGE_SOURCE } from "./manPage.js";
14
14
  import { promptForApprovalDecision, runWorkflow } from "./engine.js";
15
15
  import { cmdAuth, cmdPublish, cmdPull, cmdRemoteInfo, cmdSearch } from "./remote/commands.js";
16
16
  import { emitRunTelemetryBestEffort } from "./remote/telemetry.js";
17
- import { adapterImplementationStatuses, runtimeDoctorChecks, validateRuntimeRequirements, } from "./runtimePreflight.js";
17
+ import { adapterImplementationStatuses, adapterMockFallbackWarnings, runtimeDoctorChecks, validateRuntimeRequirements, } from "./runtimePreflight.js";
18
18
  function cliDisplayName() {
19
19
  const invokedAs = path.basename(process.argv[1] ?? "");
20
20
  if (invokedAs === "workflow-manager" || invokedAs === "wfm") {
@@ -159,6 +159,18 @@ const WORKFLOW_SCAFFOLD_JSON = {
159
159
  payload: { mockResult: "success" },
160
160
  },
161
161
  },
162
+ {
163
+ key: "acp_review",
164
+ kind: "task",
165
+ objective: "Example ACP step: set useRealAdapter + acpAgent to run a real agent; mocks otherwise",
166
+ dependsOn: ["hardening"],
167
+ validation: { mode: "none", required: false, autoConfirm: true },
168
+ taskSpec: {
169
+ adapterKey: "acp",
170
+ init: { systemPrompts: ["Review the implementation"] },
171
+ payload: { mockResult: "success" },
172
+ },
173
+ },
162
174
  ],
163
175
  };
164
176
  const WORKFLOW_SCAFFOLD_MARKDOWN = `---
@@ -244,6 +256,20 @@ steps:
244
256
  systemPrompts: [Prioritize correctness and readability]
245
257
  payload:
246
258
  mockResult: success
259
+ - key: acp_review
260
+ kind: task
261
+ objective: "Example ACP step: set useRealAdapter + acpAgent to run a real agent; mocks otherwise"
262
+ dependsOn: [hardening]
263
+ validation:
264
+ mode: none
265
+ required: false
266
+ autoConfirm: true
267
+ taskSpec:
268
+ adapterKey: acp
269
+ init:
270
+ systemPrompts: [Review the implementation]
271
+ payload:
272
+ mockResult: success
247
273
  ---
248
274
 
249
275
  # Workflow Notes
@@ -486,12 +512,14 @@ function cmdDoctor(args) {
486
512
  let workflow = null;
487
513
  let workflowErrors = [];
488
514
  let runtimeErrors = [];
515
+ let adapterWarnings = [];
489
516
  if (workflowPath) {
490
517
  try {
491
518
  workflow = parseWorkflowFile(path.resolve(workflowPath));
492
519
  workflowErrors = validateWorkflow(workflow);
493
520
  if (workflowErrors.length === 0) {
494
521
  runtimeErrors = validateRuntimeRequirements(workflow);
522
+ adapterWarnings = adapterMockFallbackWarnings(workflow);
495
523
  }
496
524
  }
497
525
  catch (err) {
@@ -515,6 +543,7 @@ function cmdDoctor(args) {
515
543
  title: workflow.title,
516
544
  errors: workflowErrors,
517
545
  runtimeErrors,
546
+ adapterWarnings,
518
547
  }
519
548
  : null,
520
549
  baselineErrors,
@@ -548,6 +577,12 @@ function cmdDoctor(args) {
548
577
  else {
549
578
  console.log("- OK workflow schema and runtime requirements");
550
579
  }
580
+ if (adapterWarnings.length > 0) {
581
+ console.log("\nAdapter warnings:");
582
+ for (const warning of adapterWarnings) {
583
+ console.log(`- ${warning.stepKey}: ${warning.message}`);
584
+ }
585
+ }
551
586
  }
552
587
  return exitCode;
553
588
  }
@@ -629,6 +664,9 @@ async function cmdRun(filePath) {
629
664
  });
630
665
  return 1;
631
666
  }
667
+ for (const warning of adapterMockFallbackWarnings(workflow)) {
668
+ process.stderr.write(`⚠ ${warning.stepKey}: ${warning.message}\n`);
669
+ }
632
670
  const objective = getFlag("--objective");
633
671
  const inputRaw = getFlag("--input");
634
672
  let input = {};
@@ -1,4 +1,4 @@
1
- import type { AdapterKey, WorkflowDefinition } from "./types.js";
1
+ import type { AdapterKey, StepDefinition, WorkflowDefinition } from "./types.js";
2
2
  export interface RuntimeDoctorCheck {
3
3
  key: string;
4
4
  label: string;
@@ -13,4 +13,19 @@ export interface AdapterImplementationStatus {
13
13
  }
14
14
  export declare function validateRuntimeRequirements(definition: WorkflowDefinition, env?: NodeJS.ProcessEnv): string[];
15
15
  export declare function runtimeDoctorChecks(env?: NodeJS.ProcessEnv): RuntimeDoctorCheck[];
16
+ export interface AdapterMockFallbackWarning {
17
+ stepKey: string;
18
+ adapter: AdapterKey;
19
+ message: string;
20
+ }
21
+ /**
22
+ * Detects a step that explicitly selects a non-pi adapter whose real execution
23
+ * path is not enabled, so the engine will route it to the mock executor. Non-pi
24
+ * agents run through ACP, which needs `useRealAdapter: true` plus a resolvable
25
+ * agent command. Returns a message explaining the gap, or null when the step
26
+ * will run as the user expects (default pi-agent, an enabled ACP/legacy path, or
27
+ * an intentional mock selection).
28
+ */
29
+ export declare function adapterMockFallbackReason(step: StepDefinition): string | null;
30
+ export declare function adapterMockFallbackWarnings(definition: WorkflowDefinition): AdapterMockFallbackWarning[];
16
31
  export declare function adapterImplementationStatuses(): AdapterImplementationStatus[];
@@ -1,9 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveAcpCommand, shouldUseRealAcp } from "./acpExecutor.js";
3
4
  import { resolveTaskAdapter } from "./adapters.js";
4
5
  import { shouldUseRealClaudeCode } from "./claudeCodeExecutor.js";
5
6
  import { shouldUseRealOpencode } from "./opencodeExecutor.js";
6
7
  import { DEFAULT_PI_COMMAND } from "./piAgentExecutor.js";
8
+ const ACP_ROUTABLE_ADAPTERS = new Set(["acp", "claude-code", "opencode", "codex"]);
9
+ function legacyExecutorEnabled(step) {
10
+ return asRecord(step.taskSpec?.payload).legacyExecutor === true;
11
+ }
7
12
  function asRecord(value) {
8
13
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
9
14
  }
@@ -98,12 +103,23 @@ function runtimeRequirement(step, env) {
98
103
  // explicitly declared env vars are enforced for pi steps.
99
104
  return { stepKey: step.key, adapter, command: piAgentCommand(step, env), envVars: explicitRequiredEnv(step) };
100
105
  }
101
- if (adapter === "opencode" && shouldUseRealOpencode(step)) {
106
+ const legacy = legacyExecutorEnabled(step);
107
+ if (legacy && adapter === "opencode" && shouldUseRealOpencode(step)) {
102
108
  return { stepKey: step.key, adapter, command: "opencode", envVars };
103
109
  }
104
- if (adapter === "claude-code" && shouldUseRealClaudeCode(step)) {
110
+ if (legacy && adapter === "claude-code" && shouldUseRealClaudeCode(step)) {
105
111
  return { stepKey: step.key, adapter, command: "claude", envVars };
106
112
  }
113
+ if (shouldUseRealAcp(step)) {
114
+ // ACP agents manage their own credentials (like pi), so only explicitly declared
115
+ // env vars are enforced; the resolved agent command must exist on the host.
116
+ return {
117
+ stepKey: step.key,
118
+ adapter,
119
+ command: resolveAcpCommand(step, env)?.command,
120
+ envVars: explicitRequiredEnv(step),
121
+ };
122
+ }
107
123
  if (envVars.length > 0 && adapter !== "mock") {
108
124
  return { stepKey: step.key, adapter, envVars };
109
125
  }
@@ -149,15 +165,74 @@ function envCheck(key, label, envVar, env) {
149
165
  }
150
166
  export function runtimeDoctorChecks(env = process.env) {
151
167
  const piAgentStep = { key: "pi-agent", kind: "task", taskSpec: {} };
168
+ const acpCommand = env.WFM_ACP_COMMAND?.trim();
169
+ const acpCheck = acpCommand
170
+ ? commandCheck("acp", "ACP agent command", acpCommand, false, env)
171
+ : {
172
+ key: "acp",
173
+ label: "ACP agent command",
174
+ status: "info",
175
+ required: false,
176
+ detail: "configured per step (payload.acpCommand / acpAgent) or via WFM_ACP_COMMAND",
177
+ };
152
178
  return [
153
179
  commandCheck("pi-agent", "Pi command", piAgentCommand(piAgentStep, env), true, env),
154
- commandCheck("opencode", "OpenCode command", "opencode", false, env),
155
- commandCheck("claude", "Claude Code command", "claude", false, env),
180
+ acpCheck,
181
+ commandCheck("opencode", "OpenCode command (legacy)", "opencode", false, env),
182
+ commandCheck("claude", "Claude Code command (legacy)", "claude", false, env),
156
183
  envCheck("openrouter-key", "OpenRouter API key", "OPENROUTER_API_KEY", env),
157
184
  envCheck("openai-key", "OpenAI API key", "OPENAI_API_KEY", env),
158
185
  envCheck("anthropic-key", "Anthropic API key", "ANTHROPIC_API_KEY", env),
159
186
  ];
160
187
  }
188
+ /**
189
+ * Detects a step that explicitly selects a non-pi adapter whose real execution
190
+ * path is not enabled, so the engine will route it to the mock executor. Non-pi
191
+ * agents run through ACP, which needs `useRealAdapter: true` plus a resolvable
192
+ * agent command. Returns a message explaining the gap, or null when the step
193
+ * will run as the user expects (default pi-agent, an enabled ACP/legacy path, or
194
+ * an intentional mock selection).
195
+ */
196
+ export function adapterMockFallbackReason(step) {
197
+ if (step.kind !== "task" || !step.taskSpec?.adapterKey) {
198
+ return null;
199
+ }
200
+ const adapter = resolveTaskAdapter(step.taskSpec.adapterKey);
201
+ if (!ACP_ROUTABLE_ADAPTERS.has(adapter)) {
202
+ return null;
203
+ }
204
+ const legacy = legacyExecutorEnabled(step);
205
+ if (legacy && adapter === "opencode" && shouldUseRealOpencode(step)) {
206
+ return null;
207
+ }
208
+ if (legacy && adapter === "claude-code" && shouldUseRealClaudeCode(step)) {
209
+ return null;
210
+ }
211
+ if (shouldUseRealAcp(step)) {
212
+ return null;
213
+ }
214
+ if (asRecord(step.taskSpec?.payload).useRealAdapter === true) {
215
+ // The user opted into a real run but no ACP agent command could be resolved.
216
+ return `adapterKey '${adapter}' has useRealAdapter set, but no ACP agent command could be resolved, so the step runs as a mock. Set taskSpec.payload.acpCommand or acpAgent (or WFM_ACP_COMMAND) to run it through ACP.`;
217
+ }
218
+ if (resolveAcpCommand(step, process.env) !== null) {
219
+ // A concrete agent is named (a preset like claude-code/opencode, or an explicit
220
+ // command) but useRealAdapter is off, so the step still mocks.
221
+ return `adapterKey '${adapter}' is set, but useRealAdapter is not enabled, so the step runs as a mock. Set taskSpec.payload.useRealAdapter: true to run it through ACP.`;
222
+ }
223
+ // Bare acp/codex with no agent configured is treated as an intentional mock.
224
+ return null;
225
+ }
226
+ export function adapterMockFallbackWarnings(definition) {
227
+ const warnings = [];
228
+ for (const step of definition.steps) {
229
+ const message = adapterMockFallbackReason(step);
230
+ if (message) {
231
+ warnings.push({ stepKey: step.key, adapter: resolveTaskAdapter(step.taskSpec?.adapterKey), message });
232
+ }
233
+ }
234
+ return warnings;
235
+ }
161
236
  export function adapterImplementationStatuses() {
162
237
  return [
163
238
  {
@@ -165,6 +240,11 @@ export function adapterImplementationStatuses() {
165
240
  status: "real",
166
241
  detail: "default host-backed adapter driving the pi coding agent CLI",
167
242
  },
243
+ {
244
+ adapter: "acp",
245
+ status: "real",
246
+ detail: "Agent Client Protocol adapter; runs any ACP agent (via acpCommand/acpAgent) when useRealAdapter is true",
247
+ },
168
248
  {
169
249
  adapter: "mock",
170
250
  status: "mock",
@@ -173,17 +253,17 @@ export function adapterImplementationStatuses() {
173
253
  {
174
254
  adapter: "opencode",
175
255
  status: "partial",
176
- detail: "mock-routed by default; real host smoke path only when useRealAdapter and opencodeSmokeTest are true",
256
+ detail: "routed through ACP when useRealAdapter is true; bespoke executor deprecated (payload.legacyExecutor)",
177
257
  },
178
258
  {
179
259
  adapter: "codex",
180
- status: "mock",
181
- detail: "currently mock-routed; real Codex executor is not implemented yet",
260
+ status: "partial",
261
+ detail: "routed through ACP when useRealAdapter and an acpCommand/acpAgent are set; otherwise mock",
182
262
  },
183
263
  {
184
264
  adapter: "claude-code",
185
265
  status: "partial",
186
- detail: "mock-routed by default; real host CLI path only when useRealAdapter is true",
266
+ detail: "routed through ACP when useRealAdapter is true; bespoke executor deprecated (payload.legacyExecutor)",
187
267
  },
188
268
  ];
189
269
  }
package/dist/types.d.ts CHANGED
@@ -5,7 +5,7 @@ export type NodeType = "AGENT" | "HUMAN" | "SYSTEM";
5
5
  export type ExecutionStatus = "SUCCESS" | "QA_REJECTED" | "YIELD_EXTERNAL" | "FAILED";
6
6
  export type QaAction = "PROCEED" | "RETRY_CURRENT" | "ROLLBACK_PREVIOUS" | "RESTART_ALL";
7
7
  export type ValidationMode = "none" | "human" | "external";
8
- export type AdapterKey = "pi-agent" | "mock" | "opencode" | "codex" | "claude-code";
8
+ export type AdapterKey = "pi-agent" | "mock" | "opencode" | "codex" | "claude-code" | "acp";
9
9
  export interface RetryPolicy {
10
10
  maxAttempts?: number;
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workflow-manager/runner",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,8 +33,8 @@
33
33
  "lint:fix": "biome lint --write --error-on-warnings",
34
34
  "lint:staged": "lint-staged --relative --concurrent false",
35
35
  "man": "man ./man/wfm.1",
36
- "test:unit": "bun test tests/parser.test.ts tests/engine.test.ts tests/runtimePreflight.test.ts tests/mockExecutor.test.ts tests/opencodeExecutor.test.ts tests/claudeCodeExecutor.test.ts tests/piAgentExecutor.test.ts tests/skillResolver.test.ts tests/publish-bundle.test.ts tests/remote.test.ts tests/installer.test.ts tests/run-telemetry.test.ts tests/runnerApi.test.ts tests/runnerCli.test.ts tests/supabase-functions.test.ts tests/supabase-ops.test.ts tests/remote-registry-app.test.ts",
37
- "test:release": "bun test tests/parser.test.ts tests/engine.test.ts tests/runtimePreflight.test.ts tests/mockExecutor.test.ts tests/opencodeExecutor.test.ts tests/claudeCodeExecutor.test.ts tests/piAgentExecutor.test.ts tests/skillResolver.test.ts tests/publish-bundle.test.ts tests/remote.test.ts tests/run-telemetry.test.ts tests/runnerApi.test.ts tests/runnerCli.test.ts tests/supabase-functions.test.ts tests/supabase-ops.test.ts tests/remote-registry-app.test.ts tests/story-workflow.e2e.test.ts tests/opencode-real.e2e.test.ts",
36
+ "test:unit": "bun test tests/parser.test.ts tests/engine.test.ts tests/runtimePreflight.test.ts tests/mockExecutor.test.ts tests/opencodeExecutor.test.ts tests/claudeCodeExecutor.test.ts tests/acpExecutor.test.ts tests/piAgentExecutor.test.ts tests/skillResolver.test.ts tests/publish-bundle.test.ts tests/remote.test.ts tests/installer.test.ts tests/run-telemetry.test.ts tests/runnerApi.test.ts tests/runnerCli.test.ts tests/supabase-functions.test.ts tests/supabase-ops.test.ts tests/remote-registry-app.test.ts",
37
+ "test:release": "bun test tests/parser.test.ts tests/engine.test.ts tests/runtimePreflight.test.ts tests/mockExecutor.test.ts tests/opencodeExecutor.test.ts tests/claudeCodeExecutor.test.ts tests/acpExecutor.test.ts tests/piAgentExecutor.test.ts tests/skillResolver.test.ts tests/publish-bundle.test.ts tests/remote.test.ts tests/run-telemetry.test.ts tests/runnerApi.test.ts tests/runnerCli.test.ts tests/supabase-functions.test.ts tests/supabase-ops.test.ts tests/remote-registry-app.test.ts tests/story-workflow.e2e.test.ts tests/opencode-real.e2e.test.ts",
38
38
  "test:e2e": "bun test tests/story-workflow.e2e.test.ts tests/opencode-real.e2e.test.ts",
39
39
  "test:e2e:real": "WORKFLOW_MANAGER_REAL_OPENCODE=1 bun test tests/opencode-real.e2e.test.ts",
40
40
  "test": "bun test",
@@ -72,6 +72,7 @@
72
72
  ],
73
73
  "license": "MIT",
74
74
  "dependencies": {
75
+ "@zed-industries/agent-client-protocol": "^0.4.5",
75
76
  "gray-matter": "^4.0.3"
76
77
  },
77
78
  "devDependencies": {
@@ -62,9 +62,10 @@ Adapters (set per step via `taskSpec.adapterKey`; omit for the default):
62
62
  | Adapter | Kind | Notes |
63
63
  | --- | --- | --- |
64
64
  | `pi-agent` | real (default) | Drives the host `pi` coding agent CLI in print mode. `pi` must be on `PATH`; its auth lives in `~/.pi`, not env vars. |
65
- | `opencode` | real (opt-in) | Requires the `opencode` CLI on `PATH`. |
66
- | `claude-code` | real (opt-in) | Requires the `claude` CLI on `PATH`. |
67
- | `codex` | mock-routed | Deterministic simulation. |
65
+ | `acp` | real (opt-in) | Connects to any Agent Client Protocol agent over JSON-RPC/stdio. Needs `payload.useRealAdapter: true` and an agent via `payload.acpCommand`/`acpArgs`, `payload.acpAgent` preset, or `WFM_ACP_COMMAND`. Permissions via `payload.acpPermissions` (`allow`/`deny`/`reads-only`). |
66
+ | `claude-code` | real (opt-in) | ACP preset (`claude-code-acp`). Routes through `acp` when `useRealAdapter` is true. Legacy `claude` CLI path via `payload.legacyExecutor: true`. |
67
+ | `opencode` | real (opt-in) | ACP preset (`opencode acp`). Routes through `acp` when `useRealAdapter` is true. Legacy `opencode` CLI path via `payload.legacyExecutor: true`. |
68
+ | `codex` | mock / opt-in | Mock by default; routes through `acp` when `useRealAdapter` and an `acpCommand`/`acpAgent` are set. |
68
69
  | `mock` | mock | Deterministic simulation for tests and examples. |
69
70
 
70
71
  Provider API keys are inferred from `taskSpec.init.model` and checked by `wfm doctor` / run preflight: