@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.
Files changed (49) hide show
  1. package/README.md +68 -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/channels.d.ts +48 -0
  15. package/dist/inspector/channels.js +130 -0
  16. package/dist/inspector/chat/acp.js +22 -72
  17. package/dist/inspector/chat/sessions.d.ts +17 -1
  18. package/dist/inspector/chat/sessions.js +280 -44
  19. package/dist/inspector/chat/types.d.ts +18 -0
  20. package/dist/inspector/notifications.js +2 -0
  21. package/dist/inspector/runs.d.ts +1 -0
  22. package/dist/inspector/runs.js +1 -0
  23. package/dist/inspector/server.js +76 -4
  24. package/dist/inspector/workflows.d.ts +4 -0
  25. package/dist/inspector/workflows.js +16 -1
  26. package/dist/run.d.ts +1 -0
  27. package/dist/run.js +8 -4
  28. package/dist/stats.d.ts +2 -0
  29. package/dist/yaml.d.ts +12 -0
  30. package/dist/yaml.js +59 -34
  31. package/inspector/dist/assets/{dist-AXonJXol.js → dist-5tG7aS9B.js} +1 -1
  32. package/inspector/dist/assets/{dist-DdbyO9w9.js → dist-C3XpBw6W.js} +1 -1
  33. package/inspector/dist/assets/{dist-Dyv2EX0S.js → dist-CE4xSQoL.js} +1 -1
  34. package/inspector/dist/assets/{dist-HuQTcyVK.js → dist-CvxuLI-i.js} +1 -1
  35. package/inspector/dist/assets/{dist-BJLTdJRF.js → dist-CxiuM_jC.js} +1 -1
  36. package/inspector/dist/assets/{dist-uQIhtGKj.js → dist-D8KGprRA.js} +1 -1
  37. package/inspector/dist/assets/{dist-XL4xXfDo.js → dist-DAeASF1A.js} +1 -1
  38. package/inspector/dist/assets/{dist-D9-MFS0x.js → dist-DmP7Dxbp.js} +1 -1
  39. package/inspector/dist/assets/{dist-Bv3fOisq.js → dist-DzY0J5Mz.js} +1 -1
  40. package/inspector/dist/assets/dist-IEe8XFs2.js +1 -0
  41. package/inspector/dist/assets/{dist-Cex4_N6v.js → dist-W68I5dk2.js} +1 -1
  42. package/inspector/dist/assets/{editor--4qv8VP5.js → editor-CyEovn1g.js} +3 -3
  43. package/inspector/dist/assets/{index-BSqc9eg2.js → index-B2d-nOUv.js} +17 -17
  44. package/inspector/dist/assets/index-BRw2ED9p.css +1 -0
  45. package/inspector/dist/index.html +2 -2
  46. package/package.json +1 -1
  47. package/schema/workflow.schema.json +18 -4
  48. package/inspector/dist/assets/dist-UTSke4yL.js +0 -1
  49. 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;
@@ -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";
@@ -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[];