@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.
- package/README.md +25 -0
- package/dist/acp.d.ts +43 -0
- package/dist/acp.js +76 -0
- package/dist/agent.d.ts +3 -1
- package/dist/cli/doctor.js +10 -2
- package/dist/cli/kraftwerk.js +12 -6
- package/dist/harness.d.ts +6 -0
- package/dist/harnesses/acp.d.ts +20 -0
- package/dist/harnesses/acp.js +195 -0
- package/dist/harnesses/registry.d.ts +2 -2
- package/dist/harnesses/registry.js +8 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inspector/chat/acp.js +22 -72
- package/dist/inspector/runs.d.ts +1 -0
- package/dist/inspector/runs.js +1 -0
- package/dist/inspector/server.js +2 -2
- package/dist/inspector/workflows.d.ts +4 -0
- package/dist/inspector/workflows.js +16 -1
- package/dist/run.d.ts +1 -0
- package/dist/run.js +8 -4
- package/dist/stats.d.ts +2 -0
- package/dist/yaml.d.ts +12 -0
- package/dist/yaml.js +59 -34
- package/inspector/dist/assets/{dist-g0pveAaF.js → dist-5tG7aS9B.js} +1 -1
- package/inspector/dist/assets/{dist-DKds1beX.js → dist-C3XpBw6W.js} +1 -1
- package/inspector/dist/assets/{dist-CASrSR5E.js → dist-CE4xSQoL.js} +1 -1
- package/inspector/dist/assets/{dist-BO4gDbWB.js → dist-CvxuLI-i.js} +1 -1
- package/inspector/dist/assets/{dist-h7tJ3T-Z.js → dist-CxiuM_jC.js} +1 -1
- package/inspector/dist/assets/{dist-Bj7XitTo.js → dist-D8KGprRA.js} +1 -1
- package/inspector/dist/assets/{dist-CoT4dTT9.js → dist-DAeASF1A.js} +1 -1
- package/inspector/dist/assets/{dist-BvHfHbzl.js → dist-DmP7Dxbp.js} +1 -1
- package/inspector/dist/assets/{dist-BiIrAs8U.js → dist-DzY0J5Mz.js} +1 -1
- package/inspector/dist/assets/dist-IEe8XFs2.js +1 -0
- package/inspector/dist/assets/{dist-CqbReWDG.js → dist-W68I5dk2.js} +1 -1
- package/inspector/dist/assets/{editor-BiyVJ7Wz.js → editor-CyEovn1g.js} +3 -3
- package/inspector/dist/assets/{index-lwVGUk9H.js → index-B2d-nOUv.js} +16 -16
- package/inspector/dist/assets/index-BRw2ED9p.css +1 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/schema/workflow.schema.json +18 -4
- package/inspector/dist/assets/dist-Dg2YcNTD.js +0 -1
- 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;
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/cli/kraftwerk.js
CHANGED
|
@@ -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
|
|
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 &&
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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`
|
|
6
|
-
* Default is claude — an agent without a harness runs on
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
package/dist/inspector/runs.d.ts
CHANGED
package/dist/inspector/runs.js
CHANGED
package/dist/inspector/server.js
CHANGED
|
@@ -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: "
|
|
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,
|