@wrongstack/acp 0.309.0 → 0.310.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,9 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  // src/types/acp-v1.ts
5
5
  var ACP_PROTOCOL_VERSION = 1;
6
6
  function assertNeverSessionUpdate(x) {
7
- throw new Error(
8
- `Unhandled sessionUpdate: ${JSON.stringify(x)}`
9
- );
7
+ throw new Error(`Unhandled sessionUpdate: ${JSON.stringify(x)}`);
10
8
  }
11
9
 
12
10
  // src/version.ts
@@ -1217,9 +1215,7 @@ var ACPToolsRegistry = class {
1217
1215
  /** Build the ACP tools/list payload from registered tools. */
1218
1216
  buildToolList() {
1219
1217
  return {
1220
- tools: Array.from(this.tools.values()).map(
1221
- (t) => toACPToolDefinition(t, this.owner)
1222
- )
1218
+ tools: Array.from(this.tools.values()).map((t) => toACPToolDefinition(t, this.owner))
1223
1219
  };
1224
1220
  }
1225
1221
  /**
@@ -1522,37 +1518,9 @@ if (isEntrypoint) {
1522
1518
  });
1523
1519
  }
1524
1520
 
1525
- // src/client/acp-session-content.ts
1526
- function textContent(text) {
1527
- return { type: "text", text };
1528
- }
1529
- function imageContent(mimeType, data) {
1530
- return { type: "image", mimeType, data };
1531
- }
1532
- function audioContent(mimeType, data) {
1533
- return { type: "audio", mimeType, data };
1534
- }
1535
- function extractText(block) {
1536
- if (typeof block !== "object" || block === null) return "";
1537
- const b = block;
1538
- if (b.type === "text" && typeof b.text === "string") return b.text;
1539
- if (b.type === "resource" && b.resource && typeof b.resource === "object" && typeof b.resource.text === "string") {
1540
- return b.resource.text;
1541
- }
1542
- return "";
1543
- }
1544
- function isRecord(v) {
1545
- return typeof v === "object" && v !== null && !Array.isArray(v);
1546
- }
1547
- function emptyRunResult(stopReason) {
1548
- return {
1549
- text: "",
1550
- stopReason,
1551
- hasText: false,
1552
- toolCalls: [],
1553
- diffs: [],
1554
- thoughts: ""
1555
- };
1521
+ // src/client/acp-message-routing.ts
1522
+ function isBestEffortAckMethod(method) {
1523
+ return method === "mcp/connect" || method === "mcp/message" || method === "mcp/disconnect" || method === "elicitation/create" || method === "elicitation/complete";
1556
1524
  }
1557
1525
 
1558
1526
  // src/client/file-server.ts
@@ -1737,610 +1705,485 @@ function safeRealpathSync(p) {
1737
1705
  }
1738
1706
  }
1739
1707
 
1740
- // src/client/permission.ts
1741
- function pickAllow(options) {
1742
- const ranked = [...options].sort((a, b) => {
1743
- const score = (k) => {
1744
- if (k === "allow_once") return 0;
1745
- if (k === "allow_always") return 1;
1746
- if (k === "reject_once") return 2;
1747
- return 3;
1748
- };
1749
- return score(a.kind) - score(b.kind);
1750
- });
1751
- const chosen = ranked[0];
1752
- if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
1753
- return { outcome: "cancelled" };
1708
+ // src/client/acp-session-callbacks.ts
1709
+ var DEFAULT_PERMISSION_TIMEOUT_MS = 6e4;
1710
+ async function handleAcpPermissionRequest(msg, permissionPolicy, sender, callbackOptions = {}) {
1711
+ const id = msg.id;
1712
+ if (id === void 0) return;
1713
+ const params = msg.params;
1714
+ const toolCall = params?.toolCall;
1715
+ const permissionOptions = Array.isArray(params?.options) ? params.options : [];
1716
+ if (!toolCall) {
1717
+ await sender.sendErrorResponse(id, -32602, "toolCall is required");
1718
+ return;
1754
1719
  }
1755
- return { outcome: "selected", optionId: chosen.optionId };
1756
- }
1757
- function pickReject(options) {
1758
- const reject = options.find(
1759
- (o) => o.kind === "reject_once" || o.kind === "reject_always"
1760
- );
1761
- return reject ? { outcome: "selected", optionId: reject.optionId } : { outcome: "cancelled" };
1762
- }
1763
- var READ_ONLY_KINDS = /* @__PURE__ */ new Set(["read", "search", "fetch", "think"]);
1764
- var defaultPermissionPolicy = async (req) => {
1765
- if (req.signal.aborted) return { outcome: "cancelled" };
1766
- return pickAllow(req.options);
1767
- };
1768
- var readOnlyPermissionPolicy = async (req) => {
1769
- if (req.signal.aborted) return { outcome: "cancelled" };
1770
- const kind = req.toolCall.kind;
1771
- if (kind && READ_ONLY_KINDS.has(kind)) {
1772
- return pickAllow(req.options);
1720
+ try {
1721
+ const outcome = await runPermissionWithDeadline(
1722
+ permissionPolicy,
1723
+ {
1724
+ toolCall,
1725
+ options: permissionOptions
1726
+ },
1727
+ callbackOptions
1728
+ );
1729
+ await sender.sendResult(id, { outcome });
1730
+ } catch (err) {
1731
+ const message = err instanceof Error ? err.message : String(err);
1732
+ const code = isAbortLikeError(err) ? -32800 : -32603;
1733
+ await sender.sendErrorResponse(id, code, `permission policy failed: ${message}`);
1773
1734
  }
1774
- return pickReject(req.options);
1775
- };
1776
- function makePermissionPolicy(decide) {
1777
- return async (req) => {
1778
- if (req.signal.aborted) return { outcome: "cancelled" };
1779
- const allow = await decide(req);
1780
- return allow ? pickAllow(req.options) : pickReject(req.options);
1781
- };
1782
1735
  }
1783
-
1784
- // src/client/terminal-server.ts
1785
- import { spawn } from "node:child_process";
1786
- import { randomBytes as randomBytes2 } from "node:crypto";
1787
- import { realpathSync as realpathSync2 } from "node:fs";
1788
- import * as path3 from "node:path";
1789
- import { buildChildEnv } from "@wrongstack/core/utils";
1790
- import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
1791
- var EMPTY_BUFFER = Buffer.alloc(0);
1792
- var DEBUG_DISPOSE = typeof process !== "undefined" && !!process.env?.WRONGSTACK_DEBUG && process.env.WRONGSTACK_DEBUG !== "0" && process.env.WRONGSTACK_DEBUG !== "false";
1793
- var TerminalServer = class {
1794
- terminals = /* @__PURE__ */ new Map();
1795
- /**
1796
- * Stable per-instance identifier for debug logs. 8 hex chars is enough
1797
- * to disambiguate concurrent TerminalServers in a trace; not meant to
1798
- * be cryptographically unique.
1799
- */
1800
- instanceId;
1801
- projectRoot;
1802
- commandTimeoutMs;
1803
- outputByteLimit;
1804
- maxOutputByteLimit;
1805
- maxTerminals;
1806
- abortSignal;
1807
- abortHandler = () => this.dispose();
1808
- disposed = false;
1809
- nextId = 1;
1810
- constructor(opts) {
1811
- this.projectRoot = path3.resolve(opts.projectRoot);
1812
- this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
1813
- this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
1814
- this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
1815
- this.instanceId = `term_srv_${randomBytes2(4).toString("hex")}`;
1816
- this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
1817
- this.abortSignal = opts.signal;
1818
- if (opts.signal) {
1819
- opts.signal.addEventListener("abort", this.abortHandler, { once: true });
1820
- }
1736
+ async function handleAcpFsRequest(msg, fileServer, permissionPolicy, sender, callbackOptions = {}) {
1737
+ const id = msg.id;
1738
+ if (id === void 0) return;
1739
+ const params = msg.params;
1740
+ if (!params?.path) {
1741
+ await sender.sendErrorResponse(id, -32602, "path is required");
1742
+ return;
1821
1743
  }
1822
- /** Spawn a new terminal. Returns the agent-facing id. */
1823
- create(params) {
1824
- if (this.disposed) {
1825
- throw new Error(
1826
- "TerminalServer is disposed \u2014 create a new TerminalServer instead of reusing this one"
1744
+ if (msg.method === "fs/write_text_file") {
1745
+ const authorization = await authorizeAcpCallback(
1746
+ permissionPolicy,
1747
+ {
1748
+ toolCallId: `acp-fs-write-${id}`,
1749
+ title: `Write file: ${params.path}`,
1750
+ kind: "edit",
1751
+ rawInput: { path: params.path, sessionId: params.sessionId }
1752
+ },
1753
+ callbackOptions
1754
+ );
1755
+ if (authorization !== "allowed") {
1756
+ const isCancelled = authorization === "cancelled";
1757
+ await sender.sendErrorResponse(
1758
+ id,
1759
+ isCancelled ? -32800 : -32602,
1760
+ isCancelled ? "filesystem write permission request cancelled or timed out" : "filesystem write denied by permission policy"
1827
1761
  );
1762
+ return;
1828
1763
  }
1829
- if (this.terminals.size >= this.maxTerminals) {
1830
- throw new Error(
1831
- `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
1832
- );
1764
+ }
1765
+ try {
1766
+ if (msg.method === "fs/read_text_file") {
1767
+ const result = await fileServer.readTextFile({
1768
+ sessionId: params.sessionId ?? "",
1769
+ path: params.path
1770
+ });
1771
+ await sender.sendResult(id, result);
1772
+ } else {
1773
+ await fileServer.writeTextFile({
1774
+ sessionId: params.sessionId ?? "",
1775
+ path: params.path,
1776
+ content: params.content ?? ""
1777
+ });
1778
+ await sender.sendResult(id, {});
1833
1779
  }
1834
- const id = `term_${this.nextId++}`;
1835
- const cwd = this.resolveCwd(params.cwd);
1836
- const perCallByteLimit = Math.min(
1837
- Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
1838
- this.maxOutputByteLimit
1839
- );
1840
- const proc = spawn(params.command, params.args ?? [], {
1841
- cwd,
1842
- env: this.buildEnv(params.env),
1843
- stdio: ["ignore", "pipe", "pipe"],
1844
- windowsHide: true
1845
- // shell: false on purpose. The terminal server is invoked with
1846
- // the agent's explicit argv; turning on shell-mode would make
1847
- // the command a single shell-parsed string, which breaks
1848
- // Windows cmd quoting for the common case of running node with
1849
- // `-e "<script>"`. If a future feature needs shell features
1850
- // (pipes, redirects), it should be opt-in per-call, not the
1851
- // default.
1852
- });
1853
- const state = {
1854
- proc,
1855
- cwd,
1856
- command: params.command,
1857
- args: params.args ?? [],
1858
- outputChunks: [],
1859
- outputHead: 0,
1860
- retainedBytes: 0,
1861
- truncated: false,
1862
- exitStatus: void 0,
1863
- timeoutHandle: null,
1864
- exitPromise: new Promise((resolve4) => {
1865
- proc.on("close", (code, signalName) => {
1866
- if (state.timeoutHandle) {
1867
- clearTimeout(state.timeoutHandle);
1868
- state.timeoutHandle = null;
1869
- }
1870
- const exitStatus = {
1871
- exitCode: typeof code === "number" ? code : null,
1872
- signal: typeof signalName === "string" ? signalName : null
1873
- };
1874
- state.exitStatus = exitStatus;
1875
- resolve4(exitStatus);
1876
- });
1877
- proc.on("error", (err) => {
1878
- if (state.timeoutHandle) {
1879
- clearTimeout(state.timeoutHandle);
1880
- state.timeoutHandle = null;
1881
- }
1882
- const exitStatus = { exitCode: 127, signal: null };
1883
- state.exitStatus = exitStatus;
1884
- let errorOutput = Buffer.from(`[spawn error] ${err.message}
1885
- `, "utf8");
1886
- if (errorOutput.length > perCallByteLimit) {
1887
- let start = errorOutput.length - perCallByteLimit;
1888
- while (start < errorOutput.length && (errorOutput[start] & 192) === 128) start++;
1889
- errorOutput = errorOutput.subarray(start);
1890
- state.truncated = true;
1891
- }
1892
- state.outputChunks.push(errorOutput);
1893
- state.retainedBytes = errorOutput.length;
1894
- resolve4(exitStatus);
1895
- });
1896
- })
1897
- };
1898
- proc.stdout?.setEncoding("utf8");
1899
- proc.stderr?.setEncoding("utf8");
1900
- const onData = (chunk) => {
1901
- const outputChunk = Buffer.from(chunk, "utf8");
1902
- state.outputChunks.push(outputChunk);
1903
- state.retainedBytes += outputChunk.length;
1904
- if (state.retainedBytes > perCallByteLimit) state.truncated = true;
1905
- while (state.retainedBytes > perCallByteLimit && state.outputHead < state.outputChunks.length) {
1906
- const first = state.outputChunks[state.outputHead];
1907
- const overflow = state.retainedBytes - perCallByteLimit;
1908
- if (first.length <= overflow) {
1909
- state.outputChunks[state.outputHead] = EMPTY_BUFFER;
1910
- state.outputHead++;
1911
- state.retainedBytes -= first.length;
1912
- continue;
1780
+ } catch (err) {
1781
+ const code = err instanceof FsError ? -32602 : -32603;
1782
+ const message = err instanceof Error ? err.message : String(err);
1783
+ await sender.sendErrorResponse(id, code, message);
1784
+ }
1785
+ }
1786
+ async function handleAcpTerminalRequest(msg, terminalServer, permissionPolicy, sender, callbackOptions = {}) {
1787
+ const id = msg.id;
1788
+ if (id === void 0) return;
1789
+ const params = msg.params ?? {};
1790
+ try {
1791
+ switch (msg.method) {
1792
+ case "terminal/create": {
1793
+ const authorization = await authorizeAcpCallback(
1794
+ permissionPolicy,
1795
+ {
1796
+ toolCallId: `acp-terminal-create-${id}`,
1797
+ title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
1798
+ kind: "execute",
1799
+ rawInput: {
1800
+ command: params.command,
1801
+ args: params.args,
1802
+ cwd: params.cwd,
1803
+ sessionId: params.sessionId
1804
+ }
1805
+ },
1806
+ callbackOptions
1807
+ );
1808
+ if (authorization !== "allowed") {
1809
+ const isCancelled = authorization === "cancelled";
1810
+ await sender.sendErrorResponse(
1811
+ id,
1812
+ isCancelled ? -32800 : -32602,
1813
+ isCancelled ? "terminal create permission request cancelled or timed out" : "terminal create denied by permission policy"
1814
+ );
1815
+ return;
1913
1816
  }
1914
- let start = overflow;
1915
- while (start < first.length && (first[start] & 192) === 128) start++;
1916
- state.outputChunks[state.outputHead] = first.subarray(start);
1917
- state.retainedBytes -= start;
1817
+ const createOpts = {
1818
+ sessionId: String(params.sessionId ?? ""),
1819
+ command: String(params.command ?? ""),
1820
+ args: Array.isArray(params.args) ? params.args : []
1821
+ };
1822
+ if (Array.isArray(params.env)) {
1823
+ createOpts.env = params.env;
1824
+ }
1825
+ if (typeof params.cwd === "string") {
1826
+ createOpts.cwd = params.cwd;
1827
+ }
1828
+ if (typeof params.outputByteLimit === "number") {
1829
+ createOpts.outputByteLimit = params.outputByteLimit;
1830
+ }
1831
+ const result = terminalServer.create(createOpts);
1832
+ await sender.sendResult(id, result);
1833
+ return;
1918
1834
  }
1919
- if (state.outputHead >= 256 && state.outputHead * 2 >= state.outputChunks.length) {
1920
- state.outputChunks = state.outputChunks.slice(state.outputHead);
1921
- state.outputHead = 0;
1835
+ case "terminal/output": {
1836
+ const terminalId = String(params.terminalId ?? "");
1837
+ const out = terminalServer.output(terminalId);
1838
+ await sender.sendResult(id, out);
1839
+ return;
1922
1840
  }
1923
- };
1924
- state.onData = onData;
1925
- proc.stdout?.on("data", onData);
1926
- proc.stderr?.on("data", onData);
1927
- state.timeoutHandle = setTimeout(() => {
1928
- treeKill2(proc);
1929
- }, this.commandTimeoutMs);
1930
- this.terminals.set(id, state);
1931
- return { terminalId: id };
1841
+ case "terminal/wait_for_exit": {
1842
+ const terminalId = String(params.terminalId ?? "");
1843
+ const exit = await terminalServer.waitForExit(terminalId);
1844
+ await sender.sendResult(id, exit);
1845
+ return;
1846
+ }
1847
+ case "terminal/kill": {
1848
+ const terminalId = String(params.terminalId ?? "");
1849
+ terminalServer.kill(terminalId);
1850
+ await sender.sendResult(id, {});
1851
+ return;
1852
+ }
1853
+ case "terminal/release": {
1854
+ const terminalId = String(params.terminalId ?? "");
1855
+ terminalServer.release(terminalId);
1856
+ await sender.sendResult(id, {});
1857
+ return;
1858
+ }
1859
+ default:
1860
+ await sender.sendErrorResponse(id, -32601, `unknown method: ${msg.method}`);
1861
+ }
1862
+ } catch (err) {
1863
+ const message = err instanceof Error ? err.message : String(err);
1864
+ await sender.sendErrorResponse(id, -32603, message);
1932
1865
  }
1933
- /** Return captured output and (if available) the exit status. */
1934
- output(terminalId) {
1935
- const state = this.terminals.get(terminalId);
1936
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1937
- return {
1938
- output: Buffer.concat(
1939
- state.outputChunks.slice(state.outputHead),
1940
- state.retainedBytes
1941
- ).toString("utf8"),
1942
- truncated: state.truncated,
1943
- ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
1944
- };
1866
+ }
1867
+ async function authorizeAcpCallback(permissionPolicy, partial, callbackOptions) {
1868
+ try {
1869
+ const outcome = await runPermissionWithDeadline(
1870
+ permissionPolicy,
1871
+ {
1872
+ toolCall: {
1873
+ sessionUpdate: "tool_call_update",
1874
+ toolCallId: partial.toolCallId,
1875
+ title: partial.title,
1876
+ kind: partial.kind,
1877
+ status: "pending",
1878
+ ...partial.rawInput ? { rawInput: partial.rawInput } : {}
1879
+ },
1880
+ options: [
1881
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
1882
+ { optionId: "reject", name: "Reject", kind: "reject_once" }
1883
+ ]
1884
+ },
1885
+ callbackOptions
1886
+ );
1887
+ return outcome.outcome === "selected" && outcome.optionId !== "reject" && outcome.optionId !== "reject_once" && outcome.optionId !== "reject_always" ? "allowed" : "denied";
1888
+ } catch (err) {
1889
+ return isAbortLikeError(err) ? "cancelled" : "denied";
1945
1890
  }
1946
- /** Block until the process exits. Resolves with the exit status. */
1947
- async waitForExit(terminalId) {
1948
- const state = this.terminals.get(terminalId);
1949
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1950
- return state.exitPromise;
1891
+ }
1892
+ async function runPermissionWithDeadline(permissionPolicy, call, callbackOptions) {
1893
+ const timeoutMs = resolvePermissionDeadline(callbackOptions.permissionTimeoutMs);
1894
+ const controller = new AbortController();
1895
+ const abort = () => controller.abort();
1896
+ const timer = timeoutMs === null ? null : setTimeout(abort, timeoutMs);
1897
+ let removeAbort;
1898
+ if (callbackOptions.signal) {
1899
+ if (callbackOptions.signal.aborted) {
1900
+ abort();
1901
+ } else {
1902
+ callbackOptions.signal.addEventListener("abort", abort, { once: true });
1903
+ removeAbort = () => callbackOptions.signal?.removeEventListener("abort", abort);
1904
+ }
1951
1905
  }
1952
- /**
1953
- * Kill the process but keep the terminal record (agent can still read output).
1954
- * On POSIX this signals only the direct child; descendants may survive because
1955
- * terminal processes are not spawned as process-group leaders.
1956
- */
1957
- kill(terminalId) {
1958
- const state = this.terminals.get(terminalId);
1959
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1960
- treeKill2(state.proc);
1906
+ try {
1907
+ return await Promise.race([
1908
+ permissionPolicy({
1909
+ toolCall: call.toolCall,
1910
+ options: call.options,
1911
+ signal: controller.signal
1912
+ }),
1913
+ rejectOnAbort(controller.signal)
1914
+ ]);
1915
+ } finally {
1916
+ if (timer !== null) clearTimeout(timer);
1917
+ removeAbort?.();
1961
1918
  }
1962
- /** Kill the process if alive and remove the record. */
1963
- release(terminalId) {
1964
- const state = this.terminals.get(terminalId);
1965
- if (!state) return;
1966
- if (state.timeoutHandle) {
1967
- clearTimeout(state.timeoutHandle);
1968
- state.timeoutHandle = null;
1969
- }
1970
- if (state.onData) {
1971
- state.proc.stdout?.off("data", state.onData);
1972
- state.proc.stderr?.off("data", state.onData);
1973
- state.onData = void 0;
1919
+ }
1920
+ function rejectOnAbort(signal) {
1921
+ return new Promise((_, reject) => {
1922
+ const rejectAbort = () => reject(new Error("permission request cancelled or timed out"));
1923
+ if (signal.aborted) {
1924
+ rejectAbort();
1925
+ return;
1974
1926
  }
1975
- state.proc.stdout?.destroy?.();
1976
- state.proc.stderr?.destroy?.();
1977
- state.outputChunks.length = 0;
1978
- state.outputHead = 0;
1979
- state.retainedBytes = 0;
1980
- treeKill2(state.proc, { force: true });
1981
- this.terminals.delete(terminalId);
1927
+ signal.addEventListener("abort", rejectAbort, { once: true });
1928
+ });
1929
+ }
1930
+ function isAbortLikeError(err) {
1931
+ return err instanceof Error && /cancelled|canceled|timed out|aborted/i.test(err.message);
1932
+ }
1933
+ function resolvePermissionDeadline(value) {
1934
+ if (value === Number.POSITIVE_INFINITY) return null;
1935
+ if (value !== void 0 && Number.isFinite(value) && value > 0) return Math.trunc(value);
1936
+ return DEFAULT_PERMISSION_TIMEOUT_MS;
1937
+ }
1938
+
1939
+ // src/client/acp-session-content.ts
1940
+ function textContent(text) {
1941
+ return { type: "text", text };
1942
+ }
1943
+ function imageContent(mimeType, data) {
1944
+ return { type: "image", mimeType, data };
1945
+ }
1946
+ function audioContent(mimeType, data) {
1947
+ return { type: "audio", mimeType, data };
1948
+ }
1949
+ function extractText(block) {
1950
+ if (typeof block !== "object" || block === null) return "";
1951
+ const b = block;
1952
+ if (b.type === "text" && typeof b.text === "string") return b.text;
1953
+ if (b.type === "resource" && b.resource && typeof b.resource === "object" && typeof b.resource.text === "string") {
1954
+ return b.resource.text;
1982
1955
  }
1983
- /**
1984
- * Release all resources held by this server: kill every active terminal
1985
- * and detach the host `AbortSignal` listener.
1986
- *
1987
- * Idempotent — calling it multiple times is safe. Required because the
1988
- * previously-coded `releaseAll()` was the only path that removed the
1989
- * abort listener: if the host never called it (unhandled error path,
1990
- * host crash, GC of the session without explicit close), the listener
1991
- * pinned `this` (terminals Map, output buffers) for the lifetime of the
1992
- * signal. With `dispose()` this is no longer leak-prone.
1993
- *
1994
- * Also exposed as `[Symbol.dispose]` for `using` blocks in Node ≥ 22.
1995
- *
1996
- * RAM-leak audit 2026-08-11, MEDIUM (Finding 2).
1997
- */
1998
- dispose() {
1999
- if (this.disposed) return;
2000
- if (DEBUG_DISPOSE) {
2001
- const activeChildren = this.terminals.size;
2002
- console.debug(
2003
- JSON.stringify({
2004
- event: "terminal_server.disposed",
2005
- instanceId: this.instanceId,
2006
- activeChildren,
2007
- hadSignal: this.abortSignal !== void 0,
2008
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2009
- })
2010
- );
2011
- }
2012
- this.disposed = true;
2013
- this.abortSignal?.removeEventListener("abort", this.abortHandler);
2014
- for (const id of [...this.terminals.keys()]) {
2015
- this.release(id);
2016
- }
2017
- }
2018
- /** Alias for `dispose()` — enables `using new TerminalServer(...)`. */
2019
- [Symbol.dispose]() {
2020
- this.dispose();
1956
+ return "";
1957
+ }
1958
+ function isRecord(v) {
1959
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1960
+ }
1961
+ function emptyRunResult(stopReason) {
1962
+ return {
1963
+ text: "",
1964
+ stopReason,
1965
+ hasText: false,
1966
+ toolCalls: [],
1967
+ diffs: [],
1968
+ thoughts: ""
1969
+ };
1970
+ }
1971
+
1972
+ // src/client/acp-session-errors.ts
1973
+ var ACPSessionError = class extends Error {
1974
+ kind;
1975
+ cause;
1976
+ constructor(kind, message, cause) {
1977
+ super(message);
1978
+ this.name = "ACPSessionError";
1979
+ this.kind = kind;
1980
+ this.cause = cause;
2021
1981
  }
2022
- /**
2023
- * Kill all active terminals. Used on session close.
2024
- *
2025
- * @deprecated Prefer `dispose()` (or `using { … }` via `Symbol.dispose`).
2026
- * `releaseAll` is retained as a delegated wrapper for callers that still
2027
- * reference it; new code should call `dispose()` directly so the
2028
- * host-signal listener is removed unconditionally.
2029
- */
2030
- releaseAll() {
2031
- this.dispose();
1982
+ };
1983
+ function isJsonRpcError(v) {
1984
+ return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
1985
+ }
1986
+
1987
+ // src/client/acp-session-ops.ts
1988
+ function filterMcpServers(agentCapabilities, servers) {
1989
+ if (!servers || servers.length === 0) return [];
1990
+ const mcpCaps = agentCapabilities.mcpCapabilities ?? {};
1991
+ return servers.filter((s) => {
1992
+ if ("type" in s && s.type === "http") return mcpCaps.http === true;
1993
+ if ("type" in s && s.type === "sse") return mcpCaps.sse === true;
1994
+ return true;
1995
+ });
1996
+ }
1997
+ async function executeLoadSession(ctx, sessionId, mcpServers, cwd) {
1998
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1999
+ if (!ctx.agentCapabilities.loadSession) {
2000
+ throw new ACPSessionError(
2001
+ "unsupported_capability",
2002
+ "agent does not support session/load (loadSession capability not advertised)"
2003
+ );
2032
2004
  }
2033
- resolveCwd(cwd) {
2034
- if (!cwd) return this.projectRoot;
2035
- const resolved = path3.resolve(cwd);
2036
- const rootWithSep = this.projectRoot.endsWith(path3.sep) ? this.projectRoot : this.projectRoot + path3.sep;
2037
- if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
2038
- return this.projectRoot;
2039
- }
2040
- try {
2041
- const realRoot = realpathSync2(this.projectRoot);
2042
- const realCwd = realpathSync2(resolved);
2043
- const realRootWithSep = realRoot.endsWith(path3.sep) ? realRoot : realRoot + path3.sep;
2044
- if (realCwd !== realRoot && !realCwd.startsWith(realRootWithSep)) {
2045
- return realRoot;
2046
- }
2047
- return realCwd;
2048
- } catch {
2049
- return this.projectRoot;
2050
- }
2005
+ if (ctx.sessionId) {
2006
+ await ctx.closeSession();
2051
2007
  }
2052
- buildEnv(agentEnv) {
2053
- const env = buildChildEnv();
2054
- if (agentEnv) {
2055
- for (const { name, value } of agentEnv) {
2056
- const upper = name.toUpperCase();
2057
- if (DENIED_AGENT_ENV_KEYS.has(upper)) continue;
2058
- env[name] = value;
2059
- }
2060
- }
2061
- return env;
2008
+ ctx.resetScratch();
2009
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2010
+ const id = ctx.allocId();
2011
+ const result = await ctx.sendRequest(id, "session/load", {
2012
+ sessionId,
2013
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2014
+ mcpServers: servers
2015
+ });
2016
+ if (isJsonRpcError(result)) {
2017
+ throw new ACPSessionError("prompt_failed", `session/load failed: ${result.message}`, result);
2062
2018
  }
2063
- /**
2064
- * Clamp an agent-supplied numeric to a finite positive safe integer, falling
2065
- * back to `defaultValue` for undefined/NaN/non-finite values. Prevents
2066
- * negative, NaN, or Infinity values from disabling output caps or causing
2067
- * unbounded memory growth.
2068
- */
2069
- clampFiniteInt(value, defaultValue) {
2070
- if (value === void 0 || !Number.isFinite(value) || value < 1) {
2071
- return defaultValue;
2072
- }
2073
- return Math.trunc(value);
2019
+ ctx.setSessionId(sessionId);
2020
+ }
2021
+ async function executeResumeSession(ctx, sessionId, mcpServers, cwd) {
2022
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2023
+ if (!ctx.agentCapabilities.sessionCapabilities?.resume) {
2024
+ throw new ACPSessionError(
2025
+ "unsupported_capability",
2026
+ "agent does not support session/resume (sessionCapabilities.resume not advertised)"
2027
+ );
2074
2028
  }
2075
- };
2076
- var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
2077
- "NODE_OPTIONS",
2078
- "LD_PRELOAD",
2079
- "LD_LIBRARY_PATH",
2080
- "DYLD_INSERT_LIBRARIES",
2081
- "DYLD_LIBRARY_PATH",
2082
- "DYLD_FALLBACK_LIBRARY_PATH",
2083
- "PATH",
2084
- "PYTHONPATH",
2085
- "PYTHONSTARTUP",
2086
- "PERL5OPT",
2087
- "PERLLIB",
2088
- "RUBYOPT",
2089
- "RUBYLIB"
2090
- ]);
2091
-
2092
- // src/client/trust-boundary-permission.ts
2093
- function pickOption(options, allowed) {
2094
- const kinds = allowed ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
2095
- for (const kind of kinds) {
2096
- const option = options.find((candidate) => candidate.kind === kind);
2097
- if (option) return { outcome: "selected", optionId: option.optionId };
2029
+ if (ctx.sessionId) {
2030
+ await ctx.closeSession();
2098
2031
  }
2099
- return { outcome: "cancelled" };
2100
- }
2101
- function riskFor(kind) {
2102
- if (kind === "read" || kind === "search" || kind === "fetch" || kind === "think") return "low";
2103
- if (kind === "edit" || kind === "move") return "elevated";
2104
- if (kind === "delete" || kind === "execute") return "high";
2105
- return "elevated";
2106
- }
2107
- function capabilityFor(request) {
2108
- const raw = request.toolCall.rawInput;
2109
- if (typeof raw?.path === "string") {
2110
- return request.toolCall.kind === "read" || request.toolCall.kind === "search" ? "filesystem.read" : "filesystem.write";
2032
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2033
+ const id = ctx.allocId();
2034
+ const result = await ctx.sendRequest(id, "session/resume", {
2035
+ sessionId,
2036
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2037
+ mcpServers: servers
2038
+ });
2039
+ if (isJsonRpcError(result)) {
2040
+ throw new ACPSessionError("prompt_failed", `session/resume failed: ${result.message}`, result);
2111
2041
  }
2112
- if (typeof raw?.command === "string" || request.toolCall.kind === "execute")
2113
- return "process.spawn";
2114
- if (request.toolCall.kind === "fetch") return "network.fetch";
2115
- return `tool.${request.toolCall.kind ?? "unknown"}`;
2042
+ ctx.setSessionId(sessionId);
2116
2043
  }
2117
- function subjectFor(request) {
2118
- const raw = request.toolCall.rawInput;
2119
- const title = request.toolCall.title ?? `ACP tool call ${String(request.toolCall.toolCallId)}`;
2120
- if (typeof raw?.path === "string") {
2121
- return { kind: "path", id: raw.path, attributes: { toolKind: request.toolCall.kind ?? null } };
2044
+ async function executeListSessions(ctx, cursor, cwd) {
2045
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2046
+ if (!ctx.agentCapabilities.sessionCapabilities?.list) {
2047
+ throw new ACPSessionError(
2048
+ "unsupported_capability",
2049
+ "agent does not support session/list (sessionCapabilities.list not advertised)"
2050
+ );
2122
2051
  }
2123
- if (typeof raw?.command === "string") {
2124
- return {
2125
- kind: "command",
2126
- id: raw.command,
2127
- attributes: { toolKind: request.toolCall.kind ?? null }
2128
- };
2052
+ const id = ctx.allocId();
2053
+ const params = {};
2054
+ if (cursor !== void 0) params.cursor = cursor;
2055
+ if (cwd !== void 0) params.cwd = cwd;
2056
+ const result = await ctx.sendRequest(id, "session/list", params);
2057
+ if (isJsonRpcError(result)) {
2058
+ throw new ACPSessionError("prompt_failed", `session/list failed: ${result.message}`, result);
2129
2059
  }
2060
+ const r = result;
2130
2061
  return {
2131
- kind: "resource",
2132
- id: title,
2133
- attributes: { toolKind: request.toolCall.kind ?? null }
2062
+ sessions: r.sessions ?? [],
2063
+ nextCursor: r.nextCursor
2134
2064
  };
2135
2065
  }
2136
- function isAllowed(decision) {
2137
- return decision.kind === "allow" || decision.kind === "scoped-token";
2066
+ async function executeDeleteSession(ctx, sessionId) {
2067
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2068
+ if (!ctx.agentCapabilities.sessionCapabilities?.delete) {
2069
+ throw new ACPSessionError(
2070
+ "unsupported_capability",
2071
+ "agent does not support session/delete (sessionCapabilities.delete not advertised)"
2072
+ );
2073
+ }
2074
+ const id = ctx.allocId();
2075
+ const result = await ctx.sendRequest(id, "session/delete", { sessionId });
2076
+ if (isJsonRpcError(result)) {
2077
+ throw new ACPSessionError("prompt_failed", `session/delete failed: ${result.message}`, result);
2078
+ }
2079
+ if (ctx.sessionId === sessionId) {
2080
+ ctx.setSessionId(null);
2081
+ }
2138
2082
  }
2139
- function toTrustBoundaryRequest(request, options) {
2140
- const rawSessionId = request.toolCall.rawInput?.sessionId;
2141
- const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
2142
- return {
2143
- version: 1,
2144
- requestId: String(request.toolCall.toolCallId),
2145
- actor: {
2146
- ...options.actor ?? { kind: "agent" },
2147
- ...sessionId ? { sessionId } : {}
2148
- },
2149
- surface: "acp",
2150
- capability: capabilityFor(request),
2151
- subject: subjectFor(request),
2152
- risk: riskFor(request.toolCall.kind),
2153
- scope: {
2154
- ...options.scope ?? {},
2155
- ...sessionId ? { sessionId } : {}
2156
- },
2157
- ...options.authContext ? { authContext: options.authContext } : {},
2158
- metadata: {
2159
- ...request.toolCall.title ? { title: request.toolCall.title } : {},
2160
- toolKind: request.toolCall.kind ?? null
2161
- }
2162
- };
2083
+ async function executeForkSession(ctx, sourceSessionId, cwd, mcpServers) {
2084
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2085
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2086
+ const id = ctx.allocId();
2087
+ const result = await ctx.sendRequest(id, "session/fork", {
2088
+ sessionId: sourceSessionId,
2089
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2090
+ ...servers.length > 0 ? { mcpServers: servers } : {}
2091
+ });
2092
+ if (isJsonRpcError(result)) {
2093
+ throw new ACPSessionError("prompt_failed", `session/fork failed: ${result.message}`, result);
2094
+ }
2095
+ const newId = result.sessionId;
2096
+ if (typeof newId !== "string" || !newId) {
2097
+ throw new ACPSessionError("protocol_error", "session/fork returned no sessionId", result);
2098
+ }
2099
+ return newId;
2163
2100
  }
2164
- function makeTrustBoundaryPermissionPolicy(options) {
2165
- return async (request) => {
2166
- if (request.signal.aborted) return { outcome: "cancelled" };
2167
- const decision = await options.boundary.evaluate(toTrustBoundaryRequest(request, options));
2168
- if (request.signal.aborted) return { outcome: "cancelled" };
2169
- return pickOption(request.options, isAllowed(decision));
2170
- };
2101
+ async function executeSetMode(ctx, sessionId, modeId) {
2102
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2103
+ const id = ctx.allocId();
2104
+ const result = await ctx.sendRequest(id, "session/set_mode", { sessionId, modeId });
2105
+ if (isJsonRpcError(result)) {
2106
+ throw new ACPSessionError(
2107
+ "prompt_failed",
2108
+ `session/set_mode failed: ${result.message}`,
2109
+ result
2110
+ );
2111
+ }
2171
2112
  }
2172
-
2173
- // src/client/websocket-transport.ts
2174
- var WebSocketClientTransport = class {
2175
- ws = null;
2176
- handlers = /* @__PURE__ */ new Set();
2177
- closed = false;
2178
- opts;
2179
- maxBufferedBytes;
2180
- maxMessageChars;
2181
- constructor(opts) {
2182
- this.opts = opts;
2183
- this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
2184
- this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
2113
+ async function executeSetConfigOption(ctx, sessionId, configId, value) {
2114
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2115
+ const id = ctx.allocId();
2116
+ const result = await ctx.sendRequest(id, "session/set_config_option", {
2117
+ sessionId,
2118
+ configId,
2119
+ value
2120
+ });
2121
+ if (isJsonRpcError(result)) {
2122
+ throw new ACPSessionError(
2123
+ "prompt_failed",
2124
+ `session/set_config_option failed: ${result.message}`,
2125
+ result
2126
+ );
2185
2127
  }
2186
- /** Pending start() promise resolve/reject — settled in stop() to avoid leaking. */
2187
- pendingStart = null;
2188
- start() {
2189
- if (this.closed || this.ws !== null || this.pendingStart !== null) {
2190
- return Promise.reject(new Error("WebSocket transport has already been started or stopped"));
2191
- }
2192
- const WS = globalThis.WebSocket;
2193
- if (!WS) {
2194
- return Promise.reject(
2195
- new Error(
2196
- "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
2197
- )
2198
- );
2199
- }
2200
- const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
2201
- return new Promise((resolve4, reject) => {
2202
- const ws = new WS(this.opts.url, this.opts.protocols);
2203
- this.ws = ws;
2204
- const timer = setTimeout(() => {
2205
- const pending = this.pendingStart;
2206
- if (pending === null) return;
2207
- this.pendingStart = null;
2208
- this.closed = true;
2209
- if (this.ws === ws) this.ws = null;
2210
- this.handlers.clear();
2211
- try {
2212
- ws.close();
2213
- } catch {
2214
- }
2215
- pending.reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
2216
- }, timeoutMs);
2217
- this.pendingStart = { resolve: resolve4, reject, timer };
2218
- ws.addEventListener("open", () => {
2219
- const pending = this.pendingStart;
2220
- if (pending === null) return;
2221
- this.pendingStart = null;
2222
- clearTimeout(pending.timer);
2223
- pending.resolve();
2224
- });
2225
- ws.addEventListener("error", (ev) => {
2226
- const pending = this.pendingStart;
2227
- if (pending === null) {
2228
- this.stop();
2229
- return;
2230
- }
2231
- this.pendingStart = null;
2232
- this.closed = true;
2233
- if (this.ws === ws) this.ws = null;
2234
- this.handlers.clear();
2235
- clearTimeout(pending.timer);
2236
- const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
2237
- pending.reject(new Error(message));
2238
- });
2239
- ws.addEventListener("close", () => {
2240
- this.closed = true;
2241
- if (this.ws === ws) this.ws = null;
2242
- this.handlers.clear();
2243
- const pending = this.pendingStart;
2244
- if (pending !== null) {
2245
- this.pendingStart = null;
2246
- clearTimeout(pending.timer);
2247
- pending.reject(new Error("WebSocket closed before the connection opened"));
2248
- }
2249
- });
2250
- ws.addEventListener("message", (ev) => {
2251
- this.onData(ev.data);
2252
- });
2253
- });
2128
+ }
2129
+ async function executeListProviders(ctx) {
2130
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2131
+ const id = ctx.allocId();
2132
+ const result = await ctx.sendRequest(id, "providers/list", {});
2133
+ if (isJsonRpcError(result)) {
2134
+ throw new ACPSessionError("prompt_failed", `providers/list failed: ${result.message}`, result);
2254
2135
  }
2255
- send(msg) {
2256
- if (this.closed || !this.ws) {
2257
- return Promise.reject(new Error("WebSocket transport is not open"));
2258
- }
2259
- try {
2260
- const serialized = JSON.stringify(msg);
2261
- const buffered = Number.isFinite(this.ws.bufferedAmount) ? this.ws.bufferedAmount : 0;
2262
- if (buffered + Buffer.byteLength(serialized, "utf8") > this.maxBufferedBytes) {
2263
- this.stop();
2264
- return Promise.reject(new Error("WebSocket transport send buffer limit exceeded"));
2265
- }
2266
- this.ws.send(serialized);
2267
- return Promise.resolve();
2268
- } catch (err) {
2269
- return Promise.reject(err instanceof Error ? err : new Error(String(err)));
2270
- }
2136
+ const r = result;
2137
+ return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
2138
+ }
2139
+ async function executeSetProvider(ctx, providerId, config) {
2140
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2141
+ const id = ctx.allocId();
2142
+ const result = await ctx.sendRequest(id, "providers/set", { providerId, ...config ?? {} });
2143
+ if (isJsonRpcError(result)) {
2144
+ throw new ACPSessionError("prompt_failed", `providers/set failed: ${result.message}`, result);
2271
2145
  }
2272
- onMessage(handler) {
2273
- this.handlers.add(handler);
2274
- return () => this.handlers.delete(handler);
2146
+ }
2147
+ async function executeDisableProvider(ctx) {
2148
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2149
+ const id = ctx.allocId();
2150
+ const result = await ctx.sendRequest(id, "providers/disable", {});
2151
+ if (isJsonRpcError(result)) {
2152
+ throw new ACPSessionError(
2153
+ "prompt_failed",
2154
+ `providers/disable failed: ${result.message}`,
2155
+ result
2156
+ );
2275
2157
  }
2276
- stop() {
2277
- this.closed = true;
2278
- this.handlers.clear();
2279
- if (this.pendingStart !== null) {
2280
- const pending = this.pendingStart;
2281
- this.pendingStart = null;
2282
- clearTimeout(pending.timer);
2283
- try {
2284
- pending.reject(new Error("WebSocket transport stopped while connecting"));
2285
- } catch {
2286
- }
2287
- }
2288
- if (this.ws) {
2289
- try {
2290
- this.ws.close();
2291
- } catch {
2292
- }
2293
- this.ws = null;
2294
- }
2158
+ }
2159
+ async function executeMcpMessage(ctx, connectionId, message) {
2160
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2161
+ const id = ctx.allocId();
2162
+ const result = await ctx.sendRequest(id, "mcp/message", { connectionId, message });
2163
+ if (isJsonRpcError(result)) {
2164
+ throw new ACPSessionError("prompt_failed", `mcp/message failed: ${result.message}`, result);
2295
2165
  }
2296
- onData(data) {
2297
- const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
2298
- if (text.length > this.maxMessageChars) {
2299
- this.stop();
2300
- return;
2301
- }
2302
- if (!text.trim()) return;
2303
- let msg;
2304
- try {
2305
- msg = JSON.parse(text);
2306
- } catch {
2307
- for (const line of text.split("\n")) {
2308
- if (!line.trim()) continue;
2309
- try {
2310
- this.dispatch(JSON.parse(line));
2311
- } catch {
2312
- }
2313
- }
2314
- return;
2315
- }
2316
- this.dispatch(msg);
2166
+ return result;
2167
+ }
2168
+ async function executeCreateSession(ctx) {
2169
+ const servers = filterMcpServers(ctx.agentCapabilities, ctx.opts.mcpServers);
2170
+ const id = ctx.allocId();
2171
+ const result = await ctx.sendRequest(id, "session/new", {
2172
+ cwd: ctx.opts.cwd ?? ctx.opts.projectRoot,
2173
+ mcpServers: servers
2174
+ });
2175
+ if (isJsonRpcError(result)) {
2176
+ throw new ACPSessionError(
2177
+ "session_create_failed",
2178
+ `session/new failed: ${result.message}`,
2179
+ result
2180
+ );
2317
2181
  }
2318
- dispatch(msg) {
2319
- for (const handler of [...this.handlers]) {
2320
- try {
2321
- handler(msg);
2322
- } catch {
2323
- }
2324
- }
2182
+ const sessionId = result.sessionId;
2183
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
2184
+ throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
2325
2185
  }
2326
- };
2327
- function finitePositiveLimit(value, fallback) {
2328
- return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
2329
- }
2330
-
2331
- // src/client/acp-session-errors.ts
2332
- var ACPSessionError = class extends Error {
2333
- kind;
2334
- cause;
2335
- constructor(kind, message, cause) {
2336
- super(message);
2337
- this.name = "ACPSessionError";
2338
- this.kind = kind;
2339
- this.cause = cause;
2340
- }
2341
- };
2342
- function isJsonRpcError(v) {
2343
- return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
2186
+ return sessionId;
2344
2187
  }
2345
2188
 
2346
2189
  // src/client/acp-session-updates.ts
@@ -2434,454 +2277,593 @@ function captureToolCall(u, isNew, scratch, emitProgress) {
2434
2277
  });
2435
2278
  }
2436
2279
 
2437
- // src/client/acp-session-callbacks.ts
2438
- var DEFAULT_PERMISSION_TIMEOUT_MS = 6e4;
2439
- async function handleAcpPermissionRequest(msg, permissionPolicy, sender, callbackOptions = {}) {
2440
- const id = msg.id;
2441
- if (id === void 0) return;
2442
- const params = msg.params;
2443
- const toolCall = params?.toolCall;
2444
- const permissionOptions = Array.isArray(params?.options) ? params.options : [];
2445
- if (!toolCall) {
2446
- await sender.sendErrorResponse(id, -32602, "toolCall is required");
2447
- return;
2280
+ // src/client/permission.ts
2281
+ function pickAllow(options) {
2282
+ const ranked = [...options].sort((a, b) => {
2283
+ const score = (k) => {
2284
+ if (k === "allow_once") return 0;
2285
+ if (k === "allow_always") return 1;
2286
+ if (k === "reject_once") return 2;
2287
+ return 3;
2288
+ };
2289
+ return score(a.kind) - score(b.kind);
2290
+ });
2291
+ const chosen = ranked[0];
2292
+ if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
2293
+ return { outcome: "cancelled" };
2448
2294
  }
2449
- try {
2450
- const outcome = await runPermissionWithDeadline(
2451
- permissionPolicy,
2452
- {
2453
- toolCall,
2454
- options: permissionOptions
2455
- },
2456
- callbackOptions
2457
- );
2458
- await sender.sendResult(id, { outcome });
2459
- } catch (err) {
2460
- const message = err instanceof Error ? err.message : String(err);
2461
- const code = isAbortLikeError(err) ? -32800 : -32603;
2462
- await sender.sendErrorResponse(id, code, `permission policy failed: ${message}`);
2295
+ return { outcome: "selected", optionId: chosen.optionId };
2296
+ }
2297
+ function pickReject(options) {
2298
+ const reject = options.find((o) => o.kind === "reject_once" || o.kind === "reject_always");
2299
+ return reject ? { outcome: "selected", optionId: reject.optionId } : { outcome: "cancelled" };
2300
+ }
2301
+ var READ_ONLY_KINDS = /* @__PURE__ */ new Set(["read", "search", "fetch", "think"]);
2302
+ var defaultPermissionPolicy = async (req) => {
2303
+ if (req.signal.aborted) return { outcome: "cancelled" };
2304
+ return pickAllow(req.options);
2305
+ };
2306
+ var readOnlyPermissionPolicy = async (req) => {
2307
+ if (req.signal.aborted) return { outcome: "cancelled" };
2308
+ const kind = req.toolCall.kind;
2309
+ if (kind && READ_ONLY_KINDS.has(kind)) {
2310
+ return pickAllow(req.options);
2463
2311
  }
2312
+ return pickReject(req.options);
2313
+ };
2314
+ function makePermissionPolicy(decide) {
2315
+ return async (req) => {
2316
+ if (req.signal.aborted) return { outcome: "cancelled" };
2317
+ const allow = await decide(req);
2318
+ return allow ? pickAllow(req.options) : pickReject(req.options);
2319
+ };
2464
2320
  }
2465
- async function handleAcpFsRequest(msg, fileServer, permissionPolicy, sender, callbackOptions = {}) {
2466
- const id = msg.id;
2467
- if (id === void 0) return;
2468
- const params = msg.params;
2469
- if (!params?.path) {
2470
- await sender.sendErrorResponse(id, -32602, "path is required");
2471
- return;
2321
+
2322
+ // src/client/terminal-server.ts
2323
+ import { spawn } from "node:child_process";
2324
+ import { randomBytes as randomBytes2 } from "node:crypto";
2325
+ import { realpathSync as realpathSync2 } from "node:fs";
2326
+ import * as path3 from "node:path";
2327
+ import { buildChildEnv } from "@wrongstack/core/utils";
2328
+ import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
2329
+ var EMPTY_BUFFER = Buffer.alloc(0);
2330
+ var DEBUG_DISPOSE = typeof process !== "undefined" && !!process.env?.WRONGSTACK_DEBUG && process.env.WRONGSTACK_DEBUG !== "0" && process.env.WRONGSTACK_DEBUG !== "false";
2331
+ var TerminalServer = class {
2332
+ terminals = /* @__PURE__ */ new Map();
2333
+ /**
2334
+ * Stable per-instance identifier for debug logs. 8 hex chars is enough
2335
+ * to disambiguate concurrent TerminalServers in a trace; not meant to
2336
+ * be cryptographically unique.
2337
+ */
2338
+ instanceId;
2339
+ projectRoot;
2340
+ commandTimeoutMs;
2341
+ outputByteLimit;
2342
+ maxOutputByteLimit;
2343
+ maxTerminals;
2344
+ abortSignal;
2345
+ abortHandler = () => this.dispose();
2346
+ disposed = false;
2347
+ nextId = 1;
2348
+ constructor(opts) {
2349
+ this.projectRoot = path3.resolve(opts.projectRoot);
2350
+ this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
2351
+ this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
2352
+ this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
2353
+ this.instanceId = `term_srv_${randomBytes2(4).toString("hex")}`;
2354
+ this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
2355
+ this.abortSignal = opts.signal;
2356
+ if (opts.signal) {
2357
+ opts.signal.addEventListener("abort", this.abortHandler, { once: true });
2358
+ }
2472
2359
  }
2473
- if (msg.method === "fs/write_text_file") {
2474
- const authorization = await authorizeAcpCallback(
2475
- permissionPolicy,
2476
- {
2477
- toolCallId: `acp-fs-write-${id}`,
2478
- title: `Write file: ${params.path}`,
2479
- kind: "edit",
2480
- rawInput: { path: params.path, sessionId: params.sessionId }
2481
- },
2482
- callbackOptions
2483
- );
2484
- if (authorization !== "allowed") {
2485
- const isCancelled = authorization === "cancelled";
2486
- await sender.sendErrorResponse(
2487
- id,
2488
- isCancelled ? -32800 : -32602,
2489
- isCancelled ? "filesystem write permission request cancelled or timed out" : "filesystem write denied by permission policy"
2360
+ /** Spawn a new terminal. Returns the agent-facing id. */
2361
+ create(params) {
2362
+ if (this.disposed) {
2363
+ throw new Error(
2364
+ "TerminalServer is disposed \u2014 create a new TerminalServer instead of reusing this one"
2490
2365
  );
2491
- return;
2492
2366
  }
2493
- }
2494
- try {
2495
- if (msg.method === "fs/read_text_file") {
2496
- const result = await fileServer.readTextFile({
2497
- sessionId: params.sessionId ?? "",
2498
- path: params.path
2499
- });
2500
- await sender.sendResult(id, result);
2501
- } else {
2502
- await fileServer.writeTextFile({
2503
- sessionId: params.sessionId ?? "",
2504
- path: params.path,
2505
- content: params.content ?? ""
2506
- });
2507
- await sender.sendResult(id, {});
2367
+ if (this.terminals.size >= this.maxTerminals) {
2368
+ throw new Error(
2369
+ `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
2370
+ );
2508
2371
  }
2509
- } catch (err) {
2510
- const code = err instanceof FsError ? -32602 : -32603;
2511
- const message = err instanceof Error ? err.message : String(err);
2512
- await sender.sendErrorResponse(id, code, message);
2513
- }
2514
- }
2515
- async function handleAcpTerminalRequest(msg, terminalServer, permissionPolicy, sender, callbackOptions = {}) {
2516
- const id = msg.id;
2517
- if (id === void 0) return;
2518
- const params = msg.params ?? {};
2519
- try {
2520
- switch (msg.method) {
2521
- case "terminal/create": {
2522
- const authorization = await authorizeAcpCallback(
2523
- permissionPolicy,
2524
- {
2525
- toolCallId: `acp-terminal-create-${id}`,
2526
- title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
2527
- kind: "execute",
2528
- rawInput: {
2529
- command: params.command,
2530
- args: params.args,
2531
- cwd: params.cwd,
2532
- sessionId: params.sessionId
2533
- }
2534
- },
2535
- callbackOptions
2536
- );
2537
- if (authorization !== "allowed") {
2538
- const isCancelled = authorization === "cancelled";
2539
- await sender.sendErrorResponse(
2540
- id,
2541
- isCancelled ? -32800 : -32602,
2542
- isCancelled ? "terminal create permission request cancelled or timed out" : "terminal create denied by permission policy"
2543
- );
2544
- return;
2545
- }
2546
- const createOpts = {
2547
- sessionId: String(params.sessionId ?? ""),
2548
- command: String(params.command ?? ""),
2549
- args: Array.isArray(params.args) ? params.args : []
2550
- };
2551
- if (Array.isArray(params.env)) {
2552
- createOpts.env = params.env;
2553
- }
2554
- if (typeof params.cwd === "string") {
2555
- createOpts.cwd = params.cwd;
2556
- }
2557
- if (typeof params.outputByteLimit === "number") {
2558
- createOpts.outputByteLimit = params.outputByteLimit;
2372
+ const id = `term_${this.nextId++}`;
2373
+ const cwd = this.resolveCwd(params.cwd);
2374
+ const perCallByteLimit = Math.min(
2375
+ Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
2376
+ this.maxOutputByteLimit
2377
+ );
2378
+ const proc = spawn(params.command, params.args ?? [], {
2379
+ cwd,
2380
+ env: this.buildEnv(params.env),
2381
+ stdio: ["ignore", "pipe", "pipe"],
2382
+ windowsHide: true
2383
+ // shell: false on purpose. The terminal server is invoked with
2384
+ // the agent's explicit argv; turning on shell-mode would make
2385
+ // the command a single shell-parsed string, which breaks
2386
+ // Windows cmd quoting for the common case of running node with
2387
+ // `-e "<script>"`. If a future feature needs shell features
2388
+ // (pipes, redirects), it should be opt-in per-call, not the
2389
+ // default.
2390
+ });
2391
+ const state = {
2392
+ proc,
2393
+ cwd,
2394
+ command: params.command,
2395
+ args: params.args ?? [],
2396
+ outputChunks: [],
2397
+ outputHead: 0,
2398
+ retainedBytes: 0,
2399
+ truncated: false,
2400
+ exitStatus: void 0,
2401
+ timeoutHandle: null,
2402
+ exitPromise: new Promise((resolve4) => {
2403
+ proc.on("close", (code, signalName) => {
2404
+ if (state.timeoutHandle) {
2405
+ clearTimeout(state.timeoutHandle);
2406
+ state.timeoutHandle = null;
2407
+ }
2408
+ const exitStatus = {
2409
+ exitCode: typeof code === "number" ? code : null,
2410
+ signal: typeof signalName === "string" ? signalName : null
2411
+ };
2412
+ state.exitStatus = exitStatus;
2413
+ resolve4(exitStatus);
2414
+ });
2415
+ proc.on("error", (err) => {
2416
+ if (state.timeoutHandle) {
2417
+ clearTimeout(state.timeoutHandle);
2418
+ state.timeoutHandle = null;
2419
+ }
2420
+ const exitStatus = { exitCode: 127, signal: null };
2421
+ state.exitStatus = exitStatus;
2422
+ let errorOutput = Buffer.from(`[spawn error] ${err.message}
2423
+ `, "utf8");
2424
+ if (errorOutput.length > perCallByteLimit) {
2425
+ let start = errorOutput.length - perCallByteLimit;
2426
+ while (start < errorOutput.length && (errorOutput[start] & 192) === 128) start++;
2427
+ errorOutput = errorOutput.subarray(start);
2428
+ state.truncated = true;
2429
+ }
2430
+ state.outputChunks.push(errorOutput);
2431
+ state.retainedBytes = errorOutput.length;
2432
+ resolve4(exitStatus);
2433
+ });
2434
+ })
2435
+ };
2436
+ proc.stdout?.setEncoding("utf8");
2437
+ proc.stderr?.setEncoding("utf8");
2438
+ const onData = (chunk) => {
2439
+ const outputChunk = Buffer.from(chunk, "utf8");
2440
+ state.outputChunks.push(outputChunk);
2441
+ state.retainedBytes += outputChunk.length;
2442
+ if (state.retainedBytes > perCallByteLimit) state.truncated = true;
2443
+ while (state.retainedBytes > perCallByteLimit && state.outputHead < state.outputChunks.length) {
2444
+ const first = state.outputChunks[state.outputHead];
2445
+ const overflow = state.retainedBytes - perCallByteLimit;
2446
+ if (first.length <= overflow) {
2447
+ state.outputChunks[state.outputHead] = EMPTY_BUFFER;
2448
+ state.outputHead++;
2449
+ state.retainedBytes -= first.length;
2450
+ continue;
2559
2451
  }
2560
- const result = terminalServer.create(createOpts);
2561
- await sender.sendResult(id, result);
2562
- return;
2563
- }
2564
- case "terminal/output": {
2565
- const terminalId = String(params.terminalId ?? "");
2566
- const out = terminalServer.output(terminalId);
2567
- await sender.sendResult(id, out);
2568
- return;
2569
- }
2570
- case "terminal/wait_for_exit": {
2571
- const terminalId = String(params.terminalId ?? "");
2572
- const exit = await terminalServer.waitForExit(terminalId);
2573
- await sender.sendResult(id, exit);
2574
- return;
2575
- }
2576
- case "terminal/kill": {
2577
- const terminalId = String(params.terminalId ?? "");
2578
- terminalServer.kill(terminalId);
2579
- await sender.sendResult(id, {});
2580
- return;
2452
+ let start = overflow;
2453
+ while (start < first.length && (first[start] & 192) === 128) start++;
2454
+ state.outputChunks[state.outputHead] = first.subarray(start);
2455
+ state.retainedBytes -= start;
2581
2456
  }
2582
- case "terminal/release": {
2583
- const terminalId = String(params.terminalId ?? "");
2584
- terminalServer.release(terminalId);
2585
- await sender.sendResult(id, {});
2586
- return;
2457
+ if (state.outputHead >= 256 && state.outputHead * 2 >= state.outputChunks.length) {
2458
+ state.outputChunks = state.outputChunks.slice(state.outputHead);
2459
+ state.outputHead = 0;
2587
2460
  }
2588
- default:
2589
- await sender.sendErrorResponse(id, -32601, `unknown method: ${msg.method}`);
2461
+ };
2462
+ state.onData = onData;
2463
+ proc.stdout?.on("data", onData);
2464
+ proc.stderr?.on("data", onData);
2465
+ state.timeoutHandle = setTimeout(() => {
2466
+ treeKill2(proc);
2467
+ }, this.commandTimeoutMs);
2468
+ this.terminals.set(id, state);
2469
+ return { terminalId: id };
2470
+ }
2471
+ /** Return captured output and (if available) the exit status. */
2472
+ output(terminalId) {
2473
+ const state = this.terminals.get(terminalId);
2474
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
2475
+ return {
2476
+ output: Buffer.concat(
2477
+ state.outputChunks.slice(state.outputHead),
2478
+ state.retainedBytes
2479
+ ).toString("utf8"),
2480
+ truncated: state.truncated,
2481
+ ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
2482
+ };
2483
+ }
2484
+ /** Block until the process exits. Resolves with the exit status. */
2485
+ async waitForExit(terminalId) {
2486
+ const state = this.terminals.get(terminalId);
2487
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
2488
+ return state.exitPromise;
2489
+ }
2490
+ /**
2491
+ * Kill the process but keep the terminal record (agent can still read output).
2492
+ * On POSIX this signals only the direct child; descendants may survive because
2493
+ * terminal processes are not spawned as process-group leaders.
2494
+ */
2495
+ kill(terminalId) {
2496
+ const state = this.terminals.get(terminalId);
2497
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
2498
+ treeKill2(state.proc);
2499
+ }
2500
+ /** Kill the process if alive and remove the record. */
2501
+ release(terminalId) {
2502
+ const state = this.terminals.get(terminalId);
2503
+ if (!state) return;
2504
+ if (state.timeoutHandle) {
2505
+ clearTimeout(state.timeoutHandle);
2506
+ state.timeoutHandle = null;
2590
2507
  }
2591
- } catch (err) {
2592
- const message = err instanceof Error ? err.message : String(err);
2593
- await sender.sendErrorResponse(id, -32603, message);
2508
+ if (state.onData) {
2509
+ state.proc.stdout?.off("data", state.onData);
2510
+ state.proc.stderr?.off("data", state.onData);
2511
+ state.onData = void 0;
2512
+ }
2513
+ state.proc.stdout?.destroy?.();
2514
+ state.proc.stderr?.destroy?.();
2515
+ state.outputChunks.length = 0;
2516
+ state.outputHead = 0;
2517
+ state.retainedBytes = 0;
2518
+ treeKill2(state.proc, { force: true });
2519
+ this.terminals.delete(terminalId);
2594
2520
  }
2595
- }
2596
- async function authorizeAcpCallback(permissionPolicy, partial, callbackOptions) {
2597
- try {
2598
- const outcome = await runPermissionWithDeadline(
2599
- permissionPolicy,
2600
- {
2601
- toolCall: {
2602
- sessionUpdate: "tool_call_update",
2603
- toolCallId: partial.toolCallId,
2604
- title: partial.title,
2605
- kind: partial.kind,
2606
- status: "pending",
2607
- ...partial.rawInput ? { rawInput: partial.rawInput } : {}
2608
- },
2609
- options: [
2610
- { optionId: "allow", name: "Allow", kind: "allow_once" },
2611
- { optionId: "reject", name: "Reject", kind: "reject_once" }
2612
- ]
2613
- },
2614
- callbackOptions
2615
- );
2616
- return outcome.outcome === "selected" && outcome.optionId !== "reject" && outcome.optionId !== "reject_once" && outcome.optionId !== "reject_always" ? "allowed" : "denied";
2617
- } catch (err) {
2618
- return isAbortLikeError(err) ? "cancelled" : "denied";
2521
+ /**
2522
+ * Release all resources held by this server: kill every active terminal
2523
+ * and detach the host `AbortSignal` listener.
2524
+ *
2525
+ * Idempotent — calling it multiple times is safe. Required because the
2526
+ * previously-coded `releaseAll()` was the only path that removed the
2527
+ * abort listener: if the host never called it (unhandled error path,
2528
+ * host crash, GC of the session without explicit close), the listener
2529
+ * pinned `this` (terminals Map, output buffers) for the lifetime of the
2530
+ * signal. With `dispose()` this is no longer leak-prone.
2531
+ *
2532
+ * Also exposed as `[Symbol.dispose]` for `using` blocks in Node ≥ 22.
2533
+ *
2534
+ * RAM-leak audit 2026-08-11, MEDIUM (Finding 2).
2535
+ */
2536
+ dispose() {
2537
+ if (this.disposed) return;
2538
+ if (DEBUG_DISPOSE) {
2539
+ const activeChildren = this.terminals.size;
2540
+ console.debug(
2541
+ JSON.stringify({
2542
+ event: "terminal_server.disposed",
2543
+ instanceId: this.instanceId,
2544
+ activeChildren,
2545
+ hadSignal: this.abortSignal !== void 0,
2546
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2547
+ })
2548
+ );
2549
+ }
2550
+ this.disposed = true;
2551
+ this.abortSignal?.removeEventListener("abort", this.abortHandler);
2552
+ for (const id of [...this.terminals.keys()]) {
2553
+ this.release(id);
2554
+ }
2555
+ }
2556
+ /** Alias for `dispose()` — enables `using new TerminalServer(...)`. */
2557
+ [Symbol.dispose]() {
2558
+ this.dispose();
2559
+ }
2560
+ /**
2561
+ * Kill all active terminals. Used on session close.
2562
+ *
2563
+ * @deprecated Prefer `dispose()` (or `using { … }` via `Symbol.dispose`).
2564
+ * `releaseAll` is retained as a delegated wrapper for callers that still
2565
+ * reference it; new code should call `dispose()` directly so the
2566
+ * host-signal listener is removed unconditionally.
2567
+ */
2568
+ releaseAll() {
2569
+ this.dispose();
2619
2570
  }
2620
- }
2621
- async function runPermissionWithDeadline(permissionPolicy, call, callbackOptions) {
2622
- const timeoutMs = resolvePermissionDeadline(callbackOptions.permissionTimeoutMs);
2623
- const controller = new AbortController();
2624
- const abort = () => controller.abort();
2625
- const timer = timeoutMs === null ? null : setTimeout(abort, timeoutMs);
2626
- let removeAbort;
2627
- if (callbackOptions.signal) {
2628
- if (callbackOptions.signal.aborted) {
2629
- abort();
2630
- } else {
2631
- callbackOptions.signal.addEventListener("abort", abort, { once: true });
2632
- removeAbort = () => callbackOptions.signal?.removeEventListener("abort", abort);
2571
+ resolveCwd(cwd) {
2572
+ if (!cwd) return this.projectRoot;
2573
+ const resolved = path3.resolve(cwd);
2574
+ const rootWithSep = this.projectRoot.endsWith(path3.sep) ? this.projectRoot : this.projectRoot + path3.sep;
2575
+ if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
2576
+ return this.projectRoot;
2577
+ }
2578
+ try {
2579
+ const realRoot = realpathSync2(this.projectRoot);
2580
+ const realCwd = realpathSync2(resolved);
2581
+ const realRootWithSep = realRoot.endsWith(path3.sep) ? realRoot : realRoot + path3.sep;
2582
+ if (realCwd !== realRoot && !realCwd.startsWith(realRootWithSep)) {
2583
+ return realRoot;
2584
+ }
2585
+ return realCwd;
2586
+ } catch {
2587
+ return this.projectRoot;
2633
2588
  }
2634
2589
  }
2635
- try {
2636
- return await Promise.race([
2637
- permissionPolicy({
2638
- toolCall: call.toolCall,
2639
- options: call.options,
2640
- signal: controller.signal
2641
- }),
2642
- rejectOnAbort(controller.signal)
2643
- ]);
2644
- } finally {
2645
- if (timer !== null) clearTimeout(timer);
2646
- removeAbort?.();
2647
- }
2648
- }
2649
- function rejectOnAbort(signal) {
2650
- return new Promise((_, reject) => {
2651
- const rejectAbort = () => reject(new Error("permission request cancelled or timed out"));
2652
- if (signal.aborted) {
2653
- rejectAbort();
2654
- return;
2590
+ buildEnv(agentEnv) {
2591
+ const env = buildChildEnv();
2592
+ if (agentEnv) {
2593
+ for (const { name, value } of agentEnv) {
2594
+ const upper = name.toUpperCase();
2595
+ if (DENIED_AGENT_ENV_KEYS.has(upper)) continue;
2596
+ env[name] = value;
2597
+ }
2655
2598
  }
2656
- signal.addEventListener("abort", rejectAbort, { once: true });
2657
- });
2658
- }
2659
- function isAbortLikeError(err) {
2660
- return err instanceof Error && /cancelled|canceled|timed out|aborted/i.test(err.message);
2661
- }
2662
- function resolvePermissionDeadline(value) {
2663
- if (value === Number.POSITIVE_INFINITY) return null;
2664
- if (value !== void 0 && Number.isFinite(value) && value > 0) return Math.trunc(value);
2665
- return DEFAULT_PERMISSION_TIMEOUT_MS;
2666
- }
2667
-
2668
- // src/client/acp-message-routing.ts
2669
- function isBestEffortAckMethod(method) {
2670
- return method === "mcp/connect" || method === "mcp/message" || method === "mcp/disconnect" || method === "elicitation/create" || method === "elicitation/complete";
2671
- }
2672
-
2673
- // src/client/acp-session-ops.ts
2674
- function filterMcpServers(agentCapabilities, servers) {
2675
- if (!servers || servers.length === 0) return [];
2676
- const mcpCaps = agentCapabilities.mcpCapabilities ?? {};
2677
- return servers.filter((s) => {
2678
- if ("type" in s && s.type === "http") return mcpCaps.http === true;
2679
- if ("type" in s && s.type === "sse") return mcpCaps.sse === true;
2680
- return true;
2681
- });
2682
- }
2683
- async function executeLoadSession(ctx, sessionId, mcpServers, cwd) {
2684
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2685
- if (!ctx.agentCapabilities.loadSession) {
2686
- throw new ACPSessionError(
2687
- "unsupported_capability",
2688
- "agent does not support session/load (loadSession capability not advertised)"
2689
- );
2599
+ return env;
2690
2600
  }
2691
- if (ctx.sessionId) {
2692
- await ctx.closeSession();
2601
+ /**
2602
+ * Clamp an agent-supplied numeric to a finite positive safe integer, falling
2603
+ * back to `defaultValue` for undefined/NaN/non-finite values. Prevents
2604
+ * negative, NaN, or Infinity values from disabling output caps or causing
2605
+ * unbounded memory growth.
2606
+ */
2607
+ clampFiniteInt(value, defaultValue) {
2608
+ if (value === void 0 || !Number.isFinite(value) || value < 1) {
2609
+ return defaultValue;
2610
+ }
2611
+ return Math.trunc(value);
2693
2612
  }
2694
- ctx.resetScratch();
2695
- const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2696
- const id = ctx.allocId();
2697
- const result = await ctx.sendRequest(id, "session/load", {
2698
- sessionId,
2699
- cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2700
- mcpServers: servers
2701
- });
2702
- if (isJsonRpcError(result)) {
2703
- throw new ACPSessionError("prompt_failed", `session/load failed: ${result.message}`, result);
2613
+ };
2614
+ var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
2615
+ "NODE_OPTIONS",
2616
+ "LD_PRELOAD",
2617
+ "LD_LIBRARY_PATH",
2618
+ "DYLD_INSERT_LIBRARIES",
2619
+ "DYLD_LIBRARY_PATH",
2620
+ "DYLD_FALLBACK_LIBRARY_PATH",
2621
+ "PATH",
2622
+ "PYTHONPATH",
2623
+ "PYTHONSTARTUP",
2624
+ "PERL5OPT",
2625
+ "PERLLIB",
2626
+ "RUBYOPT",
2627
+ "RUBYLIB"
2628
+ ]);
2629
+
2630
+ // src/client/trust-boundary-permission.ts
2631
+ function pickOption(options, allowed) {
2632
+ const kinds = allowed ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
2633
+ for (const kind of kinds) {
2634
+ const option = options.find((candidate) => candidate.kind === kind);
2635
+ if (option) return { outcome: "selected", optionId: option.optionId };
2704
2636
  }
2705
- ctx.setSessionId(sessionId);
2637
+ return { outcome: "cancelled" };
2706
2638
  }
2707
- async function executeResumeSession(ctx, sessionId, mcpServers, cwd) {
2708
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2709
- if (!ctx.agentCapabilities.sessionCapabilities?.resume) {
2710
- throw new ACPSessionError(
2711
- "unsupported_capability",
2712
- "agent does not support session/resume (sessionCapabilities.resume not advertised)"
2713
- );
2714
- }
2715
- if (ctx.sessionId) {
2716
- await ctx.closeSession();
2717
- }
2718
- const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2719
- const id = ctx.allocId();
2720
- const result = await ctx.sendRequest(id, "session/resume", {
2721
- sessionId,
2722
- cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2723
- mcpServers: servers
2724
- });
2725
- if (isJsonRpcError(result)) {
2726
- throw new ACPSessionError(
2727
- "prompt_failed",
2728
- `session/resume failed: ${result.message}`,
2729
- result
2730
- );
2639
+ function riskFor(kind) {
2640
+ if (kind === "read" || kind === "search" || kind === "fetch" || kind === "think") return "low";
2641
+ if (kind === "edit" || kind === "move") return "elevated";
2642
+ if (kind === "delete" || kind === "execute") return "high";
2643
+ return "elevated";
2644
+ }
2645
+ function capabilityFor(request) {
2646
+ const raw = request.toolCall.rawInput;
2647
+ if (typeof raw?.path === "string") {
2648
+ return request.toolCall.kind === "read" || request.toolCall.kind === "search" ? "filesystem.read" : "filesystem.write";
2731
2649
  }
2732
- ctx.setSessionId(sessionId);
2650
+ if (typeof raw?.command === "string" || request.toolCall.kind === "execute")
2651
+ return "process.spawn";
2652
+ if (request.toolCall.kind === "fetch") return "network.fetch";
2653
+ return `tool.${request.toolCall.kind ?? "unknown"}`;
2733
2654
  }
2734
- async function executeListSessions(ctx, cursor, cwd) {
2735
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2736
- if (!ctx.agentCapabilities.sessionCapabilities?.list) {
2737
- throw new ACPSessionError(
2738
- "unsupported_capability",
2739
- "agent does not support session/list (sessionCapabilities.list not advertised)"
2740
- );
2655
+ function subjectFor(request) {
2656
+ const raw = request.toolCall.rawInput;
2657
+ const title = request.toolCall.title ?? `ACP tool call ${String(request.toolCall.toolCallId)}`;
2658
+ if (typeof raw?.path === "string") {
2659
+ return { kind: "path", id: raw.path, attributes: { toolKind: request.toolCall.kind ?? null } };
2741
2660
  }
2742
- const id = ctx.allocId();
2743
- const params = {};
2744
- if (cursor !== void 0) params.cursor = cursor;
2745
- if (cwd !== void 0) params.cwd = cwd;
2746
- const result = await ctx.sendRequest(id, "session/list", params);
2747
- if (isJsonRpcError(result)) {
2748
- throw new ACPSessionError("prompt_failed", `session/list failed: ${result.message}`, result);
2661
+ if (typeof raw?.command === "string") {
2662
+ return {
2663
+ kind: "command",
2664
+ id: raw.command,
2665
+ attributes: { toolKind: request.toolCall.kind ?? null }
2666
+ };
2749
2667
  }
2750
- const r = result;
2751
2668
  return {
2752
- sessions: r.sessions ?? [],
2753
- nextCursor: r.nextCursor
2669
+ kind: "resource",
2670
+ id: title,
2671
+ attributes: { toolKind: request.toolCall.kind ?? null }
2754
2672
  };
2755
2673
  }
2756
- async function executeDeleteSession(ctx, sessionId) {
2757
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2758
- if (!ctx.agentCapabilities.sessionCapabilities?.delete) {
2759
- throw new ACPSessionError(
2760
- "unsupported_capability",
2761
- "agent does not support session/delete (sessionCapabilities.delete not advertised)"
2762
- );
2763
- }
2764
- const id = ctx.allocId();
2765
- const result = await ctx.sendRequest(id, "session/delete", { sessionId });
2766
- if (isJsonRpcError(result)) {
2767
- throw new ACPSessionError(
2768
- "prompt_failed",
2769
- `session/delete failed: ${result.message}`,
2770
- result
2771
- );
2772
- }
2773
- if (ctx.sessionId === sessionId) {
2774
- ctx.setSessionId(null);
2775
- }
2674
+ function isAllowed(decision) {
2675
+ return decision.kind === "allow" || decision.kind === "scoped-token";
2776
2676
  }
2777
- async function executeForkSession(ctx, sourceSessionId, cwd, mcpServers) {
2778
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2779
- const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2780
- const id = ctx.allocId();
2781
- const result = await ctx.sendRequest(id, "session/fork", {
2782
- sessionId: sourceSessionId,
2783
- cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2784
- ...servers.length > 0 ? { mcpServers: servers } : {}
2785
- });
2786
- if (isJsonRpcError(result)) {
2787
- throw new ACPSessionError("prompt_failed", `session/fork failed: ${result.message}`, result);
2788
- }
2789
- const newId = result.sessionId;
2790
- if (typeof newId !== "string" || !newId) {
2791
- throw new ACPSessionError("protocol_error", "session/fork returned no sessionId", result);
2792
- }
2793
- return newId;
2677
+ function toTrustBoundaryRequest(request, options) {
2678
+ const rawSessionId = request.toolCall.rawInput?.sessionId;
2679
+ const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
2680
+ return {
2681
+ version: 1,
2682
+ requestId: String(request.toolCall.toolCallId),
2683
+ actor: {
2684
+ ...options.actor ?? { kind: "agent" },
2685
+ ...sessionId ? { sessionId } : {}
2686
+ },
2687
+ surface: "acp",
2688
+ capability: capabilityFor(request),
2689
+ subject: subjectFor(request),
2690
+ risk: riskFor(request.toolCall.kind),
2691
+ scope: {
2692
+ ...options.scope ?? {},
2693
+ ...sessionId ? { sessionId } : {}
2694
+ },
2695
+ ...options.authContext ? { authContext: options.authContext } : {},
2696
+ metadata: {
2697
+ ...request.toolCall.title ? { title: request.toolCall.title } : {},
2698
+ toolKind: request.toolCall.kind ?? null
2699
+ }
2700
+ };
2794
2701
  }
2795
- async function executeSetMode(ctx, sessionId, modeId) {
2796
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2797
- const id = ctx.allocId();
2798
- const result = await ctx.sendRequest(id, "session/set_mode", { sessionId, modeId });
2799
- if (isJsonRpcError(result)) {
2800
- throw new ACPSessionError(
2801
- "prompt_failed",
2802
- `session/set_mode failed: ${result.message}`,
2803
- result
2804
- );
2805
- }
2702
+ function makeTrustBoundaryPermissionPolicy(options) {
2703
+ return async (request) => {
2704
+ if (request.signal.aborted) return { outcome: "cancelled" };
2705
+ const decision = await options.boundary.evaluate(toTrustBoundaryRequest(request, options));
2706
+ if (request.signal.aborted) return { outcome: "cancelled" };
2707
+ return pickOption(request.options, isAllowed(decision));
2708
+ };
2806
2709
  }
2807
- async function executeSetConfigOption(ctx, sessionId, configId, value) {
2808
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2809
- const id = ctx.allocId();
2810
- const result = await ctx.sendRequest(id, "session/set_config_option", {
2811
- sessionId,
2812
- configId,
2813
- value
2814
- });
2815
- if (isJsonRpcError(result)) {
2816
- throw new ACPSessionError(
2817
- "prompt_failed",
2818
- `session/set_config_option failed: ${result.message}`,
2819
- result
2820
- );
2710
+
2711
+ // src/client/websocket-transport.ts
2712
+ var WebSocketClientTransport = class {
2713
+ ws = null;
2714
+ handlers = /* @__PURE__ */ new Set();
2715
+ closed = false;
2716
+ opts;
2717
+ maxBufferedBytes;
2718
+ maxMessageChars;
2719
+ constructor(opts) {
2720
+ this.opts = opts;
2721
+ this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
2722
+ this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
2821
2723
  }
2822
- }
2823
- async function executeListProviders(ctx) {
2824
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2825
- const id = ctx.allocId();
2826
- const result = await ctx.sendRequest(id, "providers/list", {});
2827
- if (isJsonRpcError(result)) {
2828
- throw new ACPSessionError(
2829
- "prompt_failed",
2830
- `providers/list failed: ${result.message}`,
2831
- result
2832
- );
2724
+ /** Pending start() promise resolve/reject — settled in stop() to avoid leaking. */
2725
+ pendingStart = null;
2726
+ start() {
2727
+ if (this.closed || this.ws !== null || this.pendingStart !== null) {
2728
+ return Promise.reject(new Error("WebSocket transport has already been started or stopped"));
2729
+ }
2730
+ const WS = globalThis.WebSocket;
2731
+ if (!WS) {
2732
+ return Promise.reject(
2733
+ new Error(
2734
+ "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
2735
+ )
2736
+ );
2737
+ }
2738
+ const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
2739
+ return new Promise((resolve4, reject) => {
2740
+ const ws = new WS(this.opts.url, this.opts.protocols);
2741
+ this.ws = ws;
2742
+ const timer = setTimeout(() => {
2743
+ const pending = this.pendingStart;
2744
+ if (pending === null) return;
2745
+ this.pendingStart = null;
2746
+ this.closed = true;
2747
+ if (this.ws === ws) this.ws = null;
2748
+ this.handlers.clear();
2749
+ try {
2750
+ ws.close();
2751
+ } catch {
2752
+ }
2753
+ pending.reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
2754
+ }, timeoutMs);
2755
+ this.pendingStart = { resolve: resolve4, reject, timer };
2756
+ ws.addEventListener("open", () => {
2757
+ const pending = this.pendingStart;
2758
+ if (pending === null) return;
2759
+ this.pendingStart = null;
2760
+ clearTimeout(pending.timer);
2761
+ pending.resolve();
2762
+ });
2763
+ ws.addEventListener("error", (ev) => {
2764
+ const pending = this.pendingStart;
2765
+ if (pending === null) {
2766
+ this.stop();
2767
+ return;
2768
+ }
2769
+ this.pendingStart = null;
2770
+ this.closed = true;
2771
+ if (this.ws === ws) this.ws = null;
2772
+ this.handlers.clear();
2773
+ clearTimeout(pending.timer);
2774
+ const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
2775
+ pending.reject(new Error(message));
2776
+ });
2777
+ ws.addEventListener("close", () => {
2778
+ this.closed = true;
2779
+ if (this.ws === ws) this.ws = null;
2780
+ this.handlers.clear();
2781
+ const pending = this.pendingStart;
2782
+ if (pending !== null) {
2783
+ this.pendingStart = null;
2784
+ clearTimeout(pending.timer);
2785
+ pending.reject(new Error("WebSocket closed before the connection opened"));
2786
+ }
2787
+ });
2788
+ ws.addEventListener("message", (ev) => {
2789
+ this.onData(ev.data);
2790
+ });
2791
+ });
2833
2792
  }
2834
- const r = result;
2835
- return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
2836
- }
2837
- async function executeSetProvider(ctx, providerId, config) {
2838
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2839
- const id = ctx.allocId();
2840
- const result = await ctx.sendRequest(id, "providers/set", { providerId, ...config ?? {} });
2841
- if (isJsonRpcError(result)) {
2842
- throw new ACPSessionError("prompt_failed", `providers/set failed: ${result.message}`, result);
2793
+ send(msg) {
2794
+ if (this.closed || !this.ws) {
2795
+ return Promise.reject(new Error("WebSocket transport is not open"));
2796
+ }
2797
+ try {
2798
+ const serialized = JSON.stringify(msg);
2799
+ const buffered = Number.isFinite(this.ws.bufferedAmount) ? this.ws.bufferedAmount : 0;
2800
+ if (buffered + Buffer.byteLength(serialized, "utf8") > this.maxBufferedBytes) {
2801
+ this.stop();
2802
+ return Promise.reject(new Error("WebSocket transport send buffer limit exceeded"));
2803
+ }
2804
+ this.ws.send(serialized);
2805
+ return Promise.resolve();
2806
+ } catch (err) {
2807
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
2808
+ }
2843
2809
  }
2844
- }
2845
- async function executeDisableProvider(ctx) {
2846
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2847
- const id = ctx.allocId();
2848
- const result = await ctx.sendRequest(id, "providers/disable", {});
2849
- if (isJsonRpcError(result)) {
2850
- throw new ACPSessionError(
2851
- "prompt_failed",
2852
- `providers/disable failed: ${result.message}`,
2853
- result
2854
- );
2810
+ onMessage(handler) {
2811
+ this.handlers.add(handler);
2812
+ return () => this.handlers.delete(handler);
2855
2813
  }
2856
- }
2857
- async function executeMcpMessage(ctx, connectionId, message) {
2858
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2859
- const id = ctx.allocId();
2860
- const result = await ctx.sendRequest(id, "mcp/message", { connectionId, message });
2861
- if (isJsonRpcError(result)) {
2862
- throw new ACPSessionError("prompt_failed", `mcp/message failed: ${result.message}`, result);
2814
+ stop() {
2815
+ this.closed = true;
2816
+ this.handlers.clear();
2817
+ if (this.pendingStart !== null) {
2818
+ const pending = this.pendingStart;
2819
+ this.pendingStart = null;
2820
+ clearTimeout(pending.timer);
2821
+ try {
2822
+ pending.reject(new Error("WebSocket transport stopped while connecting"));
2823
+ } catch {
2824
+ }
2825
+ }
2826
+ if (this.ws) {
2827
+ try {
2828
+ this.ws.close();
2829
+ } catch {
2830
+ }
2831
+ this.ws = null;
2832
+ }
2863
2833
  }
2864
- return result;
2865
- }
2866
- async function executeCreateSession(ctx) {
2867
- const servers = filterMcpServers(ctx.agentCapabilities, ctx.opts.mcpServers);
2868
- const id = ctx.allocId();
2869
- const result = await ctx.sendRequest(id, "session/new", {
2870
- cwd: ctx.opts.cwd ?? ctx.opts.projectRoot,
2871
- mcpServers: servers
2872
- });
2873
- if (isJsonRpcError(result)) {
2874
- throw new ACPSessionError(
2875
- "session_create_failed",
2876
- `session/new failed: ${result.message}`,
2877
- result
2878
- );
2834
+ onData(data) {
2835
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
2836
+ if (text.length > this.maxMessageChars) {
2837
+ this.stop();
2838
+ return;
2839
+ }
2840
+ if (!text.trim()) return;
2841
+ let msg;
2842
+ try {
2843
+ msg = JSON.parse(text);
2844
+ } catch {
2845
+ for (const line of text.split("\n")) {
2846
+ if (!line.trim()) continue;
2847
+ try {
2848
+ this.dispatch(JSON.parse(line));
2849
+ } catch {
2850
+ }
2851
+ }
2852
+ return;
2853
+ }
2854
+ this.dispatch(msg);
2879
2855
  }
2880
- const sessionId = result.sessionId;
2881
- if (typeof sessionId !== "string" || sessionId.length === 0) {
2882
- throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
2856
+ dispatch(msg) {
2857
+ for (const handler of [...this.handlers]) {
2858
+ try {
2859
+ handler(msg);
2860
+ } catch {
2861
+ }
2862
+ }
2883
2863
  }
2884
- return sessionId;
2864
+ };
2865
+ function finitePositiveLimit(value, fallback) {
2866
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
2885
2867
  }
2886
2868
 
2887
2869
  // src/client/acp-session.ts
@@ -4005,11 +3987,7 @@ async function makeACPSubagentRunnerWithStop(options) {
4005
3987
  options.onProgress?.(event);
4006
3988
  };
4007
3989
  try {
4008
- const result = await session.prompt(
4009
- [textContent(task.description)],
4010
- ctx.signal,
4011
- onProgress
4012
- );
3990
+ const result = await session.prompt([textContent(task.description)], ctx.signal, onProgress);
4013
3991
  return {
4014
3992
  result: result.text,
4015
3993
  iterations: 1,
@@ -4366,11 +4344,7 @@ var EnsembleRegistry = class {
4366
4344
  if (this.cache && Date.now() - this.cache.at < PROBE_CACHE_MS) {
4367
4345
  return this.cache.result;
4368
4346
  }
4369
- const result = await probeWithBound(
4370
- this.catalog,
4371
- (d) => this.detect(d),
4372
- MAX_PARALLEL_PROBES
4373
- );
4347
+ const result = await probeWithBound(this.catalog, (d) => this.detect(d), MAX_PARALLEL_PROBES);
4374
4348
  this.cache = { at: Date.now(), result };
4375
4349
  return result;
4376
4350
  }