@netnodeag/kraftwerk 0.41.0 → 0.42.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.
Files changed (43) hide show
  1. package/README.md +25 -0
  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/kraftwerk.js +12 -6
  7. package/dist/harness.d.ts +6 -0
  8. package/dist/harnesses/acp.d.ts +20 -0
  9. package/dist/harnesses/acp.js +195 -0
  10. package/dist/harnesses/registry.d.ts +2 -2
  11. package/dist/harnesses/registry.js +8 -3
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/inspector/chat/acp.js +22 -72
  15. package/dist/inspector/runs.d.ts +1 -0
  16. package/dist/inspector/runs.js +1 -0
  17. package/dist/inspector/server.js +2 -2
  18. package/dist/inspector/workflows.d.ts +4 -0
  19. package/dist/inspector/workflows.js +16 -1
  20. package/dist/run.d.ts +1 -0
  21. package/dist/run.js +8 -4
  22. package/dist/stats.d.ts +2 -0
  23. package/dist/yaml.d.ts +12 -0
  24. package/dist/yaml.js +59 -34
  25. package/inspector/dist/assets/{dist-g0pveAaF.js → dist-5tG7aS9B.js} +1 -1
  26. package/inspector/dist/assets/{dist-DKds1beX.js → dist-C3XpBw6W.js} +1 -1
  27. package/inspector/dist/assets/{dist-CASrSR5E.js → dist-CE4xSQoL.js} +1 -1
  28. package/inspector/dist/assets/{dist-BO4gDbWB.js → dist-CvxuLI-i.js} +1 -1
  29. package/inspector/dist/assets/{dist-h7tJ3T-Z.js → dist-CxiuM_jC.js} +1 -1
  30. package/inspector/dist/assets/{dist-Bj7XitTo.js → dist-D8KGprRA.js} +1 -1
  31. package/inspector/dist/assets/{dist-CoT4dTT9.js → dist-DAeASF1A.js} +1 -1
  32. package/inspector/dist/assets/{dist-BvHfHbzl.js → dist-DmP7Dxbp.js} +1 -1
  33. package/inspector/dist/assets/{dist-BiIrAs8U.js → dist-DzY0J5Mz.js} +1 -1
  34. package/inspector/dist/assets/dist-IEe8XFs2.js +1 -0
  35. package/inspector/dist/assets/{dist-CqbReWDG.js → dist-W68I5dk2.js} +1 -1
  36. package/inspector/dist/assets/{editor-BiyVJ7Wz.js → editor-CyEovn1g.js} +3 -3
  37. package/inspector/dist/assets/{index-lwVGUk9H.js → index-B2d-nOUv.js} +16 -16
  38. package/inspector/dist/assets/index-BRw2ED9p.css +1 -0
  39. package/inspector/dist/index.html +2 -2
  40. package/package.json +1 -1
  41. package/schema/workflow.schema.json +18 -4
  42. package/inspector/dist/assets/dist-Dg2YcNTD.js +0 -1
  43. package/inspector/dist/assets/index-CHI0eOQa.css +0 -1
package/README.md CHANGED
@@ -583,6 +583,31 @@ Prerequisites per harness:
583
583
  models reuse the Claude subscription OAuth, other vendors need their key in
584
584
  the env (check with `pi auth check --provider deepseek`).
585
585
 
586
+ ### Agent protocol
587
+
588
+ Phases can also run over the [Agent Client Protocol](https://agentclientprotocol.com)
589
+ — the same adapters the inspector's chats use (`claude-agent-acp`,
590
+ `codex-acp`), bundled with kraftwerk, so nothing needs to be on the PATH.
591
+ Set `protocol: acp` at the top of a workflow (every agent) or on one agent;
592
+ `runs-on` still picks the harness (pi has no adapter). The adapter process
593
+ stays alive for the whole run, so phases on one harness share a session
594
+ like `--resume` does on the CLI. Persona and workspace context travel inside
595
+ each phase prompt. On claude the agent's `tools`, CLI grants and MCP servers
596
+ become the session's allowlist (settings sources off) and the session is
597
+ switched to `acceptEdits`; codex runs in its workspace-write mode. A permission request the harness
598
+ still raises is declined — nobody watches a workflow run, and kraftwerk
599
+ never answers for a human. A programmatic `Run` ends its adapters with
600
+ `disposeAcpSessions(runDir)` (the YAML runner does this itself).
601
+
602
+ ```yaml
603
+ name: tagline
604
+ protocol: acp # all agents over ACP; per agent: protocol: cli | acp
605
+ agents:
606
+ writer:
607
+ runs-on: claude # or codex
608
+ model: sonnet
609
+ ```
610
+
586
611
  ## YAML workflows
