@wrongstack/acp 0.292.1 → 0.295.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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 +6 -189
  5. package/dist/agent/protocol-handler.d.ts.map +1 -1
  6. package/dist/agent/server-agent-turn.d.ts +7 -1
  7. package/dist/agent/server-agent-turn.d.ts.map +1 -1
  8. package/dist/agent/stdio-transport.d.ts +12 -1
  9. package/dist/agent/stdio-transport.d.ts.map +1 -1
  10. package/dist/agent/tools-registry.d.ts +1 -1
  11. package/dist/agent/tools-registry.d.ts.map +1 -1
  12. package/dist/agent/wrongstack-acp-agent.d.ts +2 -0
  13. package/dist/agent/wrongstack-acp-agent.d.ts.map +1 -1
  14. package/dist/agent.js +172 -22
  15. package/dist/agent.js.map +4 -4
  16. package/dist/client/acp-session.d.ts +19 -6
  17. package/dist/client/acp-session.d.ts.map +1 -1
  18. package/dist/client/index.d.ts +11 -9
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/permission.d.ts +7 -3
  21. package/dist/client/permission.d.ts.map +1 -1
  22. package/dist/client/terminal-server.d.ts +5 -0
  23. package/dist/client/terminal-server.d.ts.map +1 -1
  24. package/dist/client/tool-translator.d.ts +1 -1
  25. package/dist/client/tool-translator.d.ts.map +1 -1
  26. package/dist/client/trust-boundary-permission.d.ts +16 -0
  27. package/dist/client/trust-boundary-permission.d.ts.map +1 -0
  28. package/dist/client/websocket-transport.d.ts +6 -0
  29. package/dist/client/websocket-transport.d.ts.map +1 -1
  30. package/dist/client.js +440 -225
  31. package/dist/client.js.map +4 -4
  32. package/dist/index.d.ts +29 -29
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2433 -2121
  35. package/dist/index.js.map +4 -4
  36. package/dist/integration/acp-subagent-runner.d.ts +3 -3
  37. package/dist/integration/acp-subagent-runner.d.ts.map +1 -1
  38. package/dist/integration/ensemble-runner.d.ts.map +1 -1
  39. package/dist/legacy.d.ts +8 -0
  40. package/dist/legacy.d.ts.map +1 -0
  41. package/dist/legacy.js +6 -0
  42. package/dist/legacy.js.map +7 -0
  43. package/dist/sdk.d.ts +10 -8
  44. package/dist/sdk.d.ts.map +1 -1
  45. package/dist/sdk.js +22 -0
  46. package/dist/sdk.js.map +3 -3
  47. package/dist/v1.d.ts +3 -0
  48. package/dist/v1.d.ts.map +1 -0
  49. package/dist/v1.js +12 -0
  50. package/dist/v1.js.map +7 -0
  51. package/dist/version.d.ts +2 -0
  52. package/dist/version.d.ts.map +1 -0
  53. package/dist/wrongstack-acp-agent.js +107 -16
  54. package/dist/wrongstack-acp-agent.js.map +4 -4
  55. package/package.json +10 -2
package/dist/index.js CHANGED
@@ -1,628 +1,526 @@
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() {
13
+ try {
14
+ const packageJson = require2("../package.json");
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) > 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 = sessionId ? this.replayFor?.(sessionId) : void 0;
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;
370
- }
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" }] };
377
- }
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) });
384
- }
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"]
428
+ session.abort.abort();
429
+ if (sessionId) {
430
+ this.sessions.delete(sessionId);
431
+ this.disposeSession(sessionId);
432
+ }
433
+ await this.transport.send(toWire({
434
+ jsonrpc: "2.0",
435
+ id,
436
+ result: {}
437
+ }));
438
+ return false;
408
439
  }
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."
440
+ async handleSessionDelete(id, params) {
441
+ const p = params ?? {};
442
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
443
+ if (!sessionId) {
444
+ await this.sendError(id, -32e3, `session not found: ${sessionId}`);
445
+ return false;
446
+ }
447
+ if (!this.sessions.has(sessionId)) {
448
+ await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
449
+ return false;
450
+ }
451
+ const session = this.sessions.get(sessionId);
452
+ session.abort.abort();
453
+ this.sessions.delete(sessionId);
454
+ this.disposeSession(sessionId);
455
+ await this.transport.send(toWire({
456
+ jsonrpc: "2.0",
457
+ id,
458
+ result: {}
459
+ }));
460
+ return false;
416
461
  }
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");
462
+ async handleSessionFork(id, params) {
463
+ const p = params ?? {};
464
+ const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
465
+ const source = sourceId ? this.sessions.get(sourceId) : void 0;
466
+ if (!sourceId || !source) {
467
+ await this.sendError(id, -32e3, `session not found: ${sourceId}`);
521
468
  return false;
522
469
  }
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);
470
+ if (this.sessions.size >= this.maxSessions) {
471
+ await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
566
472
  return false;
567
473
  }
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;
474
+ const now = (/* @__PURE__ */ new Date()).toISOString();
475
+ const sessionId = `sess_${this.allocId()}`;
476
+ const forked = {
477
+ id: sessionId,
478
+ cwd: typeof p.cwd === "string" ? p.cwd : source.cwd,
479
+ abort: new AbortController(),
480
+ modeId: source.modeId,
481
+ createdAt: now,
482
+ updatedAt: now,
483
+ ...source.title !== void 0 ? { title: source.title } : {}
484
+ };
485
+ const history = (this.replayFor?.(sourceId) ?? []).map((update) => ({
486
+ sessionUpdate: update.sessionUpdate,
487
+ content: structuredClone(update.content)
488
+ }));
489
+ this.sessions.set(sessionId, forked);
490
+ this.seedFor?.(sessionId, history);
491
+ this.onSessionNew(forked);
492
+ await this.persist(forked, history);
493
+ await this.sendNotification({
494
+ sessionId,
495
+ update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
496
+ });
575
497
  await this.transport.send(toWire({
576
498
  jsonrpc: "2.0",
577
499
  id,
578
500
  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,
501
+ sessionId,
611
502
  modes: this.modes,
612
503
  configOptions: this.configOptions
613
504
  }
614
505
  }));
615
506
  return false;
616
507
  }
617
- async handleAuthenticate(id, _params) {
508
+ async handleProvidersList(id, _params) {
618
509
  await this.transport.send(toWire({
619
510
  jsonrpc: "2.0",
620
511
  id,
621
- result: { outcome: "unauthenticated" }
512
+ result: {
513
+ providers: [],
514
+ currentProviderId: null
515
+ }
622
516
  }));
623
517
  return false;
624
518
  }
625
- async handleLogout(id, _params) {
519
+ async handleProvidersSet(id, _params) {
520
+ await this.sendError(id, -32e3, "provider configuration not available through ACP; use wstack auth");
521
+ return false;
522
+ }
523
+ async handleProvidersDisable(id, _params) {
626
524
  await this.transport.send(toWire({
627
525
  jsonrpc: "2.0",
628
526
  id,
@@ -630,251 +528,11 @@ var ACPProtocolHandler = class {
630
528
  }));
631
529
  return false;
632
530
  }
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) {
531
+ async handleMcpMessage(id, _params) {
532
+ await this.sendError(id, -32e3, "MCP message routing not available through ACP");
533
+ return false;
534
+ }
535
+ async handleSessionPrompt(id, params) {
878
536
  const p = params ?? {};
879
537
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
880
538
  if (!sessionId || !this.sessions.has(sessionId)) {
@@ -1078,259 +736,276 @@ function errorToJsonRpc(err) {
1078
736
  return { code: -32603, message };
1079
737
  }
1080
738
 
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();
1117
- }
1118
- }
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;
739
+ // src/agent/stdio-transport.ts
740
+ import { expectDefined, writeErr } from "@wrongstack/core/utils";
741
+
742
+ // src/win32-cmd.ts
743
+ var WIN32_CMD_META = /[&|<>"\r\n\0]/;
744
+ function buildWin32CmdShimInvocation(command, args = []) {
745
+ assertSafeWin32CmdArgs([command, ...args]);
746
+ const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
747
+ return {
748
+ command: process.env["COMSPEC"] ?? "cmd.exe",
749
+ args: ["/d", "/c", line],
750
+ windowsVerbatimArguments: true
751
+ };
752
+ }
753
+ function assertSafeWin32CmdArgs(args) {
754
+ for (const arg of args) {
755
+ if (typeof arg === "string" && WIN32_CMD_META.test(arg)) {
756
+ throw new Error(
757
+ '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)
758
+ );
1129
759
  }
1130
- this.transport.close();
1131
760
  }
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" } }));
761
+ }
762
+ function quoteWin32CmdArg(arg) {
763
+ return `"${arg}"`;
764
+ }
765
+
766
+ // src/agent/stdio-transport.ts
767
+ var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
768
+ var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
769
+ function positiveLimit(value, fallback) {
770
+ return Number.isFinite(value) && (value ?? 0) > 0 ? Math.floor(value) : fallback;
771
+ }
772
+ var StdioTransport = class {
773
+ stdin = process.stdin;
774
+ stdout = process.stdout;
775
+ stderr = process.stderr;
776
+ buffer = "";
777
+ handlers = /* @__PURE__ */ new Set();
778
+ closed = false;
779
+ resolveRead = null;
780
+ messageQueue = [];
781
+ maxFrameChars;
782
+ maxQueuedMessages;
783
+ constructor(opts = {}) {
784
+ this.maxFrameChars = positiveLimit(opts.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
785
+ this.maxQueuedMessages = positiveLimit(
786
+ opts.maxQueuedMessages,
787
+ DEFAULT_MAX_QUEUED_MESSAGES
788
+ );
789
+ this.stdin.resume();
790
+ this.stdin.setEncoding("utf8");
791
+ this.stdin.on("data", (chunk) => this.onData(chunk));
792
+ this.stdin.on("end", () => this.handleClose());
793
+ this.stdin.on("error", (err) => this.failAll(err));
794
+ }
795
+ sendStartupMarker() {
796
+ this.stdout.write("[wstack-acp]\n", "utf8");
797
+ }
798
+ send(msg) {
799
+ if (this.closed) return Promise.resolve();
800
+ return new Promise((resolve3) => {
801
+ const line = JSON.stringify(msg) + "\n";
802
+ this.stdout.write(line, "utf8", () => resolve3());
803
+ });
804
+ }
805
+ sendRaw(chunk) {
806
+ this.stdout.write(chunk, "utf8");
807
+ }
808
+ read() {
809
+ if (this.messageQueue.length > 0) return Promise.resolve(expectDefined(this.messageQueue.shift()));
810
+ if (this.closed) return Promise.resolve(null);
811
+ return new Promise((resolve3) => {
812
+ this.resolveRead = resolve3;
813
+ });
814
+ }
815
+ onMessage(handler) {
816
+ this.handlers.add(handler);
817
+ return () => this.handlers.delete(handler);
818
+ }
819
+ close() {
820
+ this.closed = true;
821
+ this.stdin.pause();
822
+ this.resolveRead?.(null);
823
+ this.resolveRead = null;
824
+ this.buffer = "";
825
+ this.messageQueue.length = 0;
826
+ this.handlers.clear();
827
+ }
828
+ onData(chunk) {
829
+ this.buffer += chunk;
830
+ const lines = this.buffer.split("\n");
831
+ this.buffer = lines.pop() ?? "";
832
+ if (this.buffer.length > this.maxFrameChars) {
833
+ this.stderr.write(
834
+ `[wstack-acp frame error] pending frame exceeds ${this.maxFrameChars} characters
835
+ `,
836
+ "utf8"
837
+ );
838
+ this.close();
839
+ return;
840
+ }
841
+ for (const raw of lines) {
842
+ if (!raw.trim()) continue;
843
+ if (raw.length > this.maxFrameChars) {
844
+ this.stderr.write(
845
+ `[wstack-acp frame error] frame exceeds ${this.maxFrameChars} characters
846
+ `,
847
+ "utf8"
848
+ );
849
+ this.close();
1185
850
  return;
1186
851
  }
1187
- let msg;
1188
852
  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;
853
+ this.dispatch(JSON.parse(raw));
854
+ } catch (err) {
855
+ this.stderr.write(`[wstack-acp parse error] ${err}
856
+ `, "utf8");
1194
857
  }
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: [] }));
858
+ }
859
+ }
860
+ dispatch(msg) {
861
+ if (this.resolveRead) {
862
+ const resolve3 = this.resolveRead;
863
+ this.resolveRead = null;
864
+ resolve3(msg);
865
+ } else {
866
+ if (this.messageQueue.length >= this.maxQueuedMessages) {
867
+ this.stderr.write(
868
+ `[wstack-acp queue error] pending message queue exceeds ${this.maxQueuedMessages} entries
869
+ `,
870
+ "utf8"
871
+ );
872
+ this.close();
1203
873
  return;
1204
874
  }
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);
875
+ this.messageQueue.push(msg);
876
+ }
877
+ for (const handler of this.handlers) {
1228
878
  try {
1229
- await requestPromise;
1230
- } catch {
879
+ handler(msg);
880
+ } catch (err) {
881
+ this.stderr.write(`[wstack-acp handler error] ${err}
882
+ `, "utf8");
1231
883
  }
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
884
  }
1250
885
  }
886
+ handleClose() {
887
+ this.close();
888
+ }
889
+ failAll(err) {
890
+ this.stderr.write(`[wstack-acp stdin error] ${err.message}
891
+ `, "utf8");
892
+ this.close();
893
+ }
1251
894
  };
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;
895
+ var ClientTransport = class {
896
+ child = null;
897
+ buffer = "";
1271
898
  handlers = /* @__PURE__ */ new Set();
1272
899
  closed = false;
900
+ resolveRead = null;
901
+ messageQueue = [];
1273
902
  opts;
1274
- constructor(opts) {
1275
- this.opts = opts;
903
+ maxFrameChars;
904
+ maxQueuedMessages;
905
+ constructor(options) {
906
+ this.opts = {
907
+ handshakeTimeoutMs: 3e4,
908
+ ...options
909
+ };
910
+ this.maxFrameChars = positiveLimit(options.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
911
+ this.maxQueuedMessages = positiveLimit(
912
+ options.maxQueuedMessages,
913
+ DEFAULT_MAX_QUEUED_MESSAGES
914
+ );
1276
915
  }
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;
916
+ async start() {
917
+ if (this.child) return;
918
+ const [{ spawn: spawn3 }, { buildChildEnv: buildChildEnv2 }, os] = await Promise.all([
919
+ import("node:child_process"),
920
+ import("@wrongstack/core/utils"),
921
+ import("node:os")
922
+ ]);
1287
923
  return new Promise((resolve3, reject) => {
924
+ const timeout = setTimeout(() => {
925
+ reject(
926
+ new Error(`ACP child process failed to start within ${this.opts.handshakeTimeoutMs}ms`)
927
+ );
928
+ }, this.opts.handshakeTimeoutMs);
929
+ const isPkgLauncher = this.opts.command === "npx" || this.opts.command === "uvx";
930
+ const spawnCwd = isPkgLauncher ? os.homedir() : this.opts.cwd;
931
+ try {
932
+ const childArgs = this.opts.args ?? [];
933
+ const shim = process.platform === "win32" ? buildWin32CmdShimInvocation(this.opts.command, childArgs) : null;
934
+ this.child = spawn3(shim?.command ?? this.opts.command, shim?.args ?? childArgs, {
935
+ env: { ...buildChildEnv2(), ...this.opts.env },
936
+ cwd: spawnCwd,
937
+ stdio: ["pipe", "pipe", "pipe"],
938
+ windowsHide: true,
939
+ ...shim ? { windowsVerbatimArguments: shim.windowsVerbatimArguments } : {}
940
+ });
941
+ } catch (err) {
942
+ clearTimeout(timeout);
943
+ reject(err);
944
+ return;
945
+ }
946
+ const child = this.child;
947
+ child.stdout.setEncoding("utf8");
1288
948
  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) => {
949
+ const onSpawnFailure = (err) => {
1307
950
  if (settled) {
1308
951
  this.closed = true;
1309
952
  return;
1310
953
  }
1311
954
  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
- });
955
+ clearTimeout(timeout);
956
+ reject(err);
957
+ };
958
+ child.on("error", onSpawnFailure);
959
+ child.stdout.on("error", onSpawnFailure);
960
+ if (this.opts.skipHandshakeMarker) {
961
+ child.stdout.on("data", (c) => this.onChildData(c));
962
+ child.stderr.on("data", (c) => this.onChildError(c));
963
+ child.on("close", (code) => this.onChildClose(code));
964
+ child.once("spawn", () => {
965
+ if (settled) return;
966
+ settled = true;
967
+ clearTimeout(timeout);
968
+ resolve3();
969
+ });
970
+ return;
971
+ }
972
+ const onReady = () => {
973
+ if (settled) return;
974
+ settled = true;
975
+ child.stdout.on("data", (c) => this.onChildData(c));
976
+ child.stderr.on("data", (c) => this.onChildError(c));
977
+ child.on("close", (code) => this.onChildClose(code));
978
+ clearTimeout(timeout);
979
+ resolve3();
980
+ };
981
+ const waitForMarker = (chunk) => {
982
+ this.buffer += chunk;
983
+ const idx = this.buffer.indexOf("[wstack-acp]\n");
984
+ if (idx !== -1) {
985
+ this.buffer = this.buffer.slice(idx + "[wstack-acp]\n".length);
986
+ child.stdout.removeListener("data", waitForMarker);
987
+ onReady();
988
+ }
989
+ };
990
+ child.stdout.on("data", waitForMarker);
1322
991
  });
1323
992
  }
1324
993
  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
- }
994
+ if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
995
+ return new Promise((resolve3, reject) => {
996
+ const line = JSON.stringify(msg) + "\n";
997
+ this.child?.stdin.write(line, "utf8", (err) => {
998
+ if (err) reject(err);
999
+ else resolve3();
1000
+ });
1001
+ });
1002
+ }
1003
+ read() {
1004
+ if (this.messageQueue.length > 0) return Promise.resolve(expectDefined(this.messageQueue.shift()));
1005
+ if (this.closed) return Promise.resolve(null);
1006
+ return new Promise((resolve3) => {
1007
+ this.resolveRead = resolve3;
1008
+ });
1334
1009
  }
