@netnodeag/kraftwerk 0.41.0 → 0.42.1

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 (49) hide show
  1. package/README.md +28 -3
  2. package/dist/acp.d.ts +43 -0
  3. package/dist/acp.js +76 -0
  4. package/dist/agent.d.ts +3 -1
  5. package/dist/cli/doctor.js +10 -2
  6. package/dist/cli/knowledge.js +3 -3
  7. package/dist/cli/kraftwerk.js +13 -7
  8. package/dist/harness.d.ts +6 -0
  9. package/dist/harnesses/acp.d.ts +20 -0
  10. package/dist/harnesses/acp.js +195 -0
  11. package/dist/harnesses/registry.d.ts +2 -2
  12. package/dist/harnesses/registry.js +8 -3
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/inspector/chat/acp.js +22 -72
  16. package/dist/inspector/chat/sessions.js +1 -1
  17. package/dist/inspector/knowledge.d.ts +1 -1
  18. package/dist/inspector/knowledge.js +1 -1
  19. package/dist/inspector/runs.d.ts +1 -0
  20. package/dist/inspector/runs.js +1 -0
  21. package/dist/inspector/server.js +2 -2
  22. package/dist/inspector/workflows.d.ts +4 -0
  23. package/dist/inspector/workflows.js +16 -1
  24. package/dist/okf.d.ts +1 -1
  25. package/dist/okf.js +1 -1
  26. package/dist/run.d.ts +1 -0
  27. package/dist/run.js +8 -4
  28. package/dist/stats.d.ts +2 -0
  29. package/dist/yaml.d.ts +12 -0
  30. package/dist/yaml.js +59 -34
  31. package/inspector/dist/assets/{dist-CASrSR5E.js → dist-BJxiusVh.js} +1 -1
  32. package/inspector/dist/assets/{dist-g0pveAaF.js → dist-BnxCrUQZ.js} +1 -1
  33. package/inspector/dist/assets/{dist-CqbReWDG.js → dist-BpgTFX4N.js} +1 -1
  34. package/inspector/dist/assets/dist-C6Q5FWM0.js +1 -0
  35. package/inspector/dist/assets/{dist-DKds1beX.js → dist-C8aIUi2c.js} +1 -1
  36. package/inspector/dist/assets/{dist-BvHfHbzl.js → dist-Cn-NK0yx.js} +1 -1
  37. package/inspector/dist/assets/{dist-CoT4dTT9.js → dist-DQbdG8Cf.js} +1 -1
  38. package/inspector/dist/assets/{dist-Bj7XitTo.js → dist-DkSZQh-S.js} +1 -1
  39. package/inspector/dist/assets/{dist-h7tJ3T-Z.js → dist-OsHgcj_x.js} +1 -1
  40. package/inspector/dist/assets/{dist-BiIrAs8U.js → dist-cZmo-9Se.js} +1 -1
  41. package/inspector/dist/assets/{dist-BO4gDbWB.js → dist-z049FsVf.js} +1 -1
  42. package/inspector/dist/assets/{editor-BiyVJ7Wz.js → editor-BQkS4m10.js} +3 -3
  43. package/inspector/dist/assets/{index-lwVGUk9H.js → index-DGlIITXq.js} +16 -16
  44. package/inspector/dist/assets/index-DuZ9U4BZ.css +1 -0
  45. package/inspector/dist/index.html +2 -2
  46. package/package.json +1 -1
  47. package/schema/workflow.schema.json +18 -4
  48. package/inspector/dist/assets/dist-Dg2YcNTD.js +0 -1
  49. package/inspector/dist/assets/index-CHI0eOQa.css +0 -1
@@ -1,80 +1,18 @@
1
- import { spawn } from "node:child_process";
2
- import { Readable, Writable } from "node:stream";
3
- import { fileURLToPath } from "node:url";
4
- import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError, } from "@agentclientprotocol/sdk";
1
+ import { RequestError } from "@agentclientprotocol/sdk";
2
+ import { adapterEnv, connectAcp } from "../../acp.js";
5
3
  import { unattendedMode } from "./permissions.js";