587
612
 
588
613
  Linear workflows can be pure config, GitHub-Actions-flavored with `steps`,
package/dist/acp.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { ClientSideConnection, type Client, type McpServer } from "@agentclientprotocol/sdk";
3
+ import type { McpServerConfig } from "./harness.js";
4
+ /**
5
+ * Agent Client Protocol plumbing shared by the inspector's chats and the
6
+ * workflow ACP harness: the adapter packages (bundled with kraftwerk, so no
7
+ * CLI on the PATH is needed), model/effort tuning, and spawning one adapter
8
+ * as a subprocess speaking ACP over its stdio.
9
+ *
10
+ * Auth rides on the local CLI logins (Claude Code / Codex) via the
11
+ * inherited environment — same story as the CLI harnesses.
12
+ */
13
+ export type AcpAgent = "claude" | "codex";
14
+ /**
15
+ * Model/effort overrides ride on adapter-specific channels: the claude
16
+ * adapter takes the model via session `_meta.claudeCode.options` and the
17
+ * thinking budget via env; codex-acp merges a CODEX_CONFIG env JSON into
18
+ * the session config it hands to `codex app-server`.
19
+ */
20
+ export declare function adapterEnv(agent: AcpAgent, tuning: {
21
+ model?: string;
22
+ effort?: string;
23
+ }): NodeJS.ProcessEnv;
24
+ /** Kraftwerk's MCP config (stdio or url) -> the protocol's session mcpServers entries. */
25
+ export declare function acpMcpServers(servers?: Record<string, McpServerConfig>): McpServer[];
26
+ export interface AcpProcess {
27
+ child: ChildProcessWithoutNullStreams;
28
+ conn: ClientSideConnection;
29
+ }
30
+ /**
31
+ * Spawn the adapter for `agent` in `cwd`, wire the ACP connection to
32
+ * `client`, and run the initialize handshake. Process failures reach the
33
+ * caller through the callbacks (a failed spawn is otherwise an unhandled
34
+ * 'error' event that takes the host process down).
35
+ */
36
+ export declare function connectAcp(agent: AcpAgent, opts: {
37
+ cwd: string;
38
+ env: NodeJS.ProcessEnv;
39
+ client: Client;
40
+ clientName: string;
41
+ onError(err: Error): void;
42
+ onClose(code: number | null, stderr: string): void;
43
+ }): Promise<AcpProcess>;
package/dist/acp.js ADDED
@@ -0,0 +1,76 @@
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, } from "@agentclientprotocol/sdk";
5
+ const ADAPTERS = {
6
+ claude: "@agentclientprotocol/claude-agent-acp/dist/index.js",
7
+ codex: "@agentclientprotocol/codex-acp/dist/index.js",
8
+ };
9
+ /** Effort tier -> Claude thinking budget (the adapter reads MAX_THINKING_TOKENS). */
10
+ const CLAUDE_THINKING_BUDGET = {
11
+ low: 2048,
12
+ medium: 8192,
13
+ high: 16384,
14
+ xhigh: 32000,
15
+ max: 63999,
16
+ };
17
+ /**
18
+ * Model/effort overrides ride on adapter-specific channels: the claude
19
+ * adapter takes the model via session `_meta.claudeCode.options` and the
20
+ * thinking budget via env; codex-acp merges a CODEX_CONFIG env JSON into
21
+ * the session config it hands to `codex app-server`.
22
+ */
23
+ export function adapterEnv(agent, tuning) {
24
+ const env = { ...process.env };
25
+ if (agent === "claude" && tuning.effort && CLAUDE_THINKING_BUDGET[tuning.effort]) {
26
+ env.MAX_THINKING_TOKENS = String(CLAUDE_THINKING_BUDGET[tuning.effort]);
27
+ }
28
+ if (agent === "codex" && (tuning.model || tuning.effort)) {
29
+ env.CODEX_CONFIG = JSON.stringify({
30
+ ...(tuning.model ? { model: tuning.model } : {}),
31
+ ...(tuning.effort ? { model_reasoning_effort: tuning.effort } : {}),
32
+ });
33
+ }
34
+ return env;
35
+ }
36
+ /** Kraftwerk's MCP config (stdio or url) -> the protocol's session mcpServers entries. */
37
+ export function acpMcpServers(servers = {}) {
38
+ return Object.entries(servers).map(([name, cfg]) => "url" in cfg
39
+ ? { type: "http", name, url: cfg.url, headers: [] }
40
+ : {
41
+ name,
42
+ command: cfg.command,
43
+ args: cfg.args ?? [],
44
+ env: Object.entries(cfg.env ?? {}).map(([n, value]) => ({ name: n, value })),
45
+ });
46
+ }
47
+ /**
48
+ * Spawn the adapter for `agent` in `cwd`, wire the ACP connection to
49
+ * `client`, and run the initialize handshake. Process failures reach the
50
+ * caller through the callbacks (a failed spawn is otherwise an unhandled
51
+ * 'error' event that takes the host process down).
52
+ */
53
+ export async function connectAcp(agent, opts) {
54
+ const entry = fileURLToPath(import.meta.resolve(ADAPTERS[agent]));
55
+ const child = spawn(process.execPath, [entry], {
56
+ cwd: opts.cwd,
57
+ stdio: ["pipe", "pipe", "pipe"],
58
+ env: opts.env,
59
+ });
60
+ let stderr = "";
61
+ child.stderr.on("data", (c) => {
62
+ stderr += c.toString("utf8");
63
+ if (stderr.length > 20_000)
64
+ stderr = stderr.slice(-20_000);
65
+ });
66
+ child.on("error", opts.onError);
67
+ child.on("close", (code) => opts.onClose(code, stderr.trim()));
68
+ const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
69
+ const conn = new ClientSideConnection(() => opts.client, stream);
70
+ await conn.initialize({
71
+ protocolVersion: PROTOCOL_VERSION,
72
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
73
+ clientInfo: { name: opts.clientName, version: "1.0.0" },
74
+ });
75
+ return { child, conn };
76
+ }
package/dist/agent.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HarnessId, McpServerConfig } from "./harness.js";
1
+ import type { AgentProtocol, HarnessId, McpServerConfig } from "./harness.js";
2
2
  /**
3
3
  * An agent is exactly four things:
4
4
  *
@@ -45,5 +45,7 @@ export interface AgentDefinition {
45
45
  mcp?: Record<string, McpServerConfig>;
46
46
  /** Which runtime executes this agent. Default: "claude". */