1335
1010
  onMessage(handler) {
1336
1011
  this.handlers.add(handler);
@@ -1338,34 +1013,73 @@ var WebSocketClientTransport = class {
1338
1013
  }
1339
1014
  stop() {
1340
1015
  this.closed = true;
1341
- if (this.ws) {
1016
+ this.resolveRead?.(null);
1017
+ this.resolveRead = null;
1018
+ this.buffer = "";
1019
+ this.messageQueue.length = 0;
1020
+ this.handlers.clear();
1021
+ const child = this.child;
1022
+ if (!child) return;
1023
+ try {
1024
+ child.kill();
1025
+ } catch {
1026
+ }
1027
+ this.child = null;
1028
+ }
1029
+ onChildData(chunk) {
1030
+ this.buffer += chunk;
1031
+ const lines = this.buffer.split("\n");
1032
+ this.buffer = lines.pop() ?? "";
1033
+ if (this.buffer.length > this.maxFrameChars) {
1034
+ writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
1035
+ `);
1036
+ this.stop();
1037
+ return;
1038
+ }
1039
+ for (const raw of lines) {
1040
+ if (!raw.trim()) continue;
1041
+ if (raw.length > this.maxFrameChars) {
1042
+ writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
1043
+ `);
1044
+ this.stop();
1045
+ return;
1046
+ }
1342
1047
  try {
1343
- this.ws.close();
1048
+ this.dispatch(JSON.parse(raw));
1344
1049
  } catch {
1345
1050
  }
1346
- this.ws = null;
1347
1051
  }
1348
1052
  }
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;
1053
+ onChildError(chunk) {
1054
+ writeErr(`[acp-child stderr] ${chunk}`);
1055
+ }
1056
+ onChildClose(code) {
1057
+ this.closed = true;
1058
+ this.resolveRead?.(null);
1059
+ this.resolveRead = null;
1060
+ this.buffer = "";
1061
+ this.messageQueue.length = 0;
1062
+ this.handlers.clear();
1063
+ if (code !== 0 && code !== null) {
1064
+ writeErr(`[acp-child exited with code ${code}]
1065
+ `);
1364
1066
  }
1365
- this.dispatch(msg);
1366
1067
  }
1367
1068
  dispatch(msg) {
1368
- for (const handler of [...this.handlers]) {
1069
+ if (this.resolveRead) {
1070
+ const resolve3 = this.resolveRead;
1071
+ this.resolveRead = null;
1072
+ resolve3(msg);
1073
+ } else if (this.handlers.size === 0) {
1074
+ if (this.messageQueue.length >= this.maxQueuedMessages) {
1075
+ writeErr(`[acp-child message queue exceeds ${this.maxQueuedMessages} entries]
1076
+ `);
1077
+ this.stop();
1078
+ return;
1079
+ }
1080
+ this.messageQueue.push(msg);
1081
+ }
1082
+ for (const handler of this.handlers) {
1369
1083
  try {
1370
1084
  handler(msg);
1371
1085
  } catch {
@@ -1374,495 +1088,978 @@ var WebSocketClientTransport = class {
1374
1088
  }
1375
1089
  };
1376
1090
 
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 };
1091
+ // src/agent/tools-registry.ts
1092
+ var ACPToolsRegistry = class {
1093
+ tools = /* @__PURE__ */ new Map();
1094
+ owner;
1095
+ constructor(owner = "wrongstack") {
1096
+ this.owner = owner;
1389
1097
  }
1390
1098
  /**
1391
- * Start listening to a transport for tool responses and cancellations.
1392
- * Call this once after constructing the translator and before sending tasks.
1099
+ * Register one or more tools.
1100
+ * Throws on duplicate name unless force=true.
1393
1101
  */
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
- });
1102
+ register(tools) {
1103
+ for (const tool of tools) {
1104
+ this.tools.set(tool.name, tool);
1105
+ }
1413
1106
  }
1414
1107
  /**
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.
1108
+ * Replace the current tool set.
1418
1109
  */
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
- });
1110
+ setTools(tools) {
1111
+ this.tools.clear();
1112
+ for (const tool of tools) this.tools.set(tool.name, tool);
1433
1113
  }
1434
- cancelAll() {
1435
- for (const [, p] of this.pending) {
1436
- clearTimeout(p.timeout);
1437
- }
1438
- this.pending.clear();
1114
+ get(name) {
1115
+ return this.tools.get(name);
1439
1116
  }
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;
1117
+ has(name) {
1118
+ return this.tools.has(name);
1457
1119
  }
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;
1120
+ list() {
1121
+ return Array.from(this.tools.values());
1471
1122
  }
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);
1477
- 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
1491
- });
1492
- return { content };
1493
- } 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);
1501
- }
1123
+ /** Build the ACP tools/list payload from registered tools. */
1124
+ buildToolList() {
1125
+ return {
1126
+ tools: Array.from(this.tools.values()).map(
1127
+ (t) => toACPToolDefinition(t, this.owner)
1128
+ )
1129
+ };
1502
1130
  }
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
- );
1512
- }
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`;
1131
+ /**
1132
+ * Execute a tool by name and return ACP-formatted result.
1133
+ * Returns null if the tool is not found.
1134
+ */
1135
+ async execute(name, args, ctx, signal) {
1136
+ const tool = this.tools.get(name);
1137
+ if (!tool) return null;
1517
1138
  try {
1518
- await fsp.writeFile(tmp, params.content, {
1519
- encoding: "utf8",
1520
- signal: controller.signal
1139
+ const result = await tool.execute(args, ctx, {
1140
+ signal
1521
1141
  });
1522
- await this.assertRealInside(tmp);
1523
- await this.assertRealInside(path.dirname(safe));
1524
- await fsp.rename(tmp, safe);
1142
+ return toACPToolResult(result);
1525
1143
  } 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);
1144
+ const msg = err instanceof Error ? err.message : String(err);
1145
+ return { content: [{ type: "text", text: msg }], isError: true };
1540
1146
  }
1541
1147
  }
1542
- /**
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.
1549
- */
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");
1553
- }
1554
- if (!path.isAbsolute(p)) {
1555
- throw new FsError("INVALID_PATH", p, "path must be absolute (ACP requirement)");
1556
- }
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");
1148
+ };
1149
+ function toACPToolDefinition(tool, _owner) {
1150
+ return {
1151
+ name: tool.name,
1152
+ description: tool.description,
1153
+ inputSchema: toACPInputSchema(tool.inputSchema),
1154
+ annotations: {
1155
+ title: tool.name,
1156
+ description: tool.usageHint ?? tool.description,
1157
+ priority: toolToPriority(tool),
1158
+ alwaysAccept: tool.permission === "auto"
1561
1159
  }
1562
- await this.assertRealInside(resolved);
1563
- return resolved;
1160
+ };
1161
+ }
1162
+ function toACPInputSchema(src) {
1163
+ if (!src || typeof src !== "object") {
1164
+ return {};
1564
1165
  }
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;
1583
- }
1584
- throw mapFsError(err, resolvedPath);
1585
- }
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
- );
1166
+ const s = src;
1167
+ const out = {};
1168
+ if (typeof s.type === "string") out.type = s.type;
1169
+ if (Array.isArray(s.enum)) out.enum = s.enum;
1170
+ if (typeof s.description === "string") out.description = s.description;
1171
+ if ("default" in s) out.default = s.default;
1172
+ if (typeof s.minimum === "number") out.minimum = s.minimum;
1173
+ if (typeof s.maximum === "number") out.maximum = s.maximum;
1174
+ if (s.items) out.items = toACPInputSchema(s.items);
1175
+ if (s.properties && typeof s.properties === "object") {
1176
+ const props = {};
1177
+ for (const [k, v] of Object.entries(s.properties)) {
1178
+ props[k] = toACPInputSchema(v);
1592
1179
  }
1180
+ out.properties = props;
1181
+ if (Array.isArray(s.required)) out.required = s.required;
1593
1182
  }
1594
- };
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);
1183
+ return out;
1603
1184
  }
1604
- function safeRealpathSync(p) {
1605
- try {
1606
- return realpathSync(p);
1607
- } catch {
1608
- return p;
1185
+ function toACPToolResult(result) {
1186
+ const blocks = [];
1187
+ if (result === void 0 || result === null) {
1188
+ return { content: [{ type: "text", text: "ok" }] };
1609
1189
  }
1610
- }
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" };
1190
+ if (typeof result === "string") {
1191
+ blocks.push({ type: "text", text: result });
1192
+ } else if (typeof result === "object") {
1193
+ blocks.push({ type: "text", text: JSON.stringify(result, null, 2) });
1194
+ } else {
1195
+ blocks.push({ type: "text", text: String(result) });
1626
1196
  }
1627
- return { outcome: "selected", optionId: chosen.optionId };
1197
+ return { content: blocks };
1628
1198
  }
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" };
1634
- }
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);
1639
- };
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);
1645
- }
1646
- return pickReject(req.options);
1647
- };
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
- };
1199
+ function toolToPriority(tool) {
1200
+ if (tool.riskTier === "destructive") return "high";
1201
+ if (tool.riskTier === "standard" || tool.permission === "confirm") return "medium";
1202
+ return "low";
1654
1203
  }
1655
1204
 
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;
1668
- 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());
1675
- }
1676
- }
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
1691
- // (pipes, redirects), it should be opt-in per-call, not the
1692
- // default.
1205
+ // src/agent/wrongstack-acp-agent.ts
1206
+ import { fileURLToPath } from "node:url";
1207
+ import { createServer } from "node:http";
1208
+ import { writeErr as writeErr2 } from "@wrongstack/core/utils";
1209
+ var WrongStackACPServer = class {
1210
+ transport;
1211
+ handler;
1212
+ options;
1213
+ /** HTTP server when transport mode is HTTP. */
1214
+ httpServer = null;
1215
+ running = false;
1216
+ constructor(opts = {}) {
1217
+ this.options = opts;
1218
+ this.transport = new StdioTransport();
1219
+ const runTurn = opts.runTurn ?? defaultEchoRunTurn;
1220
+ this.handler = new ACPProtocolHandler({
1221
+ transport: this.transport,
1222
+ defaultCwd: opts.defaultCwd ?? process.cwd(),
1223
+ runTurn,
1224
+ agentName: opts.agentName,
1225
+ ...opts.replayFor ? { replayFor: opts.replayFor } : {},
1226
+ ...opts.seedFor ? { seedFor: opts.seedFor } : {},
1227
+ ...opts.disposeFor ? { disposeFor: opts.disposeFor } : {},
1228
+ ...opts.store ? { store: opts.store } : {}
1693
1229
  });
1694
- const state = {
1695
- proc,
1696
- cwd,
1697
- command: params.command,
1698
- args: params.args ?? [],
1699
- output: "",
1700
- retainedBytes: 0,
1701
- truncated: false,
1702
- exitStatus: void 0,
1703
- timeoutHandle: null,
1704
- exitPromise: new Promise((resolve3) => {
1705
- proc.on("close", (code, signalName) => {
1706
- if (state.timeoutHandle) {
1707
- clearTimeout(state.timeoutHandle);
1708
- state.timeoutHandle = null;
1709
- }
1710
- const exitStatus = {
1711
- exitCode: typeof code === "number" ? code : null,
1712
- signal: typeof signalName === "string" ? signalName : null
1713
- };
1714
- state.exitStatus = exitStatus;
1715
- resolve3(exitStatus);
1716
- });
1717
- proc.on("error", (err) => {
1718
- if (state.timeoutHandle) {
1719
- clearTimeout(state.timeoutHandle);
1720
- state.timeoutHandle = null;
1721
- }
1722
- const exitStatus = { exitCode: 127, signal: null };
1723
- state.exitStatus = exitStatus;
1724
- state.output += `[spawn error] ${err.message}
1725
- `;
1726
- state.retainedBytes = Buffer.byteLength(state.output, "utf8");
1727
- resolve3(exitStatus);
1728
- });
1729
- })
1730
- };
1731
- const perCallByteLimit = Math.min(
1732
- Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
1733
- this.maxOutputByteLimit
1734
- );
1735
- proc.stdout?.setEncoding("utf8");
1736
- proc.stderr?.setEncoding("utf8");
1737
- 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;
1746
- }
1747
- state.retainedBytes = newBytes;
1748
- state.truncated = true;
1749
- }
1750
- };
1751
- proc.stdout?.on("data", onData);
1752
- proc.stderr?.on("data", onData);
1753
- state.timeoutHandle = setTimeout(() => {
1754
- try {
1755
- proc.kill("SIGTERM");
1756
- } catch {
1757
- }
1758
- }, this.commandTimeoutMs);
1759
- this.terminals.set(id, state);
1760
- return { terminalId: id };
1761
1230
  }
1762
- /** Return captured output and (if available) the exit status. */
1763
- output(terminalId) {
1764
- const state = this.terminals.get(terminalId);
1765
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1766
- return {
1767
- output: state.output,
1768
- truncated: state.truncated,
1769
- ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
1770
- };
1771
- }
1772
- /** Block until the process exits. Resolves with the exit status. */
1773
- async waitForExit(terminalId) {
1774
- const state = this.terminals.get(terminalId);
1775
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1776
- return state.exitPromise;
1777
- }
1778
- /** Kill the process but keep the terminal record (agent can still read output). */
1779
- kill(terminalId) {
1780
- const state = this.terminals.get(terminalId);
1781
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1782
- try {
1783
- state.proc.kill("SIGTERM");
1784
- } catch {
1785
- }
1786
- }
1787
- /** Kill the process if alive and remove the record. */
1788
- release(terminalId) {
1789
- const state = this.terminals.get(terminalId);
1790
- if (!state) return;
1791
- if (state.timeoutHandle) {
1792
- clearTimeout(state.timeoutHandle);
1793
- state.timeoutHandle = null;
1794
- }
1795
- try {
1796
- state.proc.kill("SIGKILL");
1797
- } catch {
1798
- }
1799
- this.terminals.delete(terminalId);
1800
- }
1801
- /** Kill all active terminals. Used on session close. */
1802
- releaseAll() {
1803
- for (const id of [...this.terminals.keys()]) {
1804
- this.release(id);
1231
+ /**
1232
+ * Start the server. Mode depends on `options.transport`:
1233
+ * - 'stdio' (default): reads JSON-RPC from stdin, writes to stdout.
1234
+ * - number: listens as HTTP on the given port.
1235
+ */
1236
+ async start() {
1237
+ const transportMode = this.options.transport;
1238
+ if (typeof transportMode === "number") {
1239
+ await this.startHttp(transportMode);
1240
+ } else {
1241
+ await this.startStdio();
1805
1242
  }
1806
1243
  }
1807
- resolveCwd(cwd) {
1808
- if (!cwd) return this.projectRoot;
1809
- const resolved = path2.resolve(cwd);
1810
- const rootWithSep = this.projectRoot.endsWith(path2.sep) ? this.projectRoot : this.projectRoot + path2.sep;
1811
- if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
1812
- return this.projectRoot;
1244
+ async startStdio() {
1245
+ if (this.options.legacyStartupMarker) {
1246
+ this.transport.sendStartupMarker();
1813
1247
  }
1248
+ this.running = true;
1814
1249
  try {
1815
- const realRoot = realpathSync2(this.projectRoot);
1816
- const realCwd = realpathSync2(resolved);
1817
- const realRootWithSep = realRoot.endsWith(path2.sep) ? realRoot : realRoot + path2.sep;
1818
- if (realCwd !== realRoot && !realCwd.startsWith(realRootWithSep)) {
1819
- return realRoot;
1250
+ while (this.running) {
1251
+ const msg = await this.transport.read();
1252
+ if (!msg) break;
1253
+ const terminal = await this.handler.handleMessage(msg);
1254
+ if (terminal) break;
1820
1255
  }
1821
- return realCwd;
1822
- } catch {
1823
- return this.projectRoot;
1256
+ } finally {
1257
+ this.handler.close();
1258
+ this.transport.close();
1824
1259
  }
1825
1260
  }
1826
- buildEnv(agentEnv) {
1827
- const env = buildChildEnv();
1828
- if (agentEnv) {
1829
- for (const { name, value } of agentEnv) {
1830
- const upper = name.toUpperCase();
1831
- if (DENIED_AGENT_ENV_KEYS.has(upper)) continue;
1832
- env[name] = value;
1261
+ async startHttp(port) {
1262
+ const host = this.options.host ?? "127.0.0.1";
1263
+ const handler = this.handler;
1264
+ const authToken = this.options.authToken;
1265
+ let httpChain = Promise.resolve();
1266
+ this.httpServer = createServer(async (req, res) => {
1267
+ if (authToken) {
1268
+ const url = new URL(req.url ?? "/", `http://${host}:${port}`);
1269
+ const queryToken = url.searchParams.get("token");
1270
+ const authHeader = req.headers["authorization"];
1271
+ const bearerToken = Array.isArray(authHeader) ? authHeader[0]?.replace(/^Bearer\s+/i, "") : authHeader?.replace(/^Bearer\s+/i, "");
1272
+ const supplied = queryToken ?? bearerToken ?? "";
1273
+ if (supplied !== authToken) {
1274
+ res.writeHead(401, { "Content-Type": "application/json" });
1275
+ res.end(JSON.stringify({ error: { code: -32001, message: "Unauthorized" } }));
1276
+ return;
1277
+ }
1833
1278
  }
