@workflow-manager/runner 0.4.1 → 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,5 +1,6 @@
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";
@@ -296,12 +297,21 @@ 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
+ }
305
315
  const fallbackReason = adapterMockFallbackReason(step);
306
316
  if (fallbackReason) {
307
317
  hooks?.onStderr?.(`[wfm] ${step.key}: ${fallbackReason}\n`);
package/dist/index.js CHANGED
@@ -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
@@ -19,12 +19,12 @@ export interface AdapterMockFallbackWarning {
19
19
  message: string;
20
20
  }
21
21
  /**
22
- * Detects a step that explicitly selects a host-backed adapter whose real
23
- * execution path is gated behind opt-in payload flags that are not set. Without
24
- * those flags the engine routes the step to the mock executor; returning a
25
- * message here lets callers warn instead of silently mocking. Returns null when
26
- * the step will run as the user expects (default pi-agent, an enabled real
27
- * adapter, or an intentional mock/codex selection).
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
28
  */
29
29
  export declare function adapterMockFallbackReason(step: StepDefinition): string | null;
30
30
  export declare function adapterMockFallbackWarnings(definition: WorkflowDefinition): AdapterMockFallbackWarning[];
@@ -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,34 +165,62 @@ 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
  }
161
188
  /**
162
- * Detects a step that explicitly selects a host-backed adapter whose real
163
- * execution path is gated behind opt-in payload flags that are not set. Without
164
- * those flags the engine routes the step to the mock executor; returning a
165
- * message here lets callers warn instead of silently mocking. Returns null when
166
- * the step will run as the user expects (default pi-agent, an enabled real
167
- * adapter, or an intentional mock/codex selection).
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).
168
195
  */
169
196
  export function adapterMockFallbackReason(step) {
170
197
  if (step.kind !== "task" || !step.taskSpec?.adapterKey) {
171
198
  return null;
172
199
  }
173
200
  const adapter = resolveTaskAdapter(step.taskSpec.adapterKey);
174
- if (adapter === "claude-code" && !shouldUseRealClaudeCode(step)) {
175
- return "adapterKey 'claude-code' is set, but the real Claude Code CLI path is off, so the step runs as a mock. Set taskSpec.payload.useRealAdapter: true to drive the real `claude` CLI.";
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;
176
210
  }
177
- if (adapter === "opencode" && !shouldUseRealOpencode(step)) {
178
- return "adapterKey 'opencode' is set, but the real OpenCode CLI path is off, so the step runs as a mock. Set taskSpec.payload.useRealAdapter: true and taskSpec.payload.opencodeSmokeTest: true to drive the real `opencode` CLI.";
211
+ if (shouldUseRealAcp(step)) {
212
+ return null;
179
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.
180
224
  return null;
181
225
  }
182
226
  export function adapterMockFallbackWarnings(definition) {
@@ -196,6 +240,11 @@ export function adapterImplementationStatuses() {
196
240
  status: "real",
197
241
  detail: "default host-backed adapter driving the pi coding agent CLI",
198
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
+ },
199
248
  {
200
249
  adapter: "mock",
201
250
  status: "mock",
@@ -204,17 +253,17 @@ export function adapterImplementationStatuses() {
204
253
  {
205
254
  adapter: "opencode",
206
255
  status: "partial",
207
- 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)",
208
257
  },
209
258
  {
210
259
  adapter: "codex",
211
- status: "mock",
212
- 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",
213
262
  },
214
263
  {
215
264
  adapter: "claude-code",
216
265
  status: "partial",
217
- 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)",
218
267
  },
219
268
  ];
220
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.1",
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: