@wrongstack/acp 0.293.0 → 0.295.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +22 -2
  2. package/dist/agent/protocol-contract.d.ts +210 -0
  3. package/dist/agent/protocol-contract.d.ts.map +1 -0
  4. package/dist/agent/protocol-handler.d.ts +15 -189
  5. package/dist/agent/protocol-handler.d.ts.map +1 -1
  6. package/dist/agent/server-agent-turn.d.ts +89 -1
  7. package/dist/agent/server-agent-turn.d.ts.map +1 -1
  8. package/dist/agent/session-store.d.ts.map +1 -1
  9. package/dist/agent/stdio-transport.d.ts +30 -1
  10. package/dist/agent/stdio-transport.d.ts.map +1 -1
  11. package/dist/agent/tools-registry.d.ts +1 -1
  12. package/dist/agent/tools-registry.d.ts.map +1 -1
  13. package/dist/agent/wrongstack-acp-agent.d.ts +37 -0
  14. package/dist/agent/wrongstack-acp-agent.d.ts.map +1 -1
  15. package/dist/agent.js +189 -32
  16. package/dist/agent.js.map +4 -4
  17. package/dist/client/acp-session.d.ts +13 -1
  18. package/dist/client/acp-session.d.ts.map +1 -1
  19. package/dist/client/file-server.d.ts +19 -0
  20. package/dist/client/file-server.d.ts.map +1 -1
  21. package/dist/client/index.d.ts +11 -9
  22. package/dist/client/index.d.ts.map +1 -1
  23. package/dist/client/terminal-server.d.ts +5 -0
  24. package/dist/client/terminal-server.d.ts.map +1 -1
  25. package/dist/client/tool-translator.d.ts +1 -1
  26. package/dist/client/tool-translator.d.ts.map +1 -1
  27. package/dist/client/trust-boundary-permission.d.ts +31 -0
  28. package/dist/client/trust-boundary-permission.d.ts.map +1 -0
  29. package/dist/client/websocket-transport.d.ts +6 -0
  30. package/dist/client/websocket-transport.d.ts.map +1 -1
  31. package/dist/client.js +459 -243
  32. package/dist/client.js.map +4 -4
  33. package/dist/index.d.ts +29 -29
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2116 -1802
  36. package/dist/index.js.map +4 -4
  37. package/dist/integration/acp-bench.d.ts +32 -0
  38. package/dist/integration/acp-bench.d.ts.map +1 -1
  39. package/dist/integration/acp-subagent-runner.d.ts +3 -3
  40. package/dist/integration/acp-subagent-runner.d.ts.map +1 -1
  41. package/dist/integration/ensemble-runner.d.ts +22 -1
  42. package/dist/integration/ensemble-runner.d.ts.map +1 -1
  43. package/dist/legacy.d.ts +8 -0
  44. package/dist/legacy.d.ts.map +1 -0
  45. package/dist/legacy.js +6 -0
  46. package/dist/legacy.js.map +7 -0
  47. package/dist/registry/acp-registry-fetch.d.ts +1 -1
  48. package/dist/registry/acp-registry-fetch.d.ts.map +1 -1
  49. package/dist/registry/ensemble-registry.d.ts +22 -0
  50. package/dist/registry/ensemble-registry.d.ts.map +1 -1
  51. package/dist/sdk.d.ts +10 -8
  52. package/dist/sdk.d.ts.map +1 -1
  53. package/dist/sdk.js +22 -0
  54. package/dist/sdk.js.map +3 -3
  55. package/dist/v1.d.ts +3 -0
  56. package/dist/v1.d.ts.map +1 -0
  57. package/dist/v1.js +12 -0
  58. package/dist/v1.js.map +7 -0
  59. package/dist/version.d.ts +10 -0
  60. package/dist/version.d.ts.map +1 -0
  61. package/dist/wrongstack-acp-agent.js +121 -24
  62. package/dist/wrongstack-acp-agent.js.map +4 -4
  63. package/package.json +10 -2
