@netnodeag/kraftwerk 0.40.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 +68 -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/channels.d.ts +48 -0
- package/dist/inspector/channels.js +130 -0
- package/dist/inspector/chat/acp.js +22 -72
- package/dist/inspector/chat/sessions.d.ts +17 -1
- package/dist/inspector/chat/sessions.js +280 -44
- package/dist/inspector/chat/types.d.ts +18 -0
- package/dist/inspector/notifications.js +2 -0
- package/dist/inspector/runs.d.ts +1 -0
- package/dist/inspector/runs.js +1 -0
- package/dist/inspector/server.js +76 -4
- 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-AXonJXol.js → dist-5tG7aS9B.js} +1 -1
- package/inspector/dist/assets/{dist-DdbyO9w9.js → dist-C3XpBw6W.js} +1 -1
- package/inspector/dist/assets/{dist-Dyv2EX0S.js → dist-CE4xSQoL.js} +1 -1
- package/inspector/dist/assets/{dist-HuQTcyVK.js → dist-CvxuLI-i.js} +1 -1
- package/inspector/dist/assets/{dist-BJLTdJRF.js → dist-CxiuM_jC.js} +1 -1
- package/inspector/dist/assets/{dist-uQIhtGKj.js → dist-D8KGprRA.js} +1 -1
- package/inspector/dist/assets/{dist-XL4xXfDo.js → dist-DAeASF1A.js} +1 -1
- package/inspector/dist/assets/{dist-D9-MFS0x.js → dist-DmP7Dxbp.js} +1 -1
- package/inspector/dist/assets/{dist-Bv3fOisq.js → dist-DzY0J5Mz.js} +1 -1
- package/inspector/dist/assets/dist-IEe8XFs2.js +1 -0
- package/inspector/dist/assets/{dist-Cex4_N6v.js → dist-W68I5dk2.js} +1 -1
- package/inspector/dist/assets/{editor--4qv8VP5.js → editor-CyEovn1g.js} +3 -3
- package/inspector/dist/assets/{index-BSqc9eg2.js → index-B2d-nOUv.js} +17 -17
- 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-UTSke4yL.js +0 -1
- package/inspector/dist/assets/index-E1E7-_NW.css +0 -1
package/README.md
CHANGED
|
@@ -417,6 +417,49 @@ Manage routines on the agent page, where you can create, edit and
|
|
|
417
417
|
delete them, toggle enabled, hit "run now", and jump to the last run's
|
|
418
418
|
session. Schedules missed while the server is down are skipped, not replayed.
|
|
419
419
|
|
|
420
|
+
## Channels
|
|
421
|
+
|
|
422
|
+
A channel is one conversation shared by several agents and humans — a Slack
|
|
423
|
+
channel where the coworkers are agents. The definition is git-tracked with
|
|
424
|
+
the workspace, the transcript is a chat like any other:
|
|
425
|
+
|
|
426
|
+
```
|
|
427
|
+
channels/<slug>/channel.yml # name, purpose, members, responder, maxHops
|
|
428
|
+
output/chats/<chat-id>/ # the transcript (scope { kind: channel })
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
```yaml
|
|
432
|
+
name: Website relaunch
|
|
433
|
+
purpose: ship the new site by March
|
|
434
|
+
members: [researcher, writer, dev-ops] # agent slugs
|
|
435
|
+
responder: researcher # answers when nobody is @mentioned (optional)
|
|
436
|
+
maxHops: 3 # agent-to-agent handovers per human message
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Every member agent has its own seat in the channel: its own process, its
|
|
440
|
+
own persona, skills, model and permissions, exactly as in a direct session.
|
|
441
|
+
What it receives is not the whole transcript each time but the messages
|
|
442
|
+
since its last turn, each prefixed with the author (`[Lukas]:` a human,
|
|
443
|
+
`[@writer]:` an agent). Who answers:
|
|
444
|
+
|
|
445
|
+
- `@mention` an agent and it wakes; several mentions run in parallel and
|
|
446
|
+
their replies stream in as separate posts.
|
|
447
|
+
- No mention: the channel's responder answers, or nobody if none is set.
|
|
448
|
+
- Agents hand over by mentioning each other; `maxHops` bounds the chain
|
|
449
|
+
per human message so two agents never talk forever.
|
|
450
|
+
- Humans are never blocked. An agent that is busy when mentioned again runs
|
|
451
|
+
once more when its turn ends, with everything it missed.
|
|
452
|
+
- Permission questions from any agent show as cards in the channel and in
|
|
453
|
+
the bell; anyone present can answer.
|
|
454
|
+
|
|
455
|
+
Create channels on the channels screen, or from an existing agent session
|
|
456
|
+
with **add coworker**: the session becomes the channel's transcript, the
|
|
457
|
+
agent keeps its process and memory, and the agents you pick join. Your
|
|
458
|
+
messages carry the name set in the composer ("posting as"), stored per
|
|
459
|
+
browser. API: `GET/POST /api/channels`, `GET/PUT/DELETE /api/channels/:slug`,
|
|
460
|
+
`POST /api/channels/from-chat {chatId, name, members}`; messages go through
|
|
461
|
+
the chat endpoint with `from` for the poster's name.
|
|
462
|
+
|
|
420
463
|
## Context & Knowledge
|
|
421
464
|
|
|
422
465
|
Alongside runs and chats, a project can keep curated knowledge as
|
|
@@ -540,6 +583,31 @@ Prerequisites per harness:
|
|
|
540
583
|
models reuse the Claude subscription OAuth, other vendors need their key in
|
|
541
584
|
the env (check with `pi auth check --provider deepseek`).
|
|
542
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
|
+
|
|
543
611
|
## YAML workflows
|
|
544
612
|
|
|
545
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";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channels: one transcript shared by several agents and humans — a Slack
|
|
3
|
+
* channel where the coworkers are agents. The definition is git-tracked
|
|
4
|
+
* with the workspace so the whole team sees the same channels:
|
|
5
|
+
*
|
|
6
|
+
* channels/<slug>/channel.yml # name, purpose, members (agent slugs), responder, maxHops
|
|
7
|
+
*
|
|
8
|
+
* The transcript itself is a chat (scope { kind: "channel", slug }) under
|
|
9
|
+
* <output>/chats/, run-state like every other chat. sessions.ts gives each
|
|
10
|
+
* member agent its own seat (process) in that chat and routes messages:
|
|
11
|
+
* an @mention wakes that agent, no mention wakes the channel's responder,
|
|
12
|
+
* an agent mentioning another hands over — bounded by maxHops per human
|
|
13
|
+
* message so two agents never talk forever.
|
|
14
|
+
*/
|
|
15
|
+
export interface Channel {
|
|
16
|
+
slug: string;
|
|
17
|
+
name: string;
|
|
18
|
+
purpose?: string;
|
|
19
|
+
/** Agent slugs that sit in the channel. */
|
|
20
|
+
members: string[];
|
|
21
|
+
/** Who answers a message that mentions nobody; absent = nobody does. */
|
|
22
|
+
responder?: string;
|
|
23
|
+
/** Agent-to-agent handovers allowed per human message. */
|
|
24
|
+
maxHops: number;
|
|
25
|
+
}
|
|
26
|
+
export declare const DEFAULT_MAX_HOPS = 3;
|
|
27
|
+
export declare function channelsRoot(): Promise<string>;
|
|
28
|
+
export declare function safeChannelSlug(slug: string): string;
|
|
29
|
+
export declare function listChannels(): Promise<Channel[]>;
|
|
30
|
+
export declare function getChannel(slug: string): Promise<Channel | null>;
|
|
31
|
+
export interface SaveChannelInput {
|
|
32
|
+
slug?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
purpose?: string;
|
|
35
|
+
members?: string[];
|
|
36
|
+
/** Agent slug, or "" / null to clear. */
|
|
37
|
+
responder?: string | null;
|
|
38
|
+
maxHops?: number;
|
|
39
|
+
}
|
|
40
|
+
/** Create (no slug: derived from the name) or update a channel definition. */
|
|
41
|
+
export declare function saveChannel(input: SaveChannelInput): Promise<Channel>;
|
|
42
|
+
export declare function deleteChannel(slug: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Who a channel message wakes. @mentions of members win (in text order, each
|
|
45
|
+
* once); a message that mentions nobody goes to the responder, or to nobody.
|
|
46
|
+
* `self` (the author, when an agent) never wakes itself.
|
|
47
|
+
*/
|
|
48
|
+
export declare function mentionTargets(text: string, channel: Pick<Channel, "members" | "responder">, self?: string): string[];
|