47
47
  harness?: HarnessId;
48
+ /** How that runtime is driven: headless CLI (default) or the Agent Client Protocol. */
49
+ protocol?: AgentProtocol;
48
50
  }
49
51
  export declare function defineAgent(agent: AgentDefinition): AgentDefinition;
@@ -137,11 +137,19 @@ export async function runDoctor(cwd) {
137
137
  }
138
138
  }
139
139
  // Harnesses: hard requirement only if a discovered workflow runs on them.
140
+ // Over the agent protocol the bundled adapters bring their own runtime.
140
141
  const needed = new Set();
142
+ let acp = false;
141
143
  for (const e of found) {
142
- for (const a of e.workflow?.meta.agents ?? [])
143
- needed.add(a.harness ?? "claude");
144
+ for (const a of e.workflow?.meta.agents ?? []) {
145
+ if (a.protocol === "acp")
146
+ acp = true;
147
+ else
148
+ needed.add(a.harness ?? "claude");
149
+ }
144
150
  }
151
+ if (acp)
152
+ report("ok", "agent protocol adapters", "claude-agent-acp + codex-acp bundled with kraftwerk");
145
153
  for (const harness of ["claude", "codex", "pi"]) {
146
154
  const version = cliVersion(harness);
147
155
  const isNeeded = needed.has(harness);
@@ -62,7 +62,10 @@ const program = new Command()
62
62
  .version(pkg.version);
63
63
  const agentLabel = (workflow) => workflow.meta.agents
64
64
  .map((a) => {
65
- const harness = a.harness && a.harness !== "claude" ? `${a.harness}:` : "";
65
+ const where = [a.harness && a.harness !== "claude" ? a.harness : "", a.protocol === "acp" ? "acp" : ""]
66
+ .filter(Boolean)
67
+ .join("/");
68
+ const harness = where ? `${where}:` : "";
66
69
  const effort = a.effort ? `, ${a.effort}` : "";
67
70
  return `${a.id} ${chalk.dim(`(${harness}${a.model}${effort})`)}`;
68
71
  })
@@ -88,6 +91,7 @@ program
88
91
  id: a.id,
89
92
  model: a.model,
90
93
  harness: a.harness ?? "claude",
94
+ protocol: a.protocol ?? "cli",
91
95
  })),