6
4
  /**
7
5
  * ACP-backed chat: spawn an adapter (claude-agent-acp / codex-acp) as a
8
6
  * subprocess, speak Agent Client Protocol over its stdio, and translate
9
7
  * session/update notifications into chat events. One subprocess lives for
10
- * the whole chat; the ACP session id carries the conversation.
11
- *
12
- * Auth rides on the local CLI logins (Claude Code / Codex) via the
13
- * inherited environment — same story as the kraftwerk harnesses.
8
+ * the whole chat; the ACP session id carries the conversation. The
9
+ * adapter plumbing is shared with the workflow ACP harness (src/acp.ts).
14
10
  */
15
- const ADAPTERS = {
16
- claude: "@agentclientprotocol/claude-agent-acp/dist/index.js",
17
- codex: "@agentclientprotocol/codex-acp/dist/index.js",
18
- };
19
- /** Effort tier -> Claude thinking budget (the adapter reads MAX_THINKING_TOKENS). */
20
- const CLAUDE_THINKING_BUDGET = {
21
- low: 2048,
22
- medium: 8192,
23
- high: 16384,
24
- xhigh: 32000,
25
- max: 63999,
26
- };
27
- /**
28
- * Model/effort overrides ride on adapter-specific channels: the claude
29
- * adapter takes the model via session `_meta.claudeCode.options` and the
30
- * thinking budget via env; codex-acp merges a CODEX_CONFIG env JSON into
31
- * the session config it hands to `codex app-server`.
32
- */
33
- function tuningEnv(agent, tuning) {
34
- const env = { ...process.env };
35
- if (agent === "claude" && tuning.effort && CLAUDE_THINKING_BUDGET[tuning.effort]) {
36
- env.MAX_THINKING_TOKENS = String(CLAUDE_THINKING_BUDGET[tuning.effort]);
37
- }
38
- if (agent === "codex" && (tuning.model || tuning.effort)) {
39
- env.CODEX_CONFIG = JSON.stringify({
40
- ...(tuning.model ? { model: tuning.model } : {}),
41
- ...(tuning.effort ? { model_reasoning_effort: tuning.effort } : {}),
42
- });
43
- }
44
- return env;
45
- }
46
11
  function contentText(content) {
47
12
  return content.type === "text" ? content.text : "";
48
13
  }
49
14
  export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
50
- const entry = fileURLToPath(import.meta.resolve(ADAPTERS[agent]));
51
- const child = spawn(process.execPath, [entry], {
52
- cwd,
53
- stdio: ["pipe", "pipe", "pipe"],
54
- env: tuningEnv(agent, tuning),
55
- });
56
- let stderr = "";
57
- child.stderr.on("data", (c) => {
58
- stderr += c.toString("utf8");
59
- if (stderr.length > 20_000)
60
- stderr = stderr.slice(-20_000);
61
- });
62
15
  let dead = false;
63
- // Without a listener a failed spawn (cwd gone, node missing) is an
64
- // unhandled 'error' event that takes the whole inspector down.
65
- child.on("error", (err) => {
66
- dead = true;
67
- hooks.emit({ type: "error", message: `could not start the ${agent} agent: ${err.message}` });
68
- });
69
- child.on("close", (code) => {
70
- dead = true;
71
- if (code !== 0 && code !== null) {
72
- hooks.emit({
73
- type: "error",
74
- message: `${agent} agent exited (code ${code})${stderr.trim() ? `: ${stderr.trim().slice(-500)}` : ""}`,
75
- });
76
- }
77
- });
78
16
  const client = {
79
17
  sessionUpdate(params) {
80
18
  const u = params.update;
@@ -118,12 +56,24 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
118
56
  : { outcome: { outcome: "cancelled" } };
119
57
  },
120
58
  };