package/dist/index.js CHANGED
@@ -1,628 +1,524 @@
1
- // src/agent/stdio-transport.ts
2
- import { expectDefined, writeErr } from "@wrongstack/core";
3
-
4
- // src/win32-cmd.ts
5
- var WIN32_CMD_META = /[&|<>"\r\n\0]/;
6
- function buildWin32CmdShimInvocation(command, args = []) {
7
- assertSafeWin32CmdArgs([command, ...args]);
8
- const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
9
- return {
10
- command: process.env["COMSPEC"] ?? "cmd.exe",
11
- args: ["/d", "/c", line],
12
- windowsVerbatimArguments: true
13
- };
1
+ // src/types/acp-v1.ts
2
+ var ACP_PROTOCOL_VERSION = 1;
3
+ function assertNeverSessionUpdate(x) {
4
+ throw new Error(
5
+ `Unhandled sessionUpdate: ${JSON.stringify(x)}`
6
+ );
14
7
  }
15
- function assertSafeWin32CmdArgs(args) {
16
- for (const arg of args) {
17
- if (typeof arg === "string" && WIN32_CMD_META.test(arg)) {
18
- throw new Error(
19
- 'win32 cmd shim spawn: argument contains a shell metacharacter (one of & | < > ", or a newline) that could enable command injection through the .cmd/.bat wrapper - refusing to run. Offending argument: ' + JSON.stringify(arg)
20
- );
8
+
9
+ // src/version.ts
10
+ import { createRequire } from "node:module";
11
+ var require2 = createRequire(import.meta.url);
12
+ function readPackageVersion(load = () => require2("../package.json")) {
13
+ try {
14
+ const packageJson = load();
15
+ if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
16
+ return packageJson.version;
21
17
  }
18
+ } catch {
22
19
  }
20
+ return "dev";
23
21
  }
24
- function quoteWin32CmdArg(arg) {
25
- return `"${arg}"`;
22
+ var ACP_PACKAGE_VERSION = readPackageVersion();
23
+
24
+ // src/agent/protocol-contract.ts
25
+ function toWire(msg) {
26
+ return msg;
26
27
  }
28
+ var WRONGSTACK_VERSION = ACP_PACKAGE_VERSION;
27
29
 
28
- // src/agent/stdio-transport.ts
29
- var StdioTransport = class {
30
- stdin = process.stdin;
31
- stdout = process.stdout;
32
- stderr = process.stderr;
33
- buffer = "";
34
- handlers = /* @__PURE__ */ new Set();
35
- closed = false;
36
- resolveRead = null;
37
- messageQueue = [];
38
- constructor() {
39
- this.stdin.resume();
40
- this.stdin.setEncoding("utf8");
41
- this.stdin.on("data", (chunk) => this.onData(chunk));
42
- this.stdin.on("end", () => this.handleClose());
43
- this.stdin.on("error", (err) => this.failAll(err));
30
+ // src/agent/protocol-handler.ts
31
+ var WRONGSTACK_AUTH_METHODS = [
32
+ {
33
+ id: "wrongstack-auth",
34
+ name: "Run wstack auth",
35
+ description: "Configure a WrongStack model provider in an interactive terminal.",
36
+ type: "terminal",
37
+ args: ["auth"]
44
38
  }
45
- sendStartupMarker() {
46
- this.stdout.write("[wstack-acp]\n", "utf8");
39
+ ];
40
+ var DEFAULT_MODE_ID = "code";
41
+ var DEFAULT_MAX_SESSIONS = 64;
42
+ var DEFAULT_MODES = [
43
+ {
44
+ id: DEFAULT_MODE_ID,
45
+ name: "Code",
46
+ description: "Default agent mode for code-generation tasks."
47
47
  }
48
- send(msg) {
49
- if (this.closed) return Promise.resolve();
50
- return new Promise((resolve3) => {
51
- const line = JSON.stringify(msg) + "\n";
52
- this.stdout.write(line, "utf8", () => resolve3());
48
+ ];
49
+ var ACPProtocolHandler = class {
50
+ transport;
51
+ defaultCwd;
52
+ runTurn;
53
+ onSessionNew;
54
+ modes;
55
+ configOptions;
56
+ agentName;
57
+ replayFor;
58
+ seedFor;
59
+ disposeFor;
60
+ maxSessions;
61
+ store;
62
+ initialized = false;
63
+ clientCapabilities = {};
64
+ sessions = /* @__PURE__ */ new Map();
65
+ nextId = 1;
66
+ // Outbound request correlation (server → client requests, e.g.
67
+ // session/request_permission). Keyed by our own `srv_N` ids.
68
+ pendingOut = /* @__PURE__ */ new Map();
69
+ nextOutId = 1;
70
+ constructor(opts) {
71
+ this.transport = opts.transport;
72
+ this.defaultCwd = opts.defaultCwd;
73
+ this.runTurn = opts.runTurn;
74
+ this.onSessionNew = opts.onSessionNew ?? (() => {
53
75
  });
76
+ this.modes = opts.modes ?? DEFAULT_MODES;
77
+ this.configOptions = opts.configOptions ?? [];
78
+ this.agentName = opts.agentName ?? "wrongstack";
79
+ this.replayFor = opts.replayFor;
80
+ this.seedFor = opts.seedFor;
81
+ this.disposeFor = opts.disposeFor;
82
+ this.maxSessions = Number.isFinite(opts.maxSessions) && opts.maxSessions > 0 ? Math.floor(opts.maxSessions) : DEFAULT_MAX_SESSIONS;
83
+ this.store = opts.store;
84
+ if (typeof this.transport.onMessage === "function") {
85
+ this.transport.onMessage((m) => this.maybeResolvePending(m));
86
+ }
54
87
  }
55
- sendRaw(chunk) {
56
- this.stdout.write(chunk, "utf8");
57
- }
58
- read() {
59
- if (this.messageQueue.length > 0) return Promise.resolve(expectDefined(this.messageQueue.shift()));
60
- if (this.closed) return Promise.resolve(null);
61
- return new Promise((resolve3) => {
62
- this.resolveRead = resolve3;
88
+ /**
89
+ * Send a request to the client and await its response. Used for
90
+ * server-initiated calls like `session/request_permission`. Rejects on
91
+ * timeout or transport error so the caller can pick a safe fallback.
92
+ */
93
+ request(method, params, timeoutMs = 6e4) {
94
+ const id = `srv_${this.nextOutId++}`;
95
+ return new Promise((resolve3, reject) => {
96
+ const timer = setTimeout(() => {
97
+ this.pendingOut.delete(id);
98
+ reject(new Error(`${method} timed out after ${timeoutMs}ms`));
99
+ }, timeoutMs);
100
+ this.pendingOut.set(id, { resolve: resolve3, reject, timer });
101
+ this.transport.send(toWire({ jsonrpc: "2.0", id, method, params })).catch((e) => {
102
+ clearTimeout(timer);
103
+ this.pendingOut.delete(id);
104
+ reject(e instanceof Error ? e : new Error(String(e)));
105
+ });
63
106
  });
64
107
  }
65
- onMessage(handler) {
66
- this.handlers.add(handler);
67
- return () => this.handlers.delete(handler);
108
+ maybeResolvePending(m) {
109
+ const id = m.id;
110
+ if (typeof id !== "string") return;
111
+ const pending = this.pendingOut.get(id);
112
+ if (!pending) return;
113
+ this.pendingOut.delete(id);
114
+ clearTimeout(pending.timer);
115
+ const err = m.error;
116
+ if (err) pending.reject(new Error(err.message ?? "client request failed"));
117
+ else pending.resolve(m.result);
118
+ }
119
+ /**
120
+ * Process one inbound message. Returns true if this was a terminal
121
+ * message (rare; reserved for future use by the server's own
122
+ * shutdown signal).
123
+ */
124
+ async handleMessage(msg) {
125
+ if (typeof msg !== "object" || msg === null) return false;
126
+ const m = msg;
127
+ if (m.id !== void 0 && (m.result !== void 0 || m.error !== void 0)) {
128
+ return false;
129
+ }
130
+ if (m.id !== void 0 && typeof m.method === "string") {
131
+ return this.handleRequest(m.id, m.method, m.params);
132
+ }
133
+ if (typeof m.method === "string") {
134
+ return this.handleNotification(m.method, m.params);
135
+ }
136
+ return false;
68
137
  }
138
+ /** Abort all active turns and drop session state. */
69
139
  close() {
70
- this.closed = true;
71
- this.stdin.pause();
72
- this.resolveRead?.(null);
73
- this.resolveRead = null;
140
+ for (const [sessionId, session] of this.sessions) {
141
+ session.abort.abort();
142
+ this.disposeSession(sessionId);
143
+ }
144
+ this.sessions.clear();
145
+ for (const [, p] of this.pendingOut) {
146
+ clearTimeout(p.timer);
147
+ p.reject(new Error("protocol handler closed"));
148
+ }
149
+ this.pendingOut.clear();
74
150
  }
75
- onData(chunk) {
76
- this.buffer += chunk;
77
- const lines = this.buffer.split("\n");
78
- this.buffer = lines.pop() ?? "";
79
- for (const raw of lines) {
80
- if (!raw.trim()) continue;
81
- try {
82
- this.dispatch(JSON.parse(raw));
83
- } catch (err) {
84
- this.stderr.write(`[wstack-acp parse error] ${err}
85
- `, "utf8");
86
- }
151
+ disposeSession(sessionId) {
152
+ try {
153
+ this.disposeFor?.(sessionId);
154
+ } catch {
87
155
  }
88
156
  }
89
- dispatch(msg) {
90
- if (this.resolveRead) {
91
- const resolve3 = this.resolveRead;
92
- this.resolveRead = null;
93
- resolve3(msg);
94
- } else {
95
- this.messageQueue.push(msg);
157
+ // ────────────────────────────────────────────────────────────────────
158
+ // Requests
159
+ // ────────────────────────────────────────────────────────────────────
160
+ async handleRequest(id, method, params) {
161
+ if (method !== "initialize" && !this.initialized) {
162
+ await this.sendError(id, -32e3, "Not initialized");
163
+ return false;
96
164
  }
97
- for (const handler of this.handlers) {
98
- try {
99
- handler(msg);
100
- } catch (err) {
101
- this.stderr.write(`[wstack-acp handler error] ${err}
102
- `, "utf8");
165
+ try {
166
+ switch (method) {
167
+ case "initialize":
168
+ return await this.handleInitialize(id, params);
169
+ case "authenticate":
170
+ return await this.handleAuthenticate(id, params);
171
+ case "logout":
172
+ return await this.handleLogout(id, params);
173
+ case "session/new":
174
+ return await this.handleSessionNew(id, params);
175
+ case "session/load":
176
+ return await this.handleSessionLoad(id, params);
177
+ case "session/resume":
178
+ return await this.handleSessionResume(id, params);
179
+ case "session/close":
180
+ return await this.handleSessionClose(id, params);
181
+ case "session/delete":
182
+ return await this.handleSessionDelete(id, params);
183
+ case "session/prompt":
184
+ return await this.handleSessionPrompt(id, params);
185
+ case "session/set_mode":
186
+ return await this.handleSetMode(id, params);
187
+ case "session/set_config_option":
188
+ return await this.handleSetConfigOption(id, params);
189
+ case "session/list":
190
+ return await this.handleSessionList(id);
191
+ case "session/fork":
192
+ return await this.handleSessionFork(id, params);
193
+ case "providers/list":
194
+ return await this.handleProvidersList(id, params);
195
+ case "providers/set":
196
+ return await this.handleProvidersSet(id, params);
197
+ case "providers/disable":
198
+ return await this.handleProvidersDisable(id, params);
199
+ case "mcp/message":
200
+ return await this.handleMcpMessage(id, params);
201
+ default:
202
+ await this.sendError(id, -32601, `Unknown method: ${method}`);
203
+ return false;
103
204
  }
205
+ } catch (err) {
206
+ const { code, message, data } = errorToJsonRpc(err);
207
+ await this.sendError(id, code, message, data);
208
+ return false;
104
209
  }
105
210
  }
106
- handleClose() {
107
- this.closed = true;
108
- this.resolveRead?.(null);
109
- this.resolveRead = null;
110
- }
111
- failAll(err) {
112
- this.stderr.write(`[wstack-acp stdin error] ${err.message}
113
- `, "utf8");
114
- this.close();
115
- }
116
- };
117
- var ClientTransport = class {
118
- child = null;
119
- buffer = "";
120
- handlers = /* @__PURE__ */ new Set();
121
- closed = false;
122
- resolveRead = null;
123
- messageQueue = [];
124
- opts;
125
- constructor(options) {
126
- this.opts = {
127
- handshakeTimeoutMs: 3e4,
128
- ...options
129
- };
130
- }
131
- async start() {
132
- if (this.child) return;
133
- const [{ spawn: spawn3 }, { buildChildEnv: buildChildEnv2 }, os] = await Promise.all([
134
- import("node:child_process"),
135
- import("@wrongstack/core"),
136
- import("node:os")
137
- ]);
138
- return new Promise((resolve3, reject) => {
139
- const timeout = setTimeout(() => {
140
- reject(
141
- new Error(`ACP child process failed to start within ${this.opts.handshakeTimeoutMs}ms`)
142
- );
143
- }, this.opts.handshakeTimeoutMs);
144
- const isPkgLauncher = this.opts.command === "npx" || this.opts.command === "uvx";
145
- const spawnCwd = isPkgLauncher ? os.homedir() : this.opts.cwd;
146
- try {
147
- const childArgs = this.opts.args ?? [];
148
- const shim = process.platform === "win32" ? buildWin32CmdShimInvocation(this.opts.command, childArgs) : null;
149
- this.child = spawn3(shim?.command ?? this.opts.command, shim?.args ?? childArgs, {
150
- env: { ...buildChildEnv2(), ...this.opts.env },
151
- cwd: spawnCwd,
152
- stdio: ["pipe", "pipe", "pipe"],
153
- windowsHide: true,
154
- ...shim ? { windowsVerbatimArguments: shim.windowsVerbatimArguments } : {}
155
- });
156
- } catch (err) {
157
- clearTimeout(timeout);
158
- reject(err);
159
- return;
160
- }
161
- const child = this.child;
162
- child.stdout.setEncoding("utf8");
163
- let settled = false;
164
- const onSpawnFailure = (err) => {
165
- if (settled) {
166
- this.closed = true;
167
- return;
168
- }
169
- settled = true;
170
- clearTimeout(timeout);
171
- reject(err);
172
- };
173
- child.on("error", onSpawnFailure);
174
- child.stdout.on("error", onSpawnFailure);
175
- if (this.opts.skipHandshakeMarker) {
176
- child.stdout.on("data", (c) => this.onChildData(c));
177
- child.stderr.on("data", (c) => this.onChildError(c));
178
- child.on("close", (code) => this.onChildClose(code));
179
- child.once("spawn", () => {
180
- if (settled) return;
181
- settled = true;
182
- clearTimeout(timeout);
183
- resolve3();
184
- });
185
- return;
186
- }
187
- const onReady = () => {
188
- if (settled) return;
189
- settled = true;
190
- child.stdout.on("data", (c) => this.onChildData(c));
191
- child.stderr.on("data", (c) => this.onChildError(c));
192
- child.on("close", (code) => this.onChildClose(code));
193
- clearTimeout(timeout);
194
- resolve3();
195
- };
196
- const waitForMarker = (chunk) => {
197
- this.buffer += chunk;
198
- const idx = this.buffer.indexOf("[wstack-acp]\n");
199
- if (idx !== -1) {
200
- this.buffer = this.buffer.slice(idx + "[wstack-acp]\n".length);
201
- child.stdout.removeListener("data", waitForMarker);
202
- onReady();
203
- }
204
- };
205
- child.stdout.on("data", waitForMarker);
206
- });
207
- }
208
- send(msg) {
209
- if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
210
- return new Promise((resolve3, reject) => {
211
- const line = JSON.stringify(msg) + "\n";
212
- this.child?.stdin.write(line, "utf8", (err) => {
213
- if (err) reject(err);
214
- else resolve3();
215
- });
216
- });
217
- }
218
- read() {
219
- if (this.messageQueue.length > 0) return Promise.resolve(expectDefined(this.messageQueue.shift()));
220
- if (this.closed) return Promise.resolve(null);
221
- return new Promise((resolve3) => {
222
- this.resolveRead = resolve3;
223
- });
224
- }
225
- onMessage(handler) {
226
- this.handlers.add(handler);
227
- return () => this.handlers.delete(handler);
228
- }
229
- stop() {
230
- if (!this.child) return;
231
- this.closed = true;
232
- try {
233
- this.child.kill();
234
- } catch {
211
+ async handleInitialize(id, params) {
212
+ const p = params ?? {};
213
+ if (p.clientCapabilities && typeof p.clientCapabilities === "object") {
214
+ this.clientCapabilities = p.clientCapabilities;
235
215
  }
236
- this.child = null;
237
- }
238
- onChildData(chunk) {
239
- this.buffer += chunk;
240
- const lines = this.buffer.split("\n");
241
- this.buffer = lines.pop() ?? "";
242
- for (const raw of lines) {
243
- if (!raw.trim()) continue;
244
- try {
245
- this.dispatch(JSON.parse(raw));
246
- } catch {
216
+ this.initialized = true;
217
+ await this.transport.send(toWire({
218
+ jsonrpc: "2.0",
219
+ id,
220
+ result: {
221
+ protocolVersion: ACP_PROTOCOL_VERSION,
222
+ agentCapabilities: {
223
+ loadSession: true,
224
+ promptCapabilities: {
225
+ // We route ACP image blocks into the core agent's multimodal
226
+ // input (server-agent-turn.promptToAgentInput); whether the
227
+ // model can see them is the configured provider's concern.
228
+ image: true,
229
+ audio: false,
230
+ embeddedContext: true
231
+ },
232
+ mcpCapabilities: {
233
+ http: false,
234
+ sse: false
235
+ },
236
+ sessionCapabilities: {
237
+ close: {},
238
+ list: {},
239
+ delete: {},
240
+ resume: {},
241
+ fork: {}
242
+ },
243
+ auth: {
244
+ logout: {}
245
+ }
246
+ },
247
+ agentInfo: {
248
+ name: this.agentName,
249
+ title: "WrongStack",
250
+ version: WRONGSTACK_VERSION
251
+ },
252
+ authMethods: WRONGSTACK_AUTH_METHODS,
253
+ modes: this.modes,
254
+ configOptions: this.configOptions
247
255
  }
248
- }
256
+ }));
257
+ return false;
249
258
  }
250
- onChildError(chunk) {
251
- writeErr(`[acp-child stderr] ${chunk}`);
259
+ async handleAuthenticate(id, _params) {
260
+ await this.transport.send(toWire({
261
+ jsonrpc: "2.0",
262
+ id,
263
+ result: { outcome: "unauthenticated" }
264
+ }));
265
+ return false;
252
266
  }
253
- onChildClose(code) {
254
- this.closed = true;
255
- this.resolveRead?.(null);
256
- this.resolveRead = null;
257
- if (code !== 0 && code !== null) {
258
- writeErr(`[acp-child exited with code ${code}]
259
- `);
260
- }
267
+ async handleLogout(id, _params) {
268
+ await this.transport.send(toWire({
269
+ jsonrpc: "2.0",
270
+ id,
271
+ result: {}
272
+ }));
273
+ return false;
261
274
  }
262
- dispatch(msg) {
263
- if (this.resolveRead) {
264
- const resolve3 = this.resolveRead;
265
- this.resolveRead = null;
266
- resolve3(msg);
267
- } else {
268
- this.messageQueue.push(msg);
275
+ async handleSessionNew(id, params) {
276
+ if (this.sessions.size >= this.maxSessions) {
277
+ await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
278
+ return false;
269
279
  }
270
- for (const handler of this.handlers) {
271
- try {
272
- handler(msg);
273
- } catch {
280
+ const p = params ?? {};
281
+ const cwd = typeof p.cwd === "string" ? p.cwd : this.defaultCwd;
282
+ const sessionId = `sess_${this.allocId()}`;
283
+ const now = (/* @__PURE__ */ new Date()).toISOString();
284
+ const state = {
285
+ id: sessionId,
286
+ cwd,
287
+ abort: new AbortController(),
288
+ modeId: DEFAULT_MODE_ID,
289
+ createdAt: now,
290
+ updatedAt: now
291
+ };
292
+ this.sessions.set(sessionId, state);
293
+ this.onSessionNew(state);
294
+ await this.persist(state);
295
+ await this.sendNotification({
296
+ sessionId,
297
+ update: {
298
+ sessionUpdate: "current_mode_update",
299
+ modeId: this.modes[0]?.id ?? DEFAULT_MODE_ID
274
300
  }
301
+ });
302
+ if (this.configOptions.length > 0) {
303
+ await this.sendNotification({
304
+ sessionId,
305
+ update: {
306
+ sessionUpdate: "config_option_update",
307
+ configOptions: [...this.configOptions]
308
+ }
309
+ });
275
310
  }
311
+ await this.transport.send(toWire({
312
+ jsonrpc: "2.0",
313
+ id,
314
+ result: {
315
+ sessionId,
316
+ modes: this.modes,
317
+ configOptions: this.configOptions
318
+ }
319
+ }));
320
+ return false;
276
321
  }
277
- };
278
-
279
- // src/agent/tools-registry.ts
280
- var ACPToolsRegistry = class {
281
- tools = /* @__PURE__ */ new Map();
282
- owner;
283
- constructor(owner = "wrongstack") {
284
- this.owner = owner;
285
- }
286
- /**
287
- * Register one or more tools.
288
- * Throws on duplicate name unless force=true.
289
- */
290
- register(tools) {
291
- for (const tool of tools) {
292
- this.tools.set(tool.name, tool);
322
+ async handleSessionLoad(id, params) {
323
+ const p = params ?? {};
324
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
325
+ const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
326
+ const existing = sessionId ? this.sessions.get(sessionId) : void 0;
327
+ if (!existing && sessionId && this.store) {
328
+ const persisted = await this.store.load(sessionId);
329
+ if (persisted) {
330
+ if (this.sessions.size >= this.maxSessions) {
331
+ await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
332
+ return false;
333
+ }
334
+ const restored = {
335
+ id: sessionId,
336
+ cwd: persisted.cwd ?? loadCwd ?? this.defaultCwd,
337
+ abort: new AbortController(),
338
+ modeId: persisted.modeId ?? DEFAULT_MODE_ID,
339
+ createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
340
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
341
+ ...persisted.title !== void 0 ? { title: persisted.title } : {}
342
+ };
343
+ this.sessions.set(sessionId, restored);
344
+ this.seedFor?.(sessionId, persisted.history ?? []);
345
+ for (const update of persisted.history ?? []) {
346
+ await this.sendNotification({ sessionId, update });
347
+ }
348
+ await this.sendNotification({
349
+ sessionId,
350
+ update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
351
+ });
352
+ await this.transport.send(toWire({
353
+ jsonrpc: "2.0",
354
+ id,
355
+ result: {
356
+ initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
357
+ }
358
+ }));
359
+ return false;
360
+ }
293
361
  }
294
- }
295
- /**
296
- * Replace the current tool set.
297
- */
298
- setTools(tools) {
299
- this.tools.clear();
300
- for (const tool of tools) this.tools.set(tool.name, tool);
301
- }
302
- get(name) {
303
- return this.tools.get(name);
304
- }
305
- has(name) {
306
- return this.tools.has(name);
307
- }
308
- list() {
309
- return Array.from(this.tools.values());
310
- }
311
- /** Build the ACP tools/list payload from registered tools. */
312
- buildToolList() {
313
- return {
314
- tools: Array.from(this.tools.values()).map(
315
- (t) => toACPToolDefinition(t, this.owner)
316
- )
317
- };
318
- }
319
- /**
320
- * Execute a tool by name and return ACP-formatted result.
321
- * Returns null if the tool is not found.
322
- */
323
- async execute(name, args, ctx, signal) {
324
- const tool = this.tools.get(name);
325
- if (!tool) return null;
326
- try {
327
- const result = await tool.execute(args, ctx, {
328
- signal
362
+ if (existing) {
363
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
364
+ const replay = this.replayFor?.(sessionId);
365
+ if (replay) {
366
+ for (const update of replay) {
367
+ await this.sendNotification({ sessionId, update });
368
+ }
369
+ }
370
+ await this.sendNotification({
371
+ sessionId,
372
+ update: {
373
+ sessionUpdate: "session_info_update",
374
+ updatedAt: existing.updatedAt
375
+ }
329
376
  });
330
- return toACPToolResult(result);
331
- } catch (err) {
332
- const msg = err instanceof Error ? err.message : String(err);
333
- return { content: [{ type: "text", text: msg }], isError: true };
377
+ await this.sendNotification({
378
+ sessionId,
379
+ update: {
380
+ sessionUpdate: "current_mode_update",
381
+ modeId: existing.modeId
382
+ }
383
+ });
384
+ await this.transport.send(toWire({
385
+ jsonrpc: "2.0",
386
+ id,
387
+ result: {
388
+ initialMode: {
389
+ currentModeId: existing.modeId,
390
+ availableModes: this.modes
391
+ }
392
+ }
393
+ }));
394
+ return false;
334
395
  }
396
+ await this.sendError(id, -32e3, `session not found: ${sessionId}`);
397
+ return false;
335
398
  }
336
- };
337
- function toACPToolDefinition(tool, _owner) {
338
- return {
339
- name: tool.name,
340
- description: tool.description,
341
- inputSchema: toACPInputSchema(tool.inputSchema),
342
- annotations: {
343
- title: tool.name,
344
- description: tool.usageHint ?? tool.description,
345
- priority: toolToPriority(tool),
346
- alwaysAccept: tool.permission === "auto"
399
+ async handleSessionResume(id, params) {
400
+ const p = params ?? {};
401
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
402
+ const existing = sessionId ? this.sessions.get(sessionId) : void 0;
403
+ if (existing) {
404
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
405
+ await this.transport.send(toWire({
406
+ jsonrpc: "2.0",
407
+ id,
408
+ result: {
409
+ initialMode: {
410
+ currentModeId: existing.modeId,
411
+ availableModes: this.modes
412
+ }
413
+ }
414
+ }));
415
+ return false;
347
416
  }
348
- };
349
- }
350
- function toACPInputSchema(src) {
351
- if (!src || typeof src !== "object") {
352
- return {};
417
+ await this.sendError(id, -32e3, `session not found: ${sessionId}`);
418
+ return false;
353
419
  }
354
- const s = src;
355
- const out = {};
356
- if (typeof s.type === "string") out.type = s.type;
357
- if (Array.isArray(s.enum)) out.enum = s.enum;
358
- if (typeof s.description === "string") out.description = s.description;
359
- if ("default" in s) out.default = s.default;
360
- if (typeof s.minimum === "number") out.minimum = s.minimum;
361
- if (typeof s.maximum === "number") out.maximum = s.maximum;
362
- if (s.items) out.items = toACPInputSchema(s.items);
363
- if (s.properties && typeof s.properties === "object") {
364
- const props = {};
365
- for (const [k, v] of Object.entries(s.properties)) {
366
- props[k] = toACPInputSchema(v);
420
+ async handleSessionClose(id, params) {
421
+ const p = params ?? {};
422
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
423
+ const session = sessionId ? this.sessions.get(sessionId) : void 0;
424
+ if (!session) {
425
+ await this.sendError(id, -32e3, `session not found: ${sessionId}`);
426
+ return false;
367
427
  }
368
- out.properties = props;
369
- if (Array.isArray(s.required)) out.required = s.required;
428
+ session.abort.abort();
429
+ this.sessions.delete(sessionId);
430
+ this.disposeSession(sessionId);
431
+ await this.transport.send(toWire({
432
+ jsonrpc: "2.0",
433
+ id,
434
+ result: {}
435
+ }));
436
+ return false;
370
437
  }
371
- return out;
372
- }
373
- function toACPToolResult(result) {
374
- const blocks = [];
375
- if (result === void 0 || result === null) {
376
- return { content: [{ type: "text", text: "ok" }] };
438
+ async handleSessionDelete(id, params) {
439
+ const p = params ?? {};
440
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
441
+ if (!sessionId) {
442
+ await this.sendError(id, -32e3, `session not found: ${sessionId}`);
443
+ return false;
444
+ }
445
+ if (!this.sessions.has(sessionId)) {
446
+ await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
447
+ return false;
448
+ }
449
+ const session = this.sessions.get(sessionId);
450
+ session.abort.abort();
451
+ this.sessions.delete(sessionId);
452
+ this.disposeSession(sessionId);
453
+ await this.transport.send(toWire({
454
+ jsonrpc: "2.0",
455
+ id,
456
+ result: {}
457
+ }));
458
+ return false;
377
459
  }
378
- if (typeof result === "string") {
379
- blocks.push({ type: "text", text: result });
380
- } else if (typeof result === "object") {
381
- blocks.push({ type: "text", text: JSON.stringify(result, null, 2) });
382
- } else {
383
- blocks.push({ type: "text", text: String(result) });
460
+ async handleSessionFork(id, params) {
461
+ const p = params ?? {};
462
+ const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
463
+ const source = sourceId ? this.sessions.get(sourceId) : void 0;
464
+ if (!sourceId || !source) {
465
+ await this.sendError(id, -32e3, `session not found: ${sourceId}`);
466
+ return false;
467
+ }
468
+ if (this.sessions.size >= this.maxSessions) {
469
+ await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
470
+ return false;
471
+ }
472
+ const now = (/* @__PURE__ */ new Date()).toISOString();
473
+ const sessionId = `sess_${this.allocId()}`;
474
+ const forked = {
475
+ id: sessionId,
476
+ cwd: typeof p.cwd === "string" ? p.cwd : source.cwd,
477
+ abort: new AbortController(),
478
+ modeId: source.modeId,
479
+ createdAt: now,
480
+ updatedAt: now,
481
+ ...source.title !== void 0 ? { title: source.title } : {}
482
+ };
483
+ const history = (this.replayFor?.(sourceId) ?? []).map((update) => ({
484
+ sessionUpdate: update.sessionUpdate,
485
+ content: structuredClone(update.content)
486
+ }));
487
+ this.sessions.set(sessionId, forked);
488
+ this.seedFor?.(sessionId, history);
489
+ this.onSessionNew(forked);
490
+ await this.persist(forked, history);
491
+ await this.sendNotification({
492
+ sessionId,
493
+ update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
494
+ });
495
+ await this.transport.send(toWire({
496
+ jsonrpc: "2.0",
497
+ id,
498
+ result: {
499
+ sessionId,
500
+ modes: this.modes,
501
+ configOptions: this.configOptions
502
+ }
503
+ }));
504
+ return false;
384
505
  }
385
- return { content: blocks };
386
- }
387
- function toolToPriority(tool) {
388
- if (tool.riskTier === "destructive") return "high";
389
- if (tool.riskTier === "standard" || tool.permission === "confirm") return "medium";
390
- return "low";
391
- }
392
-
393
- // src/types/acp-v1.ts
394
- var ACP_PROTOCOL_VERSION = 1;
395
-
396
- // src/agent/protocol-handler.ts
397
- function toWire(msg) {
398
- return msg;
399
- }
400
- var WRONGSTACK_VERSION = "0.274.1";
401
- var WRONGSTACK_AUTH_METHODS = [
402
- {
403
- id: "wrongstack-auth",
404
- name: "Run wstack auth",
405
- description: "Configure a WrongStack model provider in an interactive terminal.",
406
- type: "terminal",
407
- args: ["auth"]
408
- }
409
- ];
410
- var DEFAULT_MODE_ID = "code";
411
- var DEFAULT_MODES = [
412
- {
413
- id: DEFAULT_MODE_ID,
414
- name: "Code",
415
- description: "Default agent mode for code-generation tasks."
416
- }
417
- ];
418
- var ACPProtocolHandler = class {
419
- transport;
420
- defaultCwd;
421
- runTurn;
422
- onSessionNew;
423
- modes;
424
- configOptions;
425
- agentName;
426
- replayFor;
427
- seedFor;
428
- store;
429
- initialized = false;
430
- clientCapabilities = {};
431
- sessions = /* @__PURE__ */ new Map();
432
- nextId = 1;
433
- // Outbound request correlation (server → client requests, e.g.
434
- // session/request_permission). Keyed by our own `srv_N` ids.
435
- pendingOut = /* @__PURE__ */ new Map();
436
- nextOutId = 1;
437
- constructor(opts) {
438
- this.transport = opts.transport;
439
- this.defaultCwd = opts.defaultCwd;
440
- this.runTurn = opts.runTurn;
441
- this.onSessionNew = opts.onSessionNew ?? (() => {
442
- });
443
- this.modes = opts.modes ?? DEFAULT_MODES;
444
- this.configOptions = opts.configOptions ?? [];
445
- this.agentName = opts.agentName ?? "wrongstack";
446
- this.replayFor = opts.replayFor;
447
- this.seedFor = opts.seedFor;
448
- this.store = opts.store;
449
- if (typeof this.transport.onMessage === "function") {
450
- this.transport.onMessage((m) => this.maybeResolvePending(m));
451
- }
452
- }
453
- /**
454
- * Send a request to the client and await its response. Used for
455
- * server-initiated calls like `session/request_permission`. Rejects on
456
- * timeout or transport error so the caller can pick a safe fallback.
457
- */
458
- request(method, params, timeoutMs = 6e4) {
459
- const id = `srv_${this.nextOutId++}`;
460
- return new Promise((resolve3, reject) => {
461
- const timer = setTimeout(() => {
462
- this.pendingOut.delete(id);
463
- reject(new Error(`${method} timed out after ${timeoutMs}ms`));
464
- }, timeoutMs);
465
- this.pendingOut.set(id, { resolve: resolve3, reject, timer });
466
- this.transport.send(toWire({ jsonrpc: "2.0", id, method, params })).catch((e) => {
467
- clearTimeout(timer);
468
- this.pendingOut.delete(id);
469
- reject(e instanceof Error ? e : new Error(String(e)));
470
- });
471
- });
472
- }
473
- maybeResolvePending(m) {
474
- const id = m.id;
475
- if (typeof id !== "string") return;
476
- const pending = this.pendingOut.get(id);
477
- if (!pending) return;
478
- this.pendingOut.delete(id);
479
- clearTimeout(pending.timer);
480
- const err = m.error;
481
- if (err) pending.reject(new Error(err.message ?? "client request failed"));
482
- else pending.resolve(m.result);
483
- }
484
- /**
485
- * Process one inbound message. Returns true if this was a terminal
486
- * message (rare; reserved for future use by the server's own
487
- * shutdown signal).
488
- */
489
- async handleMessage(msg) {
490
- if (typeof msg !== "object" || msg === null) return false;
491
- const m = msg;
492
- if (m.id !== void 0 && (m.result !== void 0 || m.error !== void 0)) {
493
- return false;
494
- }
495
- if (m.id !== void 0 && typeof m.method === "string") {
496
- return this.handleRequest(m.id, m.method, m.params);
497
- }
498
- if (typeof m.method === "string") {
499
- return this.handleNotification(m.method, m.params);
500
- }
501
- return false;
502
- }
503
- /** Abort all active turns and drop session state. */
504
- close() {
505
- for (const [, session] of this.sessions) {
506
- session.abort.abort();
507
- }
508
- this.sessions.clear();
509
- for (const [, p] of this.pendingOut) {
510
- clearTimeout(p.timer);
511
- p.reject(new Error("protocol handler closed"));
512
- }
513
- this.pendingOut.clear();
514
- }
515
- // ────────────────────────────────────────────────────────────────────
516
- // Requests
517
- // ────────────────────────────────────────────────────────────────────
518
- async handleRequest(id, method, params) {
519
- if (method !== "initialize" && !this.initialized) {
520
- await this.sendError(id, -32e3, "Not initialized");
521
- return false;
522
- }
523
- try {
524
- switch (method) {
525
- case "initialize":
526
- return await this.handleInitialize(id, params);
527
- case "authenticate":
528
- return await this.handleAuthenticate(id, params);
529
- case "logout":
530
- return await this.handleLogout(id, params);
531
- case "session/new":
532
- return await this.handleSessionNew(id, params);
533
- case "session/load":
534
- return await this.handleSessionLoad(id, params);
535
- case "session/resume":
536
- return await this.handleSessionResume(id, params);
537
- case "session/close":
538
- return await this.handleSessionClose(id, params);
539
- case "session/delete":
540
- return await this.handleSessionDelete(id, params);
541
- case "session/prompt":
542
- return await this.handleSessionPrompt(id, params);
543
- case "session/set_mode":
544
- return await this.handleSetMode(id, params);
545
- case "session/set_config_option":
546
- return await this.handleSetConfigOption(id, params);
547
- case "session/list":
548
- return await this.handleSessionList(id);
549
- case "session/fork":
550
- return await this.handleSessionFork(id, params);
551
- case "providers/list":
552
- return await this.handleProvidersList(id, params);
553
- case "providers/set":
554
- return await this.handleProvidersSet(id, params);
555
- case "providers/disable":
556
- return await this.handleProvidersDisable(id, params);
557
- case "mcp/message":
558
- return await this.handleMcpMessage(id, params);
559
- default:
560
- await this.sendError(id, -32601, `Unknown method: ${method}`);
561
- return false;
562
- }
563
- } catch (err) {
564
- const { code, message, data } = errorToJsonRpc(err);
565
- await this.sendError(id, code, message, data);
566
- return false;
567
- }
568
- }
569
- async handleInitialize(id, params) {
570
- const p = params ?? {};
571
- if (p.clientCapabilities && typeof p.clientCapabilities === "object") {
572
- this.clientCapabilities = p.clientCapabilities;
573
- }
574
- this.initialized = true;
506
+ async handleProvidersList(id, _params) {
575
507
  await this.transport.send(toWire({
576
508
  jsonrpc: "2.0",
577
509
  id,
578
510
  result: {
579
- protocolVersion: ACP_PROTOCOL_VERSION,
580
- agentCapabilities: {
581
- loadSession: true,
582
- promptCapabilities: {
583
- // We route ACP image blocks into the core agent's multimodal
584
- // input (server-agent-turn.promptToAgentInput); whether the
585
- // model can see them is the configured provider's concern.
586
- image: true,
587
- audio: false,
588
- embeddedContext: true
589
- },
590
- mcpCapabilities: {
591
- http: false,
592
- sse: false
593
- },
594
- sessionCapabilities: {
595
- close: {},
596
- list: {},
597
- delete: {},
598
- resume: {},
599
- fork: {}
600
- },
601
- auth: {
602
- logout: {}
603
- }
604
- },
605
- agentInfo: {
606
- name: this.agentName,
607
- title: "WrongStack",
608
- version: WRONGSTACK_VERSION
609
- },
610
- authMethods: WRONGSTACK_AUTH_METHODS,
611
- modes: this.modes,
612
- configOptions: this.configOptions
511
+ providers: [],
512
+ currentProviderId: null
613
513
  }
614
514
  }));
615
515
  return false;
616
516
  }
617
- async handleAuthenticate(id, _params) {
618
- await this.transport.send(toWire({
619
- jsonrpc: "2.0",
620
- id,
621
- result: { outcome: "unauthenticated" }
622
- }));
517
+ async handleProvidersSet(id, _params) {
518
+ await this.sendError(id, -32e3, "provider configuration not available through ACP; use wstack auth");
623
519
  return false;
624
520
  }
625
- async handleLogout(id, _params) {
521
+ async handleProvidersDisable(id, _params) {
626
522
  await this.transport.send(toWire({
627
523
  jsonrpc: "2.0",
628
524
  id,
@@ -630,251 +526,11 @@ var ACPProtocolHandler = class {
630
526
  }));
631
527
  return false;
632
528
  }
633
- async handleSessionNew(id, params) {
634
- const p = params ?? {};
635
- const cwd = typeof p.cwd === "string" ? p.cwd : this.defaultCwd;
636
- const sessionId = `sess_${this.allocId()}`;
637
- const now = (/* @__PURE__ */ new Date()).toISOString();
638
- const state = {
639
- id: sessionId,
640
- cwd,
641
- abort: new AbortController(),
642
- modeId: DEFAULT_MODE_ID,
643
- createdAt: now,
644
- updatedAt: now
645
- };
646
- this.sessions.set(sessionId, state);
647
- this.onSessionNew(state);
648
- await this.persist(state);
649
- await this.sendNotification({
650
- sessionId,
651
- update: {
652
- sessionUpdate: "current_mode_update",
653
- modeId: this.modes[0]?.id ?? DEFAULT_MODE_ID
654
- }
655
- });
656
- if (this.configOptions.length > 0) {
657
- await this.sendNotification({
658
- sessionId,
659
- update: {
660
- sessionUpdate: "config_option_update",
661
- configOptions: [...this.configOptions]
662
- }
663
- });
664
- }
665
- await this.transport.send(toWire({
666
- jsonrpc: "2.0",
667
- id,
668
- result: {
669
- sessionId,
670
- modes: this.modes,
671
- configOptions: this.configOptions
672
- }
673
- }));
674
- return false;
675
- }
676
- async handleSessionLoad(id, params) {
677
- const p = params ?? {};
678
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
679
- const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
680
- const existing = sessionId ? this.sessions.get(sessionId) : void 0;
681
- if (!existing && sessionId && this.store) {
682
- const persisted = await this.store.load(sessionId);
683
- if (persisted) {
684
- const restored = {
685
- id: sessionId,
686
- cwd: persisted.cwd ?? loadCwd ?? this.defaultCwd,
687
- abort: new AbortController(),
688
- modeId: persisted.modeId ?? DEFAULT_MODE_ID,
689
- createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
690
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
691
- ...persisted.title !== void 0 ? { title: persisted.title } : {}
692
- };
693
- this.sessions.set(sessionId, restored);
694
- this.seedFor?.(sessionId, persisted.history ?? []);
695
- for (const update of persisted.history ?? []) {
696
- await this.sendNotification({ sessionId, update });
697
- }
698
- await this.sendNotification({
699
- sessionId,
700
- update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
701
- });
702
- await this.transport.send(toWire({
703
- jsonrpc: "2.0",
704
- id,
705
- result: {
706
- initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
707
- }
708
- }));
709
- return false;
710
- }
711
- }
712
- if (existing) {
713
- existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
714
- const replay = sessionId ? this.replayFor?.(sessionId) : void 0;
715
- if (replay) {
716
- for (const update of replay) {
717
- await this.sendNotification({ sessionId, update });
718
- }
719
- }
720
- await this.sendNotification({
721
- sessionId,
722
- update: {
723
- sessionUpdate: "session_info_update",
724
- updatedAt: existing.updatedAt
725
- }
726
- });
727
- await this.sendNotification({
728
- sessionId,
729
- update: {
730
- sessionUpdate: "current_mode_update",
731
- modeId: existing.modeId
732
- }
733
- });
734
- await this.transport.send(toWire({
735
- jsonrpc: "2.0",
736
- id,
737
- result: {
738
- initialMode: {
739
- currentModeId: existing.modeId,
740
- availableModes: this.modes
741
- }
742
- }
743
- }));
744
- return false;
745
- }
746
- await this.sendError(id, -32e3, `session not found: ${sessionId}`);
747
- return false;
748
- }
749
- async handleSessionResume(id, params) {
750
- const p = params ?? {};
751
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
752
- const existing = sessionId ? this.sessions.get(sessionId) : void 0;
753
- if (existing) {
754
- existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
755
- await this.transport.send(toWire({
756
- jsonrpc: "2.0",
757
- id,
758
- result: {
759
- initialMode: {
760
- currentModeId: existing.modeId,
761
- availableModes: this.modes
762
- }
763
- }
764
- }));
765
- return false;
766
- }
767
- await this.sendError(id, -32e3, `session not found: ${sessionId}`);
768
- return false;
769
- }
770
- async handleSessionClose(id, params) {
771
- const p = params ?? {};
772
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
773
- const session = sessionId ? this.sessions.get(sessionId) : void 0;
774
- if (!session) {
775
- await this.sendError(id, -32e3, `session not found: ${sessionId}`);
776
- return false;
777
- }
778
- session.abort.abort();
779
- if (sessionId) this.sessions.delete(sessionId);
780
- await this.transport.send(toWire({
781
- jsonrpc: "2.0",
782
- id,
783
- result: {}
784
- }));
785
- return false;
786
- }
787
- async handleSessionDelete(id, params) {
788
- const p = params ?? {};
789
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
790
- if (!sessionId) {
791
- await this.sendError(id, -32e3, `session not found: ${sessionId}`);
792
- return false;
793
- }
794
- if (!this.sessions.has(sessionId)) {
795
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
796
- return false;
797
- }
798
- const session = this.sessions.get(sessionId);
799
- session.abort.abort();
800
- this.sessions.delete(sessionId);
801
- await this.transport.send(toWire({
802
- jsonrpc: "2.0",
803
- id,
804
- result: {}
805
- }));
806
- return false;
807
- }
808
- async handleSessionFork(id, params) {
809
- const p = params ?? {};
810
- const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
811
- const source = sourceId ? this.sessions.get(sourceId) : void 0;
812
- if (!sourceId || !source) {
813
- await this.sendError(id, -32e3, `session not found: ${sourceId}`);
814
- return false;
815
- }
816
- const now = (/* @__PURE__ */ new Date()).toISOString();
817
- const sessionId = `sess_${this.allocId()}`;
818
- const forked = {
819
- id: sessionId,
820
- cwd: typeof p.cwd === "string" ? p.cwd : source.cwd,
821
- abort: new AbortController(),
822
- modeId: source.modeId,
823
- createdAt: now,
824
- updatedAt: now,
825
- ...source.title !== void 0 ? { title: source.title } : {}
826
- };
827
- const history = (this.replayFor?.(sourceId) ?? []).map((update) => ({
828
- sessionUpdate: update.sessionUpdate,
829
- content: structuredClone(update.content)
830
- }));
831
- this.sessions.set(sessionId, forked);
832
- this.seedFor?.(sessionId, history);
833
- this.onSessionNew(forked);
834
- await this.persist(forked, history);
835
- await this.sendNotification({
836
- sessionId,
837
- update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
838
- });
839
- await this.transport.send(toWire({
840
- jsonrpc: "2.0",
841
- id,
842
- result: {
843
- sessionId,
844
- modes: this.modes,
845
- configOptions: this.configOptions
846
- }
847
- }));
848
- return false;
849
- }
850
- async handleProvidersList(id, _params) {
851
- await this.transport.send(toWire({
852
- jsonrpc: "2.0",
853
- id,
854
- result: {
855
- providers: [],
856
- currentProviderId: null
857
- }
858
- }));
859
- return false;
860
- }
861
- async handleProvidersSet(id, _params) {
862
- await this.sendError(id, -32e3, "provider configuration not available through ACP; use wstack auth");
863
- return false;
864
- }
865
- async handleProvidersDisable(id, _params) {
866
- await this.transport.send(toWire({
867
- jsonrpc: "2.0",
868
- id,
869
- result: {}
870
- }));
871
- return false;
872
- }
873
- async handleMcpMessage(id, _params) {
874
- await this.sendError(id, -32e3, "MCP message routing not available through ACP");
875
- return false;
876
- }
877
- async handleSessionPrompt(id, params) {
529
+ async handleMcpMessage(id, _params) {
530
+ await this.sendError(id, -32e3, "MCP message routing not available through ACP");
531
+ return false;
532
+ }
533
+ async handleSessionPrompt(id, params) {
878
534
  const p = params ?? {};
879
535
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
880
536
  if (!sessionId || !this.sessions.has(sessionId)) {
@@ -1078,259 +734,273 @@ function errorToJsonRpc(err) {
1078
734
  return { code: -32603, message };
1079
735
  }
1080
736
 
1081
- // src/agent/wrongstack-acp-agent.ts
1082
- import { fileURLToPath } from "node:url";
1083
- import { createServer } from "node:http";
1084
- import { writeErr as writeErr2 } from "@wrongstack/core";
1085
- var WrongStackACPServer = class {
1086
- transport;
1087
- handler;
1088
- options;
1089
- /** HTTP server when transport mode is HTTP. */
1090
- httpServer = null;
1091
- running = false;
1092
- constructor(opts = {}) {
1093
- this.options = opts;
1094
- this.transport = new StdioTransport();
1095
- const runTurn = opts.runTurn ?? defaultEchoRunTurn;
1096
- this.handler = new ACPProtocolHandler({
1097
- transport: this.transport,
1098
- defaultCwd: opts.defaultCwd ?? process.cwd(),
1099
- runTurn,
1100
- agentName: opts.agentName,
1101
- ...opts.replayFor ? { replayFor: opts.replayFor } : {},
1102
- ...opts.seedFor ? { seedFor: opts.seedFor } : {},
1103
- ...opts.store ? { store: opts.store } : {}
1104
- });
1105
- }
1106
- /**
1107
- * Start the server. Mode depends on `options.transport`:
1108
- * - 'stdio' (default): reads JSON-RPC from stdin, writes to stdout.
1109
- * - number: listens as HTTP on the given port.
1110
- */
1111
- async start() {
1112
- const transportMode = this.options.transport;
1113
- if (typeof transportMode === "number") {
1114
- await this.startHttp(transportMode);
1115
- } else {
1116
- await this.startStdio();
737
+ // src/agent/stdio-transport.ts
738
+ import { expectDefined, writeErr } from "@wrongstack/core/utils";
739
+ import { treeKill } from "@wrongstack/core/utils/tree-kill";
740
+
741
+ // src/win32-cmd.ts
742
+ var WIN32_CMD_META = /[&|<>"\r\n\0]/;
743
+ function buildWin32CmdShimInvocation(command, args = []) {
744
+ assertSafeWin32CmdArgs([command, ...args]);
745
+ const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
746
+ return {
747
+ command: process.env["COMSPEC"] ?? "cmd.exe",
748
+ args: ["/d", "/c", line],
749
+ windowsVerbatimArguments: true
750
+ };
751
+ }
752
+ function assertSafeWin32CmdArgs(args) {
753
+ for (const arg of args) {
754
+ if (typeof arg === "string" && WIN32_CMD_META.test(arg)) {
755
+ throw new Error(
756
+ 'win32 cmd shim spawn: argument contains a shell metacharacter (one of & | < > ", or a newline) that could enable command injection through the .cmd/.bat wrapper - refusing to run. Offending argument: ' + JSON.stringify(arg)
757
+ );
1117
758
  }
1118
759
  }
1119
- async startStdio() {
1120
- if (this.options.legacyStartupMarker) {
1121
- this.transport.sendStartupMarker();
1122
- }
1123
- this.running = true;
1124
- while (this.running) {
1125
- const msg = await this.transport.read();
1126
- if (!msg) break;
1127
- const terminal = await this.handler.handleMessage(msg);
1128
- if (terminal) break;
1129
- }
1130
- this.transport.close();
760
+ }
761
+ function quoteWin32CmdArg(arg) {
762
+ return `"${arg}"`;
763
+ }
764
+
765
+ // src/agent/stdio-transport.ts
766
+ var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
767
+ var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
768
+ function positiveLimit(value, fallback) {
769
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
770
+ }
771
+ var StdioTransport = class {
772
+ stdin = process.stdin;
773
+ stdout = process.stdout;
774
+ stderr = process.stderr;
775
+ buffer = "";
776
+ handlers = /* @__PURE__ */ new Set();
777
+ closed = false;
778
+ resolveRead = null;
779
+ messageQueue = [];
780
+ maxFrameChars;
781
+ maxQueuedMessages;
782
+ constructor(opts = {}) {
783
+ this.maxFrameChars = positiveLimit(opts.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
784
+ this.maxQueuedMessages = positiveLimit(opts.maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES);
785
+ this.stdin.resume();
786
+ this.stdin.setEncoding("utf8");
787
+ this.stdin.on("data", (chunk) => this.onData(chunk));
788
+ this.stdin.on("end", () => this.handleClose());
789
+ this.stdin.on("error", (err) => this.failAll(err));
1131
790
  }
1132
- async startHttp(port) {
1133
- const host = this.options.host ?? "127.0.0.1";
1134
- const handler = this.handler;
1135
- const authToken = this.options.authToken;
1136
- let httpChain = Promise.resolve();
1137
- this.httpServer = createServer(async (req, res) => {
1138
- if (authToken) {
1139
- const url = new URL(req.url ?? "/", `http://${host}:${port}`);
1140
- const queryToken = url.searchParams.get("token");
1141
- const authHeader = req.headers["authorization"];
1142
- const bearerToken = Array.isArray(authHeader) ? authHeader[0]?.replace(/^Bearer\s+/i, "") : authHeader?.replace(/^Bearer\s+/i, "");
1143
- const supplied = queryToken ?? bearerToken ?? "";
1144
- if (supplied !== authToken) {
1145
- res.writeHead(401, { "Content-Type": "application/json" });
1146
- res.end(JSON.stringify({ error: { code: -32001, message: "Unauthorized" } }));
1147
- return;
1148
- }
1149
- }
1150
- const selfOrigin = `http://${host}:${port}`;
1151
- const reqOrigin = Array.isArray(req.headers.origin) ? req.headers.origin[0] : req.headers.origin;
1152
- if (reqOrigin && reqOrigin !== selfOrigin) {
1153
- res.writeHead(403);
1154
- res.end(JSON.stringify({ error: "cross-origin request forbidden" }));
1155
- return;
1156
- }
1157
- if (reqOrigin) res.setHeader("Access-Control-Allow-Origin", reqOrigin);
1158
- res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
1159
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Authorization");
1160
- if (req.method === "OPTIONS") {
1161
- res.writeHead(204);
1162
- res.end();
1163
- return;
1164
- }
1165
- if (req.method !== "POST") {
1166
- res.writeHead(405);
1167
- res.end(JSON.stringify({ error: "method not allowed" }));
1168
- return;
1169
- }
1170
- const MAX_HTTP_BODY = 10 * 1024 * 1024;
1171
- let body = "";
1172
- let bodyBytes = 0;
1173
- let tooLarge = false;
1174
- for await (const chunk of req) {
1175
- bodyBytes += chunk.length;
1176
- if (bodyBytes > MAX_HTTP_BODY) {
1177
- tooLarge = true;
1178
- break;
1179
- }
1180
- body += chunk;
1181
- }
1182
- if (tooLarge) {
1183
- res.writeHead(413, { "Content-Type": "application/json" });
1184
- res.end(JSON.stringify({ error: { code: -32700, message: "Request body too large" } }));
791
+ sendStartupMarker() {
792
+ this.stdout.write("[wstack-acp]\n", "utf8");
793
+ }
794
+ send(msg) {
795
+ if (this.closed) return Promise.resolve();
796
+ return new Promise((resolve3) => {
797
+ const line = JSON.stringify(msg) + "\n";
798
+ this.stdout.write(line, "utf8", () => resolve3());
799
+ });
800
+ }
801
+ sendRaw(chunk) {
802
+ this.stdout.write(chunk, "utf8");
803
+ }
804
+ read() {
805
+ if (this.messageQueue.length > 0)
806
+ return Promise.resolve(expectDefined(this.messageQueue.shift()));
807
+ if (this.closed) return Promise.resolve(null);
808
+ return new Promise((resolve3) => {
809
+ this.resolveRead = resolve3;
810
+ });
811
+ }
812
+ onMessage(handler) {
813
+ this.handlers.add(handler);
814
+ return () => this.handlers.delete(handler);
815
+ }
816
+ close() {
817
+ this.closed = true;
818
+ this.stdin.pause();
819
+ this.resolveRead?.(null);
820
+ this.resolveRead = null;
821
+ this.buffer = "";
822
+ this.messageQueue.length = 0;
823
+ this.handlers.clear();
824
+ }
825
+ onData(chunk) {
826
+ this.buffer += chunk;
827
+ const lines = this.buffer.split("\n");
828
+ this.buffer = lines.pop() ?? "";
829
+ if (this.buffer.length > this.maxFrameChars) {
830
+ this.stderr.write(
831
+ `[wstack-acp frame error] pending frame exceeds ${this.maxFrameChars} characters
832
+ `,
833
+ "utf8"
834
+ );
835
+ this.close();
836
+ return;
837
+ }
838
+ for (const raw of lines) {
839
+ if (!raw.trim()) continue;
840
+ if (raw.length > this.maxFrameChars) {
841
+ this.stderr.write(
842
+ `[wstack-acp frame error] frame exceeds ${this.maxFrameChars} characters
843
+ `,
844
+ "utf8"
845
+ );
846
+ this.close();
1185
847
  return;
1186
848
  }
1187
- let msg;
1188
849
  try {
1189
- msg = JSON.parse(body);
1190
- } catch {
1191
- res.writeHead(400);
1192
- res.end(JSON.stringify({ error: { code: -32700, message: "Parse error" } }));
1193
- return;
850
+ this.dispatch(JSON.parse(raw));
851
+ } catch (err) {
852
+ this.stderr.write(`[wstack-acp parse error] ${err}
853
+ `, "utf8");
1194
854
  }
1195
- const isNotification = typeof msg === "object" && msg !== null && msg.id === void 0 && typeof msg.method === "string";
1196
- if (isNotification) {
1197
- try {
1198
- await handler.handleMessage(msg);
1199
- } catch {
1200
- }
1201
- res.writeHead(200, { "Content-Type": "application/json" });
1202
- res.end(JSON.stringify({ notifications: [] }));
855
+ }
856
+ }
857
+ dispatch(msg) {
858
+ if (this.resolveRead) {
859
+ const resolve3 = this.resolveRead;
860
+ this.resolveRead = null;
861
+ resolve3(msg);
862
+ } else {
863
+ if (this.messageQueue.length >= this.maxQueuedMessages) {
864
+ this.stderr.write(
865
+ `[wstack-acp queue error] pending message queue exceeds ${this.maxQueuedMessages} entries
866
+ `,
867
+ "utf8"
868
+ );
869
+ this.close();
1203
870
  return;
1204
871
  }
1205
- const requestPromise = httpChain.then(async () => {
1206
- const notifications = [];
1207
- let response = null;
1208
- const originalSend = this.transport.send.bind(this.transport);
1209
- this.transport.send = async (m) => {
1210
- if (m.id !== void 0 && (m.result !== void 0 || m.error !== void 0)) {
1211
- response = m;
1212
- } else if (m.method === "session/update") {
1213
- notifications.push(m.params);
1214
- } else {
1215
- notifications.push(m);
1216
- }
1217
- };
1218
- try {
1219
- await handler.handleMessage(msg);
1220
- } finally {
1221
- this.transport.send = originalSend;
1222
- }
1223
- res.writeHead(200, { "Content-Type": "application/json" });
1224
- const responseBody = response !== null ? { ...response, notifications } : { notifications };
1225
- res.end(JSON.stringify(responseBody));
1226
- });
1227
- httpChain = requestPromise.catch(() => void 0);
872
+ this.messageQueue.push(msg);
873
+ }
874
+ for (const handler of this.handlers) {
1228
875
  try {
1229
- await requestPromise;
1230
- } catch {
876
+ handler(msg);
877
+ } catch (err) {
878
+ this.stderr.write(`[wstack-acp handler error] ${err}
879
+ `, "utf8");
1231
880
  }
1232
- });
1233
- return new Promise((resolve3) => {
1234
- this.httpServer.listen(port, host, () => {
1235
- writeErr2(`[wstack-acp] HTTP server listening on http://${host}:${port}
1236
- `);
1237
- this.running = true;
1238
- resolve3();
1239
- });
1240
- });
1241
- }
1242
- /** Stop the server. */
1243
- stop() {
1244
- this.running = false;
1245
- this.transport.close();
1246
- if (this.httpServer) {
1247
- this.httpServer.close();
1248
- this.httpServer = null;
1249
881
  }
1250
882
  }
883
+ handleClose() {
884
+ this.close();
885
+ }
886
+ failAll(err) {
887
+ this.stderr.write(`[wstack-acp stdin error] ${err.message}
888
+ `, "utf8");
889
+ this.close();
890
+ }
1251
891
  };
1252
- var defaultEchoRunTurn = async (_input, _emit) => {
1253
- return { stopReason: "end_turn" };
1254
- };
1255
- async function main() {
1256
- const server = new WrongStackACPServer();
1257
- await server.start();
1258
- }
1259
- var isEntrypoint = process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === process.argv[1];
1260
- if (isEntrypoint) {
1261
- main().catch((err) => {
1262
- writeErr2(`[wstack-acp fatal] ${err}
1263
- `);
1264
- process.exit(1);
1265
- });
1266
- }
1267
-
1268
- // src/client/websocket-transport.ts
1269
- var WebSocketClientTransport = class {
1270
- ws = null;
892
+ var ClientTransport = class {
893
+ child = null;
894
+ buffer = "";
1271
895
  handlers = /* @__PURE__ */ new Set();
1272
896
  closed = false;
897
+ resolveRead = null;
898
+ messageQueue = [];
1273
899
  opts;
1274
- constructor(opts) {
1275
- this.opts = opts;
1276
- }
1277
- start() {
1278
- const WS = globalThis.WebSocket;
1279
- if (!WS) {
1280
- return Promise.reject(
1281
- new Error(
1282
- "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
1283
- )
1284
- );
1285
- }
1286
- const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
900
+ maxFrameChars;
901
+ maxQueuedMessages;
902
+ constructor(options) {
903
+ this.opts = {
904
+ handshakeTimeoutMs: 3e4,
905
+ ...options
906
+ };
907
+ this.maxFrameChars = positiveLimit(options.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
908
+ this.maxQueuedMessages = positiveLimit(options.maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES);
909
+ }
910
+ async start() {
911
+ if (this.child) return;
912
+ const [{ spawn: spawn3 }, { buildChildEnv: buildChildEnv2 }, os] = await Promise.all([
913
+ import("node:child_process"),
914
+ import("@wrongstack/core/utils"),
915
+ import("node:os")
916
+ ]);
1287
917
  return new Promise((resolve3, reject) => {
918
+ const timeout = setTimeout(() => {
919
+ reject(
920
+ new Error(`ACP child process failed to start within ${this.opts.handshakeTimeoutMs}ms`)
921
+ );
922
+ }, this.opts.handshakeTimeoutMs);
923
+ const isPkgLauncher = this.opts.command === "npx" || this.opts.command === "uvx";
924
+ const spawnCwd = isPkgLauncher ? os.homedir() : this.opts.cwd;
925
+ try {
926
+ const childArgs = this.opts.args ?? [];
927
+ const invocation = spawnInvocation(this.opts.command, childArgs, process.platform);
928
+ this.child = spawn3(invocation.command, invocation.args, {
929
+ env: { ...buildChildEnv2(), ...this.opts.env },
930
+ cwd: spawnCwd,
931
+ stdio: ["pipe", "pipe", "pipe"],
932
+ windowsHide: true,
933
+ ...verbatimOptions(invocation)
934
+ });
935
+ } catch (err) {
936
+ clearTimeout(timeout);
937
+ reject(err);
938
+ return;
939
+ }
940
+ const child = this.child;
941
+ child.stdout.setEncoding("utf8");
1288
942
  let settled = false;
1289
- const ws = new WS(this.opts.url, this.opts.protocols);
1290
- this.ws = ws;
1291
- const timer = setTimeout(() => {
1292
- if (settled) return;
1293
- settled = true;
1294
- try {
1295
- ws.close();
1296
- } catch {
1297
- }
1298
- reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
1299
- }, timeoutMs);
1300
- ws.addEventListener("open", () => {
1301
- if (settled) return;
1302
- settled = true;
1303
- clearTimeout(timer);
1304
- resolve3();
1305
- });
1306
- ws.addEventListener("error", (ev) => {
943
+ const onSpawnFailure = (err) => {
1307
944
  if (settled) {
1308
945
  this.closed = true;
1309
946
  return;
1310
947
  }
1311
948
  settled = true;
1312
- clearTimeout(timer);
1313
- const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
1314
- reject(new Error(message));
1315
- });
1316
- ws.addEventListener("close", () => {
1317
- this.closed = true;
1318
- });
1319
- ws.addEventListener("message", (ev) => {
1320
- this.onData(ev.data);
1321
- });
949
+ clearTimeout(timeout);
950
+ reject(err);
951
+ };
952
+ child.on("error", onSpawnFailure);
953
+ child.stdout.on("error", onSpawnFailure);
954
+ if (this.opts.skipHandshakeMarker) {
955
+ child.stdout.on("data", (c) => this.onChildData(c));
956
+ child.stderr.on("data", (c) => this.onChildError(c));
957
+ child.on("close", (code) => this.onChildClose(code));
958
+ child.once("spawn", () => {
959
+ if (settled) return;
960
+ settled = true;
961
+ clearTimeout(timeout);
962
+ resolve3();
963
+ });
964
+ return;
965
+ }
966
+ const onReady = () => {
967
+ if (settled) return;
968
+ settled = true;
969
+ child.stdout.on("data", (c) => this.onChildData(c));
970
+ child.stderr.on("data", (c) => this.onChildError(c));
971
+ child.on("close", (code) => this.onChildClose(code));
972
+ clearTimeout(timeout);
973
+ resolve3();
974
+ };
975
+ const waitForMarker = (chunk) => {
976
+ this.buffer += chunk;
977
+ const idx = this.buffer.indexOf("[wstack-acp]\n");
978
+ if (idx !== -1) {
979
+ this.buffer = this.buffer.slice(idx + "[wstack-acp]\n".length);
980
+ child.stdout.removeListener("data", waitForMarker);
981
+ onReady();
982
+ }
983
+ };
984
+ child.stdout.on("data", waitForMarker);
1322
985
  });
1323
986
  }
1324
987
  send(msg) {
1325
- if (this.closed || !this.ws) {
1326
- return Promise.reject(new Error("WebSocket transport is not open"));
1327
- }
1328
- try {
1329
- this.ws.send(JSON.stringify(msg));
1330
- return Promise.resolve();
1331
- } catch (err) {
1332
- return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1333
- }
988
+ if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
989
+ return new Promise((resolve3, reject) => {
990
+ const line = JSON.stringify(msg) + "\n";
991
+ this.child?.stdin.write(line, "utf8", (err) => {
992
+ if (err) reject(err);
993
+ else resolve3();
994
+ });
995
+ });
996
+ }
997
+ read() {
998
+ if (this.messageQueue.length > 0)
999
+ return Promise.resolve(expectDefined(this.messageQueue.shift()));
1000
+ if (this.closed) return Promise.resolve(null);
1001
+ return new Promise((resolve3) => {
1002
+ this.resolveRead = resolve3;
1003
+ });
1334
1004
  }
1335
1005
  onMessage(handler) {
1336
1006
  this.handlers.add(handler);
@@ -1338,34 +1008,70 @@ var WebSocketClientTransport = class {
1338
1008
  }
1339
1009
  stop() {
1340
1010
  this.closed = true;
1341
- if (this.ws) {
1011
+ this.resolveRead?.(null);
1012
+ this.resolveRead = null;
1013
+ this.buffer = "";
1014
+ this.messageQueue.length = 0;
1015
+ this.handlers.clear();
1016
+ const child = this.child;
1017
+ if (!child) return;
1018
+ treeKill(child);
1019
+ this.child = null;
1020
+ }
1021
+ onChildData(chunk) {
1022
+ this.buffer += chunk;
1023
+ const lines = this.buffer.split("\n");
1024
+ this.buffer = lines.pop() ?? "";
1025
+ if (this.buffer.length > this.maxFrameChars) {
1026
+ writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
1027
+ `);
1028
+ this.stop();
1029
+ return;
1030
+ }
1031
+ for (const raw of lines) {
1032
+ if (!raw.trim()) continue;
1033
+ if (raw.length > this.maxFrameChars) {
1034
+ writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
1035
+ `);
1036
+ this.stop();
1037
+ return;
1038
+ }
1342
1039
  try {
1343
- this.ws.close();
1040
+ this.dispatch(JSON.parse(raw));
1344
1041
  } catch {
1345
1042
  }
1346
- this.ws = null;
1347
1043
  }
1348
1044
  }
1349
- onData(data) {
1350
- const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
1351
- if (!text.trim()) return;
1352
- let msg;
1353
- try {
1354
- msg = JSON.parse(text);
1355
- } catch {
1356
- for (const line of text.split("\n")) {
1357
- if (!line.trim()) continue;
1358
- try {
1359
- this.dispatch(JSON.parse(line));
1360
- } catch {
1361
- }
1362
- }
1363
- return;
1045
+ onChildError(chunk) {
1046
+ writeErr(`[acp-child stderr] ${chunk}`);
1047
+ }
1048
+ onChildClose(code) {
1049
+ this.closed = true;
1050
+ this.resolveRead?.(null);
1051
+ this.resolveRead = null;
1052
+ this.buffer = "";
1053
+ this.messageQueue.length = 0;
1054
+ this.handlers.clear();
1055
+ if (code !== 0 && code !== null) {
1056
+ writeErr(`[acp-child exited with code ${code}]
1057
+ `);
1364
1058
  }
1365
- this.dispatch(msg);
1366
1059
  }
1367
1060
  dispatch(msg) {
1368
- for (const handler of [...this.handlers]) {
1061
+ if (this.resolveRead) {
1062
+ const resolve3 = this.resolveRead;
1063
+ this.resolveRead = null;
1064
+ resolve3(msg);
1065
+ } else if (this.handlers.size === 0) {
1066
+ if (this.messageQueue.length >= this.maxQueuedMessages) {
1067
+ writeErr(`[acp-child message queue exceeds ${this.maxQueuedMessages} entries]
1068
+ `);
1069
+ this.stop();
1070
+ return;
1071
+ }
1072
+ this.messageQueue.push(msg);
1073
+ }
1074
+ for (const handler of this.handlers) {
1369
1075
  try {
1370
1076
  handler(msg);
1371
1077
  } catch {
@@ -1373,321 +1079,604 @@ var WebSocketClientTransport = class {
1373
1079
  }
1374
1080
  }
1375
1081
  };
1082
+ function spawnInvocation(command, args, platform) {
1083
+ if (platform !== "win32") return { command, args };
1084
+ return buildWin32CmdShimInvocation(command, args);
1085
+ }
1086
+ function verbatimOptions(invocation) {
1087
+ return invocation.windowsVerbatimArguments ? { windowsVerbatimArguments: invocation.windowsVerbatimArguments } : {};
1088
+ }
1376
1089
 
1377
- // src/client/tool-translator.ts
1378
- import { expectDefined as expectDefined2 } from "@wrongstack/core";
1379
- var DEFAULT_OPTIONS = {
1380
- asyncTools: true,
1381
- pollIntervalMs: 500,
1382
- totalTimeoutMs: 12e4
1383
- };
1384
- var ToolTranslator = class {
1385
- opts;
1386
- pending = /* @__PURE__ */ new Map();
1387
- constructor(opts = {}) {
1388
- this.opts = { ...DEFAULT_OPTIONS, ...opts };
1090
+ // src/agent/tools-registry.ts
1091
+ var ACPToolsRegistry = class {
1092
+ tools = /* @__PURE__ */ new Map();
1093
+ owner;
1094
+ constructor(owner = "wrongstack") {
1095
+ this.owner = owner;
1389
1096
  }
1390
1097
  /**
1391
- * Start listening to a transport for tool responses and cancellations.
1392
- * Call this once after constructing the translator and before sending tasks.
1098
+ * Register one or more tools.
1099
+ * Throws on duplicate name unless force=true.
1393
1100
  */
1394
- attachToTransport(transport) {
1395
- transport.onMessage((msg) => {
1396
- if (msg.method === "tools/call" && msg.id !== void 0) {
1397
- const pending = this.pending.get(msg.id);
1398
- if (pending) {
1399
- clearTimeout(pending.timeout);
1400
- this.pending.delete(expectDefined2(msg.id));
1401
- pending.resolve(msg);
1402
- }
1403
- }
1404
- if (msg.method === "cancel" && msg.id !== void 0) {
1405
- const pending = this.pending.get(msg.id);
1406
- if (pending) {
1407
- clearTimeout(pending.timeout);
1408
- this.pending.delete(expectDefined2(msg.id));
1409
- pending.reject(new Error("Call cancelled by client"));
1410
- }
1411
- }
1412
- });
1101
+ register(tools) {
1102
+ for (const tool of tools) {
1103
+ this.tools.set(tool.name, tool);
1104
+ }
1413
1105
  }
1414
1106
  /**
1415
- * Send a tool call over the transport and wait for a response.
1416
- * If asyncTools is true, polls for progress and resolves when the final
1417
- * response arrives.
1107
+ * Replace the current tool set.
1418
1108
  */
1419
- async callTool(transport, name, args, callId = crypto.randomUUID()) {
1420
- await transport.send({
1421
- jsonrpc: "2.0",
1422
- method: "tools/call",
1423
- id: callId,
1424
- params: { name, arguments: args }
1425
- });
1426
- return new Promise((resolve3, reject) => {
1427
- const timeout = setTimeout(() => {
1428
- this.pending.delete(callId);
1429
- reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
1430
- }, this.opts.totalTimeoutMs);
1431
- this.pending.set(callId, { resolve: resolve3, reject, timeout });
1432
- });
1109
+ setTools(tools) {
1110
+ this.tools.clear();
1111
+ for (const tool of tools) this.tools.set(tool.name, tool);
1433
1112
  }
1434
- cancelAll() {
1435
- for (const [, p] of this.pending) {
1436
- clearTimeout(p.timeout);
1437
- }
1438
- this.pending.clear();
1113
+ get(name) {
1114
+ return this.tools.get(name);
1439
1115
  }
1440
- };
1441
-
1442
- // src/client/file-server.ts
1443
- import { randomBytes } from "node:crypto";
1444
- import { realpathSync } from "node:fs";
1445
- import * as fsp from "node:fs/promises";
1446
- import * as path from "node:path";
1447
- var DEFAULT_MAX_READ_BYTES = 5 * 1024 * 1024;
1448
- var DEFAULT_MAX_WRITE_BYTES = 5 * 1024 * 1024;
1449
- var FsError = class extends Error {
1450
- code;
1451
- path;
1452
- constructor(code, path4, message) {
1453
- super(message);
1454
- this.name = "FsError";
1455
- this.code = code;
1456
- this.path = path4;
1116
+ has(name) {
1117
+ return this.tools.has(name);
1457
1118
  }
1458
- };
1459
- var FileServer = class {
1460
- root;
1461
- realRoot;
1462
- timeoutMs;
1463
- maxReadBytes;
1464
- maxWriteBytes;
1465
- constructor(opts) {
1466
- this.root = path.resolve(opts.projectRoot);
1467
- this.realRoot = safeRealpathSync(this.root);
1468
- this.timeoutMs = opts.timeoutMs ?? 3e4;
1469
- this.maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
1470
- this.maxWriteBytes = opts.maxWriteBytes ?? DEFAULT_MAX_WRITE_BYTES;
1119
+ list() {
1120
+ return Array.from(this.tools.values());
1471
1121
  }
1472
- /** Read a text file. Returns the content as a string. */
1473
- async readTextFile(params) {
1474
- const safe = await this.resolveInside(params.path);
1475
- const controller = new AbortController();
1476
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1122
+ /** Build the ACP tools/list payload from registered tools. */
1123
+ buildToolList() {
1124
+ return {
1125
+ tools: Array.from(this.tools.values()).map(
1126
+ (t) => toACPToolDefinition(t, this.owner)
1127
+ )
1128
+ };
1129
+ }
1130
+ /**
1131
+ * Execute a tool by name and return ACP-formatted result.
1132
+ * Returns null if the tool is not found.
1133
+ */
1134
+ async execute(name, args, ctx, signal) {
1135
+ const tool = this.tools.get(name);
1136
+ if (!tool) return null;
1477
1137
  try {
1478
- const stat2 = await fsp.stat(safe).catch((err) => {
1479
- throw mapFsError(err, safe);
1480
- });
1481
- if (stat2.size > this.maxReadBytes) {
1482
- throw new FsError(
1483
- "TOO_LARGE",
1484
- safe,
1485
- `file is ${stat2.size} bytes, max read is ${this.maxReadBytes} bytes`
1486
- );
1487
- }
1488
- const content = await fsp.readFile(safe, {
1489
- encoding: "utf8",
1490
- signal: controller.signal
1138
+ const result = await tool.execute(args, ctx, {
1139
+ signal
1491
1140
  });
1492
- return { content };
1141
+ return toACPToolResult(result);
1493
1142
  } catch (err) {
1494
- if (err instanceof FsError) throw err;
1495
- if (controller.signal.aborted) {
1496
- throw new FsError("TIMEOUT", safe, `readTextFile timed out after ${this.timeoutMs}ms`);
1497
- }
1498
- throw mapFsError(err, safe);
1499
- } finally {
1500
- clearTimeout(timer);
1143
+ const msg = err instanceof Error ? err.message : String(err);
1144
+ return { content: [{ type: "text", text: msg }], isError: true };
1501
1145
  }
1502
1146
  }
1503
- /** Write a text file. Atomic via write-then-rename. */
1504
- async writeTextFile(params) {
1505
- const byteLength = Buffer.byteLength(params.content, "utf8");
1506
- if (byteLength > this.maxWriteBytes) {
1507
- throw new FsError(
1508
- "TOO_LARGE",
1509
- params.path,
1510
- `content is ${byteLength} bytes, max write is ${this.maxWriteBytes} bytes`
1511
- );
1147
+ };
1148
+ function toACPToolDefinition(tool, _owner) {
1149
+ return {
1150
+ name: tool.name,
1151
+ description: tool.description,
1152
+ inputSchema: toACPInputSchema(tool.inputSchema),
1153
+ annotations: {
1154
+ title: tool.name,
1155
+ description: tool.usageHint ?? tool.description,
1156
+ priority: toolToPriority(tool),
1157
+ alwaysAccept: tool.permission === "auto"
1512
1158
  }
1513
- const safe = await this.resolveInside(params.path);
1514
- const controller = new AbortController();
1515
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1516
- const tmp = `${safe}.${randomBytes(6).toString("hex")}.tmp`;
1517
- try {
1518
- await fsp.writeFile(tmp, params.content, {
1519
- encoding: "utf8",
1520
- signal: controller.signal
1521
- });
1522
- await this.assertRealInside(tmp);
1523
- await this.assertRealInside(path.dirname(safe));
1524
- await fsp.rename(tmp, safe);
1525
- } catch (err) {
1526
- if (err instanceof FsError) {
1527
- await fsp.unlink(tmp).catch(() => void 0);
1528
- throw err;
1529
- }
1530
- try {
1531
- await fsp.unlink(tmp);
1532
- } catch {
1533
- }
1534
- if (controller.signal.aborted) {
1535
- throw new FsError("TIMEOUT", safe, `writeTextFile timed out after ${this.timeoutMs}ms`);
1536
- }
1537
- throw mapFsError(err, safe);
1538
- } finally {
1539
- clearTimeout(timer);
1159
+ };
1160
+ }
1161
+ function toACPInputSchema(src) {
1162
+ if (!src || typeof src !== "object") {
1163
+ return {};
1164
+ }
1165
+ const s = src;
1166
+ const out = {};
1167
+ if (typeof s.type === "string") out.type = s.type;
1168
+ if (Array.isArray(s.enum)) out.enum = s.enum;
1169
+ if (typeof s.description === "string") out.description = s.description;
1170
+ if ("default" in s) out.default = s.default;
1171
+ if (typeof s.minimum === "number") out.minimum = s.minimum;
1172
+ if (typeof s.maximum === "number") out.maximum = s.maximum;
1173
+ if (s.items) out.items = toACPInputSchema(s.items);
1174
+ if (s.properties && typeof s.properties === "object") {
1175
+ const props = {};
1176
+ for (const [k, v] of Object.entries(s.properties)) {
1177
+ props[k] = toACPInputSchema(v);
1540
1178
  }
1179
+ out.properties = props;
1180
+ if (Array.isArray(s.required)) out.required = s.required;
1181
+ }
1182
+ return out;
1183
+ }
1184
+ function toACPToolResult(result) {
1185
+ const blocks = [];
1186
+ if (result === void 0 || result === null) {
1187
+ return { content: [{ type: "text", text: "ok" }] };
1188
+ }
1189
+ if (typeof result === "string") {
1190
+ blocks.push({ type: "text", text: result });
1191
+ } else if (typeof result === "object") {
1192
+ blocks.push({ type: "text", text: JSON.stringify(result, null, 2) });
1193
+ } else {
1194
+ blocks.push({ type: "text", text: String(result) });
1195
+ }
1196
+ return { content: blocks };
1197
+ }
1198
+ function toolToPriority(tool) {
1199
+ if (tool.riskTier === "destructive") return "high";
1200
+ if (tool.riskTier === "standard" || tool.permission === "confirm") return "medium";
1201
+ return "low";
1202
+ }
1203
+
1204
+ // src/agent/wrongstack-acp-agent.ts
1205
+ import { createServer } from "node:http";
1206
+ import { fileURLToPath } from "node:url";
1207
+ import { writeErr as writeErr2 } from "@wrongstack/core/utils";
1208
+ var WrongStackACPServer = class {
1209
+ transport;
1210
+ handler;
1211
+ options;
1212
+ /** HTTP server when transport mode is HTTP. */
1213
+ httpServer = null;
1214
+ running = false;
1215
+ constructor(opts = {}) {
1216
+ this.options = opts;
1217
+ this.transport = new StdioTransport();
1218
+ const runTurn = opts.runTurn ?? defaultEchoRunTurn;
1219
+ this.handler = new ACPProtocolHandler({
1220
+ transport: this.transport,
1221
+ defaultCwd: opts.defaultCwd ?? process.cwd(),
1222
+ runTurn,
1223
+ agentName: opts.agentName,
1224
+ ...opts.replayFor ? { replayFor: opts.replayFor } : {},
1225
+ ...opts.seedFor ? { seedFor: opts.seedFor } : {},
1226
+ ...opts.disposeFor ? { disposeFor: opts.disposeFor } : {},
1227
+ ...opts.store ? { store: opts.store } : {}
1228
+ });
1541
1229
  }
1542
1230
  /**
1543
- * Resolve a path and verify it is inside the project root by realpath.
1544
- * Rejects with `FsError` if the textual path, the resolved path, or the
1545
- * real (symlink-resolved) path escapes the project root.
1546
- *
1547
- * For files that don't exist yet (e.g. a write to a new file), the
1548
- * nearest existing ancestor directory is realpath-checked instead.
1231
+ * Start the server. Mode depends on `options.transport`:
1232
+ * - 'stdio' (default): reads JSON-RPC from stdin, writes to stdout.
1233
+ * - number: listens as HTTP on the given port.
1549
1234
  */
1550
- async resolveInside(p) {
1551
- if (typeof p !== "string" || p.length === 0) {
1552
- throw new FsError("INVALID_PATH", p, "path is empty or not a string");
1235
+ async start() {
1236
+ const transportMode = this.options.transport;
1237
+ if (typeof transportMode === "number") {
1238
+ await this.startHttp(transportMode);
1239
+ } else {
1240
+ await this.startStdio();
1553
1241
  }
1554
- if (!path.isAbsolute(p)) {
1555
- throw new FsError("INVALID_PATH", p, "path must be absolute (ACP requirement)");
1242
+ }
1243
+ async startStdio() {
1244
+ if (this.options.legacyStartupMarker) {
1245
+ this.transport.sendStartupMarker();
1556
1246
  }
1557
- const resolved = path.resolve(p);
1558
- const rootWithSep = this.root.endsWith(path.sep) ? this.root : this.root + path.sep;
1559
- if (resolved !== this.root && !resolved.startsWith(rootWithSep)) {
1560
- throw new FsError("OUTSIDE_ROOT", resolved, "path is outside the project root");
1247
+ this.running = true;
1248
+ try {
1249
+ while (this.running) {
1250
+ const msg = await this.transport.read();
1251
+ if (!msg) break;
1252
+ const terminal = await this.handler.handleMessage(msg);
1253
+ if (terminal) break;
1254
+ }
1255
+ } finally {
1256
+ this.handler.close();
1257
+ this.transport.close();
1561
1258
  }
1562
- await this.assertRealInside(resolved);
1563
- return resolved;
1564
1259
  }
1565
- /**
1566
- * Resolve `resolvedPath` through `fs.realpath` and verify the result is
1567
- * inside `realRoot`. For non-existent paths (new files), walk up to the
1568
- * nearest existing ancestor and check that instead.
1569
- */
1570
- async assertRealInside(resolvedPath) {
1571
- let probe = resolvedPath;
1572
- for (; ; ) {
1573
- let real;
1574
- try {
1575
- real = await fsp.realpath(probe);
1576
- } catch (err) {
1577
- const code = err.code;
1578
- if (code === "ENOENT") {
1579
- const parent = path.dirname(probe);
1580
- if (parent === probe) return;
1581
- probe = parent;
1582
- continue;
1260
+ async startHttp(port) {
1261
+ const host = this.options.host ?? "127.0.0.1";
1262
+ const handler = this.handler;
1263
+ const authToken = this.options.authToken;
1264
+ let httpChain = Promise.resolve();
1265
+ this.httpServer = createServer(async (req, res) => {
1266
+ if (authToken) {
1267
+ const url = new URL(requestPath(req.url), `http://${host}:${port}`);
1268
+ const queryToken = url.searchParams.get("token");
1269
+ const bearerToken = headerValue(req.headers.authorization)?.replace(/^Bearer\s+/i, "");
1270
+ const supplied = queryToken ?? bearerToken ?? "";
1271
+ if (supplied !== authToken) {
1272
+ res.writeHead(401, { "Content-Type": "application/json" });
1273
+ res.end(JSON.stringify({ error: { code: -32001, message: "Unauthorized" } }));
1274
+ return;
1583
1275
  }
1584
- throw mapFsError(err, resolvedPath);
1585
1276
  }
1586
- if (real === this.realRoot || real.startsWith(this.realRoot + path.sep)) return;
1587
- throw new FsError(
1588
- "OUTSIDE_ROOT",
1589
- resolvedPath,
1590
- "path resolves through a symlink outside the project root"
1591
- );
1277
+ const selfOrigin = `http://${host}:${port}`;
1278
+ const reqOrigin = headerValue(req.headers.origin);
1279
+ if (reqOrigin && reqOrigin !== selfOrigin) {
1280
+ res.writeHead(403);
1281
+ res.end(JSON.stringify({ error: "cross-origin request forbidden" }));
1282
+ return;
1283
+ }
1284
+ if (reqOrigin) res.setHeader("Access-Control-Allow-Origin", reqOrigin);
1285
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
1286
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Authorization");
1287
+ if (req.method === "OPTIONS") {
1288
+ res.writeHead(204);
1289
+ res.end();
1290
+ return;
1291
+ }
1292
+ if (req.method !== "POST") {
1293
+ res.writeHead(405);
1294
+ res.end(JSON.stringify({ error: "method not allowed" }));
1295
+ return;
1296
+ }
1297
+ const MAX_HTTP_BODY = 10 * 1024 * 1024;
1298
+ let body = "";
1299
+ let bodyBytes = 0;
1300
+ let tooLarge = false;
1301
+ for await (const chunk of req) {
1302
+ bodyBytes += chunk.length;
1303
+ if (bodyBytes > MAX_HTTP_BODY) {
1304
+ tooLarge = true;
1305
+ break;
1306
+ }
1307
+ body += chunk;
1308
+ }
1309
+ if (tooLarge) {
1310
+ res.writeHead(413, { "Content-Type": "application/json" });
1311
+ res.end(JSON.stringify({ error: { code: -32700, message: "Request body too large" } }));
1312
+ return;
1313
+ }
1314
+ let msg;
1315
+ try {
1316
+ msg = JSON.parse(body);
1317
+ } catch {
1318
+ res.writeHead(400);
1319
+ res.end(JSON.stringify({ error: { code: -32700, message: "Parse error" } }));
1320
+ return;
1321
+ }
1322
+ const isNotification = typeof msg === "object" && msg !== null && msg.id === void 0 && typeof msg.method === "string";
1323
+ if (isNotification) {
1324
+ try {
1325
+ await handler.handleMessage(msg);
1326
+ } catch {
1327
+ }
1328
+ res.writeHead(200, { "Content-Type": "application/json" });
1329
+ res.end(JSON.stringify({ notifications: [] }));
1330
+ return;
1331
+ }
1332
+ const requestPromise = httpChain.then(async () => {
1333
+ const notifications = [];
1334
+ let response = null;
1335
+ const originalSend = this.transport.send.bind(this.transport);
1336
+ this.transport.send = async (m) => {
1337
+ if (m.id !== void 0 && (m.result !== void 0 || m.error !== void 0)) {
1338
+ response = m;
1339
+ } else if (m.method === "session/update") {
1340
+ notifications.push(m.params);
1341
+ } else {
1342
+ notifications.push(m);
1343
+ }
1344
+ };
1345
+ try {
1346
+ await handler.handleMessage(msg);
1347
+ } finally {
1348
+ this.transport.send = originalSend;
1349
+ }
1350
+ res.writeHead(200, { "Content-Type": "application/json" });
1351
+ const responseBody = response !== null ? { ...response, notifications } : { notifications };
1352
+ res.end(JSON.stringify(responseBody));
1353
+ });
1354
+ httpChain = requestPromise.catch(() => void 0);
1355
+ try {
1356
+ await requestPromise;
1357
+ } catch {
1358
+ res.writeHead(500, { "Content-Type": "application/json" });
1359
+ res.end(JSON.stringify({ error: { code: -32603, message: "Internal error" } }));
1360
+ }
1361
+ });
1362
+ return new Promise((resolve3) => {
1363
+ this.httpServer.listen(port, host, () => {
1364
+ writeErr2(`[wstack-acp] HTTP server listening on http://${host}:${port}
1365
+ `);
1366
+ this.running = true;
1367
+ resolve3();
1368
+ });
1369
+ });
1370
+ }
1371
+ /** Stop the server. */
1372
+ stop() {
1373
+ this.running = false;
1374
+ this.handler.close();
1375
+ this.transport.close();
1376
+ if (this.httpServer) {
1377
+ this.httpServer.close();
1378
+ this.httpServer = null;
1592
1379
  }
1593
1380
  }
1594
1381
  };
1595
- function mapFsError(err, p) {
1596
- const code = err?.code;
1597
- if (code === "ENOENT") return new FsError("ENOENT", p, `no such file: ${p}`);
1598
- if (code === "EACCES" || code === "EPERM") {
1599
- return new FsError("EACCES", p, `permission denied: ${p}`);
1600
- }
1601
- const msg = err instanceof Error ? err.message : String(err);
1602
- return new FsError("INVALID_PATH", p, msg);
1382
+ var defaultEchoRunTurn = async (_input, _emit) => {
1383
+ return { stopReason: "end_turn" };
1384
+ };
1385
+ function headerValue(value) {
1386
+ return Array.isArray(value) ? value[0] : value;
1603
1387
  }
1604
- function safeRealpathSync(p) {
1605
- try {
1606
- return realpathSync(p);
1607
- } catch {
1608
- return p;
1609
- }
1388
+ function requestPath(value) {
1389
+ return value ?? "/";
1610
1390
  }
1611
-
1612
- // src/client/permission.ts
1613
- function pickAllow(options) {
1614
- const ranked = [...options].sort((a, b) => {
1615
- const score = (k) => {
1616
- if (k === "allow_once") return 0;
1617
- if (k === "allow_always") return 1;
1618
- if (k === "reject_once") return 2;
1619
- return 3;
1620
- };
1621
- return score(a.kind) - score(b.kind);
1622
- });
1623
- const chosen = ranked[0];
1624
- if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
1625
- return { outcome: "cancelled" };
1626
- }
1627
- return { outcome: "selected", optionId: chosen.optionId };
1391
+ async function main() {
1392
+ const server = new WrongStackACPServer();
1393
+ await server.start();
1628
1394
  }
1629
- function pickReject(options) {
1630
- const reject = options.find(
1631
- (o) => o.kind === "reject_once" || o.kind === "reject_always"
1632
- );
1633
- return reject ? { outcome: "selected", optionId: reject.optionId } : { outcome: "cancelled" };
1395
+ var isEntrypoint = process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === process.argv[1];
1396
+ if (isEntrypoint) {
1397
+ main().catch((err) => {
1398
+ writeErr2(`[wstack-acp fatal] ${err}
1399
+ `);
1400
+ process.exit(1);
1401
+ });
1634
1402
  }
1635
- var READ_ONLY_KINDS = /* @__PURE__ */ new Set(["read", "search", "fetch", "think"]);
1636
- var defaultPermissionPolicy = async (req) => {
1637
- if (req.signal.aborted) return { outcome: "cancelled" };
1638
- return pickAllow(req.options);
1403
+
1404
+ // src/client/file-server.ts
1405
+ import { randomBytes } from "node:crypto";
1406
+ import { realpathSync } from "node:fs";
1407
+ import * as fsp from "node:fs/promises";
1408
+ import * as path from "node:path";
1409
+ var DEFAULT_FILE_OPERATIONS = {
1410
+ stat: fsp.stat,
1411
+ readFile: fsp.readFile,
1412
+ writeFile: fsp.writeFile,
1413
+ realpath: fsp.realpath,
1414
+ rename: fsp.rename,
1415
+ unlink: fsp.unlink
1639
1416
  };
1640
- var readOnlyPermissionPolicy = async (req) => {
1641
- if (req.signal.aborted) return { outcome: "cancelled" };
1642
- const kind = req.toolCall.kind;
1643
- if (kind && READ_ONLY_KINDS.has(kind)) {
1644
- return pickAllow(req.options);
1417
+ var DEFAULT_MAX_READ_BYTES = 5 * 1024 * 1024;
1418
+ var DEFAULT_MAX_WRITE_BYTES = 5 * 1024 * 1024;
1419
+ var FsError = class extends Error {
1420
+ code;
1421
+ path;
1422
+ constructor(code, path4, message) {
1423
+ super(message);
1424
+ this.name = "FsError";
1425
+ this.code = code;
1426
+ this.path = path4;
1645
1427
  }
1646
- return pickReject(req.options);
1647
1428
  };
1648
- function makePermissionPolicy(decide) {
1649
- return async (req) => {
1650
- if (req.signal.aborted) return { outcome: "cancelled" };
1651
- const allow = await decide(req);
1652
- return allow ? pickAllow(req.options) : pickReject(req.options);
1653
- };
1654
- }
1655
-
1656
- // src/client/terminal-server.ts
1657
- import { spawn } from "node:child_process";
1658
- import { realpathSync as realpathSync2 } from "node:fs";
1659
- import * as path2 from "node:path";
1660
- import { buildChildEnv } from "@wrongstack/core/utils";
1661
- var TerminalServer = class {
1662
- terminals = /* @__PURE__ */ new Map();
1663
- projectRoot;
1664
- commandTimeoutMs;
1665
- outputByteLimit;
1666
- maxOutputByteLimit;
1667
- nextId = 1;
1429
+ var FileServer = class {
1430
+ root;
1431
+ realRoot;
1432
+ timeoutMs;
1433
+ maxReadBytes;
1434
+ maxWriteBytes;
1435
+ operations;
1668
1436
  constructor(opts) {
1669
- this.projectRoot = path2.resolve(opts.projectRoot);
1670
- this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
1671
- this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
1672
- this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
1673
- if (opts.signal) {
1674
- opts.signal.addEventListener("abort", () => this.releaseAll());
1437
+ this.root = path.resolve(opts.projectRoot);
1438
+ this.realRoot = safeRealpathSync(this.root);
1439
+ this.timeoutMs = opts.timeoutMs ?? 3e4;
1440
+ this.maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
1441
+ this.maxWriteBytes = opts.maxWriteBytes ?? DEFAULT_MAX_WRITE_BYTES;
1442
+ this.operations = opts.operations ?? DEFAULT_FILE_OPERATIONS;
1443
+ }
1444
+ /** Read a text file. Returns the content as a string. */
1445
+ async readTextFile(params) {
1446
+ const safe = await this.resolveInside(params.path);
1447
+ const controller = new AbortController();
1448
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1449
+ try {
1450
+ const stat2 = await this.operations.stat(safe).catch((err) => {
1451
+ throw mapFsError(err, safe);
1452
+ });
1453
+ if (stat2.size > this.maxReadBytes) {
1454
+ throw new FsError(
1455
+ "TOO_LARGE",
1456
+ safe,
1457
+ `file is ${stat2.size} bytes, max read is ${this.maxReadBytes} bytes`
1458
+ );
1459
+ }
1460
+ const content = await this.operations.readFile(safe, {
1461
+ encoding: "utf8",
1462
+ signal: controller.signal
1463
+ });
1464
+ return { content };
1465
+ } catch (err) {
1466
+ if (err instanceof FsError) throw err;
1467
+ if (controller.signal.aborted) {
1468
+ throw new FsError("TIMEOUT", safe, `readTextFile timed out after ${this.timeoutMs}ms`);
1469
+ }
1470
+ throw mapFsError(err, safe);
1471
+ } finally {
1472
+ clearTimeout(timer);
1675
1473
  }
1676
1474
  }
1677
- /** Spawn a new terminal. Returns the agent-facing id. */
1678
- create(params) {
1679
- const id = `term_${this.nextId++}`;
1680
- const cwd = this.resolveCwd(params.cwd);
1681
- const proc = spawn(params.command, params.args ?? [], {
1682
- cwd,
1683
- env: this.buildEnv(params.env),
1684
- stdio: ["ignore", "pipe", "pipe"],
1685
- windowsHide: true
1686
- // shell: false on purpose. The terminal server is invoked with
1687
- // the agent's explicit argv; turning on shell-mode would make
1688
- // the command a single shell-parsed string, which breaks
1689
- // Windows cmd quoting for the common case of running node with
1690
- // `-e "<script>"`. If a future feature needs shell features
1475
+ /** Write a text file. Atomic via write-then-rename. */
1476
+ async writeTextFile(params) {
1477
+ const byteLength = Buffer.byteLength(params.content, "utf8");
1478
+ if (byteLength > this.maxWriteBytes) {
1479
+ throw new FsError(
1480
+ "TOO_LARGE",
1481
+ params.path,
1482
+ `content is ${byteLength} bytes, max write is ${this.maxWriteBytes} bytes`
1483
+ );
1484
+ }
1485
+ const safe = await this.resolveInside(params.path);
1486
+ const controller = new AbortController();
1487
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1488
+ const tmp = `${safe}.${randomBytes(6).toString("hex")}.tmp`;
1489
+ try {
1490
+ await this.operations.writeFile(tmp, params.content, {
1491
+ encoding: "utf8",
1492
+ signal: controller.signal
1493
+ });
1494
+ await this.assertRealInside(tmp);
1495
+ await this.assertRealInside(path.dirname(safe));
1496
+ await this.operations.rename(tmp, safe);
1497
+ } catch (err) {
1498
+ if (err instanceof FsError) {
1499
+ await this.operations.unlink(tmp).catch(() => void 0);
1500
+ throw err;
1501
+ }
1502
+ try {
1503
+ await this.operations.unlink(tmp);
1504
+ } catch {
1505
+ }
1506
+ if (controller.signal.aborted) {
1507
+ throw new FsError("TIMEOUT", safe, `writeTextFile timed out after ${this.timeoutMs}ms`);
1508
+ }
1509
+ throw mapFsError(err, safe);
1510
+ } finally {
1511
+ clearTimeout(timer);
1512
+ }
1513
+ }
1514
+ /**
1515
+ * Resolve a path and verify it is inside the project root by realpath.
1516
+ * Rejects with `FsError` if the textual path, the resolved path, or the
1517
+ * real (symlink-resolved) path escapes the project root.
1518
+ *
1519
+ * For files that don't exist yet (e.g. a write to a new file), the
1520
+ * nearest existing ancestor directory is realpath-checked instead.
1521
+ */
1522
+ async resolveInside(p) {
1523
+ if (typeof p !== "string" || p.length === 0) {
1524
+ throw new FsError("INVALID_PATH", p, "path is empty or not a string");
1525
+ }
1526
+ if (!path.isAbsolute(p)) {
1527
+ throw new FsError("INVALID_PATH", p, "path must be absolute (ACP requirement)");
1528
+ }
1529
+ const resolved = path.resolve(p);
1530
+ const rootWithSep = this.root.endsWith(path.sep) ? this.root : this.root + path.sep;
1531
+ if (resolved !== this.root && !resolved.startsWith(rootWithSep)) {
1532
+ throw new FsError("OUTSIDE_ROOT", resolved, "path is outside the project root");
1533
+ }
1534
+ await this.assertRealInside(resolved);
1535
+ return resolved;
1536
+ }
1537
+ /**
1538
+ * Resolve `resolvedPath` through `fs.realpath` and verify the result is
1539
+ * inside `realRoot`. For non-existent paths (new files), walk up to the
1540
+ * nearest existing ancestor and check that instead.
1541
+ */
1542
+ async assertRealInside(resolvedPath) {
1543
+ let probe = resolvedPath;
1544
+ for (; ; ) {
1545
+ let real;
1546
+ try {
1547
+ real = await this.operations.realpath(probe);
1548
+ } catch (err) {
1549
+ const code = err.code;
1550
+ if (code === "ENOENT") {
1551
+ const parent = path.dirname(probe);
1552
+ if (parent === probe) {
1553
+ throw new FsError("ENOENT", resolvedPath, `no existing ancestor: ${resolvedPath}`);
1554
+ }
1555
+ probe = parent;
1556
+ continue;
1557
+ }
1558
+ throw mapFsError(err, resolvedPath);
1559
+ }
1560
+ if (real === this.realRoot || real.startsWith(this.realRoot + path.sep)) return;
1561
+ throw new FsError(
1562
+ "OUTSIDE_ROOT",
1563
+ resolvedPath,
1564
+ "path resolves through a symlink outside the project root"
1565
+ );
1566
+ }
1567
+ }
1568
+ };
1569
+ function mapFsError(err, p) {
1570
+ const code = err?.code;
1571
+ if (code === "ENOENT") return new FsError("ENOENT", p, `no such file: ${p}`);
1572
+ if (code === "EACCES" || code === "EPERM") {
1573
+ return new FsError("EACCES", p, `permission denied: ${p}`);
1574
+ }
1575
+ const msg = err instanceof Error ? err.message : String(err);
1576
+ return new FsError("INVALID_PATH", p, msg);
1577
+ }
1578
+ function safeRealpathSync(p) {
1579
+ try {
1580
+ return realpathSync(p);
1581
+ } catch {
1582
+ return p;
1583
+ }
1584
+ }
1585
+
1586
+ // src/client/permission.ts
1587
+ function pickAllow(options) {
1588
+ const ranked = [...options].sort((a, b) => {
1589
+ const score = (k) => {
1590
+ if (k === "allow_once") return 0;
1591
+ if (k === "allow_always") return 1;
1592
+ if (k === "reject_once") return 2;
1593
+ return 3;
1594
+ };
1595
+ return score(a.kind) - score(b.kind);
1596
+ });
1597
+ const chosen = ranked[0];
1598
+ if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
1599
+ return { outcome: "cancelled" };
1600
+ }
1601
+ return { outcome: "selected", optionId: chosen.optionId };
1602
+ }
1603
+ function pickReject(options) {
1604
+ const reject = options.find(
1605
+ (o) => o.kind === "reject_once" || o.kind === "reject_always"
1606
+ );
1607
+ return reject ? { outcome: "selected", optionId: reject.optionId } : { outcome: "cancelled" };
1608
+ }
1609
+ var READ_ONLY_KINDS = /* @__PURE__ */ new Set(["read", "search", "fetch", "think"]);
1610
+ var defaultPermissionPolicy = async (req) => {
1611
+ if (req.signal.aborted) return { outcome: "cancelled" };
1612
+ return pickAllow(req.options);
1613
+ };
1614
+ var readOnlyPermissionPolicy = async (req) => {
1615
+ if (req.signal.aborted) return { outcome: "cancelled" };
1616
+ const kind = req.toolCall.kind;
1617
+ if (kind && READ_ONLY_KINDS.has(kind)) {
1618
+ return pickAllow(req.options);
1619
+ }
1620
+ return pickReject(req.options);
1621
+ };
1622
+ function makePermissionPolicy(decide) {
1623
+ return async (req) => {
1624
+ if (req.signal.aborted) return { outcome: "cancelled" };
1625
+ const allow = await decide(req);
1626
+ return allow ? pickAllow(req.options) : pickReject(req.options);
1627
+ };
1628
+ }
1629
+
1630
+ // src/client/terminal-server.ts
1631
+ import { spawn } from "node:child_process";
1632
+ import { realpathSync as realpathSync2 } from "node:fs";
1633
+ import * as path2 from "node:path";
1634
+ import { buildChildEnv } from "@wrongstack/core/utils";
1635
+ var EMPTY_BUFFER = Buffer.alloc(0);
1636
+ var TerminalServer = class {
1637
+ terminals = /* @__PURE__ */ new Map();
1638
+ projectRoot;
1639
+ commandTimeoutMs;
1640
+ outputByteLimit;
1641
+ maxOutputByteLimit;
1642
+ maxTerminals;
1643
+ abortSignal;
1644
+ abortHandler = () => this.releaseAll();
1645
+ nextId = 1;
1646
+ constructor(opts) {
1647
+ this.projectRoot = path2.resolve(opts.projectRoot);
1648
+ this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
1649
+ this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
1650
+ this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
1651
+ this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
1652
+ this.abortSignal = opts.signal;
1653
+ if (opts.signal) {
1654
+ opts.signal.addEventListener("abort", this.abortHandler, { once: true });
1655
+ }
1656
+ }
1657
+ /** Spawn a new terminal. Returns the agent-facing id. */
1658
+ create(params) {
1659
+ if (this.terminals.size >= this.maxTerminals) {
1660
+ throw new Error(
1661
+ `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
1662
+ );
1663
+ }
1664
+ const id = `term_${this.nextId++}`;
1665
+ const cwd = this.resolveCwd(params.cwd);
1666
+ const perCallByteLimit = Math.min(
1667
+ Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
1668
+ this.maxOutputByteLimit
1669
+ );
1670
+ const proc = spawn(params.command, params.args ?? [], {
1671
+ cwd,
1672
+ env: this.buildEnv(params.env),
1673
+ stdio: ["ignore", "pipe", "pipe"],
1674
+ windowsHide: true
1675
+ // shell: false on purpose. The terminal server is invoked with
1676
+ // the agent's explicit argv; turning on shell-mode would make
1677
+ // the command a single shell-parsed string, which breaks
1678
+ // Windows cmd quoting for the common case of running node with
1679
+ // `-e "<script>"`. If a future feature needs shell features
1691
1680
  // (pipes, redirects), it should be opt-in per-call, not the
1692
1681
  // default.
1693
1682
  });
@@ -1696,7 +1685,8 @@ var TerminalServer = class {
1696
1685
  cwd,
1697
1686
  command: params.command,
1698
1687
  args: params.args ?? [],
1699
- output: "",
1688
+ outputChunks: [],
1689
+ outputHead: 0,
1700
1690
  retainedBytes: 0,
1701
1691
  truncated: false,
1702
1692
  exitStatus: void 0,
@@ -1721,31 +1711,44 @@ var TerminalServer = class {
1721
1711
  }
1722
1712
  const exitStatus = { exitCode: 127, signal: null };
1723
1713
  state.exitStatus = exitStatus;
1724
- state.output += `[spawn error] ${err.message}
1725
- `;
1726
- state.retainedBytes = Buffer.byteLength(state.output, "utf8");
1714
+ let errorOutput = Buffer.from(`[spawn error] ${err.message}
1715
+ `, "utf8");
1716
+ if (errorOutput.length > perCallByteLimit) {
1717
+ let start = errorOutput.length - perCallByteLimit;
1718
+ while (start < errorOutput.length && (errorOutput[start] & 192) === 128) start++;
1719
+ errorOutput = errorOutput.subarray(start);
1720
+ state.truncated = true;
1721
+ }
1722
+ state.outputChunks.push(errorOutput);
1723
+ state.retainedBytes = errorOutput.length;
1727
1724
  resolve3(exitStatus);
1728
1725
  });
1729
1726
  })
1730
1727
  };
1731
- const perCallByteLimit = Math.min(
1732
- Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
1733
- this.maxOutputByteLimit
1734
- );
1735
1728
  proc.stdout?.setEncoding("utf8");
1736
1729
  proc.stderr?.setEncoding("utf8");
1737
1730
  const onData = (chunk) => {
1738
- state.output += chunk;
1739
- state.retainedBytes = Buffer.byteLength(state.output, "utf8");
1740
- while (state.retainedBytes > perCallByteLimit) {
1741
- const trimmed = state.output.slice(1);
1742
- state.output = trimmed;
1743
- const newBytes = Buffer.byteLength(state.output, "utf8");
1744
- if (newBytes >= state.retainedBytes) {
1745
- break;
1731
+ const outputChunk = Buffer.from(chunk, "utf8");
1732
+ state.outputChunks.push(outputChunk);
1733
+ state.retainedBytes += outputChunk.length;
1734
+ if (state.retainedBytes > perCallByteLimit) state.truncated = true;
1735
+ while (state.retainedBytes > perCallByteLimit && state.outputHead < state.outputChunks.length) {
1736
+ const first = state.outputChunks[state.outputHead];
1737
+ const overflow = state.retainedBytes - perCallByteLimit;
1738
+ if (first.length <= overflow) {
1739
+ state.outputChunks[state.outputHead] = EMPTY_BUFFER;
1740
+ state.outputHead++;
1741
+ state.retainedBytes -= first.length;
1742
+ continue;
1746
1743
  }
1747
- state.retainedBytes = newBytes;
1748
- state.truncated = true;
1744
+ let start = overflow;
1745
+ while (start < first.length && (first[start] & 192) === 128) start++;
1746
+ state.outputChunks[state.outputHead] = first.subarray(start);
1747
+ state.retainedBytes -= start;
1748
+ }
1749
+ if (state.outputHead >= 256 && state.outputHead * 2 >= state.outputChunks.length) {
1750
+ state.outputChunks = state.outputChunks.slice(state.outputHead);
1751
+ state.outputHead = 0;
1749
1752
  }
1750
1753
  };
1751
1754
  proc.stdout?.on("data", onData);
@@ -1764,7 +1767,10 @@ var TerminalServer = class {
1764
1767
  const state = this.terminals.get(terminalId);
1765
1768
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1766
1769
  return {
1767
- output: state.output,
1770
+ output: Buffer.concat(
1771
+ state.outputChunks.slice(state.outputHead),
1772
+ state.retainedBytes
1773
+ ).toString("utf8"),
1768
1774
  truncated: state.truncated,
1769
1775
  ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
1770
1776
  };
@@ -1800,6 +1806,7 @@ var TerminalServer = class {
1800
1806
  }
1801
1807
  /** Kill all active terminals. Used on session close. */
1802
1808
  releaseAll() {
1809
+ this.abortSignal?.removeEventListener("abort", this.abortHandler);
1803
1810
  for (const id of [...this.terminals.keys()]) {
1804
1811
  this.release(id);
1805
1812
  }
@@ -1863,44 +1870,251 @@ var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
1863
1870
  "RUBYLIB"
1864
1871
  ]);
1865
1872
 
1866
- // src/client/acp-session.ts
1867
- var ACPSessionError = class extends Error {
1868
- kind;
1869
- cause;
1870
- constructor(kind, message, cause) {
1871
- super(message);
1872
- this.name = "ACPSessionError";
1873
- this.kind = kind;
1874
- this.cause = cause;
1873
+ // src/client/trust-boundary-permission.ts
1874
+ function pickOption(options, allowed) {
1875
+ const kinds = allowed ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
1876
+ for (const kind of kinds) {
1877
+ const option = options.find((candidate) => candidate.kind === kind);
1878
+ if (option) return { outcome: "selected", optionId: option.optionId };
1879
+ }
1880
+ return { outcome: "cancelled" };
1881
+ }
1882
+ function riskFor(kind) {
1883
+ if (kind === "read" || kind === "search" || kind === "fetch" || kind === "think") return "low";
1884
+ if (kind === "edit" || kind === "move") return "elevated";
1885
+ if (kind === "delete" || kind === "execute") return "high";
1886
+ return "elevated";
1887
+ }
1888
+ function capabilityFor(request) {
1889
+ const raw = request.toolCall.rawInput;
1890
+ if (typeof raw?.path === "string") {
1891
+ return request.toolCall.kind === "read" || request.toolCall.kind === "search" ? "filesystem.read" : "filesystem.write";
1892
+ }
1893
+ if (typeof raw?.command === "string" || request.toolCall.kind === "execute")
1894
+ return "process.spawn";
1895
+ if (request.toolCall.kind === "fetch") return "network.fetch";
1896
+ return `tool.${request.toolCall.kind ?? "unknown"}`;
1897
+ }
1898
+ function subjectFor(request) {
1899
+ const raw = request.toolCall.rawInput;
1900
+ const title = request.toolCall.title ?? `ACP tool call ${String(request.toolCall.toolCallId)}`;
1901
+ if (typeof raw?.path === "string") {
1902
+ return { kind: "path", id: raw.path, attributes: { toolKind: request.toolCall.kind ?? null } };
1875
1903
  }
1876
- };
1877
- function isJsonRpcError(v) {
1878
- return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
1904
+ if (typeof raw?.command === "string") {
1905
+ return {
1906
+ kind: "command",
1907
+ id: raw.command,
1908
+ attributes: { toolKind: request.toolCall.kind ?? null }
1909
+ };
1910
+ }
1911
+ return {
1912
+ kind: "resource",
1913
+ id: title,
1914
+ attributes: { toolKind: request.toolCall.kind ?? null }
1915
+ };
1879
1916
  }
1880
- var ACPSession = class _ACPSession {
1881
- transport;
1882
- fileServer;
1883
- terminalServer;
1884
- permissionPolicy;
1885
- timeoutMs;
1886
- opts;
1887
- state = "init";
1888
- sessionId = null;
1889
- /** Pending outbound requests (initialize, session/new, session/prompt, etc). */
1890
- pending = /* @__PURE__ */ new Map();
1891
- nextId = 1;
1892
- /** True after close() has been called. */
1917
+ function isAllowed(decision) {
1918
+ return decision.kind === "allow" || decision.kind === "scoped-token";
1919
+ }
1920
+ function toTrustBoundaryRequest(request, options) {
1921
+ const rawSessionId = request.toolCall.rawInput?.sessionId;
1922
+ const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
1923
+ return {
1924
+ version: 1,
1925
+ requestId: String(request.toolCall.toolCallId),
1926
+ actor: {
1927
+ ...options.actor ?? { kind: "agent" },
1928
+ ...sessionId ? { sessionId } : {}
1929
+ },
1930
+ surface: "acp",
1931
+ capability: capabilityFor(request),
1932
+ subject: subjectFor(request),
1933
+ risk: riskFor(request.toolCall.kind),
1934
+ scope: {
1935
+ ...options.scope ?? {},
1936
+ ...sessionId ? { sessionId } : {}
1937
+ },
1938
+ ...options.authContext ? { authContext: options.authContext } : {},
1939
+ metadata: {
1940
+ ...request.toolCall.title ? { title: request.toolCall.title } : {},
1941
+ toolKind: request.toolCall.kind ?? null
1942
+ }
1943
+ };
1944
+ }
1945
+ function makeTrustBoundaryPermissionPolicy(options) {
1946
+ return async (request) => {
1947
+ if (request.signal.aborted) return { outcome: "cancelled" };
1948
+ const decision = await options.boundary.evaluate(toTrustBoundaryRequest(request, options));
1949
+ if (request.signal.aborted) return { outcome: "cancelled" };
1950
+ return pickOption(request.options, isAllowed(decision));
1951
+ };
1952
+ }
1953
+
1954
+ // src/client/websocket-transport.ts
1955
+ var WebSocketClientTransport = class {
1956
+ ws = null;
1957
+ handlers = /* @__PURE__ */ new Set();
1893
1958
  closed = false;
1894
- // Agent-provided info from the initialize handshake
1895
- agentCapabilities = {};
1896
- agentInfo = null;
1897
- authMethods = [];
1898
- /** Protocol version negotiated with the agent during initialize. */
1899
- negotiatedVersion = ACP_PROTOCOL_VERSION;
1900
- constructor(opts, transport) {
1959
+ opts;
1960
+ maxBufferedBytes;
1961
+ maxMessageChars;
1962
+ constructor(opts) {
1901
1963
  this.opts = opts;
1902
- this.transport = transport;
1903
- this.timeoutMs = opts.timeoutMs ?? 5 * 6e4;
1964
+ this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
1965
+ this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
1966
+ }
1967
+ start() {
1968
+ const WS = globalThis.WebSocket;
1969
+ if (!WS) {
1970
+ return Promise.reject(
1971
+ new Error(
1972
+ "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
1973
+ )
1974
+ );
1975
+ }
1976
+ const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
1977
+ return new Promise((resolve3, reject) => {
1978
+ let settled = false;
1979
+ const ws = new WS(this.opts.url, this.opts.protocols);
1980
+ this.ws = ws;
1981
+ const timer = setTimeout(() => {
1982
+ settled = true;
1983
+ try {
1984
+ ws.close();
1985
+ } catch {
1986
+ }
1987
+ reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
1988
+ }, timeoutMs);
1989
+ ws.addEventListener("open", () => {
1990
+ if (settled) return;
1991
+ settled = true;
1992
+ clearTimeout(timer);
1993
+ resolve3();
1994
+ });
1995
+ ws.addEventListener("error", (ev) => {
1996
+ if (settled) {
1997
+ this.closed = true;
1998
+ return;
1999
+ }
2000
+ settled = true;
2001
+ clearTimeout(timer);
2002
+ const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
2003
+ reject(new Error(message));
2004
+ });
2005
+ ws.addEventListener("close", () => {
2006
+ this.closed = true;
2007
+ });
2008
+ ws.addEventListener("message", (ev) => {
2009
+ this.onData(ev.data);
2010
+ });
2011
+ });
2012
+ }
2013
+ send(msg) {
2014
+ if (this.closed || !this.ws) {
2015
+ return Promise.reject(new Error("WebSocket transport is not open"));
2016
+ }
2017
+ try {
2018
+ const serialized = JSON.stringify(msg);
2019
+ const buffered = Number.isFinite(this.ws.bufferedAmount) ? this.ws.bufferedAmount : 0;
2020
+ if (buffered + Buffer.byteLength(serialized, "utf8") > this.maxBufferedBytes) {
2021
+ this.stop();
2022
+ return Promise.reject(new Error("WebSocket transport send buffer limit exceeded"));
2023
+ }
2024
+ this.ws.send(serialized);
2025
+ return Promise.resolve();
2026
+ } catch (err) {
2027
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
2028
+ }
2029
+ }
2030
+ onMessage(handler) {
2031
+ this.handlers.add(handler);
2032
+ return () => this.handlers.delete(handler);
2033
+ }
2034
+ stop() {
2035
+ this.closed = true;
2036
+ if (this.ws) {
2037
+ try {
2038
+ this.ws.close();
2039
+ } catch {
2040
+ }
2041
+ this.ws = null;
2042
+ }
2043
+ }
2044
+ onData(data) {
2045
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
2046
+ if (text.length > this.maxMessageChars) {
2047
+ this.stop();
2048
+ return;
2049
+ }
2050
+ if (!text.trim()) return;
2051
+ let msg;
2052
+ try {
2053
+ msg = JSON.parse(text);
2054
+ } catch {
2055
+ for (const line of text.split("\n")) {
2056
+ if (!line.trim()) continue;
2057
+ try {
2058
+ this.dispatch(JSON.parse(line));
2059
+ } catch {
2060
+ }
2061
+ }
2062
+ return;
2063
+ }
2064
+ this.dispatch(msg);
2065
+ }
2066
+ dispatch(msg) {
2067
+ for (const handler of [...this.handlers]) {
2068
+ try {
2069
+ handler(msg);
2070
+ } catch {
2071
+ }
2072
+ }
2073
+ }
2074
+ };
2075
+ function finitePositiveLimit(value, fallback) {
2076
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
2077
+ }
2078
+
2079
+ // src/client/acp-session.ts
2080
+ var ACPSessionError = class extends Error {
2081
+ kind;
2082
+ cause;
2083
+ constructor(kind, message, cause) {
2084
+ super(message);
2085
+ this.name = "ACPSessionError";
2086
+ this.kind = kind;
2087
+ this.cause = cause;
2088
+ }
2089
+ };
2090
+ function isJsonRpcError(v) {
2091
+ return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
2092
+ }
2093
+ var ACPSession = class _ACPSession {
2094
+ transport;
2095
+ fileServer;
2096
+ terminalServer;
2097
+ permissionPolicy;
2098
+ timeoutMs;
2099
+ opts;
2100
+ transportOff = null;
2101
+ state = "init";
2102
+ sessionId = null;
2103
+ /** Pending outbound requests (initialize, session/new, session/prompt, etc). */
2104
+ pending = /* @__PURE__ */ new Map();
2105
+ nextId = 1;
2106
+ /** True after close() has been called. */
2107
+ closed = false;
2108
+ // Agent-provided info from the initialize handshake
2109
+ agentCapabilities = {};
2110
+ agentInfo = null;
2111
+ authMethods = [];
2112
+ /** Protocol version negotiated with the agent during initialize. */
2113
+ negotiatedVersion = ACP_PROTOCOL_VERSION;
2114
+ constructor(opts, transport) {
2115
+ this.opts = opts;
2116
+ this.transport = transport;
2117
+ this.timeoutMs = opts.timeoutMs ?? 5 * 6e4;
1904
2118
  const fsOpts = {
1905
2119
  projectRoot: opts.projectRoot
1906
2120
  };
@@ -1915,8 +2129,19 @@ var ACPSession = class _ACPSession {
1915
2129
  if (opts.terminalOutputByteLimit !== void 0) {
1916
2130
  termOpts.outputByteLimit = opts.terminalOutputByteLimit;
1917
2131
  }
2132
+ if (opts.terminalMaxCount !== void 0) {
2133
+ termOpts.maxTerminals = opts.terminalMaxCount;
2134
+ }
1918
2135
  this.terminalServer = new TerminalServer(termOpts);
1919
- this.permissionPolicy = opts.permissionPolicy ?? readOnlyPermissionPolicy;
2136
+ if (opts.permissionPolicy && opts.trustBoundary) {
2137
+ throw new TypeError("permissionPolicy and trustBoundary are mutually exclusive");
2138
+ }
2139
+ this.permissionPolicy = opts.trustBoundary ? makeTrustBoundaryPermissionPolicy({
2140
+ boundary: opts.trustBoundary,
2141
+ ...opts.trustActor ? { actor: opts.trustActor } : {},
2142
+ scope: opts.trustScope ?? { cwd: opts.projectRoot },
2143
+ ...opts.trustAuthContext ? { authContext: opts.trustAuthContext } : {}
2144
+ }) : opts.permissionPolicy ?? readOnlyPermissionPolicy;
1920
2145
  }
1921
2146
  // ──────────────────────────────────────────────────────────────────────
1922
2147
  // Public accessors
@@ -1990,10 +2215,12 @@ var ACPSession = class _ACPSession {
1990
2215
  throw new ACPSessionError("spawn_failed", `${spawnErrLabel}: ${msg}`, err);
1991
2216
  }
1992
2217
  const session = new _ACPSession(opts, transport);
1993
- transport.onMessage((msg) => session.handleMessage(msg));
2218
+ session.transportOff = transport.onMessage((msg) => session.handleMessage(msg));
1994
2219
  try {
1995
2220
  await session.initialize();
1996
2221
  } catch (err) {
2222
+ session.transportOff?.();
2223
+ session.transportOff = null;
1997
2224
  try {
1998
2225
  transport.stop();
1999
2226
  } catch {
@@ -2157,7 +2384,11 @@ var ACPSession = class _ACPSession {
2157
2384
  mcpServers: servers
2158
2385
  });
2159
2386
  if (isJsonRpcError(result)) {
2160
- throw new ACPSessionError("prompt_failed", `session/resume failed: ${result.message}`, result);
2387
+ throw new ACPSessionError(
2388
+ "prompt_failed",
2389
+ `session/resume failed: ${result.message}`,
2390
+ result
2391
+ );
2161
2392
  }
2162
2393
  this.sessionId = sessionId;
2163
2394
  }
@@ -2208,7 +2439,11 @@ var ACPSession = class _ACPSession {
2208
2439
  const id = this.allocId();
2209
2440
  const result = await this.sendRequest(id, "session/delete", { sessionId });
2210
2441
  if (isJsonRpcError(result)) {
2211
- throw new ACPSessionError("prompt_failed", `session/delete failed: ${result.message}`, result);
2442
+ throw new ACPSessionError(
2443
+ "prompt_failed",
2444
+ `session/delete failed: ${result.message}`,
2445
+ result
2446
+ );
2212
2447
  }
2213
2448
  if (this.sessionId === sessionId) {
2214
2449
  this.sessionId = null;
@@ -2243,7 +2478,11 @@ var ACPSession = class _ACPSession {
2243
2478
  const id = this.allocId();
2244
2479
  const result = await this.sendRequest(id, "session/set_mode", { sessionId, modeId });
2245
2480
  if (isJsonRpcError(result)) {
2246
- throw new ACPSessionError("prompt_failed", `session/set_mode failed: ${result.message}`, result);
2481
+ throw new ACPSessionError(
2482
+ "prompt_failed",
2483
+ `session/set_mode failed: ${result.message}`,
2484
+ result
2485
+ );
2247
2486
  }
2248
2487
  }
2249
2488
  /**
@@ -2258,7 +2497,11 @@ var ACPSession = class _ACPSession {
2258
2497
  value
2259
2498
  });
2260
2499
  if (isJsonRpcError(result)) {
2261
- throw new ACPSessionError("prompt_failed", `session/set_config_option failed: ${result.message}`, result);
2500
+ throw new ACPSessionError(
2501
+ "prompt_failed",
2502
+ `session/set_config_option failed: ${result.message}`,
2503
+ result
2504
+ );
2262
2505
  }
2263
2506
  }
2264
2507
  /**
@@ -2269,7 +2512,11 @@ var ACPSession = class _ACPSession {
2269
2512
  const id = this.allocId();
2270
2513
  const result = await this.sendRequest(id, "providers/list", {});
2271
2514
  if (isJsonRpcError(result)) {
2272
- throw new ACPSessionError("prompt_failed", `providers/list failed: ${result.message}`, result);
2515
+ throw new ACPSessionError(
2516
+ "prompt_failed",
2517
+ `providers/list failed: ${result.message}`,
2518
+ result
2519
+ );
2273
2520
  }
2274
2521
  const r = result;
2275
2522
  return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
@@ -2305,7 +2552,11 @@ var ACPSession = class _ACPSession {
2305
2552
  const id = this.allocId();
2306
2553
  const result = await this.sendRequest(id, "providers/disable", {});
2307
2554
  if (isJsonRpcError(result)) {
2308
- throw new ACPSessionError("prompt_failed", `providers/disable failed: ${result.message}`, result);
2555
+ throw new ACPSessionError(
2556
+ "prompt_failed",
2557
+ `providers/disable failed: ${result.message}`,
2558
+ result
2559
+ );
2309
2560
  }
2310
2561
  }
2311
2562
  // ──────────────────────────────────────────────────────────────────────
@@ -2412,11 +2663,7 @@ var ACPSession = class _ACPSession {
2412
2663
  }
2413
2664
  const sessionId = result.sessionId;
2414
2665
  if (typeof sessionId !== "string" || sessionId.length === 0) {
2415
- throw new ACPSessionError(
2416
- "protocol_error",
2417
- "session/new returned no sessionId",
2418
- result
2419
- );
2666
+ throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
2420
2667
  }
2421
2668
  this.sessionId = sessionId;
2422
2669
  }
@@ -2459,6 +2706,8 @@ var ACPSession = class _ACPSession {
2459
2706
  p.reject(new ACPSessionError("closed", "session was closed"));
2460
2707
  }
2461
2708
  this.pending.clear();
2709
+ this.transportOff?.();
2710
+ this.transportOff = null;
2462
2711
  try {
2463
2712
  this.transport.stop();
2464
2713
  } catch {
@@ -2494,10 +2743,7 @@ var ACPSession = class _ACPSession {
2494
2743
  const handle = setTimeout(() => {
2495
2744
  this.pending.delete(id);
2496
2745
  reject(
2497
- new ACPSessionError(
2498
- "protocol_error",
2499
- `${method} timed out after ${effectiveTimeout}ms`
2500
- )
2746
+ new ACPSessionError("protocol_error", `${method} timed out after ${effectiveTimeout}ms`)
2501
2747
  );
2502
2748
  }, effectiveTimeout);
2503
2749
  this.pending.set(id, {
@@ -2814,7 +3060,12 @@ var ACPSession = class _ACPSession {
2814
3060
  toolCallId: `acp-terminal-create-${id}`,
2815
3061
  title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
2816
3062
  kind: "execute",
2817
- rawInput: { command: params.command, args: params.args, cwd: params.cwd, sessionId: params.sessionId }
3063
+ rawInput: {
3064
+ command: params.command,
3065
+ args: params.args,
3066
+ cwd: params.cwd,
3067
+ sessionId: params.sessionId
3068
+ }
2818
3069
  });
2819
3070
  if (!allowed) {
2820
3071
  await this.sendErrorResponse(id, -32602, "terminal create denied by permission policy");
@@ -2862,45 +3113,323 @@ var ACPSession = class _ACPSession {
2862
3113
  await this.sendResult(id, {});
2863
3114
  return;
2864
3115
  }
2865
- default:
2866
- await this.sendErrorResponse(id, -32601, `unknown method: ${msg.method}`);
2867
- }
2868
- } catch (err) {
2869
- const message = err instanceof Error ? err.message : String(err);
2870
- await this.sendErrorResponse(id, -32603, message);
2871
- }
3116
+ default:
3117
+ await this.sendErrorResponse(id, -32601, `unknown method: ${msg.method}`);
3118
+ }
3119
+ } catch (err) {
3120
+ const message = err instanceof Error ? err.message : String(err);
3121
+ await this.sendErrorResponse(id, -32603, message);
3122
+ }
3123
+ }
3124
+ };
3125
+ function textContent(text) {
3126
+ return { type: "text", text };
3127
+ }
3128
+ function imageContent(mimeType, data) {
3129
+ return { type: "image", mimeType, data };
3130
+ }
3131
+ function audioContent(mimeType, data) {
3132
+ return { type: "audio", mimeType, data };
3133
+ }
3134
+ function extractText(block) {
3135
+ if (typeof block !== "object" || block === null) return "";
3136
+ const b = block;
3137
+ if (b.type === "text" && typeof b.text === "string") return b.text;
3138
+ if (b.type === "resource" && b.resource && typeof b.resource === "object" && typeof b.resource.text === "string") {
3139
+ return b.resource.text;
3140
+ }
3141
+ return "";
3142
+ }
3143
+ function isRecord(v) {
3144
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3145
+ }
3146
+ function emptyRunResult(stopReason) {
3147
+ return {
3148
+ text: "",
3149
+ stopReason,
3150
+ hasText: false,
3151
+ toolCalls: [],
3152
+ diffs: [],
3153
+ thoughts: ""
3154
+ };
3155
+ }
3156
+
3157
+ // src/client/tool-translator.ts
3158
+ import { expectDefined as expectDefined2 } from "@wrongstack/core/utils";
3159
+ var DEFAULT_OPTIONS = {
3160
+ asyncTools: true,
3161
+ pollIntervalMs: 500,
3162
+ totalTimeoutMs: 12e4
3163
+ };
3164
+ var ToolTranslator = class {
3165
+ opts;
3166
+ pending = /* @__PURE__ */ new Map();
3167
+ constructor(opts = {}) {
3168
+ this.opts = { ...DEFAULT_OPTIONS, ...opts };
3169
+ }
3170
+ /**
3171
+ * Start listening to a transport for tool responses and cancellations.
3172
+ * Call this once after constructing the translator and before sending tasks.
3173
+ */
3174
+ attachToTransport(transport) {
3175
+ transport.onMessage((msg) => {
3176
+ if (msg.method === "tools/call" && msg.id !== void 0) {
3177
+ const pending = this.pending.get(msg.id);
3178
+ if (pending) {
3179
+ clearTimeout(pending.timeout);
3180
+ this.pending.delete(expectDefined2(msg.id));
3181
+ pending.resolve(msg);
3182
+ }
3183
+ }
3184
+ if (msg.method === "cancel" && msg.id !== void 0) {
3185
+ const pending = this.pending.get(msg.id);
3186
+ if (pending) {
3187
+ clearTimeout(pending.timeout);
3188
+ this.pending.delete(expectDefined2(msg.id));
3189
+ pending.reject(new Error("Call cancelled by client"));
3190
+ }
3191
+ }
3192
+ });
3193
+ }
3194
+ /**
3195
+ * Send a tool call over the transport and wait for a response.
3196
+ * If asyncTools is true, polls for progress and resolves when the final
3197
+ * response arrives.
3198
+ */
3199
+ async callTool(transport, name, args, callId = crypto.randomUUID()) {
3200
+ await transport.send({
3201
+ jsonrpc: "2.0",
3202
+ method: "tools/call",
3203
+ id: callId,
3204
+ params: { name, arguments: args }
3205
+ });
3206
+ return new Promise((resolve3, reject) => {
3207
+ const timeout = setTimeout(() => {
3208
+ this.pending.delete(callId);
3209
+ reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
3210
+ }, this.opts.totalTimeoutMs);
3211
+ this.pending.set(callId, { resolve: resolve3, reject, timeout });
3212
+ });
3213
+ }
3214
+ cancelAll() {
3215
+ for (const [, p] of this.pending) {
3216
+ clearTimeout(p.timeout);
3217
+ }
3218
+ this.pending.clear();
3219
+ }
3220
+ };
3221
+
3222
+ // src/integration/acp-bench.ts
3223
+ import * as fsp2 from "node:fs/promises";
3224
+ import * as path3 from "node:path";
3225
+ function firstLine(s) {
3226
+ const line = s.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
3227
+ return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
3228
+ }
3229
+ function randomMarker() {
3230
+ return `ACP_OK_${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
3231
+ }
3232
+ async function benchOne(agentId, cmd, opts) {
3233
+ const checks = [];
3234
+ const startedAt = opts.now();
3235
+ let session = null;
3236
+ const signal = opts.signal ?? new AbortController().signal;
3237
+ const hsStart = opts.now();
3238
+ try {
3239
+ session = await ACPSession.start({
3240
+ command: cmd.command,
3241
+ ...cmd.args !== void 0 ? { args: [...cmd.args] } : {},
3242
+ ...cmd.env !== void 0 ? { env: cmd.env } : {},
3243
+ projectRoot: opts.projectRoot,
3244
+ timeoutMs: opts.timeoutMs
3245
+ });
3246
+ } catch (err) {
3247
+ const reason2 = err instanceof Error ? err.message : String(err);
3248
+ checks.push({ name: "handshake", ok: false, detail: reason2 });
3249
+ return {
3250
+ agentId,
3251
+ status: "fail",
3252
+ checks,
3253
+ reason: reason2,
3254
+ handshakeMs: opts.now() - hsStart,
3255
+ durationMs: opts.now() - startedAt
3256
+ };
3257
+ }
3258
+ const handshakeMs = opts.now() - hsStart;
3259
+ const agentInfo = session.getAgentInfo() ?? void 0;
3260
+ checks.push({
3261
+ name: "handshake",
3262
+ ok: true,
3263
+ detail: agentInfo ? `${agentInfo.name} ${agentInfo.version}` : void 0
3264
+ });
3265
+ let promptMs;
3266
+ let sample;
3267
+ let reason;
3268
+ try {
3269
+ const pStart = opts.now();
3270
+ const res = await session.prompt(
3271
+ [textContent(`Reply with exactly this token and nothing else: ${opts.marker}`)],
3272
+ signal
3273
+ );
3274
+ promptMs = opts.now() - pStart;
3275
+ sample = res.text ? firstLine(res.text) : void 0;
3276
+ const promptOk = res.hasText && res.stopReason !== "refusal";
3277
+ checks.push({
3278
+ name: "prompt",
3279
+ ok: promptOk,
3280
+ detail: `stopReason=${res.stopReason}${res.hasText ? "" : ", no text"}`
3281
+ });
3282
+ const markerOk = res.text.includes(opts.marker);
3283
+ checks.push({
3284
+ name: "marker",
3285
+ ok: markerOk,
3286
+ detail: markerOk ? void 0 : "reply did not contain the token"
3287
+ });
3288
+ if (opts.checkFs) {
3289
+ const fileToken = `FILE_${opts.marker}`;
3290
+ const fileName = `acp-bench-${opts.marker}.txt`;
3291
+ const filePath = path3.join(opts.projectRoot, fileName);
3292
+ let fsOk = false;
3293
+ let fsDetail;
3294
+ try {
3295
+ await fsp2.writeFile(filePath, fileToken, "utf8");
3296
+ const fsRes = await session.prompt(
3297
+ [
3298
+ textContent(
3299
+ `Read the file "${fileName}" in the current directory and reply with its exact contents.`
3300
+ )
3301
+ ],
3302
+ signal
3303
+ );
3304
+ fsOk = fsRes.text.includes(fileToken);
3305
+ if (!fsOk)
3306
+ fsDetail = "agent did not return the file contents (may not have used a read tool)";
3307
+ } catch (err) {
3308
+ fsDetail = err instanceof Error ? err.message : String(err);
3309
+ } finally {
3310
+ await removeBenchFile(filePath);
3311
+ }
3312
+ checks.push({ name: "fs", ok: fsOk, detail: fsDetail });
3313
+ }
3314
+ } catch (err) {
3315
+ reason = err instanceof Error ? err.message : String(err);
3316
+ checks.push({ name: "prompt", ok: false, detail: reason });
3317
+ } finally {
3318
+ try {
3319
+ await session.close();
3320
+ } catch {
3321
+ }
3322
+ }
3323
+ const required = checks.filter((c) => c.name !== "fs" || opts.checkFs);
3324
+ const allReq = required.every((c) => c.ok);
3325
+ const status = allReq ? "pass" : "partial";
3326
+ return {
3327
+ agentId,
3328
+ status,
3329
+ checks,
3330
+ ...agentInfo ? { agentInfo } : {},
3331
+ handshakeMs,
3332
+ ...promptMs !== void 0 ? { promptMs } : {},
3333
+ ...sample ? { sample } : {},
3334
+ ...reason ? { reason } : {},
3335
+ durationMs: opts.now() - startedAt
3336
+ };
3337
+ }
3338
+ async function runAcpBench(opts) {
3339
+ const now = opts.now ?? Date.now;
3340
+ const projectRoot = opts.projectRoot ?? process.cwd();
3341
+ const timeoutMs = opts.timeoutMs ?? 6e4;
3342
+ const checkFs = opts.checkFs ?? false;
3343
+ const marker = opts.marker ?? randomMarker();
3344
+ const concurrency = Math.max(1, opts.concurrency ?? 2);
3345
+ const seen = /* @__PURE__ */ new Set();
3346
+ const ids = [];
3347
+ for (const raw of opts.agentIds) {
3348
+ const id = raw.trim();
3349
+ if (id && !seen.has(id)) {
3350
+ seen.add(id);
3351
+ ids.push(id);
3352
+ }
3353
+ }
3354
+ const results = ids.map((agentId) => ({
3355
+ agentId,
3356
+ status: "skipped",
3357
+ checks: [],
3358
+ durationMs: 0,
3359
+ reason: "unknown agent"
3360
+ }));
3361
+ const startMs = now();
3362
+ const runnable = [];
3363
+ ids.forEach((id, index) => {
3364
+ const cmd = opts.resolveCmd(id);
3365
+ if (cmd) runnable.push({ id, cmd, index });
3366
+ });
3367
+ let next = 0;
3368
+ const workers = [];
3369
+ const workerCount = Math.min(concurrency, runnable.length);
3370
+ for (let w = 0; w < workerCount; w++) {
3371
+ workers.push(
3372
+ (async () => {
3373
+ while (true) {
3374
+ const current = next++;
3375
+ if (current >= runnable.length) return;
3376
+ const { id, cmd, index } = runnable[current];
3377
+ if (opts.signal?.aborted) {
3378
+ results[index] = {
3379
+ agentId: id,
3380
+ status: "skipped",
3381
+ checks: [],
3382
+ durationMs: 0,
3383
+ reason: "aborted"
3384
+ };
3385
+ continue;
3386
+ }
3387
+ opts.onProgress?.(id, "start");
3388
+ const r = await benchOne(id, cmd, {
3389
+ projectRoot,
3390
+ timeoutMs,
3391
+ checkFs,
3392
+ marker,
3393
+ now,
3394
+ ...opts.signal ? { signal: opts.signal } : {}
3395
+ });
3396
+ results[index] = r;
3397
+ opts.onProgress?.(id, "done", r);
3398
+ }
3399
+ })()
3400
+ );
2872
3401
  }
2873
- };
2874
- function textContent(text) {
2875
- return { type: "text", text };
2876
- }
2877
- function imageContent(mimeType, data) {
2878
- return { type: "image", mimeType, data };
2879
- }
2880
- function audioContent(mimeType, data) {
2881
- return { type: "audio", mimeType, data };
3402
+ await Promise.all(workers);
3403
+ const summary = { pass: 0, partial: 0, fail: 0, skipped: 0 };
3404
+ for (const r of results) summary[r.status]++;
3405
+ return { results, summary, totalDurationMs: now() - startMs };
2882
3406
  }
2883
- function extractText(block) {
2884
- if (typeof block !== "object" || block === null) return "";
2885
- const b = block;
2886
- if (b.type === "text" && typeof b.text === "string") return b.text;
2887
- if (b.type === "resource" && b.resource && typeof b.resource === "object" && typeof b.resource.text === "string") {
2888
- return b.resource.text;
3407
+ function renderAcpBenchText(result) {
3408
+ const icon = (s) => s === "pass" ? "\u2713" : s === "partial" ? "\u25D0" : s === "skipped" ? "\u2013" : "\u2717";
3409
+ const lines = ["ACP client bench:", ""];
3410
+ if (result.results.length === 0) {
3411
+ lines.push("No agents to bench.");
3412
+ return lines.join("\n");
2889
3413
  }
2890
- return "";
2891
- }
2892
- function isRecord(v) {
2893
- return typeof v === "object" && v !== null && !Array.isArray(v);
3414
+ for (const r of result.results) {
3415
+ const checks = r.checks.map((c) => `${c.ok ? "\u2713" : "\u2717"}${c.name}`).join(" ");
3416
+ const timing = r.handshakeMs !== void 0 ? ` hs=${r.handshakeMs}ms${r.promptMs !== void 0 ? ` prompt=${r.promptMs}ms` : ""}` : "";
3417
+ lines.push(
3418
+ ` ${icon(r.status)} ${r.agentId.padEnd(16)} ${r.status.toUpperCase().padEnd(7)} ${checks}${timing}`
3419
+ );
3420
+ if (r.agentInfo) lines.push(` agent: ${r.agentInfo.name} ${r.agentInfo.version}`);
3421
+ if (r.sample) lines.push(` reply: ${r.sample}`);
3422
+ if (r.reason) lines.push(` reason: ${r.reason}`);
3423
+ }
3424
+ const { pass, partial, fail, skipped } = result.summary;
3425
+ lines.push("");
3426
+ lines.push(
3427
+ `Bench summary: ${pass} pass, ${partial} partial, ${fail} fail, ${skipped} skipped. (${result.totalDurationMs}ms total)`
3428
+ );
3429
+ return lines.join("\n");
2894
3430
  }
2895
- function emptyRunResult(stopReason) {
2896
- return {
2897
- text: "",
2898
- stopReason,
2899
- hasText: false,
2900
- toolCalls: [],
2901
- diffs: [],
2902
- thoughts: ""
2903
- };
3431
+ async function removeBenchFile(filePath, remove = fsp2.rm) {
3432
+ await remove(filePath, { force: true }).catch(() => void 0);
2904
3433
  }
2905
3434
 
2906
3435
  // src/registry/agents.catalog.ts
@@ -3139,50 +3668,6 @@ function findAgentDescriptor(id) {
3139
3668
  return AGENTS_CATALOG.find((a) => a.id === id);
3140
3669
  }
3141
3670
 
3142
- // src/integration/run-one-acp-task.ts
3143
- import { SubagentBudget } from "@wrongstack/core/coordination";
3144
- async function runOneAcpTask(opts) {
3145
- const role = opts.role ?? "acp";
3146
- const timeoutMs = opts.timeoutMs ?? 5 * 6e4;
3147
- const { runner, stop } = await makeACPSubagentRunnerWithStop({
3148
- command: opts.command,
3149
- ...opts.args !== void 0 ? { args: opts.args } : {},
3150
- ...opts.env !== void 0 ? { env: opts.env } : {},
3151
- ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
3152
- ...opts.projectRoot !== void 0 ? { projectRoot: opts.projectRoot } : {},
3153
- role,
3154
- timeoutMs,
3155
- ...opts.onProgress !== void 0 ? { onProgress: opts.onProgress } : {},
3156
- ...opts.permissionPolicy !== void 0 ? { permissionPolicy: opts.permissionPolicy } : {}
3157
- });
3158
- try {
3159
- const budget = new SubagentBudget({
3160
- timeoutMs,
3161
- maxIterations: 2e3,
3162
- maxToolCalls: 5e3
3163
- });
3164
- budget.start();
3165
- const ctx = {
3166
- subagentId: role,
3167
- config: { id: role, name: role, role, provider: "acp", prompt: "" },
3168
- budget,
3169
- signal: opts.signal ?? new AbortController().signal,
3170
- bridge: null
3171
- };
3172
- const result = await runner({ id: `acp-${role}`, description: opts.task }, ctx);
3173
- return {
3174
- result: result.result == null ? "" : String(result.result),
3175
- iterations: result.iterations,
3176
- toolCalls: result.toolCalls
3177
- };
3178
- } finally {
3179
- try {
3180
- await stop();
3181
- } catch {
3182
- }
3183
- }
3184
- }
3185
-
3186
3671
  // src/integration/acp-subagent-runner.ts
3187
3672
  var ACP_AGENT_COMMANDS = {
3188
3673
  cline: {
@@ -3334,16 +3819,8 @@ function mapACPKind(acpKind) {
3334
3819
  }
3335
3820
  }
3336
3821
  function isRetryable(kind) {
3337
- switch (kind) {
3338
- case "provider_5xx":
3339
- case "provider_rate_limit":
3340
- case "provider_timeout":
3341
- case "tool_threw":
3342
- case "budget_timeout":
3343
- return true;
3344
- default:
3345
- return false;
3346
- }
3822
+ void kind;
3823
+ return false;
3347
3824
  }
3348
3825
  var REGISTRY_ID_ALIASES = {
3349
3826
  "claude-code": "claude-acp",
@@ -3483,11 +3960,15 @@ async function probeAcpAgents(opts) {
3483
3960
  }
3484
3961
  await runPhase(local, opts.concurrency ?? 4, localTimeout);
3485
3962
  await runPhase(pkg, 2, pkgTimeout);
3486
- return ids.map((id) => byId.get(id) ?? { id, ok: false, ms: 0, error: "not probed" });
3963
+ return ids.map((id) => byId.get(id));
3487
3964
  }
3488
3965
 
3966
+ // src/integration/ensemble-runner.ts
3967
+ import { SubagentBudget } from "@wrongstack/core/coordination";
3968
+
3489
3969
  // src/registry/ensemble-registry.ts
3490
3970
  import { spawn as spawn2 } from "node:child_process";
3971
+ import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
3491
3972
  var PROBE_TIMEOUT_MS = 5e3;
3492
3973
  var PROBE_CACHE_MS = 5e3;
3493
3974
  var MAX_PARALLEL_PROBES = 4;
@@ -3511,7 +3992,7 @@ async function probeWithBound(items, worker, limit) {
3511
3992
  await Promise.all(runners);
3512
3993
  return results;
3513
3994
  }
3514
- async function defaultProbe(desc, timeoutMs) {
3995
+ async function defaultProbe(desc, timeoutMs, spawnProcess = spawn2, platform = process.platform) {
3515
3996
  const start = Date.now();
3516
3997
  return new Promise((resolve3) => {
3517
3998
  let settled = false;
@@ -3521,7 +4002,9 @@ async function defaultProbe(desc, timeoutMs) {
3521
4002
  if (settled) return;
3522
4003
  settled = true;
3523
4004
  try {
3524
- child.kill();
4005
+ if (child.exitCode === null && child.signalCode === null) {
4006
+ treeKill2(child);
4007
+ }
3525
4008
  } catch {
3526
4009
  }
3527
4010
  resolve3(result);
@@ -3529,8 +4012,8 @@ async function defaultProbe(desc, timeoutMs) {
3529
4012
  let child;
3530
4013
  try {
3531
4014
  const probeArgs = [...desc.probe.args ?? []];
3532
- const shim = process.platform === "win32" ? buildWin32CmdShimInvocation(desc.probe.command, probeArgs) : null;
3533
- child = spawn2(shim?.command ?? desc.probe.command, shim?.args ?? probeArgs, {
4015
+ const shim = platform === "win32" ? buildWin32CmdShimInvocation(desc.probe.command, probeArgs) : null;
4016
+ child = spawnProcess(shim?.command ?? desc.probe.command, shim?.args ?? probeArgs, {
3534
4017
  stdio: ["ignore", "pipe", "pipe"],
3535
4018
  windowsHide: true,
3536
4019
  ...shim ? { windowsVerbatimArguments: shim.windowsVerbatimArguments } : {}
@@ -3563,7 +4046,7 @@ async function defaultProbe(desc, timeoutMs) {
3563
4046
  clearTimeout(timer);
3564
4047
  const durationMs = Date.now() - start;
3565
4048
  const out = (stdout + stderr).trim();
3566
- const isWindowsShellMiss = process.platform === "win32" && out.toLowerCase().includes("is not recognized");
4049
+ const isWindowsShellMiss = platform === "win32" && out.toLowerCase().includes("is not recognized");
3567
4050
  if (isWindowsShellMiss) {
3568
4051
  finish({
3569
4052
  ok: false,
@@ -3575,7 +4058,7 @@ async function defaultProbe(desc, timeoutMs) {
3575
4058
  if (out.length > 0) {
3576
4059
  finish({
3577
4060
  ok: true,
3578
- version: out.split("\n")[0]?.trim() ?? "",
4061
+ version: out.split("\n")[0].trim(),
3579
4062
  path: desc.probe.command,
3580
4063
  durationMs
3581
4064
  });
@@ -3650,93 +4133,7 @@ var EnsembleRegistry = class {
3650
4133
  }
3651
4134
  };
3652
4135
 
3653
- // src/registry/acp-registry-fetch.ts
3654
- var ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
3655
- function currentPlatformKey() {
3656
- const os = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "darwin" : "linux";
3657
- const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch;
3658
- return `${os}-${arch}`;
3659
- }
3660
- function basename(cmd) {
3661
- const cleaned = cmd.replace(/^\.\//, "").replace(/\\/g, "/");
3662
- const parts = cleaned.split("/");
3663
- return parts[parts.length - 1] || cleaned;
3664
- }
3665
- function mapRegistryEntry(entry, platformKey = currentPlatformKey()) {
3666
- if (!entry || typeof entry.id !== "string" || entry.id.length === 0) return null;
3667
- const dist = entry.distribution;
3668
- let acp = null;
3669
- if (dist?.npx?.package) {
3670
- acp = { command: "npx", args: ["-y", dist.npx.package, ...dist.npx.args ?? []] };
3671
- } else if (dist?.uvx?.package) {
3672
- acp = { command: "uvx", args: [dist.uvx.package, ...dist.uvx.args ?? []] };
3673
- } else if (dist?.binary) {
3674
- const target = dist.binary[platformKey];
3675
- if (target?.cmd) {
3676
- acp = {
3677
- command: basename(target.cmd),
3678
- args: [...target.args ?? []],
3679
- ...target.env ? { env: target.env } : {}
3680
- };
3681
- }
3682
- }
3683
- if (!acp) return null;
3684
- const probeCmd = acp.command === "npx" ? "npx" : acp.command === "uvx" ? "uvx" : acp.command;
3685
- return {
3686
- id: entry.id,
3687
- displayName: entry.name ?? entry.id,
3688
- vendor: inferVendor(entry),
3689
- probe: { command: probeCmd, args: ["--version"] },
3690
- acp,
3691
- supports: { loadSession: true, promptImages: true, terminal: true, fs: true },
3692
- integration: "native",
3693
- docs: entry.repository ?? entry.website ?? ""
3694
- };
3695
- }
3696
- function inferVendor(entry) {
3697
- const hay = `${entry.id} ${entry.name ?? ""} ${(entry.authors ?? []).join(" ")}`.toLowerCase();
3698
- if (hay.includes("anthropic") || hay.includes("claude")) return "anthropic";
3699
- if (hay.includes("google") || hay.includes("gemini")) return "google";
3700
- if (hay.includes("openai") || hay.includes("codex")) return "openai";
3701
- if (hay.includes("github") || hay.includes("copilot")) return "github";
3702
- if (hay.includes("moonshot") || hay.includes("kimi")) return "moonshot";
3703
- return "community";
3704
- }
3705
- async function fetchAcpRegistry(opts = {}) {
3706
- const url = opts.url ?? ACP_REGISTRY_URL;
3707
- const timeoutMs = opts.timeoutMs ?? 15e3;
3708
- const controller = new AbortController();
3709
- const timer = setTimeout(() => controller.abort(), timeoutMs);
3710
- const onParentAbort = () => controller.abort();
3711
- if (opts.signal) {
3712
- if (opts.signal.aborted) controller.abort();
3713
- else opts.signal.addEventListener("abort", onParentAbort, { once: true });
3714
- }
3715
- try {
3716
- const res = await fetch(url, { signal: controller.signal });
3717
- if (!res.ok) {
3718
- throw new Error(`ACP registry fetch failed: HTTP ${res.status}`);
3719
- }
3720
- const body = await res.json();
3721
- const rawAgents = Array.isArray(body) ? body : Array.isArray(body?.agents) ? body.agents : null;
3722
- if (!rawAgents) {
3723
- throw new Error("ACP registry response had no agents array");
3724
- }
3725
- const platformKey = opts.platformKey ?? currentPlatformKey();
3726
- const agents = [];
3727
- for (const raw of rawAgents) {
3728
- const mapped = mapRegistryEntry(raw, platformKey);
3729
- if (mapped) agents.push(mapped);
3730
- }
3731
- return { fetchedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString(), agents };
3732
- } finally {
3733
- clearTimeout(timer);
3734
- opts.signal?.removeEventListener("abort", onParentAbort);
3735
- }
3736
- }
3737
-
3738
4136
  // src/integration/ensemble-runner.ts
3739
- import { SubagentBudget as SubagentBudget2 } from "@wrongstack/core/coordination";
3740
4137
  var DEFAULT_MAX_CONCURRENCY = 4;
3741
4138
  async function mapBound(items, worker, limit) {
3742
4139
  const results = new Array(items.length);
@@ -3778,7 +4175,7 @@ async function runOne(agentId, cmd, task, timeoutMs, signal, onProgress) {
3778
4175
  ...onProgress ? { onProgress: (event) => onProgress(agentId, event) } : {}
3779
4176
  });
3780
4177
  try {
3781
- const budget = new SubagentBudget2({
4178
+ const budget = new SubagentBudget({
3782
4179
  timeoutMs,
3783
4180
  maxIterations: 2e3,
3784
4181
  maxToolCalls: 5e3
@@ -3879,10 +4276,7 @@ async function runEnsemble(opts) {
3879
4276
  }
3880
4277
  runnable.push({ id, cmd });
3881
4278
  }
3882
- const concurrency = Math.max(
3883
- 1,
3884
- opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY
3885
- );
4279
+ const concurrency = Math.max(1, opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY);
3886
4280
  await mapBound(
3887
4281
  runnable,
3888
4282
  async ({ id, cmd }) => {
@@ -3931,15 +4325,11 @@ function renderEnsembleText(result) {
3931
4325
  );
3932
4326
  break;
3933
4327
  case "failed":
3934
- lines.push(
3935
- `[${r.error?.kind ?? "unknown"}] ${r.error?.message ?? "failed"}`
3936
- );
4328
+ lines.push(`[${r.error?.kind ?? "unknown"}] ${r.error?.message ?? "failed"}`);
3937
4329
  lines.push(`[${r.agentId}] failed ${r.durationMs}ms`);
3938
4330
  break;
3939
4331
  case "cancelled":
3940
- lines.push(
3941
- `[${r.error?.kind ?? "aborted"}] ${r.error?.message ?? "cancelled"}`
3942
- );
4332
+ lines.push(`[${r.error?.kind ?? "aborted"}] ${r.error?.message ?? "cancelled"}`);
3943
4333
  lines.push(`[${r.agentId}] cancelled ${r.durationMs}ms`);
3944
4334
  break;
3945
4335
  case "skipped":
@@ -3952,216 +4342,135 @@ function renderEnsembleText(result) {
3952
4342
  `
3953
4343
  Ensemble summary: ${succeeded} succeeded, ${failed} failed, ${cancelled} cancelled, ${skipped} skipped. (${result.totalDurationMs}ms total)`
3954
4344
  );
3955
- return lines.join("\n");
3956
- }
3957
-
3958
- // src/integration/acp-bench.ts
3959
- import * as fsp2 from "node:fs/promises";
3960
- import * as path3 from "node:path";
3961
- function firstLine(s) {
3962
- const line = s.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
3963
- return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
3964
- }
3965
- function randomMarker() {
3966
- return `ACP_OK_${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
3967
- }
3968
- async function benchOne(agentId, cmd, opts) {
3969
- const checks = [];
3970
- const startedAt = opts.now();
3971
- let session = null;
3972
- const signal = opts.signal ?? new AbortController().signal;
3973
- const hsStart = opts.now();
3974
- try {
3975
- session = await ACPSession.start({
3976
- command: cmd.command,
3977
- ...cmd.args !== void 0 ? { args: [...cmd.args] } : {},
3978
- ...cmd.env !== void 0 ? { env: cmd.env } : {},
3979
- projectRoot: opts.projectRoot,
3980
- timeoutMs: opts.timeoutMs
3981
- });
3982
- } catch (err) {
3983
- const reason2 = err instanceof Error ? err.message : String(err);
3984
- checks.push({ name: "handshake", ok: false, detail: reason2 });
3985
- return {
3986
- agentId,
3987
- status: "fail",
3988
- checks,
3989
- reason: reason2,
3990
- handshakeMs: opts.now() - hsStart,
3991
- durationMs: opts.now() - startedAt
3992
- };
3993
- }
3994
- const handshakeMs = opts.now() - hsStart;
3995
- const agentInfo = session.getAgentInfo() ?? void 0;
3996
- checks.push({
3997
- name: "handshake",
3998
- ok: true,
3999
- detail: agentInfo ? `${agentInfo.name} ${agentInfo.version}` : void 0
4345
+ return lines.join("\n");
4346
+ }
4347
+
4348
+ // src/integration/run-one-acp-task.ts
4349
+ import { SubagentBudget as SubagentBudget2 } from "@wrongstack/core/coordination";
4350
+ async function runOneAcpTask(opts) {
4351
+ const role = opts.role ?? "acp";
4352
+ const timeoutMs = opts.timeoutMs ?? 5 * 6e4;
4353
+ const { runner, stop } = await makeACPSubagentRunnerWithStop({
4354
+ command: opts.command,
4355
+ ...opts.args !== void 0 ? { args: opts.args } : {},
4356
+ ...opts.env !== void 0 ? { env: opts.env } : {},
4357
+ ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
4358
+ ...opts.projectRoot !== void 0 ? { projectRoot: opts.projectRoot } : {},
4359
+ role,
4360
+ timeoutMs,
4361
+ ...opts.onProgress !== void 0 ? { onProgress: opts.onProgress } : {},
4362
+ ...opts.permissionPolicy !== void 0 ? { permissionPolicy: opts.permissionPolicy } : {}
4000
4363
  });
4001
- let promptMs;
4002
- let sample;
4003
- let reason;
4004
4364
  try {
4005
- const pStart = opts.now();
4006
- const res = await session.prompt(
4007
- [textContent(`Reply with exactly this token and nothing else: ${opts.marker}`)],
4008
- signal
4009
- );
4010
- promptMs = opts.now() - pStart;
4011
- sample = res.text ? firstLine(res.text) : void 0;
4012
- const promptOk = res.hasText && res.stopReason !== "refusal";
4013
- checks.push({
4014
- name: "prompt",
4015
- ok: promptOk,
4016
- detail: `stopReason=${res.stopReason}${res.hasText ? "" : ", no text"}`
4017
- });
4018
- const markerOk = res.text.includes(opts.marker);
4019
- checks.push({
4020
- name: "marker",
4021
- ok: markerOk,
4022
- detail: markerOk ? void 0 : "reply did not contain the token"
4365
+ const budget = new SubagentBudget2({
4366
+ timeoutMs,
4367
+ maxIterations: 2e3,
4368
+ maxToolCalls: 5e3
4023
4369
  });
4024
- if (opts.checkFs) {
4025
- const fileToken = `FILE_${opts.marker}`;
4026
- const fileName = `acp-bench-${opts.marker}.txt`;
4027
- const filePath = path3.join(opts.projectRoot, fileName);
4028
- let fsOk = false;
4029
- let fsDetail;
4030
- try {
4031
- await fsp2.writeFile(filePath, fileToken, "utf8");
4032
- const fsRes = await session.prompt(
4033
- [
4034
- textContent(
4035
- `Read the file "${fileName}" in the current directory and reply with its exact contents.`
4036
- )
4037
- ],
4038
- signal
4039
- );
4040
- fsOk = fsRes.text.includes(fileToken);
4041
- if (!fsOk) fsDetail = "agent did not return the file contents (may not have used a read tool)";
4042
- } catch (err) {
4043
- fsDetail = err instanceof Error ? err.message : String(err);
4044
- } finally {
4045
- await fsp2.rm(filePath, { force: true }).catch(() => {
4046
- });
4047
- }
4048
- checks.push({ name: "fs", ok: fsOk, detail: fsDetail });
4049
- }
4050
- } catch (err) {
4051
- reason = err instanceof Error ? err.message : String(err);
4052
- checks.push({ name: "prompt", ok: false, detail: reason });
4370
+ budget.start();
4371
+ const ctx = {
4372
+ subagentId: role,
4373
+ config: { id: role, name: role, role, provider: "acp", prompt: "" },
4374
+ budget,
4375
+ signal: opts.signal ?? new AbortController().signal,
4376
+ bridge: null
4377
+ };
4378
+ const result = await runner({ id: `acp-${role}`, description: opts.task }, ctx);
4379
+ return {
4380
+ result: result.result == null ? "" : String(result.result),
4381
+ iterations: result.iterations,
4382
+ toolCalls: result.toolCalls
4383
+ };
4053
4384
  } finally {
4054
4385
  try {
4055
- await session.close();
4386
+ await stop();
4056
4387
  } catch {
4057
4388
  }
4058
4389
  }
4059
- const required = checks.filter((c) => c.name !== "fs" || opts.checkFs);
4060
- const allReq = required.every((c) => c.ok);
4061
- const handshakeOk = checks.find((c) => c.name === "handshake")?.ok === true;
4062
- const status = allReq ? "pass" : handshakeOk ? "partial" : "fail";
4063
- return {
4064
- agentId,
4065
- status,
4066
- checks,
4067
- ...agentInfo ? { agentInfo } : {},
4068
- handshakeMs,
4069
- ...promptMs !== void 0 ? { promptMs } : {},
4070
- ...sample ? { sample } : {},
4071
- ...reason ? { reason } : {},
4072
- durationMs: opts.now() - startedAt
4073
- };
4074
4390
  }
4075
- async function runAcpBench(opts) {
4076
- const now = opts.now ?? Date.now;
4077
- const projectRoot = opts.projectRoot ?? process.cwd();
4078
- const timeoutMs = opts.timeoutMs ?? 6e4;
4079
- const checkFs = opts.checkFs ?? false;
4080
- const marker = opts.marker ?? randomMarker();
4081
- const concurrency = Math.max(1, opts.concurrency ?? 2);
4082
- const seen = /* @__PURE__ */ new Set();
4083
- const ids = [];
4084
- for (const raw of opts.agentIds) {
4085
- const id = raw.trim();
4086
- if (id && !seen.has(id)) {
4087
- seen.add(id);
4088
- ids.push(id);
4391
+
4392
+ // src/registry/acp-registry-fetch.ts
4393
+ var ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
4394
+ function currentPlatformKey(platform = process.platform, architecture = process.arch) {
4395
+ const os = platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : "linux";
4396
+ const arch = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : architecture;
4397
+ return `${os}-${arch}`;
4398
+ }
4399
+ function basename(cmd) {
4400
+ const cleaned = cmd.replace(/^\.\//, "").replace(/\\/g, "/");
4401
+ return cleaned.slice(cleaned.lastIndexOf("/") + 1);
4402
+ }
4403
+ function mapRegistryEntry(entry, platformKey = currentPlatformKey()) {
4404
+ if (!entry || typeof entry.id !== "string" || entry.id.length === 0) return null;
4405
+ const dist = entry.distribution;
4406
+ let acp = null;
4407
+ if (dist?.npx?.package) {
4408
+ acp = { command: "npx", args: ["-y", dist.npx.package, ...dist.npx.args ?? []] };
4409
+ } else if (dist?.uvx?.package) {
4410
+ acp = { command: "uvx", args: [dist.uvx.package, ...dist.uvx.args ?? []] };
4411
+ } else if (dist?.binary) {
4412
+ const target = dist.binary[platformKey];
4413
+ if (target?.cmd) {
4414
+ acp = {
4415
+ command: basename(target.cmd),
4416
+ args: [...target.args ?? []],
4417
+ ...target.env ? { env: target.env } : {}
4418
+ };
4089
4419
  }
4090
4420
  }
4091
- const results = ids.map((agentId) => ({
4092
- agentId,
4093
- status: "skipped",
4094
- checks: [],
4095
- durationMs: 0,
4096
- reason: "unknown agent"
4097
- }));
4098
- const startMs = now();
4099
- const runnable = [];
4100
- ids.forEach((id, index) => {
4101
- const cmd = opts.resolveCmd(id);
4102
- if (cmd) runnable.push({ id, cmd, index });
4103
- });
4104
- let next = 0;
4105
- const workers = [];
4106
- const workerCount = Math.min(concurrency, runnable.length);
4107
- for (let w = 0; w < workerCount; w++) {
4108
- workers.push(
4109
- (async () => {
4110
- while (true) {
4111
- const current = next++;
4112
- if (current >= runnable.length) return;
4113
- const { id, cmd, index } = runnable[current];
4114
- if (opts.signal?.aborted) {
4115
- results[index] = {
4116
- agentId: id,
4117
- status: "skipped",
4118
- checks: [],
4119
- durationMs: 0,
4120
- reason: "aborted"
4121
- };
4122
- continue;
4123
- }
4124
- opts.onProgress?.(id, "start");
4125
- const r = await benchOne(id, cmd, {
4126
- projectRoot,
4127
- timeoutMs,
4128
- checkFs,
4129
- marker,
4130
- now,
4131
- ...opts.signal ? { signal: opts.signal } : {}
4132
- });
4133
- results[index] = r;
4134
- opts.onProgress?.(id, "done", r);
4135
- }
4136
- })()
4137
- );
4138
- }
4139
- await Promise.all(workers);
4140
- const summary = { pass: 0, partial: 0, fail: 0, skipped: 0 };
4141
- for (const r of results) summary[r.status]++;
4142
- return { results, summary, totalDurationMs: now() - startMs };
4421
+ if (!acp) return null;
4422
+ const probeCmd = acp.command === "npx" ? "npx" : acp.command === "uvx" ? "uvx" : acp.command;
4423
+ return {
4424
+ id: entry.id,
4425
+ displayName: entry.name ?? entry.id,
4426
+ vendor: inferVendor(entry),
4427
+ probe: { command: probeCmd, args: ["--version"] },
4428
+ acp,
4429
+ supports: { loadSession: true, promptImages: true, terminal: true, fs: true },
4430
+ integration: "native",
4431
+ docs: entry.repository ?? entry.website ?? ""
4432
+ };
4143
4433
  }
4144
- function renderAcpBenchText(result) {
4145
- const icon = (s) => s === "pass" ? "\u2713" : s === "partial" ? "\u25D0" : s === "skipped" ? "\u2013" : "\u2717";
4146
- const lines = ["ACP client bench:", ""];
4147
- if (result.results.length === 0) {
4148
- lines.push("No agents to bench.");
4149
- return lines.join("\n");
4434
+ function inferVendor(entry) {
4435
+ const hay = `${entry.id} ${entry.name ?? ""} ${(entry.authors ?? []).join(" ")}`.toLowerCase();
4436
+ if (hay.includes("anthropic") || hay.includes("claude")) return "anthropic";
4437
+ if (hay.includes("google") || hay.includes("gemini")) return "google";
4438
+ if (hay.includes("openai") || hay.includes("codex")) return "openai";
4439
+ if (hay.includes("github") || hay.includes("copilot")) return "github";
4440
+ if (hay.includes("moonshot") || hay.includes("kimi")) return "moonshot";
4441
+ return "community";
4442
+ }
4443
+ async function fetchAcpRegistry(opts = {}) {
4444
+ const url = opts.url ?? ACP_REGISTRY_URL;
4445
+ const timeoutMs = opts.timeoutMs ?? 15e3;
4446
+ const controller = new AbortController();
4447
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
4448
+ const onParentAbort = () => controller.abort();
4449
+ if (opts.signal) {
4450
+ if (opts.signal.aborted) controller.abort();
4451
+ else opts.signal.addEventListener("abort", onParentAbort, { once: true });
4150
4452
  }
4151
- for (const r of result.results) {
4152
- const checks = r.checks.map((c) => `${c.ok ? "\u2713" : "\u2717"}${c.name}`).join(" ");
4153
- const timing = r.handshakeMs !== void 0 ? ` hs=${r.handshakeMs}ms${r.promptMs !== void 0 ? ` prompt=${r.promptMs}ms` : ""}` : "";
4154
- lines.push(` ${icon(r.status)} ${r.agentId.padEnd(16)} ${r.status.toUpperCase().padEnd(7)} ${checks}${timing}`);
4155
- if (r.agentInfo) lines.push(` agent: ${r.agentInfo.name} ${r.agentInfo.version}`);
4156
- if (r.sample) lines.push(` reply: ${r.sample}`);
4157
- if (r.reason) lines.push(` reason: ${r.reason}`);
4453
+ try {
4454
+ const res = await fetch(url, { signal: controller.signal });
4455
+ if (!res.ok) {
4456
+ throw new Error(`ACP registry fetch failed: HTTP ${res.status}`);
4457
+ }
4458
+ const body = await res.json();
4459
+ const rawAgents = Array.isArray(body) ? body : Array.isArray(body?.agents) ? body.agents : null;
4460
+ if (!rawAgents) {
4461
+ throw new Error("ACP registry response had no agents array");
4462
+ }
4463
+ const platformKey = opts.platformKey ?? currentPlatformKey();
4464
+ const agents = [];
4465
+ for (const raw of rawAgents) {
4466
+ const mapped = mapRegistryEntry(raw, platformKey);
4467
+ if (mapped) agents.push(mapped);
4468
+ }
4469
+ return { fetchedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString(), agents };
4470
+ } finally {
4471
+ clearTimeout(timer);
4472
+ opts.signal?.removeEventListener("abort", onParentAbort);
4158
4473
  }
4159
- const { pass, partial, fail, skipped } = result.summary;
4160
- lines.push("");
4161
- lines.push(
4162
- `Bench summary: ${pass} pass, ${partial} partial, ${fail} fail, ${skipped} skipped. (${result.totalDurationMs}ms total)`
4163
- );
4164
- return lines.join("\n");
4165
4474
  }
4166
4475
  export {
4167
4476
  ACPProtocolHandler,
@@ -4169,6 +4478,8 @@ export {
4169
4478
  ACPSessionError,
4170
4479
  ACPToolsRegistry,
4171
4480
  ACP_AGENT_COMMANDS,
4481
+ ACP_PACKAGE_VERSION,
4482
+ ACP_PROTOCOL_VERSION,
4172
4483
  ACP_REGISTRY_URL,
4173
4484
  AGENTS_CATALOG,
4174
4485
  ClientTransport,
@@ -4181,6 +4492,7 @@ export {
4181
4492
  ToolTranslator,
4182
4493
  WebSocketClientTransport,
4183
4494
  WrongStackACPServer,
4495
+ assertNeverSessionUpdate,
4184
4496
  audioContent,
4185
4497
  defaultEnsembleCmdResolver,
4186
4498
  defaultPermissionPolicy,
@@ -4190,6 +4502,7 @@ export {
4190
4502
  makeACPSubagentRunner,
4191
4503
  makeACPSubagentRunnerWithStop,
4192
4504
  makePermissionPolicy,
4505
+ makeTrustBoundaryPermissionPolicy,
4193
4506
  mapRegistryEntry,
4194
4507
  probeAcpAgent,
4195
4508
  probeAcpAgents,
@@ -4200,6 +4513,7 @@ export {
4200
4513
  runAcpBench,
4201
4514
  runEnsemble,
4202
4515
  runOneAcpTask,
4203
- textContent
4516
+ textContent,
4517
+ toTrustBoundaryRequest
4204
4518
  };
4205
4519
  //# sourceMappingURL=index.js.map