92
96
  error: e.error,
93
97
  })), null, 2));
@@ -166,13 +170,15 @@ program
166
170
  })),
167
171
  });
168
172
  const workflow = entry.workflow;
173
+ // A workflow whose steps never read ${{ request }} runs without one.
169
174
  let request = (requestParts ?? []).join(" ").trim();
170
- if (!request && machine)
171
- fail(2, "No request given (--json is non-interactive).");
172
- if (!request)
175
+ if (!request && workflow.meta.usesRequest) {
176
+ if (machine)
177
+ fail(2, "No request given (--json is non-interactive).");
173
178
  request = (await input({ message: "Request (topic, URL, ...):" })).trim();
174
- if (!request)
175
- fail(2, "No request given.");
179
+ if (!request)
180
+ fail(2, "No request given.");
181
+ }
176
182
  const missing = missingEnv(workflow.meta.requires);
177
183
  if (missing.length > 0) {
178
184
  fail(2, `Workflow "${workflow.name}" needs environment variables that are missing: ${missing.join(", ")}`);
package/dist/harness.d.ts CHANGED
@@ -12,6 +12,12 @@
12
12
  * run directory (the workspace-context contract).
13
13
  */
14
14
  export type HarnessId = "claude" | "codex" | "pi";
15
+ /**
16
+ * How the harness is driven: `cli` spawns the headless CLI per phase
17
+ * (claude -p, codex exec, pi); `acp` keeps one Agent Client Protocol
18
+ * adapter alive for the run (see harnesses/acp.ts). pi has no adapter.
19
+ */
20
+ export type AgentProtocol = "cli" | "acp";
15
21
  /**
16
22
  * One MCP server an agent may use: either a local stdio server (spawned by
17
23
  * the harness, e.g. `node multiply-server.ts`) or a remote streamable-HTTP
@@ -0,0 +1,20 @@
1
+ import type { AgentInvocation, Harness, HarnessId } from "../harness.js";
2
+ /** Claude Agent SDK options for a phase session: the CLI harness's flags, as session meta. */
3
+ export declare function claudeSessionOptions(inv: Pick<AgentInvocation, "model" | "tools" | "clis" | "mcpServers">): Record<string, unknown>;
4
+ /**
5
+ * What a tool call touched, from its input (path, url, command) or its
6
+ * first location. Undefined when the adapter has not said yet — a title
7
+ * alone may be a placeholder.
8
+ */
9
+ export declare function toolTarget(u: {
10
+ rawInput?: Record<string, unknown>;
11
+ locations?: Array<{
12
+ path?: string;
13
+ }>;
14
+ }): string | undefined;
15
+ /**
16
+ * Stop the adapter processes of one run (its run directory), or of every
17
+ * run in this process when no directory is given.
18
+ */
19
+ export declare function disposeAcpSessions(cwd?: string): void;
20
+ export declare function acpHarness(id: HarnessId): Harness;
@@ -0,0 +1,195 @@
1
+ import { RequestError, } from "@agentclientprotocol/sdk";
2
+ import { acpMcpServers, adapterEnv, connectAcp } from "../acp.js";
3
+ import { declineOption, unattendedMode } from "../inspector/chat/permissions.js";
4
+ const live = new Map();
5
+ // The run process is the adapters' only owner: never leave one behind.
6
+ process.once("exit", () => disposeAcpSessions());
7
+ /** Claude Agent SDK options for a phase session: the CLI harness's flags, as session meta. */
8
+ export function claudeSessionOptions(inv) {
9
+ return {
10
+ model: inv.model,
11
+ // Keep the run hermetic: no user/project settings, hooks, or CLAUDE.md.
12
+ settingSources: [],
13
+ allowedTools: [
14
+ ...inv.tools,
15
+ ...(inv.clis ?? []).map((name) => `Bash(${name}:*)`),
16
+ ...Object.keys(inv.mcpServers ?? {}).map((name) => `mcp__${name}`),
17
+ ],
18
+ };
19
+ }
20
+ /**
21
+ * What a tool call touched, from its input (path, url, command) or its
22
+ * first location. Undefined when the adapter has not said yet — a title
23
+ * alone may be a placeholder.
24
+ */
25
+ export function toolTarget(u) {
26
+ const input = u.rawInput ?? {};
27
+ const raw = input.file_path ?? input.url ?? input.path ?? input.command ?? u.locations?.[0]?.path;
28
+ if (raw == null)
29
+ return undefined;
30
+ const target = String(raw).replace(/\s+/g, " ").trim();
31
+ return target.length > 160 ? `${target.slice(0, 160)}…` : target;
32
+ }
33
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
34
+ async function open(agent, inv) {
35
+ let ended;
36
+ const closed = new Promise((r) => (ended = r));
37
+ const state = {
38
+ agent,
39
+ cwd: inv.cwd,
40
+ sessionId: "",
41
+ dead: false,
42
+ closed,
43
+ turn: null,
44
+ cost: 0,
45
+ };
46
+ const end = (why) => {
47
+ state.dead = true;
48
+ state.exit ??= why;
49
+ ended(state.exit);
50
+ };
51
+ const client = {
52
+ sessionUpdate(params) {
53
+ const u = params.update;
54
+ const turn = state.turn;
55
+ if (u.sessionUpdate === "agent_message_chunk" && u.content?.type === "text" && turn) {
56
+ turn.text += u.content.text ?? "";
57
+ }
58
+ else if (u.sessionUpdate === "tool_call" && turn) {
59
+ const kind = u.kind ?? "tool";
60
+ const target = toolTarget(u);
61
+ if (target)
62
+ turn.onToolUse?.(kind, target);
63
+ else
64
+ turn.pending.set(u.toolCallId ?? "", { kind, title: String(u.title ?? "") });
65
+ }
66
+ else if (u.sessionUpdate === "tool_call_update" && turn?.pending.has(u.toolCallId ?? "")) {
67
+ const target = toolTarget(u);
68
+ if (target) {
69
+ turn.onToolUse?.(turn.pending.get(u.toolCallId ?? "").kind, target);
70
+ turn.pending.delete(u.toolCallId ?? "");
71
+ }
72
+ }
73
+ else if (u.sessionUpdate === "usage_update" && typeof u.cost?.amount === "number") {
74
+ state.cost = u.cost.amount;
75
+ }
76
+ },
77
+ async requestPermission(params) {
78
+ // The harness judged this worth asking; a workflow run has nobody to ask.
79
+ const optionId = declineOption(params.options);
80
+ console.log(` ⛔ ${params.toolCall.title ?? params.toolCall.toolCallId} — permission declined (unattended run)`);
81
+ return optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } };
82
+ },
83
+ };
84
+ const { child, conn } = await connectAcp(agent, {
85
+ cwd: inv.cwd,
86
+ env: adapterEnv(agent, { model: inv.model, effort: inv.effort }),
87
+ client,
88
+ clientName: "kraftwerk",
89
+ onError: (err) => end(`could not start the ${agent} adapter: ${err.message}`),
90
+ onClose: (code, stderr) => end(`${agent} adapter exited (code ${code})${stderr ? `: ${stderr.slice(-500)}` : ""}`),
91
+ });
92
+ state.child = child;
93
+ state.conn = conn;
94
+ try {
95
+ const session = await conn.newSession({
96
+ cwd: inv.cwd,
97
+ mcpServers: acpMcpServers(inv.mcpServers),
98
+ ...(agent === "claude" ? { _meta: { claudeCode: { options: claudeSessionOptions(inv) } } } : {}),
99
+ });
100
+ state.sessionId = session.sessionId;
101
+ const modeId = unattendedMode(agent, session.modes?.currentModeId);
102
+ if (modeId) {
103
+ await conn.setSessionMode({ sessionId: session.sessionId, modeId }).catch((err) => {
104
+ console.log(` ⚠ could not select the ${modeId} mode (${err.message}) — the ${agent} default applies`);
105
+ });
106
+ }
107
+ }
108
+ catch (err) {
109
+ // A refused session leaves the adapter running; it must not outlive the failure.
110
+ if (!state.dead)
111
+ child.kill("SIGTERM");
112
+ throw await failure(state, agent, err);
113
+ }
114
+ live.set(state.sessionId, state);
115
+ return state;
116
+ }
117
+ /**
118
+ * The error to surface for a failed request. When the adapter died, the
119
+ * SDK rejects on stdout EOF before the process 'close' event carries the
120
+ * stderr tail — give that a moment and prefer it, it names the real cause.
121
+ */
122
+ async function failure(state, agent, err) {
123
+ const closing = /connection closed|closed/i.test(err.message ?? "");
124
+ if (state.dead || closing)
125
+ await Promise.race([state.closed, wait(1000)]);
126
+ if (state.exit)
127
+ return new Error(state.exit);
128
+ if (err instanceof RequestError)
129
+ return new Error(`${agent} (acp): ${err.message}`);
130
+ return err;
131
+ }
132
+ async function invoke(agent, inv) {
133
+ let s = inv.resume ? live.get(inv.resume) : undefined;
134
+ if (s?.dead) {
135
+ live.delete(s.sessionId);
136
+ s = undefined;
137
+ }
138
+ s ??= await open(agent, inv);
139
+ const turn = { text: "", onToolUse: inv.onToolUse, pending: new Map() };
140
+ s.turn = turn;
141
+ const prevCost = s.cost;
142
+ const started = Date.now();
143
+ // No per-prompt system prompt over ACP: carry persona + context in the prompt.
144
+ const prompt = `# Role and workspace context for this phase\n\n${inv.systemPrompt}\n\n` + `# Task\n\n${inv.prompt}`;
145
+ let res;
146
+ try {
147
+ res = await s.conn.prompt({ sessionId: s.sessionId, prompt: [{ type: "text", text: prompt }] });
148
+ }
149
+ catch (err) {
150
+ s.turn = null;
151
+ throw await failure(s, agent, err);
152
+ }
153
+ s.turn = null;
154
+ // Calls the adapter never named (no update carried a target) still count.
155
+ for (const p of turn.pending.values())
156
+ inv.onToolUse?.(p.kind, p.title);
157
+ if (res.stopReason !== "end_turn")
158
+ throw new Error(`${agent} (acp) stopped with ${res.stopReason}`);
159
+ if (inv.onText && turn.text.trim())
160
+ inv.onText(turn.text.trim());
161
+ const result = {
162
+ sessionId: s.sessionId,
163
+ text: turn.text,
164
+ durationMs: Date.now() - started,
165
+ };
166
+ if (res.usage) {
167
+ result.usage = {
168
+ inputTokens: res.usage.inputTokens,
169
+ outputTokens: res.usage.outputTokens,
170
+ cacheReadTokens: res.usage.cachedReadTokens ?? 0,
171
+ cacheCreationTokens: res.usage.cachedWriteTokens ?? 0,
172
+ };
173
+ }
174
+ if (s.cost > prevCost)
175
+ result.costUsd = s.cost - prevCost;
176
+ return result;
177
+ }
178
+ /**
179
+ * Stop the adapter processes of one run (its run directory), or of every
180
+ * run in this process when no directory is given.
181
+ */
182
+ export function disposeAcpSessions(cwd) {
183
+ for (const [id, s] of live) {
184
+ if (cwd && s.cwd !== cwd)
185
+ continue;
186
+ if (!s.dead)
187
+ s.child.kill("SIGTERM");
188
+ live.delete(id);
189
+ }
190
+ }
191
+ export function acpHarness(id) {
192
+ if (id === "pi")
193
+ throw new Error('runs-on "pi" has no agent-protocol adapter — use claude or codex, or protocol: cli');
194
+ return { id, invoke: (inv) => invoke(id, inv) };
195
+ }
@@ -1,2 +1,2 @@
1
- import type { Harness, HarnessId } from "../harness.js";
2
- export declare function harnessFor(id?: HarnessId): Harness;
1
+ import type { AgentProtocol, Harness, HarnessId } from "../harness.js";
2
+ export declare function harnessFor(id?: HarnessId, protocol?: AgentProtocol): Harness;
@@ -1,16 +1,21 @@
1
+ import { acpHarness } from "./acp.js";
1
2
  import { claudeHarness } from "./claude.js";