1834
- }
1835
- return env;
1836
- }
1837
- /**
1838
- * Clamp an agent-supplied numeric to a finite positive safe integer, falling
1839
- * back to `defaultValue` for undefined/NaN/non-finite values. Prevents
1840
- * negative, NaN, or Infinity values from disabling output caps or causing
1841
- * unbounded memory growth.
1842
- */
1843
- clampFiniteInt(value, defaultValue) {
1844
- if (value === void 0 || !Number.isFinite(value) || value < 1) {
1845
- return defaultValue;
1846
- }
1847
- return Math.trunc(value);
1848
- }
1849
- };
1850
- var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
1851
- "NODE_OPTIONS",
1852
- "LD_PRELOAD",
1853
- "LD_LIBRARY_PATH",
1854
- "DYLD_INSERT_LIBRARIES",
1855
- "DYLD_LIBRARY_PATH",
1856
- "DYLD_FALLBACK_LIBRARY_PATH",
1857
- "PATH",
1858
- "PYTHONPATH",
1859
- "PYTHONSTARTUP",
1860
- "PERL5OPT",
1861
- "PERLLIB",
1862
- "RUBYOPT",
1863
- "RUBYLIB"
1864
- ]);
1865
-
1279
+ const selfOrigin = `http://${host}:${port}`;
1280
+ const reqOrigin = Array.isArray(req.headers.origin) ? req.headers.origin[0] : req.headers.origin;
1281
+ if (reqOrigin && reqOrigin !== selfOrigin) {
1282
+ res.writeHead(403);
1283
+ res.end(JSON.stringify({ error: "cross-origin request forbidden" }));
1284
+ return;
1285
+ }
1286
+ if (reqOrigin) res.setHeader("Access-Control-Allow-Origin", reqOrigin);
1287
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
1288
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Authorization");
1289
+ if (req.method === "OPTIONS") {
1290
+ res.writeHead(204);
1291
+ res.end();
1292
+ return;
1293
+ }
1294
+ if (req.method !== "POST") {
1295
+ res.writeHead(405);
1296
+ res.end(JSON.stringify({ error: "method not allowed" }));
1297
+ return;
1298
+ }
1299
+ const MAX_HTTP_BODY = 10 * 1024 * 1024;
1300
+ let body = "";
1301
+ let bodyBytes = 0;
1302
+ let tooLarge = false;
1303
+ for await (const chunk of req) {
1304
+ bodyBytes += chunk.length;
1305
+ if (bodyBytes > MAX_HTTP_BODY) {
1306
+ tooLarge = true;
1307
+ break;
1308
+ }
1309
+ body += chunk;
1310
+ }
1311
+ if (tooLarge) {
1312
+ res.writeHead(413, { "Content-Type": "application/json" });
1313
+ res.end(JSON.stringify({ error: { code: -32700, message: "Request body too large" } }));
1314
+ return;
1315
+ }
1316
+ let msg;
1317
+ try {
1318
+ msg = JSON.parse(body);
1319
+ } catch {
1320
+ res.writeHead(400);
1321
+ res.end(JSON.stringify({ error: { code: -32700, message: "Parse error" } }));
1322
+ return;
1323
+ }
1324
+ const isNotification = typeof msg === "object" && msg !== null && msg.id === void 0 && typeof msg.method === "string";
1325
+ if (isNotification) {
1326
+ try {
1327
+ await handler.handleMessage(msg);
1328
+ } catch {
1329
+ }
1330
+ res.writeHead(200, { "Content-Type": "application/json" });
1331
+ res.end(JSON.stringify({ notifications: [] }));
1332
+ return;
1333
+ }
1334
+ const requestPromise = httpChain.then(async () => {
1335
+ const notifications = [];
1336
+ let response = null;
1337
+ const originalSend = this.transport.send.bind(this.transport);
1338
+ this.transport.send = async (m) => {
1339
+ if (m.id !== void 0 && (m.result !== void 0 || m.error !== void 0)) {
1340
+ response = m;
1341
+ } else if (m.method === "session/update") {
1342
+ notifications.push(m.params);
1343
+ } else {
1344
+ notifications.push(m);
1345
+ }
1346
+ };
1347
+ try {
1348
+ await handler.handleMessage(msg);
1349
+ } finally {
1350
+ this.transport.send = originalSend;
1351
+ }
1352
+ res.writeHead(200, { "Content-Type": "application/json" });
1353
+ const responseBody = response !== null ? { ...response, notifications } : { notifications };
1354
+ res.end(JSON.stringify(responseBody));
1355
+ });
1356
+ httpChain = requestPromise.catch(() => void 0);
1357
+ try {
1358
+ await requestPromise;
1359
+ } catch {
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;
1379
+ }
1380
+ }
1381
+ };
1382
+ var defaultEchoRunTurn = async (_input, _emit) => {
1383
+ return { stopReason: "end_turn" };
1384
+ };
1385
+ async function main() {
1386
+ const server = new WrongStackACPServer();
1387
+ await server.start();
1388
+ }
1389
+ var isEntrypoint = process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === process.argv[1];
1390
+ if (isEntrypoint) {
1391
+ main().catch((err) => {
1392
+ writeErr2(`[wstack-acp fatal] ${err}
1393
+ `);
1394
+ process.exit(1);
1395
+ });
1396
+ }
1397
+
1398
+ // src/client/file-server.ts
1399
+ import { randomBytes } from "node:crypto";
1400
+ import { realpathSync } from "node:fs";
1401
+ import * as fsp from "node:fs/promises";
1402
+ import * as path from "node:path";
1403
+ var DEFAULT_MAX_READ_BYTES = 5 * 1024 * 1024;
1404
+ var DEFAULT_MAX_WRITE_BYTES = 5 * 1024 * 1024;
1405
+ var FsError = class extends Error {
1406
+ code;
1407
+ path;
1408
+ constructor(code, path4, message) {
1409
+ super(message);
1410
+ this.name = "FsError";
1411
+ this.code = code;
1412
+ this.path = path4;
1413
+ }
1414
+ };
1415
+ var FileServer = class {
1416
+ root;
1417
+ realRoot;
1418
+ timeoutMs;
1419
+ maxReadBytes;
1420
+ maxWriteBytes;
1421
+ constructor(opts) {
1422
+ this.root = path.resolve(opts.projectRoot);
1423
+ this.realRoot = safeRealpathSync(this.root);
1424
+ this.timeoutMs = opts.timeoutMs ?? 3e4;
1425
+ this.maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
1426
+ this.maxWriteBytes = opts.maxWriteBytes ?? DEFAULT_MAX_WRITE_BYTES;
1427
+ }
1428
+ /** Read a text file. Returns the content as a string. */
1429
+ async readTextFile(params) {
1430
+ const safe = await this.resolveInside(params.path);
1431
+ const controller = new AbortController();
1432
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1433
+ try {
1434
+ const stat2 = await fsp.stat(safe).catch((err) => {
1435
+ throw mapFsError(err, safe);
1436
+ });
1437
+ if (stat2.size > this.maxReadBytes) {
1438
+ throw new FsError(
1439
+ "TOO_LARGE",
1440
+ safe,
1441
+ `file is ${stat2.size} bytes, max read is ${this.maxReadBytes} bytes`
1442
+ );
1443
+ }
1444
+ const content = await fsp.readFile(safe, {
1445
+ encoding: "utf8",
1446
+ signal: controller.signal
1447
+ });
1448
+ return { content };
1449
+ } catch (err) {
1450
+ if (err instanceof FsError) throw err;
1451
+ if (controller.signal.aborted) {
1452
+ throw new FsError("TIMEOUT", safe, `readTextFile timed out after ${this.timeoutMs}ms`);
1453
+ }
1454
+ throw mapFsError(err, safe);
1455
+ } finally {
1456
+ clearTimeout(timer);
1457
+ }
1458
+ }
1459
+ /** Write a text file. Atomic via write-then-rename. */
1460
+ async writeTextFile(params) {
1461
+ const byteLength = Buffer.byteLength(params.content, "utf8");
1462
+ if (byteLength > this.maxWriteBytes) {
1463
+ throw new FsError(
1464
+ "TOO_LARGE",
1465
+ params.path,
1466
+ `content is ${byteLength} bytes, max write is ${this.maxWriteBytes} bytes`
1467
+ );
1468
+ }
1469
+ const safe = await this.resolveInside(params.path);
1470
+ const controller = new AbortController();
1471
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1472
+ const tmp = `${safe}.${randomBytes(6).toString("hex")}.tmp`;
1473
+ try {
1474
+ await fsp.writeFile(tmp, params.content, {
1475
+ encoding: "utf8",
1476
+ signal: controller.signal
1477
+ });
1478
+ await this.assertRealInside(tmp);
1479
+ await this.assertRealInside(path.dirname(safe));
1480
+ await fsp.rename(tmp, safe);
1481
+ } catch (err) {
1482
+ if (err instanceof FsError) {
1483
+ await fsp.unlink(tmp).catch(() => void 0);
1484
+ throw err;
1485
+ }
1486
+ try {
1487
+ await fsp.unlink(tmp);
1488
+ } catch {
1489
+ }
1490
+ if (controller.signal.aborted) {
1491
+ throw new FsError("TIMEOUT", safe, `writeTextFile timed out after ${this.timeoutMs}ms`);
1492
+ }
1493
+ throw mapFsError(err, safe);
1494
+ } finally {
1495
+ clearTimeout(timer);
1496
+ }
1497
+ }
1498
+ /**
1499
+ * Resolve a path and verify it is inside the project root by realpath.
1500
+ * Rejects with `FsError` if the textual path, the resolved path, or the
1501
+ * real (symlink-resolved) path escapes the project root.
1502
+ *
1503
+ * For files that don't exist yet (e.g. a write to a new file), the
1504
+ * nearest existing ancestor directory is realpath-checked instead.
1505
+ */
1506
+ async resolveInside(p) {
1507
+ if (typeof p !== "string" || p.length === 0) {
1508
+ throw new FsError("INVALID_PATH", p, "path is empty or not a string");
1509
+ }
1510
+ if (!path.isAbsolute(p)) {
1511
+ throw new FsError("INVALID_PATH", p, "path must be absolute (ACP requirement)");
1512
+ }
1513
+ const resolved = path.resolve(p);
1514
+ const rootWithSep = this.root.endsWith(path.sep) ? this.root : this.root + path.sep;
1515
+ if (resolved !== this.root && !resolved.startsWith(rootWithSep)) {
1516
+ throw new FsError("OUTSIDE_ROOT", resolved, "path is outside the project root");
1517
+ }
1518
+ await this.assertRealInside(resolved);
1519
+ return resolved;
1520
+ }
1521
+ /**
1522
+ * Resolve `resolvedPath` through `fs.realpath` and verify the result is
1523
+ * inside `realRoot`. For non-existent paths (new files), walk up to the
1524
+ * nearest existing ancestor and check that instead.
1525
+ */
1526
+ async assertRealInside(resolvedPath) {
1527
+ let probe = resolvedPath;
1528
+ for (; ; ) {
1529
+ let real;
1530
+ try {
1531
+ real = await fsp.realpath(probe);
1532
+ } catch (err) {
1533
+ const code = err.code;
1534
+ if (code === "ENOENT") {
1535
+ const parent = path.dirname(probe);
1536
+ if (parent === probe) return;
1537
+ probe = parent;
1538
+ continue;
1539
+ }
1540
+ throw mapFsError(err, resolvedPath);
1541
+ }
1542
+ if (real === this.realRoot || real.startsWith(this.realRoot + path.sep)) return;
1543
+ throw new FsError(
1544
+ "OUTSIDE_ROOT",
1545
+ resolvedPath,
1546
+ "path resolves through a symlink outside the project root"
1547
+ );
1548
+ }
1549
+ }
1550
+ };
1551
+ function mapFsError(err, p) {
1552
+ const code = err?.code;
1553
+ if (code === "ENOENT") return new FsError("ENOENT", p, `no such file: ${p}`);
1554
+ if (code === "EACCES" || code === "EPERM") {
1555
+ return new FsError("EACCES", p, `permission denied: ${p}`);
1556
+ }
1557
+ const msg = err instanceof Error ? err.message : String(err);
1558
+ return new FsError("INVALID_PATH", p, msg);
1559
+ }
1560
+ function safeRealpathSync(p) {
1561
+ try {
1562
+ return realpathSync(p);
1563
+ } catch {
1564
+ return p;
1565
+ }
1566
+ }
1567
+
1568
+ // src/client/permission.ts
1569
+ function pickAllow(options) {
1570
+ const ranked = [...options].sort((a, b) => {
1571
+ const score = (k) => {
1572
+ if (k === "allow_once") return 0;
1573
+ if (k === "allow_always") return 1;
1574
+ if (k === "reject_once") return 2;
1575
+ return 3;
1576
+ };
1577
+ return score(a.kind) - score(b.kind);
1578
+ });
1579
+ const chosen = ranked[0];
1580
+ if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
1581
+ return { outcome: "cancelled" };
1582
+ }
1583
+ return { outcome: "selected", optionId: chosen.optionId };
1584
+ }
1585
+ function pickReject(options) {
1586
+ const reject = options.find(
1587
+ (o) => o.kind === "reject_once" || o.kind === "reject_always"
1588
+ );
1589
+ return reject ? { outcome: "selected", optionId: reject.optionId } : { outcome: "cancelled" };
1590
+ }
1591
+ var READ_ONLY_KINDS = /* @__PURE__ */ new Set(["read", "search", "fetch", "think"]);
1592
+ var defaultPermissionPolicy = async (req) => {
1593
+ if (req.signal.aborted) return { outcome: "cancelled" };
1594
+ return pickAllow(req.options);
1595
+ };
1596
+ var readOnlyPermissionPolicy = async (req) => {
1597
+ if (req.signal.aborted) return { outcome: "cancelled" };
1598
+ const kind = req.toolCall.kind;
1599
+ if (kind && READ_ONLY_KINDS.has(kind)) {
1600
+ return pickAllow(req.options);
1601
+ }
1602
+ return pickReject(req.options);
1603
+ };
1604
+ function makePermissionPolicy(decide) {
1605
+ return async (req) => {
1606
+ if (req.signal.aborted) return { outcome: "cancelled" };
1607
+ const allow = await decide(req);
1608
+ return allow ? pickAllow(req.options) : pickReject(req.options);
1609
+ };
1610
+ }
1611
+
1612
+ // src/client/terminal-server.ts
1613
+ import { spawn } from "node:child_process";
1614
+ import { realpathSync as realpathSync2 } from "node:fs";
1615
+ import * as path2 from "node:path";
1616
+ import { buildChildEnv } from "@wrongstack/core/utils";
1617
+ var EMPTY_BUFFER = Buffer.alloc(0);
1618
+ var TerminalServer = class {
1619
+ terminals = /* @__PURE__ */ new Map();
1620
+ projectRoot;
1621
+ commandTimeoutMs;
1622
+ outputByteLimit;
1623
+ maxOutputByteLimit;
1624
+ maxTerminals;
1625
+ abortSignal;
1626
+ abortHandler = () => this.releaseAll();
1627
+ nextId = 1;
1628
+ constructor(opts) {
1629
+ this.projectRoot = path2.resolve(opts.projectRoot);
1630
+ this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
1631
+ this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
1632
+ this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
1633
+ this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
1634
+ if (this.maxTerminals < 1) throw new RangeError("maxTerminals must be at least 1");
1635
+ this.abortSignal = opts.signal;
1636
+ if (opts.signal) {
1637
+ opts.signal.addEventListener("abort", this.abortHandler, { once: true });
1638
+ }
1639
+ }
1640
+ /** Spawn a new terminal. Returns the agent-facing id. */
1641
+ create(params) {
1642
+ if (this.terminals.size >= this.maxTerminals) {
1643
+ throw new Error(
1644
+ `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
1645
+ );
1646
+ }
1647
+ const id = `term_${this.nextId++}`;
1648
+ const cwd = this.resolveCwd(params.cwd);
1649
+ const perCallByteLimit = Math.min(
1650
+ Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
1651
+ this.maxOutputByteLimit
1652
+ );
1653
+ const proc = spawn(params.command, params.args ?? [], {
1654
+ cwd,
1655
+ env: this.buildEnv(params.env),
1656
+ stdio: ["ignore", "pipe", "pipe"],
1657
+ windowsHide: true
1658
+ // shell: false on purpose. The terminal server is invoked with
1659
+ // the agent's explicit argv; turning on shell-mode would make
1660
+ // the command a single shell-parsed string, which breaks
1661
+ // Windows cmd quoting for the common case of running node with
1662
+ // `-e "<script>"`. If a future feature needs shell features
1663
+ // (pipes, redirects), it should be opt-in per-call, not the
1664
+ // default.
1665
+ });
1666
+ const state = {
1667
+ proc,
1668
+ cwd,
1669
+ command: params.command,
1670
+ args: params.args ?? [],
1671
+ outputChunks: [],
1672
+ outputHead: 0,
1673
+ retainedBytes: 0,
1674
+ truncated: false,
1675
+ exitStatus: void 0,
1676
+ timeoutHandle: null,
1677
+ exitPromise: new Promise((resolve3) => {
1678
+ proc.on("close", (code, signalName) => {
1679
+ if (state.timeoutHandle) {
1680
+ clearTimeout(state.timeoutHandle);
1681
+ state.timeoutHandle = null;
1682
+ }
1683
+ const exitStatus = {
1684
+ exitCode: typeof code === "number" ? code : null,
1685
+ signal: typeof signalName === "string" ? signalName : null
1686
+ };
1687
+ state.exitStatus = exitStatus;
1688
+ resolve3(exitStatus);
1689
+ });
1690
+ proc.on("error", (err) => {
1691
+ if (state.timeoutHandle) {
1692
+ clearTimeout(state.timeoutHandle);
1693
+ state.timeoutHandle = null;
1694
+ }
1695
+ const exitStatus = { exitCode: 127, signal: null };
1696
+ state.exitStatus = exitStatus;
1697
+ let errorOutput = Buffer.from(`[spawn error] ${err.message}
1698
+ `, "utf8");
1699
+ if (errorOutput.length > perCallByteLimit) {
1700
+ let start = errorOutput.length - perCallByteLimit;
1701
+ while (start < errorOutput.length && (errorOutput[start] & 192) === 128) start++;
1702
+ errorOutput = errorOutput.subarray(start);
1703
+ state.truncated = true;
1704
+ }
1705
+ state.outputChunks.push(errorOutput);
1706
+ state.retainedBytes = errorOutput.length;
1707
+ resolve3(exitStatus);
1708
+ });
1709
+ })
1710
+ };
1711
+ proc.stdout?.setEncoding("utf8");
1712
+ proc.stderr?.setEncoding("utf8");
1713
+ const onData = (chunk) => {
1714
+ const outputChunk = Buffer.from(chunk, "utf8");
1715
+ state.outputChunks.push(outputChunk);
1716
+ state.retainedBytes += outputChunk.length;
1717
+ if (state.retainedBytes > perCallByteLimit) state.truncated = true;
1718
+ while (state.retainedBytes > perCallByteLimit && state.outputHead < state.outputChunks.length) {
1719
+ const first = state.outputChunks[state.outputHead];
1720
+ const overflow = state.retainedBytes - perCallByteLimit;
1721
+ if (first.length <= overflow) {
1722
+ state.outputChunks[state.outputHead] = EMPTY_BUFFER;
1723
+ state.outputHead++;
1724
+ state.retainedBytes -= first.length;
1725
+ continue;
1726
+ }
1727
+ let start = overflow;
1728
+ while (start < first.length && (first[start] & 192) === 128) start++;
1729
+ state.outputChunks[state.outputHead] = first.subarray(start);
1730
+ state.retainedBytes -= start;
1731
+ }
1732
+ if (state.outputHead >= 256 && state.outputHead * 2 >= state.outputChunks.length) {
1733
+ state.outputChunks = state.outputChunks.slice(state.outputHead);
1734
+ state.outputHead = 0;
1735
+ }
1736
+ };
1737
+ proc.stdout?.on("data", onData);
1738
+ proc.stderr?.on("data", onData);
1739
+ state.timeoutHandle = setTimeout(() => {
1740
+ try {
1741
+ proc.kill("SIGTERM");
1742
+ } catch {
1743
+ }
1744
+ }, this.commandTimeoutMs);
1745
+ this.terminals.set(id, state);
1746
+ return { terminalId: id };
1747
+ }
1748
+ /** Return captured output and (if available) the exit status. */
1749
+ output(terminalId) {
1750
+ const state = this.terminals.get(terminalId);
1751
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1752
+ return {
1753
+ output: Buffer.concat(
1754
+ state.outputChunks.slice(state.outputHead),
1755
+ state.retainedBytes
1756
+ ).toString("utf8"),
1757
+ truncated: state.truncated,
1758
+ ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
1759
+ };
1760
+ }
1761
+ /** Block until the process exits. Resolves with the exit status. */
1762
+ async waitForExit(terminalId) {
1763
+ const state = this.terminals.get(terminalId);
1764
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1765
+ return state.exitPromise;
1766
+ }
1767
+ /** Kill the process but keep the terminal record (agent can still read output). */
1768
+ kill(terminalId) {
1769
+ const state = this.terminals.get(terminalId);
1770
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1771
+ try {
1772
+ state.proc.kill("SIGTERM");
1773
+ } catch {
1774
+ }
1775
+ }
1776
+ /** Kill the process if alive and remove the record. */
1777
+ release(terminalId) {
1778
+ const state = this.terminals.get(terminalId);
1779
+ if (!state) return;
1780
+ if (state.timeoutHandle) {
1781
+ clearTimeout(state.timeoutHandle);
1782
+ state.timeoutHandle = null;
1783
+ }
1784
+ try {
1785
+ state.proc.kill("SIGKILL");
1786
+ } catch {
1787
+ }
1788
+ this.terminals.delete(terminalId);
1789
+ }
1790
+ /** Kill all active terminals. Used on session close. */
1791
+ releaseAll() {
1792
+ this.abortSignal?.removeEventListener("abort", this.abortHandler);
1793
+ for (const id of [...this.terminals.keys()]) {
1794
+ this.release(id);
1795
+ }
1796
+ }
1797
+ resolveCwd(cwd) {
1798
+ if (!cwd) return this.projectRoot;
1799
+ const resolved = path2.resolve(cwd);
1800
+ const rootWithSep = this.projectRoot.endsWith(path2.sep) ? this.projectRoot : this.projectRoot + path2.sep;
1801
+ if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
1802
+ return this.projectRoot;
1803
+ }
1804
+ try {
1805
+ const realRoot = realpathSync2(this.projectRoot);
1806
+ const realCwd = realpathSync2(resolved);
1807
+ const realRootWithSep = realRoot.endsWith(path2.sep) ? realRoot : realRoot + path2.sep;
1808
+ if (realCwd !== realRoot && !realCwd.startsWith(realRootWithSep)) {
1809
+ return realRoot;
1810
+ }
1811
+ return realCwd;
1812
+ } catch {
1813
+ return this.projectRoot;
1814
+ }
1815
+ }
1816
+ buildEnv(agentEnv) {
1817
+ const env = buildChildEnv();
1818
+ if (agentEnv) {
1819
+ for (const { name, value } of agentEnv) {
1820
+ const upper = name.toUpperCase();
1821
+ if (DENIED_AGENT_ENV_KEYS.has(upper)) continue;
1822
+ env[name] = value;
1823
+ }
1824
+ }
1825
+ return env;
1826
+ }
1827
+ /**
1828
+ * Clamp an agent-supplied numeric to a finite positive safe integer, falling
1829
+ * back to `defaultValue` for undefined/NaN/non-finite values. Prevents
1830
+ * negative, NaN, or Infinity values from disabling output caps or causing
1831
+ * unbounded memory growth.
1832
+ */
1833
+ clampFiniteInt(value, defaultValue) {
1834
+ if (value === void 0 || !Number.isFinite(value) || value < 1) {
1835
+ return defaultValue;
1836
+ }
1837
+ return Math.trunc(value);
1838
+ }
1839
+ };
1840
+ var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
1841
+ "NODE_OPTIONS",
1842
+ "LD_PRELOAD",
1843
+ "LD_LIBRARY_PATH",
1844
+ "DYLD_INSERT_LIBRARIES",
1845
+ "DYLD_LIBRARY_PATH",
1846
+ "DYLD_FALLBACK_LIBRARY_PATH",
1847
+ "PATH",
1848
+ "PYTHONPATH",
1849
+ "PYTHONSTARTUP",
1850
+ "PERL5OPT",
1851
+ "PERLLIB",
1852
+ "RUBYOPT",
1853
+ "RUBYLIB"
1854
+ ]);
1855
+
1856
+ // src/client/trust-boundary-permission.ts
1857
+ function pickOption(options, allowed) {
1858
+ const kinds = allowed ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
1859
+ for (const kind of kinds) {
1860
+ const option = options.find((candidate) => candidate.kind === kind);
1861
+ if (option) return { outcome: "selected", optionId: option.optionId };
1862
+ }
1863
+ return { outcome: "cancelled" };
1864
+ }
1865
+ function riskFor(kind) {
1866
+ if (kind === "read" || kind === "search" || kind === "fetch" || kind === "think") return "low";
1867
+ if (kind === "edit" || kind === "move") return "elevated";
1868
+ if (kind === "delete" || kind === "execute") return "high";
1869
+ return "elevated";
1870
+ }
1871
+ function capabilityFor(request) {
1872
+ const raw = request.toolCall.rawInput;
1873
+ if (typeof raw?.path === "string") {
1874
+ return request.toolCall.kind === "read" || request.toolCall.kind === "search" ? "filesystem.read" : "filesystem.write";
1875
+ }
1876
+ if (typeof raw?.command === "string" || request.toolCall.kind === "execute")
1877
+ return "process.spawn";
1878
+ if (request.toolCall.kind === "fetch") return "network.fetch";
1879
+ return `tool.${request.toolCall.kind ?? "unknown"}`;
1880
+ }
1881
+ function subjectFor(request) {
1882
+ const raw = request.toolCall.rawInput;
1883
+ const title = request.toolCall.title ?? `ACP tool call ${String(request.toolCall.toolCallId)}`;
1884
+ if (typeof raw?.path === "string") {
1885
+ return { kind: "path", id: raw.path, attributes: { toolKind: request.toolCall.kind ?? null } };
1886
+ }
1887
+ if (typeof raw?.command === "string") {
1888
+ return {
1889
+ kind: "command",
1890
+ id: raw.command,
1891
+ attributes: { toolKind: request.toolCall.kind ?? null }
1892
+ };
1893
+ }
1894
+ return {
1895
+ kind: "resource",
1896
+ id: title,
1897
+ attributes: { toolKind: request.toolCall.kind ?? null }
1898
+ };
1899
+ }
1900
+ function isAllowed(decision) {
1901
+ return decision.kind === "allow" || decision.kind === "scoped-token";
1902
+ }
1903
+ function toTrustBoundaryRequest(request, options) {
1904
+ const rawSessionId = request.toolCall.rawInput?.sessionId;
1905
+ const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
1906
+ return {
1907
+ version: 1,
1908
+ requestId: String(request.toolCall.toolCallId),
1909
+ actor: {
1910
+ ...options.actor ?? { kind: "agent" },
1911
+ ...sessionId ? { sessionId } : {}
1912
+ },
1913
+ surface: "acp",
1914
+ capability: capabilityFor(request),
1915
+ subject: subjectFor(request),
1916
+ risk: riskFor(request.toolCall.kind),
1917
+ scope: {
1918
+ ...options.scope ?? {},
1919
+ ...sessionId ? { sessionId } : {}
1920
+ },
1921
+ ...options.authContext ? { authContext: options.authContext } : {},
1922
+ metadata: {
1923
+ ...request.toolCall.title ? { title: request.toolCall.title } : {},
1924
+ toolKind: request.toolCall.kind ?? null
1925
+ }
1926
+ };
1927
+ }
1928
+ function makeTrustBoundaryPermissionPolicy(options) {
1929
+ return async (request) => {
1930
+ if (request.signal.aborted) return { outcome: "cancelled" };
1931
+ const decision = await options.boundary.evaluate(toTrustBoundaryRequest(request, options));
1932
+ if (request.signal.aborted) return { outcome: "cancelled" };
1933
+ return pickOption(request.options, isAllowed(decision));
1934
+ };
1935
+ }
1936
+
1937
+ // src/client/websocket-transport.ts
1938
+ var WebSocketClientTransport = class {
1939
+ ws = null;
1940
+ handlers = /* @__PURE__ */ new Set();
1941
+ closed = false;
1942
+ opts;
1943
+ maxBufferedBytes;
1944
+ maxMessageChars;
1945
+ constructor(opts) {
1946
+ this.opts = opts;
1947
+ this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
1948
+ this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
1949
+ }
1950
+ start() {
1951
+ const WS = globalThis.WebSocket;
1952
+ if (!WS) {
1953
+ return Promise.reject(
1954
+ new Error(
1955
+ "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
1956
+ )
1957
+ );
1958
+ }
1959
+ const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
1960
+ return new Promise((resolve3, reject) => {
1961
+ let settled = false;
1962
+ const ws = new WS(this.opts.url, this.opts.protocols);
1963
+ this.ws = ws;
1964
+ const timer = setTimeout(() => {
1965
+ if (settled) return;
1966
+ settled = true;
1967
+ try {
1968
+ ws.close();
1969
+ } catch {
1970
+ }
1971
+ reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
1972
+ }, timeoutMs);
1973
+ ws.addEventListener("open", () => {
1974
+ if (settled) return;
1975
+ settled = true;
1976
+ clearTimeout(timer);
1977
+ resolve3();
1978
+ });
1979
+ ws.addEventListener("error", (ev) => {
1980
+ if (settled) {
1981
+ this.closed = true;
1982
+ return;
1983
+ }
1984
+ settled = true;
1985
+ clearTimeout(timer);
1986
+ const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
1987
+ reject(new Error(message));
1988
+ });
1989
+ ws.addEventListener("close", () => {
1990
+ this.closed = true;
1991
+ });
1992
+ ws.addEventListener("message", (ev) => {
1993
+ this.onData(ev.data);
1994
+ });
1995
+ });
1996
+ }
1997
+ send(msg) {
1998
+ if (this.closed || !this.ws) {
1999
+ return Promise.reject(new Error("WebSocket transport is not open"));
2000
+ }
2001
+ try {
2002
+ const serialized = JSON.stringify(msg);
2003
+ const buffered = Number.isFinite(this.ws.bufferedAmount) ? this.ws.bufferedAmount ?? 0 : 0;
2004
+ if (buffered + Buffer.byteLength(serialized, "utf8") > this.maxBufferedBytes) {
2005
+ this.stop();
2006
+ return Promise.reject(new Error("WebSocket transport send buffer limit exceeded"));
2007
+ }
2008
+ this.ws.send(serialized);
2009
+ return Promise.resolve();
2010
+ } catch (err) {
2011
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
2012
+ }
2013
+ }
2014
+ onMessage(handler) {
2015
+ this.handlers.add(handler);
2016
+ return () => this.handlers.delete(handler);
2017
+ }
2018
+ stop() {
2019
+ this.closed = true;
2020
+ if (this.ws) {
2021
+ try {
2022
+ this.ws.close();
2023
+ } catch {
2024
+ }
2025
+ this.ws = null;
2026
+ }
2027
+ }
2028
+ onData(data) {
2029
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
2030
+ if (text.length > this.maxMessageChars) {
2031
+ this.stop();
2032
+ return;
2033
+ }
2034
+ if (!text.trim()) return;
2035
+ let msg;
2036
+ try {
2037
+ msg = JSON.parse(text);
2038
+ } catch {
2039
+ for (const line of text.split("\n")) {
2040
+ if (!line.trim()) continue;
2041
+ try {
2042
+ this.dispatch(JSON.parse(line));
2043
+ } catch {
2044
+ }
2045
+ }
2046
+ return;
2047
+ }
2048
+ this.dispatch(msg);
2049
+ }
2050
+ dispatch(msg) {
2051
+ for (const handler of [...this.handlers]) {
2052
+ try {
2053
+ handler(msg);
2054
+ } catch {
2055
+ }
2056
+ }
2057
+ }
2058
+ };
2059
+ function finitePositiveLimit(value, fallback) {
2060
+ return Number.isFinite(value) && (value ?? 0) > 0 ? Math.floor(value) : fallback;
2061
+ }
2062
+
1866
2063
  // src/client/acp-session.ts
1867
2064
  var ACPSessionError = class extends Error {
1868
2065
  kind;
@@ -1884,6 +2081,7 @@ var ACPSession = class _ACPSession {
1884
2081
  permissionPolicy;
1885
2082
  timeoutMs;
1886
2083
  opts;
2084
+ transportOff = null;
1887
2085
  state = "init";
1888
2086
  sessionId = null;
1889
2087
  /** Pending outbound requests (initialize, session/new, session/prompt, etc). */
@@ -1915,8 +2113,19 @@ var ACPSession = class _ACPSession {
1915
2113
  if (opts.terminalOutputByteLimit !== void 0) {
1916
2114
  termOpts.outputByteLimit = opts.terminalOutputByteLimit;
1917
2115
  }
2116
+ if (opts.terminalMaxCount !== void 0) {
2117
+ termOpts.maxTerminals = opts.terminalMaxCount;
2118
+ }
1918
2119
  this.terminalServer = new TerminalServer(termOpts);
1919
- this.permissionPolicy = opts.permissionPolicy ?? defaultPermissionPolicy;
2120
+ if (opts.permissionPolicy && opts.trustBoundary) {
2121
+ throw new TypeError("permissionPolicy and trustBoundary are mutually exclusive");
2122
+ }
2123
+ this.permissionPolicy = opts.trustBoundary ? makeTrustBoundaryPermissionPolicy({
2124
+ boundary: opts.trustBoundary,
2125
+ ...opts.trustActor ? { actor: opts.trustActor } : {},
2126
+ scope: opts.trustScope ?? { cwd: opts.projectRoot },
2127
+ ...opts.trustAuthContext ? { authContext: opts.trustAuthContext } : {}
2128
+ }) : opts.permissionPolicy ?? readOnlyPermissionPolicy;
1920
2129
  }
1921
2130
  // ──────────────────────────────────────────────────────────────────────
1922
2131
  // Public accessors
@@ -1990,10 +2199,12 @@ var ACPSession = class _ACPSession {
1990
2199
  throw new ACPSessionError("spawn_failed", `${spawnErrLabel}: ${msg}`, err);
1991
2200
  }
1992
2201
  const session = new _ACPSession(opts, transport);
1993
- transport.onMessage((msg) => session.handleMessage(msg));
2202
+ session.transportOff = transport.onMessage((msg) => session.handleMessage(msg));
1994
2203
  try {
1995
2204
  await session.initialize();
1996
2205
  } catch (err) {
2206
+ session.transportOff?.();
2207
+ session.transportOff = null;
1997
2208
  try {
1998
2209
  transport.stop();
1999
2210
  } catch {
@@ -2157,7 +2368,11 @@ var ACPSession = class _ACPSession {
2157
2368
  mcpServers: servers
2158
2369
  });
2159
2370
  if (isJsonRpcError(result)) {
2160
- throw new ACPSessionError("prompt_failed", `session/resume failed: ${result.message}`, result);
2371
+ throw new ACPSessionError(
2372
+ "prompt_failed",
2373
+ `session/resume failed: ${result.message}`,
2374
+ result
2375
+ );
2161
2376
  }
2162
2377
  this.sessionId = sessionId;
2163
2378
  }
@@ -2208,7 +2423,11 @@ var ACPSession = class _ACPSession {
2208
2423
  const id = this.allocId();
2209
2424
  const result = await this.sendRequest(id, "session/delete", { sessionId });
2210
2425
  if (isJsonRpcError(result)) {
2211
- throw new ACPSessionError("prompt_failed", `session/delete failed: ${result.message}`, result);
2426
+ throw new ACPSessionError(
2427
+ "prompt_failed",
2428
+ `session/delete failed: ${result.message}`,
2429
+ result
2430
+ );
2212
2431
  }
2213
2432
  if (this.sessionId === sessionId) {
2214
2433
  this.sessionId = null;
@@ -2243,7 +2462,11 @@ var ACPSession = class _ACPSession {
2243
2462
  const id = this.allocId();
2244
2463
  const result = await this.sendRequest(id, "session/set_mode", { sessionId, modeId });
2245
2464
  if (isJsonRpcError(result)) {
2246
- throw new ACPSessionError("prompt_failed", `session/set_mode failed: ${result.message}`, result);
2465
+ throw new ACPSessionError(
2466
+ "prompt_failed",
2467
+ `session/set_mode failed: ${result.message}`,
2468
+ result
2469
+ );
2247
2470
  }
2248
2471
  }
2249
2472
  /**
@@ -2258,7 +2481,11 @@ var ACPSession = class _ACPSession {
2258
2481
  value
2259
2482
  });
2260
2483
  if (isJsonRpcError(result)) {
2261
- throw new ACPSessionError("prompt_failed", `session/set_config_option failed: ${result.message}`, result);
2484
+ throw new ACPSessionError(
2485
+ "prompt_failed",
2486
+ `session/set_config_option failed: ${result.message}`,
2487
+ result
2488
+ );
2262
2489
  }
2263
2490
  }
2264
2491
  /**
@@ -2269,7 +2496,11 @@ var ACPSession = class _ACPSession {
2269
2496
  const id = this.allocId();
2270
2497
  const result = await this.sendRequest(id, "providers/list", {});
2271
2498
  if (isJsonRpcError(result)) {
2272
- throw new ACPSessionError("prompt_failed", `providers/list failed: ${result.message}`, result);
2499
+ throw new ACPSessionError(
2500
+ "prompt_failed",
2501
+ `providers/list failed: ${result.message}`,
2502
+ result
2503
+ );
2273
2504
  }
2274
2505
  const r = result;
2275
2506
  return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
@@ -2305,7 +2536,11 @@ var ACPSession = class _ACPSession {
2305
2536
  const id = this.allocId();
2306
2537
  const result = await this.sendRequest(id, "providers/disable", {});
2307
2538
  if (isJsonRpcError(result)) {
2308
- throw new ACPSessionError("prompt_failed", `providers/disable failed: ${result.message}`, result);
2539
+ throw new ACPSessionError(
2540
+ "prompt_failed",
2541
+ `providers/disable failed: ${result.message}`,
2542
+ result
2543
+ );
2309
2544
  }
2310
2545
  }
2311
2546
  // ──────────────────────────────────────────────────────────────────────
@@ -2412,11 +2647,7 @@ var ACPSession = class _ACPSession {
2412
2647
  }
2413
2648
  const sessionId = result.sessionId;
2414
2649
  if (typeof sessionId !== "string" || sessionId.length === 0) {
2415
- throw new ACPSessionError(
2416
- "protocol_error",
2417
- "session/new returned no sessionId",
2418
- result
2419
- );
2650
+ throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
2420
2651
  }
2421
2652
  this.sessionId = sessionId;
2422
2653
  }
@@ -2459,6 +2690,8 @@ var ACPSession = class _ACPSession {
2459
2690
  p.reject(new ACPSessionError("closed", "session was closed"));
2460
2691
  }
2461
2692
  this.pending.clear();
2693
+ this.transportOff?.();
2694
+ this.transportOff = null;
2462
2695
  try {
2463
2696
  this.transport.stop();
2464
2697
  } catch {
@@ -2494,10 +2727,7 @@ var ACPSession = class _ACPSession {
2494
2727
  const handle = setTimeout(() => {
2495
2728
  this.pending.delete(id);
2496
2729
  reject(
2497
- new ACPSessionError(
2498
- "protocol_error",
2499
- `${method} timed out after ${effectiveTimeout}ms`
2500
- )
2730
+ new ACPSessionError("protocol_error", `${method} timed out after ${effectiveTimeout}ms`)
2501
2731
  );
2502
2732
  }, effectiveTimeout);
2503
2733
  this.pending.set(id, {
@@ -2606,297 +2836,580 @@ var ACPSession = class _ACPSession {
2606
2836
  this.scratch.text += text;
2607
2837
  this.emitProgress({ type: "message", text });
2608
2838
  }
2609
- return;
2610
- }
2611
- case "thought_chunk": {
2612
- const text = extractText(u.content);
2613
- if (text) {
2614
- this.scratch.thoughts += text;
2615
- this.emitProgress({ type: "thought", text });
2839
+ return;
2840
+ }
2841
+ case "thought_chunk": {
2842
+ const text = extractText(u.content);
2843
+ if (text) {
2844
+ this.scratch.thoughts += text;
2845
+ this.emitProgress({ type: "thought", text });
2846
+ }
2847
+ return;
2848
+ }
2849
+ case "tool_call":
2850
+ case "tool_call_update": {
2851
+ this.captureToolCall(u, u.sessionUpdate === "tool_call");
2852
+ return;
2853
+ }
2854
+ case "plan":
2855
+ if (Array.isArray(u.entries)) {
2856
+ this.scratch.plan = u.entries;
2857
+ this.emitProgress({ type: "plan", entries: u.entries });
2858
+ }
2859
+ return;
2860
+ case "usage_update":
2861
+ if (typeof u.used === "number" && typeof u.size === "number") {
2862
+ const usage = {
2863
+ used: u.used,
2864
+ size: u.size,
2865
+ ...typeof u.cost === "object" && u.cost !== null ? { cost: u.cost } : {}
2866
+ };
2867
+ this.scratch.usage = usage;
2868
+ this.emitProgress({ type: "usage", usage });
2869
+ }
2870
+ return;
2871
+ case "available_commands_update":
2872
+ case "current_mode_update":
2873
+ case "config_option_update":
2874
+ case "session_info_update":
2875
+ case "user_message_chunk":
2876
+ case "next_edit_suggestions":
2877
+ case "elicitation":
2878
+ return;
2879
+ default:
2880
+ return;
2881
+ }
2882
+ }
2883
+ /**
2884
+ * Fold a `tool_call` / `tool_call_update` notification into the scratch
2885
+ * tool-call map (deduped by toolCallId), extract any `diff` content into
2886
+ * the diffs list, and emit live progress.
2887
+ */
2888
+ captureToolCall(u, isNew) {
2889
+ const toolCallId = typeof u.toolCallId === "string" ? u.toolCallId : "";
2890
+ if (!toolCallId) return;
2891
+ const prev = this.scratch.toolCalls.get(toolCallId);
2892
+ const record = {
2893
+ toolCallId,
2894
+ title: typeof u.title === "string" ? u.title : prev?.title ?? toolCallId,
2895
+ kind: typeof u.kind === "string" ? u.kind : prev?.kind,
2896
+ status: typeof u.status === "string" ? u.status : prev?.status ?? (isNew ? "pending" : "in_progress"),
2897
+ rawInput: isRecord(u.rawInput) ? u.rawInput : prev?.rawInput,
2898
+ rawOutput: isRecord(u.rawOutput) ? u.rawOutput : prev?.rawOutput
2899
+ };
2900
+ this.scratch.toolCalls.set(toolCallId, record);
2901
+ if (Array.isArray(u.content)) {
2902
+ for (const c of u.content) {
2903
+ if (c && typeof c === "object" && c.type === "diff") {
2904
+ const diff = {
2905
+ path: c.path,
2906
+ oldText: c.oldText,
2907
+ newText: c.newText
2908
+ };
2909
+ this.scratch.diffs.push(diff);
2910
+ this.emitProgress({ type: "diff", diff });
2911
+ }
2912
+ }
2913
+ }
2914
+ this.emitProgress({
2915
+ type: isNew ? "tool_call" : "tool_call_update",
2916
+ toolCall: record
2917
+ });
2918
+ }
2919
+ emitProgress(event) {
2920
+ if (!this.progressHandler) return;
2921
+ try {
2922
+ this.progressHandler(event);
2923
+ } catch {
2924
+ }
2925
+ }
2926
+ /** Live progress handler installed for the duration of a `prompt()` turn. */
2927
+ progressHandler = null;
2928
+ // Per-prompt scratch state
2929
+ scratch = { text: "", thoughts: "", toolCalls: /* @__PURE__ */ new Map(), diffs: [] };
2930
+ resetScratch() {
2931
+ this.scratch = { text: "", thoughts: "", toolCalls: /* @__PURE__ */ new Map(), diffs: [] };
2932
+ }
2933
+ async handlePermissionRequest(msg) {
2934
+ const id = msg.id;
2935
+ if (id === void 0) return;
2936
+ const params = msg.params;
2937
+ const toolCall = params?.toolCall;
2938
+ const options = Array.isArray(params?.options) ? params.options : [];
2939
+ if (!toolCall) {
2940
+ await this.sendErrorResponse(id, -32602, "toolCall is required");
2941
+ return;
2942
+ }
2943
+ const policyAbort = new AbortController();
2944
+ try {
2945
+ const outcome = await this.permissionPolicy({
2946
+ toolCall,
2947
+ options,
2948
+ signal: policyAbort.signal
2949
+ });
2950
+ await this.sendResult(id, { outcome });
2951
+ } catch (err) {
2952
+ const message = err instanceof Error ? err.message : String(err);
2953
+ await this.sendErrorResponse(id, -32603, `permission policy failed: ${message}`);
2954
+ }
2955
+ }
2956
+ /**
2957
+ * Enforce authorization at privileged callback sinks (fs/write,
2958
+ * terminal/create). Unlike `handlePermissionRequest` which responds to
2959
+ * agent-initiated `session/request_permission` messages, this method is
2960
+ * called by the handler BEFORE dispatching to FileServer/TerminalServer,
2961
+ * closing the gap where the agent simply skips the voluntary permission
2962
+ * request and sends the privileged callback directly.
2963
+ *
2964
+ * Uses the session's permission policy. The default
2965
+ * (`readOnlyPermissionPolicy`) auto-approves only side-effect-free tool
2966
+ * calls (read/search/fetch/think) and rejects everything else — this is
2967
+ * the safe-by-default posture. For trusted local agents (CLI `acp spawn`,
2968
+ * Director fan-out), inject `defaultPermissionPolicy` to grant
2969
+ * write/execute access.
2970
+ *
2971
+ * Returns true if the callback is authorized, false if denied.
2972
+ */
2973
+ async authorizeCallback(partial) {
2974
+ try {
2975
+ const outcome = await this.permissionPolicy({
2976
+ toolCall: {
2977
+ sessionUpdate: "tool_call_update",
2978
+ toolCallId: partial.toolCallId,
2979
+ title: partial.title,
2980
+ kind: partial.kind,
2981
+ status: "pending",
2982
+ ...partial.rawInput ? { rawInput: partial.rawInput } : {}
2983
+ },
2984
+ options: [
2985
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
2986
+ { optionId: "reject", name: "Reject", kind: "reject_once" }
2987
+ ],
2988
+ signal: new AbortController().signal
2989
+ });
2990
+ return outcome.outcome === "selected" && outcome.optionId !== "reject" && outcome.optionId !== "reject_once" && outcome.optionId !== "reject_always";
2991
+ } catch {
2992
+ return false;
2993
+ }
2994
+ }
2995
+ async handleFsRequest(msg) {
2996
+ const id = msg.id;
2997
+ if (id === void 0) return;
2998
+ const params = msg.params;
2999
+ if (!params?.path) {
3000
+ await this.sendErrorResponse(id, -32602, "path is required");
3001
+ return;
3002
+ }
3003
+ if (msg.method === "fs/write_text_file") {
3004
+ const allowed = await this.authorizeCallback({
3005
+ toolCallId: `acp-fs-write-${id}`,
3006
+ title: `Write file: ${params.path}`,
3007
+ kind: "edit",
3008
+ rawInput: { path: params.path, sessionId: params.sessionId }
3009
+ });
3010
+ if (!allowed) {
3011
+ await this.sendErrorResponse(id, -32602, "filesystem write denied by permission policy");
3012
+ return;
3013
+ }
3014
+ }
3015
+ try {
3016
+ if (msg.method === "fs/read_text_file") {
3017
+ const result = await this.fileServer.readTextFile({
3018
+ sessionId: params.sessionId ?? "",
3019
+ path: params.path
3020
+ });
3021
+ await this.sendResult(id, result);
3022
+ } else {
3023
+ await this.fileServer.writeTextFile({
3024
+ sessionId: params.sessionId ?? "",
3025
+ path: params.path,
3026
+ content: params.content ?? ""
3027
+ });
3028
+ await this.sendResult(id, {});
3029
+ }
3030
+ } catch (err) {
3031
+ const code = err instanceof FsError ? -32602 : -32603;
3032
+ const message = err instanceof Error ? err.message : String(err);
3033
+ await this.sendErrorResponse(id, code, message);
3034
+ }
3035
+ }
3036
+ async handleTerminalRequest(msg) {
3037
+ const id = msg.id;
3038
+ if (id === void 0) return;
3039
+ const params = msg.params ?? {};
3040
+ try {
3041
+ switch (msg.method) {
3042
+ case "terminal/create": {
3043
+ const allowed = await this.authorizeCallback({
3044
+ toolCallId: `acp-terminal-create-${id}`,
3045
+ title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
3046
+ kind: "execute",
3047
+ rawInput: {
3048
+ command: params.command,
3049
+ args: params.args,
3050
+ cwd: params.cwd,
3051
+ sessionId: params.sessionId
3052
+ }
3053
+ });
3054
+ if (!allowed) {
3055
+ await this.sendErrorResponse(id, -32602, "terminal create denied by permission policy");
3056
+ return;
3057
+ }
3058
+ const createOpts = {
3059
+ sessionId: String(params.sessionId ?? ""),
3060
+ command: String(params.command ?? ""),
3061
+ args: Array.isArray(params.args) ? params.args : []
3062
+ };
3063
+ if (Array.isArray(params.env)) {
3064
+ createOpts.env = params.env;
3065
+ }
3066
+ if (typeof params.cwd === "string") {
3067
+ createOpts.cwd = params.cwd;
3068
+ }
3069
+ if (typeof params.outputByteLimit === "number") {
3070
+ createOpts.outputByteLimit = params.outputByteLimit;
3071
+ }
3072
+ const result = this.terminalServer.create(createOpts);
3073
+ await this.sendResult(id, result);
3074
+ return;
3075
+ }
3076
+ case "terminal/output": {
3077
+ const terminalId = String(params.terminalId ?? "");
3078
+ const out = this.terminalServer.output(terminalId);
3079
+ await this.sendResult(id, out);
3080
+ return;
2616
3081
  }
2617
- return;
2618
- }
2619
- case "tool_call":
2620
- case "tool_call_update": {
2621
- this.captureToolCall(u, u.sessionUpdate === "tool_call");
2622
- return;
2623
- }
2624
- case "plan":
2625
- if (Array.isArray(u.entries)) {
2626
- this.scratch.plan = u.entries;
2627
- this.emitProgress({ type: "plan", entries: u.entries });
3082
+ case "terminal/wait_for_exit": {
3083
+ const terminalId = String(params.terminalId ?? "");
3084
+ const exit = await this.terminalServer.waitForExit(terminalId);
3085
+ await this.sendResult(id, exit);
3086
+ return;
2628
3087
  }
2629
- return;
2630
- case "usage_update":
2631
- if (typeof u.used === "number" && typeof u.size === "number") {
2632
- const usage = {
2633
- used: u.used,
2634
- size: u.size,
2635
- ...typeof u.cost === "object" && u.cost !== null ? { cost: u.cost } : {}
2636
- };
2637
- this.scratch.usage = usage;
2638
- this.emitProgress({ type: "usage", usage });
3088
+ case "terminal/kill": {
3089
+ const terminalId = String(params.terminalId ?? "");
3090
+ this.terminalServer.kill(terminalId);
3091
+ await this.sendResult(id, {});
3092
+ return;
2639
3093
  }
2640
- return;
2641
- case "available_commands_update":
2642
- case "current_mode_update":
2643
- case "config_option_update":
2644
- case "session_info_update":
2645
- case "user_message_chunk":
2646
- case "next_edit_suggestions":
2647
- case "elicitation":
2648
- return;
2649
- default:
2650
- return;
3094
+ case "terminal/release": {
3095
+ const terminalId = String(params.terminalId ?? "");
3096
+ this.terminalServer.release(terminalId);
3097
+ await this.sendResult(id, {});
3098
+ return;
3099
+ }
3100
+ default:
3101
+ await this.sendErrorResponse(id, -32601, `unknown method: ${msg.method}`);
3102
+ }
3103
+ } catch (err) {
3104
+ const message = err instanceof Error ? err.message : String(err);
3105
+ await this.sendErrorResponse(id, -32603, message);
2651
3106
  }
2652
3107
  }
3108
+ };
3109
+ function textContent(text) {
3110
+ return { type: "text", text };
3111
+ }
3112
+ function imageContent(mimeType, data) {
3113
+ return { type: "image", mimeType, data };
3114
+ }
3115
+ function audioContent(mimeType, data) {
3116
+ return { type: "audio", mimeType, data };
3117
+ }
3118
+ function extractText(block) {
3119
+ if (typeof block !== "object" || block === null) return "";
3120
+ const b = block;
3121
+ if (b.type === "text" && typeof b.text === "string") return b.text;
3122
+ if (b.type === "resource" && b.resource && typeof b.resource === "object" && typeof b.resource.text === "string") {
3123
+ return b.resource.text;
3124
+ }
3125
+ return "";
3126
+ }
3127
+ function isRecord(v) {
3128
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3129
+ }
3130
+ function emptyRunResult(stopReason) {
3131
+ return {
3132
+ text: "",
3133
+ stopReason,
3134
+ hasText: false,
3135
+ toolCalls: [],
3136
+ diffs: [],
3137
+ thoughts: ""
3138
+ };
3139
+ }
3140
+
3141
+ // src/client/tool-translator.ts
3142
+ import { expectDefined as expectDefined2 } from "@wrongstack/core/utils";
3143
+ var DEFAULT_OPTIONS = {
3144
+ asyncTools: true,
3145
+ pollIntervalMs: 500,
3146
+ totalTimeoutMs: 12e4
3147
+ };
3148
+ var ToolTranslator = class {
3149
+ opts;
3150
+ pending = /* @__PURE__ */ new Map();
3151
+ constructor(opts = {}) {
3152
+ this.opts = { ...DEFAULT_OPTIONS, ...opts };
3153
+ }
2653
3154
  /**
2654
- * Fold a `tool_call` / `tool_call_update` notification into the scratch
2655
- * tool-call map (deduped by toolCallId), extract any `diff` content into
2656
- * the diffs list, and emit live progress.
3155
+ * Start listening to a transport for tool responses and cancellations.
3156
+ * Call this once after constructing the translator and before sending tasks.
2657
3157
  */
2658
- captureToolCall(u, isNew) {
2659
- const toolCallId = typeof u.toolCallId === "string" ? u.toolCallId : "";
2660
- if (!toolCallId) return;
2661
- const prev = this.scratch.toolCalls.get(toolCallId);
2662
- const record = {
2663
- toolCallId,
2664
- title: typeof u.title === "string" ? u.title : prev?.title ?? toolCallId,
2665
- kind: typeof u.kind === "string" ? u.kind : prev?.kind,
2666
- status: typeof u.status === "string" ? u.status : prev?.status ?? (isNew ? "pending" : "in_progress"),
2667
- rawInput: isRecord(u.rawInput) ? u.rawInput : prev?.rawInput,
2668
- rawOutput: isRecord(u.rawOutput) ? u.rawOutput : prev?.rawOutput
2669
- };
2670
- this.scratch.toolCalls.set(toolCallId, record);
2671
- if (Array.isArray(u.content)) {
2672
- for (const c of u.content) {
2673
- if (c && typeof c === "object" && c.type === "diff") {
2674
- const diff = {
2675
- path: c.path,
2676
- oldText: c.oldText,
2677
- newText: c.newText
2678
- };
2679
- this.scratch.diffs.push(diff);
2680
- this.emitProgress({ type: "diff", diff });
3158
+ attachToTransport(transport) {
3159
+ transport.onMessage((msg) => {
3160
+ if (msg.method === "tools/call" && msg.id !== void 0) {
3161
+ const pending = this.pending.get(msg.id);
3162
+ if (pending) {
3163
+ clearTimeout(pending.timeout);
3164
+ this.pending.delete(expectDefined2(msg.id));
3165
+ pending.resolve(msg);
3166
+ }
3167
+ }
3168
+ if (msg.method === "cancel" && msg.id !== void 0) {
3169
+ const pending = this.pending.get(msg.id);
3170
+ if (pending) {
3171
+ clearTimeout(pending.timeout);
3172
+ this.pending.delete(expectDefined2(msg.id));
3173
+ pending.reject(new Error("Call cancelled by client"));
2681
3174
  }
2682
3175
  }
3176
+ });
3177
+ }
3178
+ /**
3179
+ * Send a tool call over the transport and wait for a response.
3180
+ * If asyncTools is true, polls for progress and resolves when the final
3181
+ * response arrives.
3182
+ */
3183
+ async callTool(transport, name, args, callId = crypto.randomUUID()) {
3184
+ await transport.send({
3185
+ jsonrpc: "2.0",
3186
+ method: "tools/call",
3187
+ id: callId,
3188
+ params: { name, arguments: args }
3189
+ });
3190
+ return new Promise((resolve3, reject) => {
3191
+ const timeout = setTimeout(() => {
3192
+ this.pending.delete(callId);
3193
+ reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
3194
+ }, this.opts.totalTimeoutMs);
3195
+ this.pending.set(callId, { resolve: resolve3, reject, timeout });
3196
+ });
3197
+ }
3198
+ cancelAll() {
3199
+ for (const [, p] of this.pending) {
3200
+ clearTimeout(p.timeout);
2683
3201
  }
2684
- this.emitProgress({
2685
- type: isNew ? "tool_call" : "tool_call_update",
2686
- toolCall: record
3202
+ this.pending.clear();
3203
+ }
3204
+ };
3205
+
3206
+ // src/integration/acp-bench.ts
3207
+ import * as fsp2 from "node:fs/promises";
3208
+ import * as path3 from "node:path";
3209
+ function firstLine(s) {
3210
+ const line = s.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
3211
+ return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
3212
+ }
3213
+ function randomMarker() {
3214
+ return `ACP_OK_${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
3215
+ }
3216
+ async function benchOne(agentId, cmd, opts) {
3217
+ const checks = [];
3218
+ const startedAt = opts.now();
3219
+ let session = null;
3220
+ const signal = opts.signal ?? new AbortController().signal;
3221
+ const hsStart = opts.now();
3222
+ try {
3223
+ session = await ACPSession.start({
3224
+ command: cmd.command,
3225
+ ...cmd.args !== void 0 ? { args: [...cmd.args] } : {},
3226
+ ...cmd.env !== void 0 ? { env: cmd.env } : {},
3227
+ projectRoot: opts.projectRoot,
3228
+ timeoutMs: opts.timeoutMs
3229
+ });
3230
+ } catch (err) {
3231
+ const reason2 = err instanceof Error ? err.message : String(err);
3232
+ checks.push({ name: "handshake", ok: false, detail: reason2 });
3233
+ return {
3234
+ agentId,
3235
+ status: "fail",
3236
+ checks,
3237
+ reason: reason2,
3238
+ handshakeMs: opts.now() - hsStart,
3239
+ durationMs: opts.now() - startedAt
3240
+ };
3241
+ }
3242
+ const handshakeMs = opts.now() - hsStart;
3243
+ const agentInfo = session.getAgentInfo() ?? void 0;
3244
+ checks.push({
3245
+ name: "handshake",
3246
+ ok: true,
3247
+ detail: agentInfo ? `${agentInfo.name} ${agentInfo.version}` : void 0
3248
+ });
3249
+ let promptMs;
3250
+ let sample;
3251
+ let reason;
3252
+ try {
3253
+ const pStart = opts.now();
3254
+ const res = await session.prompt(
3255
+ [textContent(`Reply with exactly this token and nothing else: ${opts.marker}`)],
3256
+ signal
3257
+ );
3258
+ promptMs = opts.now() - pStart;
3259
+ sample = res.text ? firstLine(res.text) : void 0;
3260
+ const promptOk = res.hasText && res.stopReason !== "refusal";
3261
+ checks.push({
3262
+ name: "prompt",
3263
+ ok: promptOk,
3264
+ detail: `stopReason=${res.stopReason}${res.hasText ? "" : ", no text"}`
2687
3265
  });
2688
- }
2689
- emitProgress(event) {
2690
- if (!this.progressHandler) return;
2691
- try {
2692
- this.progressHandler(event);
2693
- } catch {
2694
- }
2695
- }
2696
- /** Live progress handler installed for the duration of a `prompt()` turn. */
2697
- progressHandler = null;
2698
- // Per-prompt scratch state
2699
- scratch = { text: "", thoughts: "", toolCalls: /* @__PURE__ */ new Map(), diffs: [] };
2700
- resetScratch() {
2701
- this.scratch = { text: "", thoughts: "", toolCalls: /* @__PURE__ */ new Map(), diffs: [] };
2702
- }
2703
- async handlePermissionRequest(msg) {
2704
- const id = msg.id;
2705
- if (id === void 0) return;
2706
- const params = msg.params;
2707
- const toolCall = params?.toolCall;
2708
- const options = Array.isArray(params?.options) ? params.options : [];
2709
- if (!toolCall) {
2710
- await this.sendErrorResponse(id, -32602, "toolCall is required");
2711
- return;
2712
- }
2713
- const policyAbort = new AbortController();
2714
- try {
2715
- const outcome = await this.permissionPolicy({
2716
- toolCall,
2717
- options,
2718
- signal: policyAbort.signal
2719
- });
2720
- await this.sendResult(id, { outcome });
2721
- } catch (err) {
2722
- const message = err instanceof Error ? err.message : String(err);
2723
- await this.sendErrorResponse(id, -32603, `permission policy failed: ${message}`);
3266
+ const markerOk = res.text.includes(opts.marker);
3267
+ checks.push({
3268
+ name: "marker",
3269
+ ok: markerOk,
3270
+ detail: markerOk ? void 0 : "reply did not contain the token"
3271
+ });
3272
+ if (opts.checkFs) {
3273
+ const fileToken = `FILE_${opts.marker}`;
3274
+ const fileName = `acp-bench-${opts.marker}.txt`;
3275
+ const filePath = path3.join(opts.projectRoot, fileName);
3276
+ let fsOk = false;
3277
+ let fsDetail;
3278
+ try {
3279
+ await fsp2.writeFile(filePath, fileToken, "utf8");
3280
+ const fsRes = await session.prompt(
3281
+ [
3282
+ textContent(
3283
+ `Read the file "${fileName}" in the current directory and reply with its exact contents.`
3284
+ )
3285
+ ],
3286
+ signal
3287
+ );
3288
+ fsOk = fsRes.text.includes(fileToken);
3289
+ if (!fsOk) fsDetail = "agent did not return the file contents (may not have used a read tool)";
3290
+ } catch (err) {
3291
+ fsDetail = err instanceof Error ? err.message : String(err);
3292
+ } finally {
3293
+ await fsp2.rm(filePath, { force: true }).catch(() => {
3294
+ });
3295
+ }
3296
+ checks.push({ name: "fs", ok: fsOk, detail: fsDetail });
2724
3297
  }
2725
- }
2726
- /**
2727
- * Enforce authorization at privileged callback sinks (fs/write,
2728
- * terminal/create). Unlike `handlePermissionRequest` which responds to
2729
- * agent-initiated `session/request_permission` messages, this method is
2730
- * called by the handler BEFORE dispatching to FileServer/TerminalServer,
2731
- * closing the gap where the agent simply skips the voluntary permission
2732
- * request and sends the privileged callback directly.
2733
- *
2734
- * Uses the session's permission policy. The default policy
2735
- * (`defaultPermissionPolicy`) auto-approves everything — this is correct
2736
- * for trusted local agents (CLI `acp spawn`, Director fan-out). For
2737
- * untrusted/remote agents, the host should inject
2738
- * `readOnlyPermissionPolicy` or an interactive policy.
2739
- *
2740
- * Returns true if the callback is authorized, false if denied.
2741
- */
2742
- async authorizeCallback(partial) {
3298
+ } catch (err) {
3299
+ reason = err instanceof Error ? err.message : String(err);
3300
+ checks.push({ name: "prompt", ok: false, detail: reason });
3301
+ } finally {
2743
3302
  try {
2744
- const outcome = await this.permissionPolicy({
2745
- toolCall: {
2746
- sessionUpdate: "tool_call_update",
2747
- toolCallId: partial.toolCallId,
2748
- title: partial.title,
2749
- kind: partial.kind,
2750
- status: "pending"
2751
- },
2752
- options: [
2753
- { optionId: "allow", name: "Allow", kind: "allow_once" },
2754
- { optionId: "reject", name: "Reject", kind: "reject_once" }
2755
- ],
2756
- signal: new AbortController().signal
2757
- });
2758
- return outcome.outcome === "selected" && outcome.optionId !== "reject" && outcome.optionId !== "reject_once" && outcome.optionId !== "reject_always";
3303
+ await session.close();
2759
3304
  } catch {
2760
- return false;
2761
3305
  }
2762
3306
  }
2763
- async handleFsRequest(msg) {
2764
- const id = msg.id;
2765
- if (id === void 0) return;
2766
- const params = msg.params;
2767
- if (!params?.path) {
2768
- await this.sendErrorResponse(id, -32602, "path is required");
2769
- return;
2770
- }
2771
- if (msg.method === "fs/write_text_file") {
2772
- const allowed = await this.authorizeCallback({
2773
- toolCallId: `acp-fs-write-${id}`,
2774
- title: `Write file: ${params.path}`,
2775
- kind: "edit"
2776
- });
2777
- if (!allowed) {
2778
- await this.sendErrorResponse(id, -32602, "filesystem write denied by permission policy");
2779
- return;
2780
- }
2781
- }
2782
- try {
2783
- if (msg.method === "fs/read_text_file") {
2784
- const result = await this.fileServer.readTextFile({
2785
- sessionId: params.sessionId ?? "",
2786
- path: params.path
2787
- });
2788
- await this.sendResult(id, result);
2789
- } else {
2790
- await this.fileServer.writeTextFile({
2791
- sessionId: params.sessionId ?? "",
2792
- path: params.path,
2793
- content: params.content ?? ""
2794
- });
2795
- await this.sendResult(id, {});
2796
- }
2797
- } catch (err) {
2798
- const code = err instanceof FsError ? -32602 : -32603;
2799
- const message = err instanceof Error ? err.message : String(err);
2800
- await this.sendErrorResponse(id, code, message);
3307
+ const required = checks.filter((c) => c.name !== "fs" || opts.checkFs);
3308
+ const allReq = required.every((c) => c.ok);
3309
+ const handshakeOk = checks.find((c) => c.name === "handshake")?.ok === true;
3310
+ const status = allReq ? "pass" : handshakeOk ? "partial" : "fail";
3311
+ return {
3312
+ agentId,
3313
+ status,
3314
+ checks,
3315
+ ...agentInfo ? { agentInfo } : {},
3316
+ handshakeMs,
3317
+ ...promptMs !== void 0 ? { promptMs } : {},
3318
+ ...sample ? { sample } : {},
3319
+ ...reason ? { reason } : {},
3320
+ durationMs: opts.now() - startedAt
3321
+ };
3322
+ }
3323
+ async function runAcpBench(opts) {
3324
+ const now = opts.now ?? Date.now;
3325
+ const projectRoot = opts.projectRoot ?? process.cwd();
3326
+ const timeoutMs = opts.timeoutMs ?? 6e4;
3327
+ const checkFs = opts.checkFs ?? false;
3328
+ const marker = opts.marker ?? randomMarker();
3329
+ const concurrency = Math.max(1, opts.concurrency ?? 2);
3330
+ const seen = /* @__PURE__ */ new Set();
3331
+ const ids = [];
3332
+ for (const raw of opts.agentIds) {
3333
+ const id = raw.trim();
3334
+ if (id && !seen.has(id)) {
3335
+ seen.add(id);
3336
+ ids.push(id);
2801
3337
  }
2802
3338
  }
2803
- async handleTerminalRequest(msg) {
2804
- const id = msg.id;
2805
- if (id === void 0) return;
2806
- const params = msg.params ?? {};
2807
- try {
2808
- switch (msg.method) {
2809
- case "terminal/create": {
2810
- const allowed = await this.authorizeCallback({
2811
- toolCallId: `acp-terminal-create-${id}`,
2812
- title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
2813
- kind: "execute"
2814
- });
2815
- if (!allowed) {
2816
- await this.sendErrorResponse(id, -32602, "terminal create denied by permission policy");
2817
- return;
2818
- }
2819
- const createOpts = {
2820
- sessionId: String(params.sessionId ?? ""),
2821
- command: String(params.command ?? ""),
2822
- args: Array.isArray(params.args) ? params.args : []
2823
- };
2824
- if (Array.isArray(params.env)) {
2825
- createOpts.env = params.env;
2826
- }
2827
- if (typeof params.cwd === "string") {
2828
- createOpts.cwd = params.cwd;
2829
- }
2830
- if (typeof params.outputByteLimit === "number") {
2831
- createOpts.outputByteLimit = params.outputByteLimit;
2832
- }
2833
- const result = this.terminalServer.create(createOpts);
2834
- await this.sendResult(id, result);
2835
- return;
2836
- }
2837
- case "terminal/output": {
2838
- const terminalId = String(params.terminalId ?? "");
2839
- const out = this.terminalServer.output(terminalId);
2840
- await this.sendResult(id, out);
2841
- return;
2842
- }
2843
- case "terminal/wait_for_exit": {
2844
- const terminalId = String(params.terminalId ?? "");
2845
- const exit = await this.terminalServer.waitForExit(terminalId);
2846
- await this.sendResult(id, exit);
2847
- return;
2848
- }
2849
- case "terminal/kill": {
2850
- const terminalId = String(params.terminalId ?? "");
2851
- this.terminalServer.kill(terminalId);
2852
- await this.sendResult(id, {});
2853
- return;
2854
- }
2855
- case "terminal/release": {
2856
- const terminalId = String(params.terminalId ?? "");
2857
- this.terminalServer.release(terminalId);
2858
- await this.sendResult(id, {});
2859
- return;
3339
+ const results = ids.map((agentId) => ({
3340
+ agentId,
3341
+ status: "skipped",
3342
+ checks: [],
3343
+ durationMs: 0,
3344
+ reason: "unknown agent"
3345
+ }));
3346
+ const startMs = now();
3347
+ const runnable = [];
3348
+ ids.forEach((id, index) => {
3349
+ const cmd = opts.resolveCmd(id);
3350
+ if (cmd) runnable.push({ id, cmd, index });
3351
+ });
3352
+ let next = 0;
3353
+ const workers = [];
3354
+ const workerCount = Math.min(concurrency, runnable.length);
3355
+ for (let w = 0; w < workerCount; w++) {
3356
+ workers.push(
3357
+ (async () => {
3358
+ while (true) {
3359
+ const current = next++;
3360
+ if (current >= runnable.length) return;
3361
+ const { id, cmd, index } = runnable[current];
3362
+ if (opts.signal?.aborted) {
3363
+ results[index] = {
3364
+ agentId: id,
3365
+ status: "skipped",
3366
+ checks: [],
3367
+ durationMs: 0,
3368
+ reason: "aborted"
3369
+ };
3370
+ continue;
3371
+ }
3372
+ opts.onProgress?.(id, "start");
3373
+ const r = await benchOne(id, cmd, {
3374
+ projectRoot,
3375
+ timeoutMs,
3376
+ checkFs,
3377
+ marker,
3378
+ now,
3379
+ ...opts.signal ? { signal: opts.signal } : {}
3380
+ });
3381
+ results[index] = r;
3382
+ opts.onProgress?.(id, "done", r);
2860
3383
  }
2861
- default:
2862
- await this.sendErrorResponse(id, -32601, `unknown method: ${msg.method}`);
2863
- }
2864
- } catch (err) {
2865
- const message = err instanceof Error ? err.message : String(err);
2866
- await this.sendErrorResponse(id, -32603, message);
2867
- }
3384
+ })()
3385
+ );
2868
3386
  }
2869
- };
2870
- function textContent(text) {
2871
- return { type: "text", text };
2872
- }
2873
- function imageContent(mimeType, data) {
2874
- return { type: "image", mimeType, data };
2875
- }
2876
- function audioContent(mimeType, data) {
2877
- return { type: "audio", mimeType, data };
3387
+ await Promise.all(workers);
3388
+ const summary = { pass: 0, partial: 0, fail: 0, skipped: 0 };
3389
+ for (const r of results) summary[r.status]++;
3390
+ return { results, summary, totalDurationMs: now() - startMs };
2878
3391
  }
2879
- function extractText(block) {
2880
- if (typeof block !== "object" || block === null) return "";
2881
- const b = block;
2882
- if (b.type === "text" && typeof b.text === "string") return b.text;
2883
- if (b.type === "resource" && b.resource && typeof b.resource === "object" && typeof b.resource.text === "string") {
2884
- return b.resource.text;
3392
+ function renderAcpBenchText(result) {
3393
+ const icon = (s) => s === "pass" ? "\u2713" : s === "partial" ? "\u25D0" : s === "skipped" ? "\u2013" : "\u2717";
3394
+ const lines = ["ACP client bench:", ""];
3395
+ if (result.results.length === 0) {
3396
+ lines.push("No agents to bench.");
3397
+ return lines.join("\n");
2885
3398
  }
2886
- return "";
2887
- }
2888
- function isRecord(v) {
2889
- return typeof v === "object" && v !== null && !Array.isArray(v);
2890
- }
2891
- function emptyRunResult(stopReason) {
2892
- return {
2893
- text: "",
2894
- stopReason,
2895
- hasText: false,
2896
- toolCalls: [],
2897
- diffs: [],
2898
- thoughts: ""
2899
- };
3399
+ for (const r of result.results) {
3400
+ const checks = r.checks.map((c) => `${c.ok ? "\u2713" : "\u2717"}${c.name}`).join(" ");
3401
+ const timing = r.handshakeMs !== void 0 ? ` hs=${r.handshakeMs}ms${r.promptMs !== void 0 ? ` prompt=${r.promptMs}ms` : ""}` : "";
3402
+ lines.push(` ${icon(r.status)} ${r.agentId.padEnd(16)} ${r.status.toUpperCase().padEnd(7)} ${checks}${timing}`);
3403
+ if (r.agentInfo) lines.push(` agent: ${r.agentInfo.name} ${r.agentInfo.version}`);
3404
+ if (r.sample) lines.push(` reply: ${r.sample}`);
3405
+ if (r.reason) lines.push(` reason: ${r.reason}`);
3406
+ }
3407
+ const { pass, partial, fail, skipped } = result.summary;
3408
+ lines.push("");
3409
+ lines.push(
3410
+ `Bench summary: ${pass} pass, ${partial} partial, ${fail} fail, ${skipped} skipped. (${result.totalDurationMs}ms total)`
3411
+ );
3412
+ return lines.join("\n");
2900
3413
  }
2901
3414
 
2902
3415
  // src/registry/agents.catalog.ts
@@ -3135,50 +3648,6 @@ function findAgentDescriptor(id) {
3135
3648
  return AGENTS_CATALOG.find((a) => a.id === id);
3136
3649
  }
3137
3650
 
3138
- // src/integration/run-one-acp-task.ts
3139
- import { SubagentBudget } from "@wrongstack/core/coordination";
3140
- async function runOneAcpTask(opts) {
3141
- const role = opts.role ?? "acp";
3142
- const timeoutMs = opts.timeoutMs ?? 5 * 6e4;
3143
- const { runner, stop } = await makeACPSubagentRunnerWithStop({
3144
- command: opts.command,
3145
- ...opts.args !== void 0 ? { args: opts.args } : {},
3146
- ...opts.env !== void 0 ? { env: opts.env } : {},
3147
- ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
3148
- ...opts.projectRoot !== void 0 ? { projectRoot: opts.projectRoot } : {},
3149
- role,
3150
- timeoutMs,
3151
- ...opts.onProgress !== void 0 ? { onProgress: opts.onProgress } : {},
3152
- ...opts.permissionPolicy !== void 0 ? { permissionPolicy: opts.permissionPolicy } : {}
3153
- });
3154
- try {
3155
- const budget = new SubagentBudget({
3156
- timeoutMs,
3157
- maxIterations: 2e3,
3158
- maxToolCalls: 5e3
3159
- });
3160
- budget.start();
3161
- const ctx = {
3162
- subagentId: role,
3163
- config: { id: role, name: role, role, provider: "acp", prompt: "" },
3164
- budget,
3165
- signal: opts.signal ?? new AbortController().signal,
3166
- bridge: null
3167
- };
3168
- const result = await runner({ id: `acp-${role}`, description: opts.task }, ctx);
3169
- return {
3170
- result: result.result == null ? "" : String(result.result),
3171
- iterations: result.iterations,
3172
- toolCalls: result.toolCalls
3173
- };
3174
- } finally {
3175
- try {
3176
- await stop();
3177
- } catch {
3178
- }
3179
- }
3180
- }
3181
-
3182
3651
  // src/integration/acp-subagent-runner.ts
3183
3652
  var ACP_AGENT_COMMANDS = {
3184
3653
  cline: {
@@ -3646,93 +4115,8 @@ var EnsembleRegistry = class {
3646
4115
  }
3647
4116
  };
3648
4117
 
3649
- // src/registry/acp-registry-fetch.ts
3650
- var ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
3651
- function currentPlatformKey() {
3652
- const os = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "darwin" : "linux";
3653
- const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch;
3654
- return `${os}-${arch}`;
3655
- }
3656
- function basename(cmd) {
3657
- const cleaned = cmd.replace(/^\.\//, "").replace(/\\/g, "/");
3658
- const parts = cleaned.split("/");
3659
- return parts[parts.length - 1] || cleaned;
3660
- }
3661
- function mapRegistryEntry(entry, platformKey = currentPlatformKey()) {
3662
- if (!entry || typeof entry.id !== "string" || entry.id.length === 0) return null;
3663
- const dist = entry.distribution;
3664
- let acp = null;
3665
- if (dist?.npx?.package) {
3666
- acp = { command: "npx", args: ["-y", dist.npx.package, ...dist.npx.args ?? []] };
3667
- } else if (dist?.uvx?.package) {
3668
- acp = { command: "uvx", args: [dist.uvx.package, ...dist.uvx.args ?? []] };
3669
- } else if (dist?.binary) {
3670
- const target = dist.binary[platformKey];
3671
- if (target?.cmd) {
3672
- acp = {
3673
- command: basename(target.cmd),
3674
- args: [...target.args ?? []],
3675
- ...target.env ? { env: target.env } : {}
3676
- };
3677
- }
3678
- }
3679
- if (!acp) return null;
3680
- const probeCmd = acp.command === "npx" ? "npx" : acp.command === "uvx" ? "uvx" : acp.command;
3681
- return {
3682
- id: entry.id,
3683
- displayName: entry.name ?? entry.id,
3684
- vendor: inferVendor(entry),
3685
- probe: { command: probeCmd, args: ["--version"] },
3686
- acp,
3687
- supports: { loadSession: true, promptImages: true, terminal: true, fs: true },
3688
- integration: "native",
3689
- docs: entry.repository ?? entry.website ?? ""
3690
- };
3691
- }
3692
- function inferVendor(entry) {
3693
- const hay = `${entry.id} ${entry.name ?? ""} ${(entry.authors ?? []).join(" ")}`.toLowerCase();
3694
- if (hay.includes("anthropic") || hay.includes("claude")) return "anthropic";
3695
- if (hay.includes("google") || hay.includes("gemini")) return "google";
3696
- if (hay.includes("openai") || hay.includes("codex")) return "openai";
3697
- if (hay.includes("github") || hay.includes("copilot")) return "github";
3698
- if (hay.includes("moonshot") || hay.includes("kimi")) return "moonshot";
3699
- return "community";
3700
- }
3701
- async function fetchAcpRegistry(opts = {}) {
3702
- const url = opts.url ?? ACP_REGISTRY_URL;
3703
- const timeoutMs = opts.timeoutMs ?? 15e3;
3704
- const controller = new AbortController();
3705
- const timer = setTimeout(() => controller.abort(), timeoutMs);
3706
- const onParentAbort = () => controller.abort();
3707
- if (opts.signal) {
3708
- if (opts.signal.aborted) controller.abort();
3709
- else opts.signal.addEventListener("abort", onParentAbort, { once: true });
3710
- }
3711
- try {
3712
- const res = await fetch(url, { signal: controller.signal });
3713
- if (!res.ok) {
3714
- throw new Error(`ACP registry fetch failed: HTTP ${res.status}`);
3715
- }
3716
- const body = await res.json();
3717
- const rawAgents = Array.isArray(body) ? body : Array.isArray(body?.agents) ? body.agents : null;
3718
- if (!rawAgents) {
3719
- throw new Error("ACP registry response had no agents array");
3720
- }
3721
- const platformKey = opts.platformKey ?? currentPlatformKey();
3722
- const agents = [];
3723
- for (const raw of rawAgents) {
3724
- const mapped = mapRegistryEntry(raw, platformKey);
3725
- if (mapped) agents.push(mapped);
3726
- }
3727
- return { fetchedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString(), agents };
3728
- } finally {
3729
- clearTimeout(timer);
3730
- opts.signal?.removeEventListener("abort", onParentAbort);
3731
- }
3732
- }
3733
-
3734
4118
  // src/integration/ensemble-runner.ts
3735
- import { SubagentBudget as SubagentBudget2 } from "@wrongstack/core/coordination";
4119
+ import { SubagentBudget } from "@wrongstack/core/coordination";
3736
4120
  var DEFAULT_MAX_CONCURRENCY = 4;
3737
4121
  async function mapBound(items, worker, limit) {
3738
4122
  const results = new Array(items.length);
@@ -3768,10 +4152,13 @@ async function runOne(agentId, cmd, task, timeoutMs, signal, onProgress) {
3768
4152
  const { runner, stop } = await makeACPSubagentRunnerWithStop({
3769
4153
  ...cmd,
3770
4154
  timeoutMs,
4155
+ // CLI /acp parallel and Director fan-out are trusted local agents — grant write/execute access.
4156
+ // The session default is read-only for untrusted agents (acp-session.ts:221).
4157
+ permissionPolicy: defaultPermissionPolicy,
3771
4158
  ...onProgress ? { onProgress: (event) => onProgress(agentId, event) } : {}
3772
4159
  });
3773
4160
  try {
3774
- const budget = new SubagentBudget2({
4161
+ const budget = new SubagentBudget({
3775
4162
  timeoutMs,
3776
4163
  maxIterations: 2e3,
3777
4164
  maxToolCalls: 5e3
@@ -3948,213 +4335,133 @@ Ensemble summary: ${succeeded} succeeded, ${failed} failed, ${cancelled} cancell
3948
4335
  return lines.join("\n");
3949
4336
  }
3950
4337
 
3951
- // src/integration/acp-bench.ts
3952
- import * as fsp2 from "node:fs/promises";
3953
- import * as path3 from "node:path";
3954
- function firstLine(s) {
3955
- const line = s.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
3956
- return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
3957
- }
3958
- function randomMarker() {
3959
- return `ACP_OK_${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
3960
- }
3961
- async function benchOne(agentId, cmd, opts) {
3962
- const checks = [];
3963
- const startedAt = opts.now();
3964
- let session = null;
3965
- const signal = opts.signal ?? new AbortController().signal;
3966
- const hsStart = opts.now();
3967
- try {
3968
- session = await ACPSession.start({
3969
- command: cmd.command,
3970
- ...cmd.args !== void 0 ? { args: [...cmd.args] } : {},
3971
- ...cmd.env !== void 0 ? { env: cmd.env } : {},
3972
- projectRoot: opts.projectRoot,
3973
- timeoutMs: opts.timeoutMs
3974
- });
3975
- } catch (err) {
3976
- const reason2 = err instanceof Error ? err.message : String(err);
3977
- checks.push({ name: "handshake", ok: false, detail: reason2 });
3978
- return {
3979
- agentId,
3980
- status: "fail",
3981
- checks,
3982
- reason: reason2,
3983
- handshakeMs: opts.now() - hsStart,
3984
- durationMs: opts.now() - startedAt
3985
- };
3986
- }
3987
- const handshakeMs = opts.now() - hsStart;
3988
- const agentInfo = session.getAgentInfo() ?? void 0;
3989
- checks.push({
3990
- name: "handshake",
3991
- ok: true,
3992
- detail: agentInfo ? `${agentInfo.name} ${agentInfo.version}` : void 0
4338
+ // src/integration/run-one-acp-task.ts
4339
+ import { SubagentBudget as SubagentBudget2 } from "@wrongstack/core/coordination";
4340
+ async function runOneAcpTask(opts) {
4341
+ const role = opts.role ?? "acp";
4342
+ const timeoutMs = opts.timeoutMs ?? 5 * 6e4;
4343
+ const { runner, stop } = await makeACPSubagentRunnerWithStop({
4344
+ command: opts.command,
4345
+ ...opts.args !== void 0 ? { args: opts.args } : {},
4346
+ ...opts.env !== void 0 ? { env: opts.env } : {},
4347
+ ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
4348
+ ...opts.projectRoot !== void 0 ? { projectRoot: opts.projectRoot } : {},
4349
+ role,
4350
+ timeoutMs,
4351
+ ...opts.onProgress !== void 0 ? { onProgress: opts.onProgress } : {},
4352
+ ...opts.permissionPolicy !== void 0 ? { permissionPolicy: opts.permissionPolicy } : {}
3993
4353
  });
3994
- let promptMs;
3995
- let sample;
3996
- let reason;
3997
4354
  try {
3998
- const pStart = opts.now();
3999
- const res = await session.prompt(
4000
- [textContent(`Reply with exactly this token and nothing else: ${opts.marker}`)],
4001
- signal
4002
- );
4003
- promptMs = opts.now() - pStart;
4004
- sample = res.text ? firstLine(res.text) : void 0;
4005
- const promptOk = res.hasText && res.stopReason !== "refusal";
4006
- checks.push({
4007
- name: "prompt",
4008
- ok: promptOk,
4009
- detail: `stopReason=${res.stopReason}${res.hasText ? "" : ", no text"}`
4010
- });
4011
- const markerOk = res.text.includes(opts.marker);
4012
- checks.push({
4013
- name: "marker",
4014
- ok: markerOk,
4015
- detail: markerOk ? void 0 : "reply did not contain the token"
4355
+ const budget = new SubagentBudget2({
4356
+ timeoutMs,
4357
+ maxIterations: 2e3,
4358
+ maxToolCalls: 5e3
4016
4359
  });
4017
- if (opts.checkFs) {
4018
- const fileToken = `FILE_${opts.marker}`;
4019
- const fileName = `acp-bench-${opts.marker}.txt`;
4020
- const filePath = path3.join(opts.projectRoot, fileName);
4021
- let fsOk = false;
4022
- let fsDetail;
4023
- try {
4024
- await fsp2.writeFile(filePath, fileToken, "utf8");
4025
- const fsRes = await session.prompt(
4026
- [
4027
- textContent(
4028
- `Read the file "${fileName}" in the current directory and reply with its exact contents.`
4029
- )
4030
- ],
4031
- signal
4032
- );
4033
- fsOk = fsRes.text.includes(fileToken);
4034
- if (!fsOk) fsDetail = "agent did not return the file contents (may not have used a read tool)";
4035
- } catch (err) {
4036
- fsDetail = err instanceof Error ? err.message : String(err);
4037
- } finally {
4038
- await fsp2.rm(filePath, { force: true }).catch(() => {
4039
- });
4040
- }
4041
- checks.push({ name: "fs", ok: fsOk, detail: fsDetail });
4042
- }
4043
- } catch (err) {
4044
- reason = err instanceof Error ? err.message : String(err);
4045
- checks.push({ name: "prompt", ok: false, detail: reason });
4360
+ budget.start();
4361
+ const ctx = {
4362
+ subagentId: role,
4363
+ config: { id: role, name: role, role, provider: "acp", prompt: "" },
4364
+ budget,
4365
+ signal: opts.signal ?? new AbortController().signal,
4366
+ bridge: null
4367
+ };
4368
+ const result = await runner({ id: `acp-${role}`, description: opts.task }, ctx);
4369
+ return {
4370
+ result: result.result == null ? "" : String(result.result),
4371
+ iterations: result.iterations,
4372
+ toolCalls: result.toolCalls
4373
+ };
4046
4374
  } finally {
4047
4375
  try {
4048
- await session.close();
4376
+ await stop();
4049
4377
  } catch {
4050
4378
  }
4051
4379
  }
4052
- const required = checks.filter((c) => c.name !== "fs" || opts.checkFs);
4053
- const allReq = required.every((c) => c.ok);
4054
- const handshakeOk = checks.find((c) => c.name === "handshake")?.ok === true;
4055
- const status = allReq ? "pass" : handshakeOk ? "partial" : "fail";
4056
- return {
4057
- agentId,
4058
- status,
4059
- checks,
4060
- ...agentInfo ? { agentInfo } : {},
4061
- handshakeMs,
4062
- ...promptMs !== void 0 ? { promptMs } : {},
4063
- ...sample ? { sample } : {},
4064
- ...reason ? { reason } : {},
4065
- durationMs: opts.now() - startedAt
4066
- };
4067
4380
  }
4068
- async function runAcpBench(opts) {
4069
- const now = opts.now ?? Date.now;
4070
- const projectRoot = opts.projectRoot ?? process.cwd();
4071
- const timeoutMs = opts.timeoutMs ?? 6e4;
4072
- const checkFs = opts.checkFs ?? false;
4073
- const marker = opts.marker ?? randomMarker();
4074
- const concurrency = Math.max(1, opts.concurrency ?? 2);
4075
- const seen = /* @__PURE__ */ new Set();
4076
- const ids = [];
4077
- for (const raw of opts.agentIds) {
4078
- const id = raw.trim();
4079
- if (id && !seen.has(id)) {
4080
- seen.add(id);
4081
- ids.push(id);
4381
+
4382
+ // src/registry/acp-registry-fetch.ts
4383
+ var ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
4384
+ function currentPlatformKey() {
4385
+ const os = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "darwin" : "linux";
4386
+ const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch;
4387
+ return `${os}-${arch}`;
4388
+ }
4389
+ function basename(cmd) {
4390
+ const cleaned = cmd.replace(/^\.\//, "").replace(/\\/g, "/");
4391
+ const parts = cleaned.split("/");
4392
+ return parts[parts.length - 1] || cleaned;
4393
+ }
4394
+ function mapRegistryEntry(entry, platformKey = currentPlatformKey()) {
4395
+ if (!entry || typeof entry.id !== "string" || entry.id.length === 0) return null;
4396
+ const dist = entry.distribution;
4397
+ let acp = null;
4398
+ if (dist?.npx?.package) {
4399
+ acp = { command: "npx", args: ["-y", dist.npx.package, ...dist.npx.args ?? []] };
4400
+ } else if (dist?.uvx?.package) {
4401
+ acp = { command: "uvx", args: [dist.uvx.package, ...dist.uvx.args ?? []] };
4402
+ } else if (dist?.binary) {
4403
+ const target = dist.binary[platformKey];
4404
+ if (target?.cmd) {
4405
+ acp = {
4406
+ command: basename(target.cmd),
4407
+ args: [...target.args ?? []],
4408
+ ...target.env ? { env: target.env } : {}
4409
+ };
4082
4410
  }
4083
4411
  }
4084
- const results = ids.map((agentId) => ({
4085
- agentId,
4086
- status: "skipped",
4087
- checks: [],
4088
- durationMs: 0,
4089
- reason: "unknown agent"
4090
- }));
4091
- const startMs = now();
4092
- const runnable = [];
4093
- ids.forEach((id, index) => {
4094
- const cmd = opts.resolveCmd(id);
4095
- if (cmd) runnable.push({ id, cmd, index });
4096
- });
4097
- let next = 0;
4098
- const workers = [];
4099
- const workerCount = Math.min(concurrency, runnable.length);
4100
- for (let w = 0; w < workerCount; w++) {
4101
- workers.push(
4102
- (async () => {
4103
- while (true) {
4104
- const current = next++;
4105
- if (current >= runnable.length) return;
4106
- const { id, cmd, index } = runnable[current];
4107
- if (opts.signal?.aborted) {
4108
- results[index] = {
4109
- agentId: id,
4110
- status: "skipped",
4111
- checks: [],
4112
- durationMs: 0,
4113
- reason: "aborted"
4114
- };
4115
- continue;
4116
- }
4117
- opts.onProgress?.(id, "start");
4118
- const r = await benchOne(id, cmd, {
4119
- projectRoot,
4120
- timeoutMs,
4121
- checkFs,
4122
- marker,
4123
- now,
4124
- ...opts.signal ? { signal: opts.signal } : {}
4125
- });
4126
- results[index] = r;
4127
- opts.onProgress?.(id, "done", r);
4128
- }
4129
- })()
4130
- );
4131
- }
4132
- await Promise.all(workers);
4133
- const summary = { pass: 0, partial: 0, fail: 0, skipped: 0 };
4134
- for (const r of results) summary[r.status]++;
4135
- return { results, summary, totalDurationMs: now() - startMs };
4412
+ if (!acp) return null;
4413
+ const probeCmd = acp.command === "npx" ? "npx" : acp.command === "uvx" ? "uvx" : acp.command;
4414
+ return {
4415
+ id: entry.id,
4416
+ displayName: entry.name ?? entry.id,
4417
+ vendor: inferVendor(entry),
4418
+ probe: { command: probeCmd, args: ["--version"] },
4419
+ acp,
4420
+ supports: { loadSession: true, promptImages: true, terminal: true, fs: true },
4421
+ integration: "native",
4422
+ docs: entry.repository ?? entry.website ?? ""
4423
+ };
4136
4424
  }
4137
- function renderAcpBenchText(result) {
4138
- const icon = (s) => s === "pass" ? "\u2713" : s === "partial" ? "\u25D0" : s === "skipped" ? "\u2013" : "\u2717";
4139
- const lines = ["ACP client bench:", ""];
4140
- if (result.results.length === 0) {
4141
- lines.push("No agents to bench.");
4142
- return lines.join("\n");
4425
+ function inferVendor(entry) {
4426
+ const hay = `${entry.id} ${entry.name ?? ""} ${(entry.authors ?? []).join(" ")}`.toLowerCase();
4427
+ if (hay.includes("anthropic") || hay.includes("claude")) return "anthropic";
4428
+ if (hay.includes("google") || hay.includes("gemini")) return "google";
4429
+ if (hay.includes("openai") || hay.includes("codex")) return "openai";
4430
+ if (hay.includes("github") || hay.includes("copilot")) return "github";
4431
+ if (hay.includes("moonshot") || hay.includes("kimi")) return "moonshot";
4432
+ return "community";
4433
+ }
4434
+ async function fetchAcpRegistry(opts = {}) {
4435
+ const url = opts.url ?? ACP_REGISTRY_URL;
4436
+ const timeoutMs = opts.timeoutMs ?? 15e3;
4437
+ const controller = new AbortController();
4438
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
4439
+ const onParentAbort = () => controller.abort();
4440
+ if (opts.signal) {
4441
+ if (opts.signal.aborted) controller.abort();
4442
+ else opts.signal.addEventListener("abort", onParentAbort, { once: true });
4143
4443
  }
4144
- for (const r of result.results) {
4145
- const checks = r.checks.map((c) => `${c.ok ? "\u2713" : "\u2717"}${c.name}`).join(" ");
4146
- const timing = r.handshakeMs !== void 0 ? ` hs=${r.handshakeMs}ms${r.promptMs !== void 0 ? ` prompt=${r.promptMs}ms` : ""}` : "";
4147
- lines.push(` ${icon(r.status)} ${r.agentId.padEnd(16)} ${r.status.toUpperCase().padEnd(7)} ${checks}${timing}`);
4148
- if (r.agentInfo) lines.push(` agent: ${r.agentInfo.name} ${r.agentInfo.version}`);
4149
- if (r.sample) lines.push(` reply: ${r.sample}`);
4150
- if (r.reason) lines.push(` reason: ${r.reason}`);
4444
+ try {
4445
+ const res = await fetch(url, { signal: controller.signal });
4446
+ if (!res.ok) {
4447
+ throw new Error(`ACP registry fetch failed: HTTP ${res.status}`);
4448
+ }
4449
+ const body = await res.json();
4450
+ const rawAgents = Array.isArray(body) ? body : Array.isArray(body?.agents) ? body.agents : null;
4451
+ if (!rawAgents) {
4452
+ throw new Error("ACP registry response had no agents array");
4453
+ }
4454
+ const platformKey = opts.platformKey ?? currentPlatformKey();
4455
+ const agents = [];
4456
+ for (const raw of rawAgents) {
4457
+ const mapped = mapRegistryEntry(raw, platformKey);
4458
+ if (mapped) agents.push(mapped);
4459
+ }
4460
+ return { fetchedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString(), agents };
4461
+ } finally {
4462
+ clearTimeout(timer);
4463
+ opts.signal?.removeEventListener("abort", onParentAbort);
4151
4464
  }
4152
- const { pass, partial, fail, skipped } = result.summary;
4153
- lines.push("");
4154
- lines.push(
4155
- `Bench summary: ${pass} pass, ${partial} partial, ${fail} fail, ${skipped} skipped. (${result.totalDurationMs}ms total)`
4156
- );
4157
- return lines.join("\n");
4158
4465
  }
4159
4466
  export {
4160
4467
  ACPProtocolHandler,
@@ -4162,6 +4469,8 @@ export {
4162
4469
  ACPSessionError,
4163
4470
  ACPToolsRegistry,
4164
4471
  ACP_AGENT_COMMANDS,
4472
+ ACP_PACKAGE_VERSION,
4473
+ ACP_PROTOCOL_VERSION,
4165
4474
  ACP_REGISTRY_URL,
4166
4475
  AGENTS_CATALOG,
4167
4476
  ClientTransport,
@@ -4174,6 +4483,7 @@ export {
4174
4483
  ToolTranslator,
4175
4484
  WebSocketClientTransport,
4176
4485
  WrongStackACPServer,
4486
+ assertNeverSessionUpdate,
4177
4487
  audioContent,
4178
4488
  defaultEnsembleCmdResolver,
4179
4489
  defaultPermissionPolicy,
@@ -4183,6 +4493,7 @@ export {
4183
4493
  makeACPSubagentRunner,
4184
4494
  makeACPSubagentRunnerWithStop,
4185
4495
  makePermissionPolicy,
4496
+ makeTrustBoundaryPermissionPolicy,
4186
4497
  mapRegistryEntry,
4187
4498
  probeAcpAgent,
4188
4499
  probeAcpAgents,
@@ -4193,6 +4504,7 @@ export {
4193
4504
  runAcpBench,
4194
4505
  runEnsemble,
4195
4506
  runOneAcpTask,
4196
- textContent
4507
+ textContent,
4508
+ toTrustBoundaryRequest
4197
4509
  };
4198
4510
  //# sourceMappingURL=index.js.map