121
- const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
122
- const conn = new ClientSideConnection(() => client, stream);
123
- await conn.initialize({
124
- protocolVersion: PROTOCOL_VERSION,
125
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
126
- clientInfo: { name: "kraftwerk-inspector", version: "1.0.0" },
59
+ const { child, conn } = await connectAcp(agent, {
60
+ cwd,
61
+ env: adapterEnv(agent, tuning),
62
+ client,
63
+ clientName: "kraftwerk-inspector",
64
+ onError: (err) => {
65
+ dead = true;
66
+ hooks.emit({ type: "error", message: `could not start the ${agent} agent: ${err.message}` });
67
+ },
68
+ onClose: (code, stderr) => {
69
+ dead = true;
70
+ if (code !== 0 && code !== null) {
71
+ hooks.emit({
72
+ type: "error",
73
+ message: `${agent} agent exited (code ${code})${stderr ? `: ${stderr.slice(-500)}` : ""}`,
74
+ });
75
+ }
76
+ },
127
77
  });
128
78
  // Claude-only session options ride on _meta.claudeCode.options (the
129
79
  // adapter spreads them into the Agent SDK options): model override,
@@ -333,7 +333,7 @@ async function baseScopeContext(scope, agent) {
333
333
  const bundleLines = bundles
334
334
  .map((b) => `- ${b.name} (${b.concepts} concepts${b.updatedAt ? `, updated ${b.updatedAt.slice(0, 10)}` : ""})`)
335
335
  .join("\n");
336
- return (`You are the knowledge curator inside the kraftwerk inspector ("Context & Knowledge"). ` +
336
+ return (`You are the knowledge curator inside the kraftwerk inspector ("Knowledge"). ` +
337
337
  `This project keeps knowledge as OKF v0.2 bundles (Open Knowledge Format): directories of markdown ` +
338
338
  `files with YAML frontmatter under ${root || "knowledge/"}. Each direct subdirectory is one bundle; each .md file ` +
339
339
  `(except the reserved index.md and log.md) is one concept.\n\n` +
@@ -1,6 +1,6 @@
1
1
  import { type BundleInfo, type ConceptDetail, type ConceptInfo } from "../okf.js";
2
2
  /**
3
- * Context & Knowledge for the inspector: a thin API layer over the OKF
3
+ * Knowledge for the inspector: a thin API layer over the OKF
4
4
  * core (src/okf.ts). Writes from the UI are stamped as `human:user` — the
5
5
  * browser user is the human in the loop; agents write through the CLI (or
6
6
  * chat) with their own actor.
@@ -4,7 +4,7 @@ import { resolveProject } from "../config.js";
4
4
  import { getConcept, initBundle, knowledgeRoot, listBundles, listConcepts, safeBundleName, verifyConcept, writeConcept, } from "../okf.js";
5
5
  import { getProjectRoot } from "./context.js";
6
6
  /**
7
- * Context & Knowledge for the inspector: a thin API layer over the OKF
7
+ * Knowledge for the inspector: a thin API layer over the OKF
8
8
  * core (src/okf.ts). Writes from the UI are stamped as `human:user` — the
9
9
  * browser user is the human in the loop; agents write through the CLI (or
10
10
  * chat) with their own actor.
@@ -10,6 +10,7 @@ export interface PhaseView {
10
10
  agent?: string;
11
11
  model?: string;
12
12
  harness?: string;
13
+ protocol?: string;
13
14
  status: "running" | "ok" | "failed" | "blocked" | "pending";
14
15
  attempts: number;
15
16
  startedAt?: string;
@@ -42,6 +42,7 @@ function analyse(events) {
42
42
  agent: e.agent,
43
43
  model: e.model,
44
44
  harness: e.harness,
45
+ protocol: e.protocol === "acp" ? "acp" : undefined,
45
46
  status: "running",
46
47
  attempts: 0,
47
48
  startedAt: e.ts,
@@ -645,8 +645,8 @@ async function handleApi(req, res, url) {
645
645
  return json(res, { error: "invalid JSON body" }, 400);
646
646
  }
647
647
  const request = (body.request ?? "").trim();
648
- if (!request)
649
- return json(res, { error: "request text is required" }, 400);
648
+ if (!request && wf.usesRequest)
649
+ return json(res, { error: "this workflow needs a request" }, 400);
650
650
  try {
651
651
  const { runId } = triggerRun({
652
652
  workflowName: wf.name,
@@ -3,6 +3,7 @@ export interface AgentInfo {
3
3
  name?: string;
4
4
  model?: string;
5
5
  harness?: string;
6
+ protocol?: string;
6
7
  effort?: string;
7
8
  tools: string[];
8
9
  persona?: string;
@@ -25,6 +26,8 @@ export interface WorkflowSummary {
25
26
  description?: string;
26
27
  agents: number;
27
28
  steps: number;
29
+ /** False when no step reads `${{ request }}` — the ▶ button runs it straight away. */
30
+ usesRequest: boolean;
28
31
  error?: string;
29
32
  }
30
33
  export interface WorkflowDetail {
@@ -36,6 +39,7 @@ export interface WorkflowDetail {
36
39
  agents: AgentInfo[];
37
40
  steps: StepInfo[];
38
41
  files: string[];
42
+ usesRequest: boolean;
39
43
  error?: string;
40
44
  }
41
45
  export declare function listWorkflows(): Promise<{
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { resolveProject } from "../config.js";
5
+ import { referencesRequest } from "../yaml.js";
5
6
  import { getProjectRoot } from "./context.js";
6
7
  /**
7
8
  * Workflow discovery + parsing for the inspector. Uses the same root
@@ -80,6 +81,16 @@ async function parseRaw(l) {
80
81
  const text = await fs.readFile(l.yamlPath, "utf8");
81
82
  return parseYaml(text);
82
83
  }
84
+ /** Does any step read the request (prompt or script, inline or via a file in the folder)? */
85
+ async function stepsUseRequest(raw, baseDir) {
86
+ for (const s of raw?.steps ?? []) {
87
+ const script = s?.run !== undefined;
88
+ const { text } = await resolveText(script ? s.run : s?.prompt, baseDir);
89
+ if (text && referencesRequest(text, script ? "script" : "agent"))
90
+ return true;
91
+ }
92
+ return false;
93
+ }
83
94
  export async function listWorkflows() {
84
95
  const root = await workflowsRoot();
85
96
  const located = await locate();
@@ -92,10 +103,11 @@ export async function listWorkflows() {
92
103
  description: raw?.description,
93
104
  agents: Object.keys(raw?.agents ?? {}).length,
94
105
  steps: (raw?.steps ?? []).length,
106
+ usesRequest: await stepsUseRequest(raw, l.baseDir),
95
107
  };
96
108
  }
97
109
  catch (err) {
98
- return { slug: l.slug, agents: 0, steps: 0, error: err.message };
110
+ return { slug: l.slug, agents: 0, steps: 0, usesRequest: true, error: err.message };
99
111
  }
100
112
  }));
101
113
  return { root, workflows };
@@ -127,6 +139,7 @@ export async function getWorkflow(slug) {
127
139
  agents: [],
128
140
  steps: [],
129
141
  files: [],
142
+ usesRequest: true,
130
143
  error: err.message,
131
144
  };
132
145
  }
@@ -138,6 +151,7 @@ export async function getWorkflow(slug) {
138
151
  name: a?.name,
139
152
  model: a?.model,
140
153
  harness: a?.harness,
154
+ protocol: a?.protocol ?? raw?.protocol,
141
155
  effort: a?.effort,
142
156
  tools: a?.tools ?? [],
143
157
  persona: persona.text,
@@ -178,5 +192,6 @@ export async function getWorkflow(slug) {
178
192
  agents,
179
193
  steps,
180
194
  files: l.baseDir ? await listDirFiles(l.baseDir) : [path.basename(l.yamlPath)],
195
+ usesRequest: await stepsUseRequest(raw, l.baseDir),
181
196
  };
182
197
  }
package/dist/okf.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * OKF (Open Knowledge Format) core — kraftwerk's "Context & Knowledge"
2
+ * OKF (Open Knowledge Format) core — kraftwerk's "Knowledge"
3
3
  * feature. Implements OKF v0.2 (github.com/GoogleCloudPlatform/knowledge-catalog):
4
4
  * a knowledge bundle is a directory of markdown files with YAML frontmatter,
5
5
  * one concept per file, plus the reserved index.md / log.md.
package/dist/okf.js CHANGED
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml, parseDocument } from "yaml";
4
4
  /**
5
- * OKF (Open Knowledge Format) core — kraftwerk's "Context & Knowledge"
5
+ * OKF (Open Knowledge Format) core — kraftwerk's "Knowledge"
6
6
  * feature. Implements OKF v0.2 (github.com/GoogleCloudPlatform/knowledge-catalog):
7
7
  * a knowledge bundle is a directory of markdown files with YAML frontmatter,
8
8
  * one concept per file, plus the reserved index.md / log.md.
package/dist/run.d.ts CHANGED
@@ -30,6 +30,7 @@ export interface RunOptions {
30
30
  maxGateRetries?: number;
31
31
  }
32
32
  export declare class Run {
33
+ /** Session per harness AND protocol: a CLI session id means nothing to the ACP adapter and vice versa. */
33
34
  private readonly sessions;
34
35
  /** Per-phase stats in execution order; repeated phases appear once per execution. */
35
36
  readonly stats: PhaseStats[];
package/dist/run.js CHANGED
@@ -5,6 +5,7 @@ import { harnessFor } from "./harnesses/registry.js";
5
5
  import { correctionPrompt, parseEnvelope } from "./envelope.js";
6
6
  import { fmtDuration, phaseStatsLine, summaryTable } from "./stats.js";
7
7
  export class Run {
8
+ /** Session per harness AND protocol: a CLI session id means nothing to the ACP adapter and vice versa. */
8
9
  sessions = new Map();
9
10
  /** Per-phase stats in execution order; repeated phases appear once per execution. */
10
11
  stats = [];
@@ -23,7 +24,8 @@ export class Run {
23
24
  }
24
25
  async agentPhase(params) {
25
26
  const { agent } = params;
26
- const harness = harnessFor(agent.harness);
27
+ const harness = harnessFor(agent.harness, agent.protocol);
28
+ const sessionKey = `${harness.id}/${agent.protocol ?? "cli"}`;
27
29
  const modelName = harness.id === "claude" ? agent.model : `${harness.id}:${agent.model}`;
28
30
  const modelLabel = agent.effort ? `${modelName}, effort ${agent.effort}` : modelName;
29
31
  console.log(`\n▸ Phase "${params.name}" — ${agent.name} [${agent.id}] (${modelLabel})`);
@@ -32,17 +34,19 @@ export class Run {
32
34
  kind: "agent",
33
35
  agent: agent.id,
34
36
  harness: harness.id,
37
+ protocol: agent.protocol ?? "cli",
35
38
  model: agent.model,
36
39
  effort: agent.effort ?? null,
37
40
  clis: agent.clis ? Object.keys(agent.clis) : [],
38
41
  mcp: agent.mcp ? Object.keys(agent.mcp) : [],
39
- resume: this.sessions.get(harness.id) ?? null,
42
+ resume: this.sessions.get(sessionKey) ?? null,
40
43
  });
41
44
  const phaseStats = {
42
45
  phase: params.name,
43
46
  kind: "agent",
44
47
  agent: agent.id,
45
48
  harness: harness.id,
49
+ ...(agent.protocol === "acp" ? { protocol: "acp" } : {}),
46
50
  model: agent.model,
47
51
  effort: agent.effort,
48
52
  attempts: 0,
@@ -74,7 +78,7 @@ export class Run {
74
78
  tools: agent.tools,
75
79
  clis: cliEntries.map(([name]) => name),
76
80
  mcpServers: agent.mcp,
77
- resume: this.sessions.get(harness.id),
81
+ resume: this.sessions.get(sessionKey),
78
82
  onToolUse: (tool, target) => {
79
83
  console.log(` ⚙ ${tool} ${target}`.trimEnd());
80
84
  void this.trace("tool_use", { phase: params.name, attempt, tool, target });
@@ -82,7 +86,7 @@ export class Run {
82
86
  onText: this.verbose ? (text) => console.log(` 💬 ${text}`) : undefined,
83
87
  });
84
88
  if (result.sessionId)
85
- this.sessions.set(harness.id, result.sessionId);
89
+ this.sessions.set(sessionKey, result.sessionId);
86
90
  phaseStats.attempts = attempt + 1;
87
91
  phaseStats.durationMs = Date.now() - started;
88
92
  phaseStats.costUsd += result.costUsd ?? 0;
package/dist/stats.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface PhaseStats {
9
9
  agent?: string;
10
10
  /** Harness id, for agent phases. */
11
11
  harness?: string;
12
+ /** "acp" when the phase ran over the Agent Client Protocol (absent = CLI). */
13
+ protocol?: string;
12
14
  /** Model id, for agent phases. */
13
15
  model?: string;
14
16
  effort?: string;
package/dist/yaml.d.ts CHANGED
@@ -7,9 +7,21 @@ export interface LoadedWorkflow extends WorkflowDefinition {
7
7
  steps: string[];
8
8
  /** Environment variables the workflow declares under `requires:`. */
9
9
  requires: string[];
10
+ /** False when no step reads `${{ request }}` — such a workflow runs without a request. */
11
+ usesRequest: boolean;
10
12
  };
11
13
  run(opts: Parameters<WorkflowDefinition["run"]>[0]): Promise<RunResult>;
12
14
  }
15
+ /**
16
+ * Does a prompt or script read the request? Prompts through `${{ request }}`;
17
+ * scripts through that or the REQUEST env var — matched as the bare word,
18
+ * because a script may read it from bash (`$REQUEST`), Python
19
+ * (`os.environ.get("REQUEST")`) or anything else. A mention in a comment
20
+ * counts too: better one dialog too many than a run without its input.
21
+ * A workflow whose steps never read it runs without one (`kraftwerk run
22
+ * <name>`, the ▶ in the UI) — the request is an argument, not a ritual.
23
+ */
24
+ export declare const referencesRequest: (text: string, kind: "agent" | "script") => boolean;
13
25
  /** Names from `requires:` that are missing/empty in the current environment. */
14
26
  export declare const missingEnv: (requires: string[]) => string[];
15
27
  /**
package/dist/yaml.js CHANGED
@@ -2,6 +2,7 @@ import { mkdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { Ajv } from "ajv";
4
4
  import { parse } from "yaml";
5
+ import { disposeAcpSessions } from "./harnesses/acp.js";
5
6
  import { envelopeContract } from "./envelope.js";
6
7
  import { checkScript, containsText, fileNonEmpty, slotsFilled } from "./gates.js";
7
8
  import { Run } from "./run.js";
@@ -77,6 +78,16 @@ function formatAjvErrors(errors, file) {
77
78
  const cliHint = errors.some((e) => e.instancePath.startsWith("/clis")) ? `\n${CLI_HINT}` : "";
78
79
  return `${file}: schema validation failed\n${lines.map((l) => ` - ${l}`).join("\n")}${gateHint}${stepHint}${mcpHint}${cliHint}`;
79
80
  }
81
+ /**
82
+ * Does a prompt or script read the request? Prompts through `${{ request }}`;
83
+ * scripts through that or the REQUEST env var — matched as the bare word,
84
+ * because a script may read it from bash (`$REQUEST`), Python
85
+ * (`os.environ.get("REQUEST")`) or anything else. A mention in a comment
86
+ * counts too: better one dialog too many than a run without its input.
87
+ * A workflow whose steps never read it runs without one (`kraftwerk run
88
+ * <name>`, the ▶ in the UI) — the request is an argument, not a ritual.
89
+ */
90
+ export const referencesRequest = (text, kind) => /\$\{\{\s*request\s*\}\}/.test(text) || (kind === "script" && /\bREQUEST\b/.test(text));
80
91
  /** Names from `requires:` that are missing/empty in the current environment. */
81
92
  export const missingEnv = (requires) => requires.filter((name) => !process.env[name]);
82
93
  /**
@@ -176,8 +187,15 @@ export async function loadWorkflow(givenPath) {
176
187
  // CLIs available to agents: command prefix -> one-line usage hint (may be
177
188
  // empty). Purely declarative; agents opt in below.
178
189
  const cliDefs = new Map(Object.entries(raw.clis ?? {}).map(([name, hint]) => [name, String(hint ?? "")]));
190
+ // `protocol: acp` drives a harness over the Agent Client Protocol instead
191
+ // of its CLI — workflow-wide, or per agent. pi has no adapter.
192
+ const protocolDefault = raw.protocol ?? "cli";
179
193
  const agents = new Map();
180
194
  for (const [id, a] of Object.entries(raw.agents ?? {})) {
195
+ const protocol = a.protocol ?? protocolDefault;
196
+ if (protocol === "acp" && a["runs-on"] === "pi") {
197
+ fail(`agents.${id}: runs-on "pi" has no agent-protocol adapter — use claude or codex, or protocol: cli`);
198
+ }
181
199
  const mcp = {};
182
200
  for (const serverName of (a.mcp ?? [])) {
183
201
  const def = mcpDefs.get(serverName);
@@ -206,6 +224,7 @@ export async function loadWorkflow(givenPath) {
206
224
  tools: a.tools,
207
225
  persona: await resolveText(a.persona, `agents.${id}.persona`),
208
226
  harness: a["runs-on"],
227
+ ...(protocol === "acp" ? { protocol } : {}),
209
228
  ...(Object.keys(clis).length > 0 ? { clis } : {}),
210
229
  ...(Object.keys(mcp).length > 0 ? { mcp } : {}),
211
230
  });
@@ -255,6 +274,7 @@ export async function loadWorkflow(givenPath) {
255
274
  agents: [...agents.values()],
256
275
  steps: steps.map((s) => s.name),
257
276
  requires,
277
+ usesRequest: steps.some((s) => referencesRequest(s.kind === "script" ? s.script : s.prompt, s.kind)),
258
278
  },
259
279
  async run({ request, verbose }) {
260
280
  const missing = missingEnv(requires);
@@ -286,48 +306,53 @@ export async function loadWorkflow(givenPath) {
286
306
  ? { name: s.name, kind: "script" }
287
307
  : { name: s.name, kind: "agent", agent: s.agent.id, model: s.agent.model }),
288
308
  });
289
- for (const step of steps) {
290
- // `if:` preconditions — deterministic checks on run-dir files. Any
291
- // unmet precondition SKIPS the step (the run continues); this is how
292
- // a workflow avoids spawning an agent when there is nothing to do.
293
- if (step.preconditions.length > 0) {
294
- const unmet = [];
295
- for (const pre of step.preconditions) {
296
- const failure = await pre.check(runDir);
297
- if (failure)
298
- unmet.push(`${pre.name}: ${failure}`);
309
+ try {
310
+ for (const step of steps) {
311
+ // `if:` preconditions — deterministic checks on run-dir files. Any
312
+ // unmet precondition SKIPS the step (the run continues); this is how
313
+ // a workflow avoids spawning an agent when there is nothing to do.
314
+ if (step.preconditions.length > 0) {
315
+ const unmet = [];
316
+ for (const pre of step.preconditions) {
317
+ const failure = await pre.check(runDir);
318
+ if (failure)
319
+ unmet.push(`${pre.name}: ${failure}`);
320
+ }
321
+ if (unmet.length > 0) {
322
+ console.log(`\n▸ Phase "${step.name}" — skipped (if: not met)`);
323
+ for (const reason of unmet)
324
+ console.log(` ↷ ${reason}`);
325
+ await run.trace("phase_skipped", { phase: step.name, unmet });
326
+ continue;
327
+ }
299
328
  }
300
- if (unmet.length > 0) {
301
- console.log(`\n▸ Phase "${step.name}" — skipped (if: not met)`);
302
- for (const reason of unmet)
303
- console.log(` ↷ ${reason}`);
304
- await run.trace("phase_skipped", { phase: step.name, unmet });
329
+ if (step.kind === "script") {
330
+ await run.scriptPhase({
331
+ name: step.name,
332
+ script: step.script.replace(/\$\{\{\s*request\s*\}\}/g, request),
333
+ gates: step.gates,
334
+ env: {
335
+ REQUEST: request,
336
+ ...(baseDir ? { WORKFLOW_DIR: path.resolve(baseDir) } : {}),
337
+ },
338
+ });
305
339
  continue;
306
340
  }
307
- }
308
- if (step.kind === "script") {
309
- await run.scriptPhase({
341
+ const prompt = step.prompt
342
+ .replace(/\$\{\{\s*request\s*\}\}/g, request)
343
+ .replace(/\$\{\{\s*agent\s*\}\}/g, step.agent.id);
344
+ await run.agentPhase({
310
345
  name: step.name,
311
- script: step.script.replace(/\$\{\{\s*request\s*\}\}/g, request),
346
+ agent: step.agent,
347
+ prompt: `${prompt}\n\n${envelopeContract(step.name)}`,
312
348
  gates: step.gates,
313
- env: {
314
- REQUEST: request,
315
- ...(baseDir ? { WORKFLOW_DIR: path.resolve(baseDir) } : {}),
316
- },
317
349
  });
318
- continue;
319
350
  }
320
- const prompt = step.prompt
321
- .replace(/\$\{\{\s*request\s*\}\}/g, request)
322
- .replace(/\$\{\{\s*agent\s*\}\}/g, step.agent.id);
323
- await run.agentPhase({
324
- name: step.name,
325
- agent: step.agent,
326
- prompt: `${prompt}\n\n${envelopeContract(step.name)}`,
327
- gates: step.gates,
328
- });
351
+ await run.printSummary();
352
+ }
353
+ finally {
354
+ disposeAcpSessions(runDir);
329
355
  }
330
- await run.printSummary();
331
356
  console.log(`\nArtifacts: ${runDir}`);
332
357
  return { runDir, phases: run.stats, total: summaryTable(run.stats).total };
333
358
  },