2
3
  import { codexHarness } from "./codex.js";
3
4
  import { piHarness } from "./pi.js";
4
5
  /**
5
- * Maps an agent's `harness` field to the adapter that runs it.
6
- * Default is claude — an agent without a harness runs on `claude -p`.
6
+ * Maps an agent's `harness` (+ `protocol`) to the adapter that runs it.
7
+ * Default is claude over the CLI — an agent without a harness runs on
8
+ * `claude -p`; `protocol: acp` drives the same harness over the Agent
9
+ * Client Protocol instead.
7
10
  */
8
11
  const HARNESSES = {
9
12
  claude: claudeHarness,
10
13
  codex: codexHarness,
11
14
  pi: piHarness,
12
15
  };
13
- export function harnessFor(id = "claude") {
16
+ export function harnessFor(id = "claude", protocol = "cli") {
17
+ if (protocol === "acp")
18
+ return acpHarness(id);
14
19
  const harness = HARNESSES[id];
15
20
  if (!harness) {
16
21
  throw new Error(`harness "${id}" is not registered (available: ${Object.keys(HARNESSES).join(", ")})`);
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  export { defineAgent, type AgentDefinition, type EffortLevel } from "./agent.js";
11
11
  export { type AgentInvocation, type AgentResult, type Harness, type HarnessId, type McpServerConfig, type TokenUsage, } from "./harness.js";
12
12
  export { harnessFor } from "./harnesses/registry.js";
13
+ export { disposeAcpSessions } from "./harnesses/acp.js";
13
14
  export { Run, type RunOptions } from "./run.js";
14
15
  export { correctionPrompt, envelopeContract, parseEnvelope, type Envelope, } from "./envelope.js";
15
16
  export { containsText, fileNonEmpty, slotsFilled, type Gate } from "./gates.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  export { defineAgent } from "./agent.js";
11
11
  export { harnessFor } from "./harnesses/registry.js";
12
+ export { disposeAcpSessions } from "./harnesses/acp.js";
12
13
  export { Run } from "./run.js";
13
14
  export { correctionPrompt, envelopeContract, parseEnvelope, } from "./envelope.js";
14
15
  export { containsText, fileNonEmpty, slotsFilled } from "./gates.js";
@@ -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,
@@ -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,