@wrongstack/acp 0.309.1 → 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/client.js CHANGED
@@ -254,37 +254,9 @@ function verbatimOptions(invocation) {
254
254
  // src/types/acp-v1.ts
255
255
  var ACP_PROTOCOL_VERSION = 1;
256
256
 
257
- // src/client/acp-session-content.ts
258
- function textContent(text) {
259
- return { type: "text", text };
260
- }
261
- function imageContent(mimeType, data) {
262
- return { type: "image", mimeType, data };
263
- }
264
- function audioContent(mimeType, data) {
265
- return { type: "audio", mimeType, data };
266
- }
267
- function extractText(block) {
268
- if (typeof block !== "object" || block === null) return "";
269
- const b = block;
270
- if (b.type === "text" && typeof b.text === "string") return b.text;
271
- if (b.type === "resource" && b.resource && typeof b.resource === "object" && typeof b.resource.text === "string") {
272
- return b.resource.text;
273
- }
274
- return "";
275
- }
276
- function isRecord(v) {
277
- return typeof v === "object" && v !== null && !Array.isArray(v);
278
- }
279
- function emptyRunResult(stopReason) {
280
- return {
281
- text: "",
282
- stopReason,
283
- hasText: false,
284
- toolCalls: [],
285
- diffs: [],
286
- thoughts: ""
287
- };
257
+ // src/client/acp-message-routing.ts
258
+ function isBestEffortAckMethod(method) {
259
+ return method === "mcp/connect" || method === "mcp/message" || method === "mcp/disconnect" || method === "elicitation/create" || method === "elicitation/complete";
288
260
  }
289
261
 
290
262
  // src/client/file-server.ts
@@ -469,622 +441,497 @@ function safeRealpathSync(p) {
469
441
  }
470
442
  }
471
443
 
472
- // src/client/permission.ts
473
- function pickAllow(options) {
474
- const ranked = [...options].sort((a, b) => {
475
- const score = (k) => {
476
- if (k === "allow_once") return 0;
477
- if (k === "allow_always") return 1;
478
- if (k === "reject_once") return 2;
479
- return 3;
480
- };
481
- return score(a.kind) - score(b.kind);
482
- });
483
- const chosen = ranked[0];
484
- if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
485
- return { outcome: "cancelled" };
444
+ // src/client/acp-session-callbacks.ts
445
+ var DEFAULT_PERMISSION_TIMEOUT_MS = 6e4;
446
+ async function handleAcpPermissionRequest(msg, permissionPolicy, sender, callbackOptions = {}) {
447
+ const id = msg.id;
448
+ if (id === void 0) return;
449
+ const params = msg.params;
450
+ const toolCall = params?.toolCall;
451
+ const permissionOptions = Array.isArray(params?.options) ? params.options : [];
452
+ if (!toolCall) {
453
+ await sender.sendErrorResponse(id, -32602, "toolCall is required");
454
+ return;
486
455
  }
487
- return { outcome: "selected", optionId: chosen.optionId };
488
- }
489
- function pickReject(options) {
490
- const reject = options.find(
491
- (o) => o.kind === "reject_once" || o.kind === "reject_always"
492
- );
493
- return reject ? { outcome: "selected", optionId: reject.optionId } : { outcome: "cancelled" };
494
- }
495
- var READ_ONLY_KINDS = /* @__PURE__ */ new Set(["read", "search", "fetch", "think"]);
496
- var defaultPermissionPolicy = async (req) => {
497
- if (req.signal.aborted) return { outcome: "cancelled" };
498
- return pickAllow(req.options);
499
- };
500
- var readOnlyPermissionPolicy = async (req) => {
501
- if (req.signal.aborted) return { outcome: "cancelled" };
502
- const kind = req.toolCall.kind;
503
- if (kind && READ_ONLY_KINDS.has(kind)) {
504
- return pickAllow(req.options);
456
+ try {
457
+ const outcome = await runPermissionWithDeadline(
458
+ permissionPolicy,
459
+ {
460
+ toolCall,
461
+ options: permissionOptions
462
+ },
463
+ callbackOptions
464
+ );
465
+ await sender.sendResult(id, { outcome });
466
+ } catch (err) {
467
+ const message = err instanceof Error ? err.message : String(err);
468
+ const code = isAbortLikeError(err) ? -32800 : -32603;
469
+ await sender.sendErrorResponse(id, code, `permission policy failed: ${message}`);
505
470
  }
506
- return pickReject(req.options);
507
- };
508
- function makePermissionPolicy(decide) {
509
- return async (req) => {
510
- if (req.signal.aborted) return { outcome: "cancelled" };
511
- const allow = await decide(req);
512
- return allow ? pickAllow(req.options) : pickReject(req.options);
513
- };
514
471
  }
515
-
516
- // src/client/terminal-server.ts
517
- import { spawn } from "node:child_process";
518
- import { randomBytes as randomBytes2 } from "node:crypto";
519
- import { realpathSync as realpathSync2 } from "node:fs";
520
- import * as path2 from "node:path";
521
- import { buildChildEnv } from "@wrongstack/core/utils";
522
- import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
523
- var EMPTY_BUFFER = Buffer.alloc(0);
524
- var DEBUG_DISPOSE = typeof process !== "undefined" && !!process.env?.WRONGSTACK_DEBUG && process.env.WRONGSTACK_DEBUG !== "0" && process.env.WRONGSTACK_DEBUG !== "false";
525
- var TerminalServer = class {
526
- terminals = /* @__PURE__ */ new Map();
527
- /**
528
- * Stable per-instance identifier for debug logs. 8 hex chars is enough
529
- * to disambiguate concurrent TerminalServers in a trace; not meant to
530
- * be cryptographically unique.
531
- */
532
- instanceId;
533
- projectRoot;
534
- commandTimeoutMs;
535
- outputByteLimit;
536
- maxOutputByteLimit;
537
- maxTerminals;
538
- abortSignal;
539
- abortHandler = () => this.dispose();
540
- disposed = false;
541
- nextId = 1;
542
- constructor(opts) {
543
- this.projectRoot = path2.resolve(opts.projectRoot);
544
- this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
545
- this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
546
- this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
547
- this.instanceId = `term_srv_${randomBytes2(4).toString("hex")}`;
548
- this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
549
- this.abortSignal = opts.signal;
550
- if (opts.signal) {
551
- opts.signal.addEventListener("abort", this.abortHandler, { once: true });
552
- }
472
+ async function handleAcpFsRequest(msg, fileServer, permissionPolicy, sender, callbackOptions = {}) {
473
+ const id = msg.id;
474
+ if (id === void 0) return;
475
+ const params = msg.params;
476
+ if (!params?.path) {
477
+ await sender.sendErrorResponse(id, -32602, "path is required");
478
+ return;
553
479
  }
554
- /** Spawn a new terminal. Returns the agent-facing id. */
555
- create(params) {
556
- if (this.disposed) {
557
- throw new Error(
558
- "TerminalServer is disposed \u2014 create a new TerminalServer instead of reusing this one"
480
+ if (msg.method === "fs/write_text_file") {
481
+ const authorization = await authorizeAcpCallback(
482
+ permissionPolicy,
483
+ {
484
+ toolCallId: `acp-fs-write-${id}`,
485
+ title: `Write file: ${params.path}`,
486
+ kind: "edit",
487
+ rawInput: { path: params.path, sessionId: params.sessionId }
488
+ },
489
+ callbackOptions
490
+ );
491
+ if (authorization !== "allowed") {
492
+ const isCancelled = authorization === "cancelled";
493
+ await sender.sendErrorResponse(
494
+ id,
495
+ isCancelled ? -32800 : -32602,
496
+ isCancelled ? "filesystem write permission request cancelled or timed out" : "filesystem write denied by permission policy"
559
497
  );
498
+ return;
560
499
  }
561
- if (this.terminals.size >= this.maxTerminals) {
562
- throw new Error(
563
- `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
564
- );
500
+ }
501
+ try {
502
+ if (msg.method === "fs/read_text_file") {
503
+ const result = await fileServer.readTextFile({
504
+ sessionId: params.sessionId ?? "",
505
+ path: params.path
506
+ });
507
+ await sender.sendResult(id, result);
508
+ } else {
509
+ await fileServer.writeTextFile({
510
+ sessionId: params.sessionId ?? "",
511
+ path: params.path,
512
+ content: params.content ?? ""
513
+ });
514
+ await sender.sendResult(id, {});
565
515
  }
566
- const id = `term_${this.nextId++}`;
567
- const cwd = this.resolveCwd(params.cwd);
568
- const perCallByteLimit = Math.min(
569
- Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
570
- this.maxOutputByteLimit
571
- );
572
- const proc = spawn(params.command, params.args ?? [], {
573
- cwd,
574
- env: this.buildEnv(params.env),
575
- stdio: ["ignore", "pipe", "pipe"],
576
- windowsHide: true
577
- // shell: false on purpose. The terminal server is invoked with
578
- // the agent's explicit argv; turning on shell-mode would make
579
- // the command a single shell-parsed string, which breaks
580
- // Windows cmd quoting for the common case of running node with
581
- // `-e "<script>"`. If a future feature needs shell features
582
- // (pipes, redirects), it should be opt-in per-call, not the
583
- // default.
584
- });
585
- const state = {
586
- proc,
587
- cwd,
588
- command: params.command,
589
- args: params.args ?? [],
590
- outputChunks: [],
591
- outputHead: 0,
592
- retainedBytes: 0,
593
- truncated: false,
594
- exitStatus: void 0,
595
- timeoutHandle: null,
596
- exitPromise: new Promise((resolve3) => {
597
- proc.on("close", (code, signalName) => {
598
- if (state.timeoutHandle) {
599
- clearTimeout(state.timeoutHandle);
600
- state.timeoutHandle = null;
601
- }
602
- const exitStatus = {
603
- exitCode: typeof code === "number" ? code : null,
604
- signal: typeof signalName === "string" ? signalName : null
605
- };
606
- state.exitStatus = exitStatus;
607
- resolve3(exitStatus);
608
- });
609
- proc.on("error", (err) => {
610
- if (state.timeoutHandle) {
611
- clearTimeout(state.timeoutHandle);
612
- state.timeoutHandle = null;
613
- }
614
- const exitStatus = { exitCode: 127, signal: null };
615
- state.exitStatus = exitStatus;
616
- let errorOutput = Buffer.from(`[spawn error] ${err.message}
617
- `, "utf8");
618
- if (errorOutput.length > perCallByteLimit) {
619
- let start = errorOutput.length - perCallByteLimit;
620
- while (start < errorOutput.length && (errorOutput[start] & 192) === 128) start++;
621
- errorOutput = errorOutput.subarray(start);
622
- state.truncated = true;
623
- }
624
- state.outputChunks.push(errorOutput);
625
- state.retainedBytes = errorOutput.length;
626
- resolve3(exitStatus);
627
- });
628
- })
629
- };
630
- proc.stdout?.setEncoding("utf8");
631
- proc.stderr?.setEncoding("utf8");
632
- const onData = (chunk) => {
633
- const outputChunk = Buffer.from(chunk, "utf8");
634
- state.outputChunks.push(outputChunk);
635
- state.retainedBytes += outputChunk.length;
636
- if (state.retainedBytes > perCallByteLimit) state.truncated = true;
637
- while (state.retainedBytes > perCallByteLimit && state.outputHead < state.outputChunks.length) {
638
- const first = state.outputChunks[state.outputHead];
639
- const overflow = state.retainedBytes - perCallByteLimit;
640
- if (first.length <= overflow) {
641
- state.outputChunks[state.outputHead] = EMPTY_BUFFER;
642
- state.outputHead++;
643
- state.retainedBytes -= first.length;
644
- continue;
516
+ } catch (err) {
517
+ const code = err instanceof FsError ? -32602 : -32603;
518
+ const message = err instanceof Error ? err.message : String(err);
519
+ await sender.sendErrorResponse(id, code, message);
520
+ }
521
+ }
522
+ async function handleAcpTerminalRequest(msg, terminalServer, permissionPolicy, sender, callbackOptions = {}) {
523
+ const id = msg.id;
524
+ if (id === void 0) return;
525
+ const params = msg.params ?? {};
526
+ try {
527
+ switch (msg.method) {
528
+ case "terminal/create": {
529
+ const authorization = await authorizeAcpCallback(
530
+ permissionPolicy,
531
+ {
532
+ toolCallId: `acp-terminal-create-${id}`,
533
+ title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
534
+ kind: "execute",
535
+ rawInput: {
536
+ command: params.command,
537
+ args: params.args,
538
+ cwd: params.cwd,
539
+ sessionId: params.sessionId
540
+ }
541
+ },
542
+ callbackOptions
543
+ );
544
+ if (authorization !== "allowed") {
545
+ const isCancelled = authorization === "cancelled";
546
+ await sender.sendErrorResponse(
547
+ id,
548
+ isCancelled ? -32800 : -32602,
549
+ isCancelled ? "terminal create permission request cancelled or timed out" : "terminal create denied by permission policy"
550
+ );
551
+ return;
645
552
  }
646
- let start = overflow;
647
- while (start < first.length && (first[start] & 192) === 128) start++;
648
- state.outputChunks[state.outputHead] = first.subarray(start);
649
- state.retainedBytes -= start;
553
+ const createOpts = {
554
+ sessionId: String(params.sessionId ?? ""),
555
+ command: String(params.command ?? ""),
556
+ args: Array.isArray(params.args) ? params.args : []
557
+ };
558
+ if (Array.isArray(params.env)) {
559
+ createOpts.env = params.env;
560
+ }
561
+ if (typeof params.cwd === "string") {
562
+ createOpts.cwd = params.cwd;
563
+ }
564
+ if (typeof params.outputByteLimit === "number") {
565
+ createOpts.outputByteLimit = params.outputByteLimit;
566
+ }
567
+ const result = terminalServer.create(createOpts);
568
+ await sender.sendResult(id, result);
569
+ return;
650
570
  }
651
- if (state.outputHead >= 256 && state.outputHead * 2 >= state.outputChunks.length) {
652
- state.outputChunks = state.outputChunks.slice(state.outputHead);
653
- state.outputHead = 0;
571
+ case "terminal/output": {
572
+ const terminalId = String(params.terminalId ?? "");
573
+ const out = terminalServer.output(terminalId);
574
+ await sender.sendResult(id, out);
575
+ return;
654
576
  }
655
- };
656
- state.onData = onData;
657
- proc.stdout?.on("data", onData);
658
- proc.stderr?.on("data", onData);
659
- state.timeoutHandle = setTimeout(() => {
660
- treeKill2(proc);
661
- }, this.commandTimeoutMs);
662
- this.terminals.set(id, state);
663
- return { terminalId: id };
577
+ case "terminal/wait_for_exit": {
578
+ const terminalId = String(params.terminalId ?? "");
579
+ const exit = await terminalServer.waitForExit(terminalId);
580
+ await sender.sendResult(id, exit);
581
+ return;
582
+ }
583
+ case "terminal/kill": {
584
+ const terminalId = String(params.terminalId ?? "");
585
+ terminalServer.kill(terminalId);
586
+ await sender.sendResult(id, {});
587
+ return;
588
+ }
589
+ case "terminal/release": {
590
+ const terminalId = String(params.terminalId ?? "");
591
+ terminalServer.release(terminalId);
592
+ await sender.sendResult(id, {});
593
+ return;
594
+ }
595
+ default:
596
+ await sender.sendErrorResponse(id, -32601, `unknown method: ${msg.method}`);
597
+ }
598
+ } catch (err) {
599
+ const message = err instanceof Error ? err.message : String(err);
600
+ await sender.sendErrorResponse(id, -32603, message);
664
601
  }
665
- /** Return captured output and (if available) the exit status. */
666
- output(terminalId) {
667
- const state = this.terminals.get(terminalId);
668
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
669
- return {
670
- output: Buffer.concat(
671
- state.outputChunks.slice(state.outputHead),
672
- state.retainedBytes
673
- ).toString("utf8"),
674
- truncated: state.truncated,
675
- ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
676
- };
602
+ }
603
+ async function authorizeAcpCallback(permissionPolicy, partial, callbackOptions) {
604
+ try {
605
+ const outcome = await runPermissionWithDeadline(
606
+ permissionPolicy,
607
+ {
608
+ toolCall: {
609
+ sessionUpdate: "tool_call_update",
610
+ toolCallId: partial.toolCallId,
611
+ title: partial.title,
612
+ kind: partial.kind,
613
+ status: "pending",
614
+ ...partial.rawInput ? { rawInput: partial.rawInput } : {}
615
+ },
616
+ options: [
617
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
618
+ { optionId: "reject", name: "Reject", kind: "reject_once" }
619
+ ]
620
+ },
621
+ callbackOptions
622
+ );
623
+ return outcome.outcome === "selected" && outcome.optionId !== "reject" && outcome.optionId !== "reject_once" && outcome.optionId !== "reject_always" ? "allowed" : "denied";
624
+ } catch (err) {
625
+ return isAbortLikeError(err) ? "cancelled" : "denied";
677
626
  }
678
- /** Block until the process exits. Resolves with the exit status. */
679
- async waitForExit(terminalId) {
680
- const state = this.terminals.get(terminalId);
681
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
682
- return state.exitPromise;
627
+ }
628
+ async function runPermissionWithDeadline(permissionPolicy, call, callbackOptions) {
629
+ const timeoutMs = resolvePermissionDeadline(callbackOptions.permissionTimeoutMs);
630
+ const controller = new AbortController();
631
+ const abort = () => controller.abort();
632
+ const timer = timeoutMs === null ? null : setTimeout(abort, timeoutMs);
633
+ let removeAbort;
634
+ if (callbackOptions.signal) {
635
+ if (callbackOptions.signal.aborted) {
636
+ abort();
637
+ } else {
638
+ callbackOptions.signal.addEventListener("abort", abort, { once: true });
639
+ removeAbort = () => callbackOptions.signal?.removeEventListener("abort", abort);
640
+ }
683
641
  }
684
- /**
685
- * Kill the process but keep the terminal record (agent can still read output).
686
- * On POSIX this signals only the direct child; descendants may survive because
687
- * terminal processes are not spawned as process-group leaders.
688
- */
689
- kill(terminalId) {
690
- const state = this.terminals.get(terminalId);
691
- if (!state) throw new Error(`unknown terminal: ${terminalId}`);
692
- treeKill2(state.proc);
642
+ try {
643
+ return await Promise.race([
644
+ permissionPolicy({
645
+ toolCall: call.toolCall,
646
+ options: call.options,
647
+ signal: controller.signal
648
+ }),
649
+ rejectOnAbort(controller.signal)
650
+ ]);
651
+ } finally {
652
+ if (timer !== null) clearTimeout(timer);
653
+ removeAbort?.();
693
654
  }
694
- /** Kill the process if alive and remove the record. */
695
- release(terminalId) {
696
- const state = this.terminals.get(terminalId);
697
- if (!state) return;
698
- if (state.timeoutHandle) {
699
- clearTimeout(state.timeoutHandle);
700
- state.timeoutHandle = null;
701
- }
702
- if (state.onData) {
703
- state.proc.stdout?.off("data", state.onData);
704
- state.proc.stderr?.off("data", state.onData);
705
- state.onData = void 0;
655
+ }
656
+ function rejectOnAbort(signal) {
657
+ return new Promise((_, reject) => {
658
+ const rejectAbort = () => reject(new Error("permission request cancelled or timed out"));
659
+ if (signal.aborted) {
660
+ rejectAbort();
661
+ return;
706
662
  }
707
- state.proc.stdout?.destroy?.();
708
- state.proc.stderr?.destroy?.();
709
- state.outputChunks.length = 0;
710
- state.outputHead = 0;
711
- state.retainedBytes = 0;
712
- treeKill2(state.proc, { force: true });
713
- this.terminals.delete(terminalId);
663
+ signal.addEventListener("abort", rejectAbort, { once: true });
664
+ });
665
+ }
666
+ function isAbortLikeError(err) {
667
+ return err instanceof Error && /cancelled|canceled|timed out|aborted/i.test(err.message);
668
+ }
669
+ function resolvePermissionDeadline(value) {
670
+ if (value === Number.POSITIVE_INFINITY) return null;
671
+ if (value !== void 0 && Number.isFinite(value) && value > 0) return Math.trunc(value);
672
+ return DEFAULT_PERMISSION_TIMEOUT_MS;
673
+ }
674
+
675
+ // src/client/acp-session-content.ts
676
+ function textContent(text) {
677
+ return { type: "text", text };
678
+ }
679
+ function imageContent(mimeType, data) {
680
+ return { type: "image", mimeType, data };
681
+ }
682
+ function audioContent(mimeType, data) {
683
+ return { type: "audio", mimeType, data };
684
+ }
685
+ function extractText(block) {
686
+ if (typeof block !== "object" || block === null) return "";
687
+ const b = block;
688
+ if (b.type === "text" && typeof b.text === "string") return b.text;
689
+ if (b.type === "resource" && b.resource && typeof b.resource === "object" && typeof b.resource.text === "string") {
690
+ return b.resource.text;
714
691
  }
715
- /**
716
- * Release all resources held by this server: kill every active terminal
717
- * and detach the host `AbortSignal` listener.
718
- *
719
- * Idempotent — calling it multiple times is safe. Required because the
720
- * previously-coded `releaseAll()` was the only path that removed the
721
- * abort listener: if the host never called it (unhandled error path,
722
- * host crash, GC of the session without explicit close), the listener
723
- * pinned `this` (terminals Map, output buffers) for the lifetime of the
724
- * signal. With `dispose()` this is no longer leak-prone.
725
- *
726
- * Also exposed as `[Symbol.dispose]` for `using` blocks in Node ≥ 22.
727
- *
728
- * RAM-leak audit 2026-08-11, MEDIUM (Finding 2).
729
- */
730
- dispose() {
731
- if (this.disposed) return;
732
- if (DEBUG_DISPOSE) {
733
- const activeChildren = this.terminals.size;
734
- console.debug(
735
- JSON.stringify({
736
- event: "terminal_server.disposed",
737
- instanceId: this.instanceId,
738
- activeChildren,
739
- hadSignal: this.abortSignal !== void 0,
740
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
741
- })
742
- );
743
- }
744
- this.disposed = true;
745
- this.abortSignal?.removeEventListener("abort", this.abortHandler);
746
- for (const id of [...this.terminals.keys()]) {
747
- this.release(id);
748
- }
749
- }
750
- /** Alias for `dispose()` — enables `using new TerminalServer(...)`. */
751
- [Symbol.dispose]() {
752
- this.dispose();
692
+ return "";
693
+ }
694
+ function isRecord(v) {
695
+ return typeof v === "object" && v !== null && !Array.isArray(v);
696
+ }
697
+ function emptyRunResult(stopReason) {
698
+ return {
699
+ text: "",
700
+ stopReason,
701
+ hasText: false,
702
+ toolCalls: [],
703
+ diffs: [],
704
+ thoughts: ""
705
+ };
706
+ }
707
+
708
+ // src/client/acp-session-errors.ts
709
+ var ACPSessionError = class extends Error {
710
+ kind;
711
+ cause;
712
+ constructor(kind, message, cause) {
713
+ super(message);
714
+ this.name = "ACPSessionError";
715
+ this.kind = kind;
716
+ this.cause = cause;
753
717
  }
754
- /**
755
- * Kill all active terminals. Used on session close.
756
- *
757
- * @deprecated Prefer `dispose()` (or `using { … }` via `Symbol.dispose`).
758
- * `releaseAll` is retained as a delegated wrapper for callers that still
759
- * reference it; new code should call `dispose()` directly so the
760
- * host-signal listener is removed unconditionally.
761
- */
762
- releaseAll() {
763
- this.dispose();
718
+ };
719
+ function isJsonRpcError(v) {
720
+ return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
721
+ }
722
+
723
+ // src/client/acp-session-ops.ts
724
+ function filterMcpServers(agentCapabilities, servers) {
725
+ if (!servers || servers.length === 0) return [];
726
+ const mcpCaps = agentCapabilities.mcpCapabilities ?? {};
727
+ return servers.filter((s) => {
728
+ if ("type" in s && s.type === "http") return mcpCaps.http === true;
729
+ if ("type" in s && s.type === "sse") return mcpCaps.sse === true;
730
+ return true;
731
+ });
732
+ }
733
+ async function executeLoadSession(ctx, sessionId, mcpServers, cwd) {
734
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
735
+ if (!ctx.agentCapabilities.loadSession) {
736
+ throw new ACPSessionError(
737
+ "unsupported_capability",
738
+ "agent does not support session/load (loadSession capability not advertised)"
739
+ );
764
740
  }
765
- resolveCwd(cwd) {
766
- if (!cwd) return this.projectRoot;
767
- const resolved = path2.resolve(cwd);
768
- const rootWithSep = this.projectRoot.endsWith(path2.sep) ? this.projectRoot : this.projectRoot + path2.sep;
769
- if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
770
- return this.projectRoot;
771
- }
772
- try {
773
- const realRoot = realpathSync2(this.projectRoot);
774
- const realCwd = realpathSync2(resolved);
775
- const realRootWithSep = realRoot.endsWith(path2.sep) ? realRoot : realRoot + path2.sep;
776
- if (realCwd !== realRoot && !realCwd.startsWith(realRootWithSep)) {
777
- return realRoot;
778
- }
779
- return realCwd;
780
- } catch {
781
- return this.projectRoot;
782
- }
741
+ if (ctx.sessionId) {
742
+ await ctx.closeSession();
783
743
  }
784
- buildEnv(agentEnv) {
785
- const env = buildChildEnv();
786
- if (agentEnv) {
787
- for (const { name, value } of agentEnv) {
788
- const upper = name.toUpperCase();
789
- if (DENIED_AGENT_ENV_KEYS.has(upper)) continue;
790
- env[name] = value;
791
- }
792
- }
793
- return env;
744
+ ctx.resetScratch();
745
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
746
+ const id = ctx.allocId();
747
+ const result = await ctx.sendRequest(id, "session/load", {
748
+ sessionId,
749
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
750
+ mcpServers: servers
751
+ });
752
+ if (isJsonRpcError(result)) {
753
+ throw new ACPSessionError("prompt_failed", `session/load failed: ${result.message}`, result);
794
754
  }
795
- /**
796
- * Clamp an agent-supplied numeric to a finite positive safe integer, falling
797
- * back to `defaultValue` for undefined/NaN/non-finite values. Prevents
798
- * negative, NaN, or Infinity values from disabling output caps or causing
799
- * unbounded memory growth.
800
- */
801
- clampFiniteInt(value, defaultValue) {
802
- if (value === void 0 || !Number.isFinite(value) || value < 1) {
803
- return defaultValue;
804
- }
805
- return Math.trunc(value);
755
+ ctx.setSessionId(sessionId);
756
+ }
757
+ async function executeResumeSession(ctx, sessionId, mcpServers, cwd) {
758
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
759
+ if (!ctx.agentCapabilities.sessionCapabilities?.resume) {
760
+ throw new ACPSessionError(
761
+ "unsupported_capability",
762
+ "agent does not support session/resume (sessionCapabilities.resume not advertised)"
763
+ );
806
764
  }
807
- };
808
- var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
809
- "NODE_OPTIONS",
810
- "LD_PRELOAD",
811
- "LD_LIBRARY_PATH",
812
- "DYLD_INSERT_LIBRARIES",
813
- "DYLD_LIBRARY_PATH",
814
- "DYLD_FALLBACK_LIBRARY_PATH",
815
- "PATH",
816
- "PYTHONPATH",
817
- "PYTHONSTARTUP",
818
- "PERL5OPT",
819
- "PERLLIB",
820
- "RUBYOPT",
821
- "RUBYLIB"
822
- ]);
823
-
824
- // src/client/trust-boundary-permission.ts
825
- function pickOption(options, allowed) {
826
- const kinds = allowed ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
827
- for (const kind of kinds) {
828
- const option = options.find((candidate) => candidate.kind === kind);
829
- if (option) return { outcome: "selected", optionId: option.optionId };
765
+ if (ctx.sessionId) {
766
+ await ctx.closeSession();
830
767
  }
831
- return { outcome: "cancelled" };
832
- }
833
- function riskFor(kind) {
834
- if (kind === "read" || kind === "search" || kind === "fetch" || kind === "think") return "low";
835
- if (kind === "edit" || kind === "move") return "elevated";
836
- if (kind === "delete" || kind === "execute") return "high";
837
- return "elevated";
838
- }
839
- function capabilityFor(request) {
840
- const raw = request.toolCall.rawInput;
841
- if (typeof raw?.path === "string") {
842
- return request.toolCall.kind === "read" || request.toolCall.kind === "search" ? "filesystem.read" : "filesystem.write";
768
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
769
+ const id = ctx.allocId();
770
+ const result = await ctx.sendRequest(id, "session/resume", {
771
+ sessionId,
772
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
773
+ mcpServers: servers
774
+ });
775
+ if (isJsonRpcError(result)) {
776
+ throw new ACPSessionError("prompt_failed", `session/resume failed: ${result.message}`, result);
843
777
  }
844
- if (typeof raw?.command === "string" || request.toolCall.kind === "execute")
845
- return "process.spawn";
846
- if (request.toolCall.kind === "fetch") return "network.fetch";
847
- return `tool.${request.toolCall.kind ?? "unknown"}`;
778
+ ctx.setSessionId(sessionId);
848
779
  }
849
- function subjectFor(request) {
850
- const raw = request.toolCall.rawInput;
851
- const title = request.toolCall.title ?? `ACP tool call ${String(request.toolCall.toolCallId)}`;
852
- if (typeof raw?.path === "string") {
853
- return { kind: "path", id: raw.path, attributes: { toolKind: request.toolCall.kind ?? null } };
780
+ async function executeListSessions(ctx, cursor, cwd) {
781
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
782
+ if (!ctx.agentCapabilities.sessionCapabilities?.list) {
783
+ throw new ACPSessionError(
784
+ "unsupported_capability",
785
+ "agent does not support session/list (sessionCapabilities.list not advertised)"
786
+ );
854
787
  }
855
- if (typeof raw?.command === "string") {
856
- return {
857
- kind: "command",
858
- id: raw.command,
859
- attributes: { toolKind: request.toolCall.kind ?? null }
860
- };
788
+ const id = ctx.allocId();
789
+ const params = {};
790
+ if (cursor !== void 0) params.cursor = cursor;
791
+ if (cwd !== void 0) params.cwd = cwd;
792
+ const result = await ctx.sendRequest(id, "session/list", params);
793
+ if (isJsonRpcError(result)) {
794
+ throw new ACPSessionError("prompt_failed", `session/list failed: ${result.message}`, result);
861
795
  }
796
+ const r = result;
862
797
  return {
863
- kind: "resource",
864
- id: title,
865
- attributes: { toolKind: request.toolCall.kind ?? null }
798
+ sessions: r.sessions ?? [],
799
+ nextCursor: r.nextCursor
866
800
  };
867
801
  }
868
- function isAllowed(decision) {
869
- return decision.kind === "allow" || decision.kind === "scoped-token";
870
- }
871
- function toTrustBoundaryRequest(request, options) {
872
- const rawSessionId = request.toolCall.rawInput?.sessionId;
873
- const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
874
- return {
875
- version: 1,
876
- requestId: String(request.toolCall.toolCallId),
877
- actor: {
878
- ...options.actor ?? { kind: "agent" },
879
- ...sessionId ? { sessionId } : {}
880
- },
881
- surface: "acp",
882
- capability: capabilityFor(request),
883
- subject: subjectFor(request),
884
- risk: riskFor(request.toolCall.kind),
885
- scope: {
886
- ...options.scope ?? {},
887
- ...sessionId ? { sessionId } : {}
888
- },
889
- ...options.authContext ? { authContext: options.authContext } : {},
890
- metadata: {
891
- ...request.toolCall.title ? { title: request.toolCall.title } : {},
892
- toolKind: request.toolCall.kind ?? null
893
- }
894
- };
802
+ async function executeDeleteSession(ctx, sessionId) {
803
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
804
+ if (!ctx.agentCapabilities.sessionCapabilities?.delete) {
805
+ throw new ACPSessionError(
806
+ "unsupported_capability",
807
+ "agent does not support session/delete (sessionCapabilities.delete not advertised)"
808
+ );
809
+ }
810
+ const id = ctx.allocId();
811
+ const result = await ctx.sendRequest(id, "session/delete", { sessionId });
812
+ if (isJsonRpcError(result)) {
813
+ throw new ACPSessionError("prompt_failed", `session/delete failed: ${result.message}`, result);
814
+ }
815
+ if (ctx.sessionId === sessionId) {
816
+ ctx.setSessionId(null);
817
+ }
895
818
  }
896
- function makeTrustBoundaryPermissionPolicy(options) {
897
- return async (request) => {
898
- if (request.signal.aborted) return { outcome: "cancelled" };
899
- const decision = await options.boundary.evaluate(toTrustBoundaryRequest(request, options));
900
- if (request.signal.aborted) return { outcome: "cancelled" };
901
- return pickOption(request.options, isAllowed(decision));
902
- };
903
- }
904
-
905
- // src/client/websocket-transport.ts
906
- var WebSocketClientTransport = class {
907
- ws = null;
908
- handlers = /* @__PURE__ */ new Set();
909
- closed = false;
910
- opts;
911
- maxBufferedBytes;
912
- maxMessageChars;
913
- constructor(opts) {
914
- this.opts = opts;
915
- this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
916
- this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
917
- }
918
- /** Pending start() promise resolve/reject — settled in stop() to avoid leaking. */
919
- pendingStart = null;
920
- start() {
921
- if (this.closed || this.ws !== null || this.pendingStart !== null) {
922
- return Promise.reject(new Error("WebSocket transport has already been started or stopped"));
923
- }
924
- const WS = globalThis.WebSocket;
925
- if (!WS) {
926
- return Promise.reject(
927
- new Error(
928
- "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
929
- )
930
- );
931
- }
932
- const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
933
- return new Promise((resolve3, reject) => {
934
- const ws = new WS(this.opts.url, this.opts.protocols);
935
- this.ws = ws;
936
- const timer = setTimeout(() => {
937
- const pending = this.pendingStart;
938
- if (pending === null) return;
939
- this.pendingStart = null;
940
- this.closed = true;
941
- if (this.ws === ws) this.ws = null;
942
- this.handlers.clear();
943
- try {
944
- ws.close();
945
- } catch {
946
- }
947
- pending.reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
948
- }, timeoutMs);
949
- this.pendingStart = { resolve: resolve3, reject, timer };
950
- ws.addEventListener("open", () => {
951
- const pending = this.pendingStart;
952
- if (pending === null) return;
953
- this.pendingStart = null;
954
- clearTimeout(pending.timer);
955
- pending.resolve();
956
- });
957
- ws.addEventListener("error", (ev) => {
958
- const pending = this.pendingStart;
959
- if (pending === null) {
960
- this.stop();
961
- return;
962
- }
963
- this.pendingStart = null;
964
- this.closed = true;
965
- if (this.ws === ws) this.ws = null;
966
- this.handlers.clear();
967
- clearTimeout(pending.timer);
968
- const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
969
- pending.reject(new Error(message));
970
- });
971
- ws.addEventListener("close", () => {
972
- this.closed = true;
973
- if (this.ws === ws) this.ws = null;
974
- this.handlers.clear();
975
- const pending = this.pendingStart;
976
- if (pending !== null) {
977
- this.pendingStart = null;
978
- clearTimeout(pending.timer);
979
- pending.reject(new Error("WebSocket closed before the connection opened"));
980
- }
981
- });
982
- ws.addEventListener("message", (ev) => {
983
- this.onData(ev.data);
984
- });
985
- });
819
+ async function executeForkSession(ctx, sourceSessionId, cwd, mcpServers) {
820
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
821
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
822
+ const id = ctx.allocId();
823
+ const result = await ctx.sendRequest(id, "session/fork", {
824
+ sessionId: sourceSessionId,
825
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
826
+ ...servers.length > 0 ? { mcpServers: servers } : {}
827
+ });
828
+ if (isJsonRpcError(result)) {
829
+ throw new ACPSessionError("prompt_failed", `session/fork failed: ${result.message}`, result);
986
830
  }
987
- send(msg) {
988
- if (this.closed || !this.ws) {
989
- return Promise.reject(new Error("WebSocket transport is not open"));
990
- }
991
- try {
992
- const serialized = JSON.stringify(msg);
993
- const buffered = Number.isFinite(this.ws.bufferedAmount) ? this.ws.bufferedAmount : 0;
994
- if (buffered + Buffer.byteLength(serialized, "utf8") > this.maxBufferedBytes) {
995
- this.stop();
996
- return Promise.reject(new Error("WebSocket transport send buffer limit exceeded"));
997
- }
998
- this.ws.send(serialized);
999
- return Promise.resolve();
1000
- } catch (err) {
1001
- return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1002
- }
831
+ const newId = result.sessionId;
832
+ if (typeof newId !== "string" || !newId) {
833
+ throw new ACPSessionError("protocol_error", "session/fork returned no sessionId", result);
1003
834
  }
1004
- onMessage(handler) {
1005
- this.handlers.add(handler);
1006
- return () => this.handlers.delete(handler);
835
+ return newId;
836
+ }
837
+ async function executeSetMode(ctx, sessionId, modeId) {
838
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
839
+ const id = ctx.allocId();
840
+ const result = await ctx.sendRequest(id, "session/set_mode", { sessionId, modeId });
841
+ if (isJsonRpcError(result)) {
842
+ throw new ACPSessionError(
843
+ "prompt_failed",
844
+ `session/set_mode failed: ${result.message}`,
845
+ result
846
+ );
1007
847
  }
1008
- stop() {
1009
- this.closed = true;
1010
- this.handlers.clear();
1011
- if (this.pendingStart !== null) {
1012
- const pending = this.pendingStart;
1013
- this.pendingStart = null;
1014
- clearTimeout(pending.timer);
1015
- try {
1016
- pending.reject(new Error("WebSocket transport stopped while connecting"));
1017
- } catch {
1018
- }
1019
- }
1020
- if (this.ws) {
1021
- try {
1022
- this.ws.close();
1023
- } catch {
1024
- }
1025
- this.ws = null;
1026
- }
848
+ }
849
+ async function executeSetConfigOption(ctx, sessionId, configId, value) {
850
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
851
+ const id = ctx.allocId();
852
+ const result = await ctx.sendRequest(id, "session/set_config_option", {
853
+ sessionId,
854
+ configId,
855
+ value
856
+ });
857
+ if (isJsonRpcError(result)) {
858
+ throw new ACPSessionError(
859
+ "prompt_failed",
860
+ `session/set_config_option failed: ${result.message}`,
861
+ result
862
+ );
1027
863
  }
1028
- onData(data) {
1029
- const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
1030
- if (text.length > this.maxMessageChars) {
1031
- this.stop();
1032
- return;
1033
- }
1034
- if (!text.trim()) return;
1035
- let msg;
1036
- try {
1037
- msg = JSON.parse(text);
1038
- } catch {
1039
- for (const line of text.split("\n")) {
1040
- if (!line.trim()) continue;
1041
- try {
1042
- this.dispatch(JSON.parse(line));
1043
- } catch {
1044
- }
1045
- }
1046
- return;
1047
- }
1048
- this.dispatch(msg);
864
+ }
865
+ async function executeListProviders(ctx) {
866
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
867
+ const id = ctx.allocId();
868
+ const result = await ctx.sendRequest(id, "providers/list", {});
869
+ if (isJsonRpcError(result)) {
870
+ throw new ACPSessionError("prompt_failed", `providers/list failed: ${result.message}`, result);
1049
871
  }
1050
- dispatch(msg) {
1051
- for (const handler of [...this.handlers]) {
1052
- try {
1053
- handler(msg);
1054
- } catch {
1055
- }
1056
- }
872
+ const r = result;
873
+ return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
874
+ }
875
+ async function executeSetProvider(ctx, providerId, config) {
876
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
877
+ const id = ctx.allocId();
878
+ const result = await ctx.sendRequest(id, "providers/set", { providerId, ...config ?? {} });
879
+ if (isJsonRpcError(result)) {
880
+ throw new ACPSessionError("prompt_failed", `providers/set failed: ${result.message}`, result);
1057
881
  }
1058
- };
1059
- function finitePositiveLimit(value, fallback) {
1060
- return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
1061
882
  }
1062
-
1063
- // src/client/acp-session-errors.ts
1064
- var ACPSessionError = class extends Error {
1065
- kind;
1066
- cause;
1067
- constructor(kind, message, cause) {
1068
- super(message);
1069
- this.name = "ACPSessionError";
1070
- this.kind = kind;
1071
- this.cause = cause;
883
+ async function executeDisableProvider(ctx) {
884
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
885
+ const id = ctx.allocId();
886
+ const result = await ctx.sendRequest(id, "providers/disable", {});
887
+ if (isJsonRpcError(result)) {
888
+ throw new ACPSessionError(
889
+ "prompt_failed",
890
+ `providers/disable failed: ${result.message}`,
891
+ result
892
+ );
1072
893
  }
1073
- };
1074
- function isJsonRpcError(v) {
1075
- return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
1076
894
  }
1077
-
1078
- // src/client/acp-session-updates.ts
1079
- function createSessionScratch() {
1080
- return { text: "", thoughts: "", toolCalls: /* @__PURE__ */ new Map(), diffs: [] };
895
+ async function executeMcpMessage(ctx, connectionId, message) {
896
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
897
+ const id = ctx.allocId();
898
+ const result = await ctx.sendRequest(id, "mcp/message", { connectionId, message });
899
+ if (isJsonRpcError(result)) {
900
+ throw new ACPSessionError("prompt_failed", `mcp/message failed: ${result.message}`, result);
901
+ }
902
+ return result;
1081
903
  }
1082
- function handleAcpSessionUpdate(msg, scratch, emitProgress) {
1083
- const update = msg.params?.update;
1084
- if (typeof update !== "object" || update === null) return;
1085
- const u = update;
1086
- emitProgress({ type: "raw", update: u });
1087
- switch (u.sessionUpdate) {
904
+ async function executeCreateSession(ctx) {
905
+ const servers = filterMcpServers(ctx.agentCapabilities, ctx.opts.mcpServers);
906
+ const id = ctx.allocId();
907
+ const result = await ctx.sendRequest(id, "session/new", {
908
+ cwd: ctx.opts.cwd ?? ctx.opts.projectRoot,
909
+ mcpServers: servers
910
+ });
911
+ if (isJsonRpcError(result)) {
912
+ throw new ACPSessionError(
913
+ "session_create_failed",
914
+ `session/new failed: ${result.message}`,
915
+ result
916
+ );
917
+ }
918
+ const sessionId = result.sessionId;
919
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
920
+ throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
921
+ }
922
+ return sessionId;
923
+ }
924
+
925
+ // src/client/acp-session-updates.ts
926
+ function createSessionScratch() {
927
+ return { text: "", thoughts: "", toolCalls: /* @__PURE__ */ new Map(), diffs: [] };
928
+ }
929
+ function handleAcpSessionUpdate(msg, scratch, emitProgress) {
930
+ const update = msg.params?.update;
931
+ if (typeof update !== "object" || update === null) return;
932
+ const u = update;
933
+ emitProgress({ type: "raw", update: u });
934
+ switch (u.sessionUpdate) {
1088
935
  case "agent_message_chunk": {
1089
936
  const text = extractText(u.content);
1090
937
  if (text) {
@@ -1166,454 +1013,593 @@ function captureToolCall(u, isNew, scratch, emitProgress) {
1166
1013
  });
1167
1014
  }
1168
1015
 
1169
- // src/client/acp-session-callbacks.ts
1170
- var DEFAULT_PERMISSION_TIMEOUT_MS = 6e4;
1171
- async function handleAcpPermissionRequest(msg, permissionPolicy, sender, callbackOptions = {}) {
1172
- const id = msg.id;
1173
- if (id === void 0) return;
1174
- const params = msg.params;
1175
- const toolCall = params?.toolCall;
1176
- const permissionOptions = Array.isArray(params?.options) ? params.options : [];
1177
- if (!toolCall) {
1178
- await sender.sendErrorResponse(id, -32602, "toolCall is required");
1179
- return;
1016
+ // src/client/permission.ts
1017
+ function pickAllow(options) {
1018
+ const ranked = [...options].sort((a, b) => {
1019
+ const score = (k) => {
1020
+ if (k === "allow_once") return 0;
1021
+ if (k === "allow_always") return 1;
1022
+ if (k === "reject_once") return 2;
1023
+ return 3;
1024
+ };
1025
+ return score(a.kind) - score(b.kind);
1026
+ });
1027
+ const chosen = ranked[0];
1028
+ if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
1029
+ return { outcome: "cancelled" };
1180
1030
  }
1181
- try {
1182
- const outcome = await runPermissionWithDeadline(
1183
- permissionPolicy,
1184
- {
1185
- toolCall,
1186
- options: permissionOptions
1187
- },
1188
- callbackOptions
1189
- );
1190
- await sender.sendResult(id, { outcome });
1191
- } catch (err) {
1192
- const message = err instanceof Error ? err.message : String(err);
1193
- const code = isAbortLikeError(err) ? -32800 : -32603;
1194
- await sender.sendErrorResponse(id, code, `permission policy failed: ${message}`);
1031
+ return { outcome: "selected", optionId: chosen.optionId };
1032
+ }
1033
+ function pickReject(options) {
1034
+ const reject = options.find((o) => o.kind === "reject_once" || o.kind === "reject_always");
1035
+ return reject ? { outcome: "selected", optionId: reject.optionId } : { outcome: "cancelled" };
1036
+ }
1037
+ var READ_ONLY_KINDS = /* @__PURE__ */ new Set(["read", "search", "fetch", "think"]);
1038
+ var defaultPermissionPolicy = async (req) => {
1039
+ if (req.signal.aborted) return { outcome: "cancelled" };
1040
+ return pickAllow(req.options);
1041
+ };
1042
+ var readOnlyPermissionPolicy = async (req) => {
1043
+ if (req.signal.aborted) return { outcome: "cancelled" };
1044
+ const kind = req.toolCall.kind;
1045
+ if (kind && READ_ONLY_KINDS.has(kind)) {
1046
+ return pickAllow(req.options);
1195
1047
  }
1048
+ return pickReject(req.options);
1049
+ };
1050
+ function makePermissionPolicy(decide) {
1051
+ return async (req) => {
1052
+ if (req.signal.aborted) return { outcome: "cancelled" };
1053
+ const allow = await decide(req);
1054
+ return allow ? pickAllow(req.options) : pickReject(req.options);
1055
+ };
1196
1056
  }
1197
- async function handleAcpFsRequest(msg, fileServer, permissionPolicy, sender, callbackOptions = {}) {
1198
- const id = msg.id;
1199
- if (id === void 0) return;
1200
- const params = msg.params;
1201
- if (!params?.path) {
1202
- await sender.sendErrorResponse(id, -32602, "path is required");
1203
- return;
1057
+
1058
+ // src/client/terminal-server.ts
1059
+ import { spawn } from "node:child_process";
1060
+ import { randomBytes as randomBytes2 } from "node:crypto";
1061
+ import { realpathSync as realpathSync2 } from "node:fs";
1062
+ import * as path2 from "node:path";
1063
+ import { buildChildEnv } from "@wrongstack/core/utils";
1064
+ import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
1065
+ var EMPTY_BUFFER = Buffer.alloc(0);
1066
+ var DEBUG_DISPOSE = typeof process !== "undefined" && !!process.env?.WRONGSTACK_DEBUG && process.env.WRONGSTACK_DEBUG !== "0" && process.env.WRONGSTACK_DEBUG !== "false";
1067
+ var TerminalServer = class {
1068
+ terminals = /* @__PURE__ */ new Map();
1069
+ /**
1070
+ * Stable per-instance identifier for debug logs. 8 hex chars is enough
1071
+ * to disambiguate concurrent TerminalServers in a trace; not meant to
1072
+ * be cryptographically unique.
1073
+ */
1074
+ instanceId;
1075
+ projectRoot;
1076
+ commandTimeoutMs;
1077
+ outputByteLimit;
1078
+ maxOutputByteLimit;
1079
+ maxTerminals;
1080
+ abortSignal;
1081
+ abortHandler = () => this.dispose();
1082
+ disposed = false;
1083
+ nextId = 1;
1084
+ constructor(opts) {
1085
+ this.projectRoot = path2.resolve(opts.projectRoot);
1086
+ this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
1087
+ this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
1088
+ this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
1089
+ this.instanceId = `term_srv_${randomBytes2(4).toString("hex")}`;
1090
+ this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
1091
+ this.abortSignal = opts.signal;
1092
+ if (opts.signal) {
1093
+ opts.signal.addEventListener("abort", this.abortHandler, { once: true });
1094
+ }
1204
1095
  }
1205
- if (msg.method === "fs/write_text_file") {
1206
- const authorization = await authorizeAcpCallback(
1207
- permissionPolicy,
1208
- {
1209
- toolCallId: `acp-fs-write-${id}`,
1210
- title: `Write file: ${params.path}`,
1211
- kind: "edit",
1212
- rawInput: { path: params.path, sessionId: params.sessionId }
1213
- },
1214
- callbackOptions
1215
- );
1216
- if (authorization !== "allowed") {
1217
- const isCancelled = authorization === "cancelled";
1218
- await sender.sendErrorResponse(
1219
- id,
1220
- isCancelled ? -32800 : -32602,
1221
- isCancelled ? "filesystem write permission request cancelled or timed out" : "filesystem write denied by permission policy"
1096
+ /** Spawn a new terminal. Returns the agent-facing id. */
1097
+ create(params) {
1098
+ if (this.disposed) {
1099
+ throw new Error(
1100
+ "TerminalServer is disposed \u2014 create a new TerminalServer instead of reusing this one"
1222
1101
  );
1223
- return;
1224
1102
  }
1225
- }
1226
- try {
1227
- if (msg.method === "fs/read_text_file") {
1228
- const result = await fileServer.readTextFile({
1229
- sessionId: params.sessionId ?? "",
1230
- path: params.path
1231
- });
1232
- await sender.sendResult(id, result);
1233
- } else {
1234
- await fileServer.writeTextFile({
1235
- sessionId: params.sessionId ?? "",
1236
- path: params.path,
1237
- content: params.content ?? ""
1238
- });
1239
- await sender.sendResult(id, {});
1103
+ if (this.terminals.size >= this.maxTerminals) {
1104
+ throw new Error(
1105
+ `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
1106
+ );
1240
1107
  }
1241
- } catch (err) {
1242
- const code = err instanceof FsError ? -32602 : -32603;
1243
- const message = err instanceof Error ? err.message : String(err);
1244
- await sender.sendErrorResponse(id, code, message);
1245
- }
1246
- }
1247
- async function handleAcpTerminalRequest(msg, terminalServer, permissionPolicy, sender, callbackOptions = {}) {
1248
- const id = msg.id;
1249
- if (id === void 0) return;
1250
- const params = msg.params ?? {};
1251
- try {
1252
- switch (msg.method) {
1253
- case "terminal/create": {
1254
- const authorization = await authorizeAcpCallback(
1255
- permissionPolicy,
1256
- {
1257
- toolCallId: `acp-terminal-create-${id}`,
1258
- title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
1259
- kind: "execute",
1260
- rawInput: {
1261
- command: params.command,
1262
- args: params.args,
1263
- cwd: params.cwd,
1264
- sessionId: params.sessionId
1265
- }
1266
- },
1267
- callbackOptions
1268
- );
1269
- if (authorization !== "allowed") {
1270
- const isCancelled = authorization === "cancelled";
1271
- await sender.sendErrorResponse(
1272
- id,
1273
- isCancelled ? -32800 : -32602,
1274
- isCancelled ? "terminal create permission request cancelled or timed out" : "terminal create denied by permission policy"
1275
- );
1276
- return;
1277
- }
1278
- const createOpts = {
1279
- sessionId: String(params.sessionId ?? ""),
1280
- command: String(params.command ?? ""),
1281
- args: Array.isArray(params.args) ? params.args : []
1282
- };
1283
- if (Array.isArray(params.env)) {
1284
- createOpts.env = params.env;
1285
- }
1286
- if (typeof params.cwd === "string") {
1287
- createOpts.cwd = params.cwd;
1288
- }
1289
- if (typeof params.outputByteLimit === "number") {
1290
- createOpts.outputByteLimit = params.outputByteLimit;
1108
+ const id = `term_${this.nextId++}`;
1109
+ const cwd = this.resolveCwd(params.cwd);
1110
+ const perCallByteLimit = Math.min(
1111
+ Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
1112
+ this.maxOutputByteLimit
1113
+ );
1114
+ const proc = spawn(params.command, params.args ?? [], {
1115
+ cwd,
1116
+ env: this.buildEnv(params.env),
1117
+ stdio: ["ignore", "pipe", "pipe"],
1118
+ windowsHide: true
1119
+ // shell: false on purpose. The terminal server is invoked with
1120
+ // the agent's explicit argv; turning on shell-mode would make
1121
+ // the command a single shell-parsed string, which breaks
1122
+ // Windows cmd quoting for the common case of running node with
1123
+ // `-e "<script>"`. If a future feature needs shell features
1124
+ // (pipes, redirects), it should be opt-in per-call, not the
1125
+ // default.
1126
+ });
1127
+ const state = {
1128
+ proc,
1129
+ cwd,
1130
+ command: params.command,
1131
+ args: params.args ?? [],
1132
+ outputChunks: [],
1133
+ outputHead: 0,
1134
+ retainedBytes: 0,
1135
+ truncated: false,
1136
+ exitStatus: void 0,
1137
+ timeoutHandle: null,
1138
+ exitPromise: new Promise((resolve3) => {
1139
+ proc.on("close", (code, signalName) => {
1140
+ if (state.timeoutHandle) {
1141
+ clearTimeout(state.timeoutHandle);
1142
+ state.timeoutHandle = null;
1143
+ }
1144
+ const exitStatus = {
1145
+ exitCode: typeof code === "number" ? code : null,
1146
+ signal: typeof signalName === "string" ? signalName : null
1147
+ };
1148
+ state.exitStatus = exitStatus;
1149
+ resolve3(exitStatus);
1150
+ });
1151
+ proc.on("error", (err) => {
1152
+ if (state.timeoutHandle) {
1153
+ clearTimeout(state.timeoutHandle);
1154
+ state.timeoutHandle = null;
1155
+ }
1156
+ const exitStatus = { exitCode: 127, signal: null };
1157
+ state.exitStatus = exitStatus;
1158
+ let errorOutput = Buffer.from(`[spawn error] ${err.message}
1159
+ `, "utf8");
1160
+ if (errorOutput.length > perCallByteLimit) {
1161
+ let start = errorOutput.length - perCallByteLimit;
1162
+ while (start < errorOutput.length && (errorOutput[start] & 192) === 128) start++;
1163
+ errorOutput = errorOutput.subarray(start);
1164
+ state.truncated = true;
1165
+ }
1166
+ state.outputChunks.push(errorOutput);
1167
+ state.retainedBytes = errorOutput.length;
1168
+ resolve3(exitStatus);
1169
+ });
1170
+ })
1171
+ };
1172
+ proc.stdout?.setEncoding("utf8");
1173
+ proc.stderr?.setEncoding("utf8");
1174
+ const onData = (chunk) => {
1175
+ const outputChunk = Buffer.from(chunk, "utf8");
1176
+ state.outputChunks.push(outputChunk);
1177
+ state.retainedBytes += outputChunk.length;
1178
+ if (state.retainedBytes > perCallByteLimit) state.truncated = true;
1179
+ while (state.retainedBytes > perCallByteLimit && state.outputHead < state.outputChunks.length) {
1180
+ const first = state.outputChunks[state.outputHead];
1181
+ const overflow = state.retainedBytes - perCallByteLimit;
1182
+ if (first.length <= overflow) {
1183
+ state.outputChunks[state.outputHead] = EMPTY_BUFFER;
1184
+ state.outputHead++;
1185
+ state.retainedBytes -= first.length;
1186
+ continue;
1291
1187
  }
1292
- const result = terminalServer.create(createOpts);
1293
- await sender.sendResult(id, result);
1294
- return;
1295
- }
1296
- case "terminal/output": {
1297
- const terminalId = String(params.terminalId ?? "");
1298
- const out = terminalServer.output(terminalId);
1299
- await sender.sendResult(id, out);
1300
- return;
1301
- }
1302
- case "terminal/wait_for_exit": {
1303
- const terminalId = String(params.terminalId ?? "");
1304
- const exit = await terminalServer.waitForExit(terminalId);
1305
- await sender.sendResult(id, exit);
1306
- return;
1307
- }
1308
- case "terminal/kill": {
1309
- const terminalId = String(params.terminalId ?? "");
1310
- terminalServer.kill(terminalId);
1311
- await sender.sendResult(id, {});
1312
- return;
1188
+ let start = overflow;
1189
+ while (start < first.length && (first[start] & 192) === 128) start++;
1190
+ state.outputChunks[state.outputHead] = first.subarray(start);
1191
+ state.retainedBytes -= start;
1313
1192
  }
1314
- case "terminal/release": {
1315
- const terminalId = String(params.terminalId ?? "");
1316
- terminalServer.release(terminalId);
1317
- await sender.sendResult(id, {});
1318
- return;
1193
+ if (state.outputHead >= 256 && state.outputHead * 2 >= state.outputChunks.length) {
1194
+ state.outputChunks = state.outputChunks.slice(state.outputHead);
1195
+ state.outputHead = 0;
1319
1196
  }
1320
- default:
1321
- await sender.sendErrorResponse(id, -32601, `unknown method: ${msg.method}`);
1197
+ };
1198
+ state.onData = onData;
1199
+ proc.stdout?.on("data", onData);
1200
+ proc.stderr?.on("data", onData);
1201
+ state.timeoutHandle = setTimeout(() => {
1202
+ treeKill2(proc);
1203
+ }, this.commandTimeoutMs);
1204
+ this.terminals.set(id, state);
1205
+ return { terminalId: id };
1206
+ }
1207
+ /** Return captured output and (if available) the exit status. */
1208
+ output(terminalId) {
1209
+ const state = this.terminals.get(terminalId);
1210
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1211
+ return {
1212
+ output: Buffer.concat(
1213
+ state.outputChunks.slice(state.outputHead),
1214
+ state.retainedBytes
1215
+ ).toString("utf8"),
1216
+ truncated: state.truncated,
1217
+ ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
1218
+ };
1219
+ }
1220
+ /** Block until the process exits. Resolves with the exit status. */
1221
+ async waitForExit(terminalId) {
1222
+ const state = this.terminals.get(terminalId);
1223
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1224
+ return state.exitPromise;
1225
+ }
1226
+ /**
1227
+ * Kill the process but keep the terminal record (agent can still read output).
1228
+ * On POSIX this signals only the direct child; descendants may survive because
1229
+ * terminal processes are not spawned as process-group leaders.
1230
+ */
1231
+ kill(terminalId) {
1232
+ const state = this.terminals.get(terminalId);
1233
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1234
+ treeKill2(state.proc);
1235
+ }
1236
+ /** Kill the process if alive and remove the record. */
1237
+ release(terminalId) {
1238
+ const state = this.terminals.get(terminalId);
1239
+ if (!state) return;
1240
+ if (state.timeoutHandle) {
1241
+ clearTimeout(state.timeoutHandle);
1242
+ state.timeoutHandle = null;
1322
1243
  }
1323
- } catch (err) {
1324
- const message = err instanceof Error ? err.message : String(err);
1325
- await sender.sendErrorResponse(id, -32603, message);
1244
+ if (state.onData) {
1245
+ state.proc.stdout?.off("data", state.onData);
1246
+ state.proc.stderr?.off("data", state.onData);
1247
+ state.onData = void 0;
1248
+ }
1249
+ state.proc.stdout?.destroy?.();
1250
+ state.proc.stderr?.destroy?.();
1251
+ state.outputChunks.length = 0;
1252
+ state.outputHead = 0;
1253
+ state.retainedBytes = 0;
1254
+ treeKill2(state.proc, { force: true });
1255
+ this.terminals.delete(terminalId);
1326
1256
  }
1327
- }
1328
- async function authorizeAcpCallback(permissionPolicy, partial, callbackOptions) {
1329
- try {
1330
- const outcome = await runPermissionWithDeadline(
1331
- permissionPolicy,
1332
- {
1333
- toolCall: {
1334
- sessionUpdate: "tool_call_update",
1335
- toolCallId: partial.toolCallId,
1336
- title: partial.title,
1337
- kind: partial.kind,
1338
- status: "pending",
1339
- ...partial.rawInput ? { rawInput: partial.rawInput } : {}
1340
- },
1341
- options: [
1342
- { optionId: "allow", name: "Allow", kind: "allow_once" },
1343
- { optionId: "reject", name: "Reject", kind: "reject_once" }
1344
- ]
1345
- },
1346
- callbackOptions
1347
- );
1348
- return outcome.outcome === "selected" && outcome.optionId !== "reject" && outcome.optionId !== "reject_once" && outcome.optionId !== "reject_always" ? "allowed" : "denied";
1349
- } catch (err) {
1350
- return isAbortLikeError(err) ? "cancelled" : "denied";
1257
+ /**
1258
+ * Release all resources held by this server: kill every active terminal
1259
+ * and detach the host `AbortSignal` listener.
1260
+ *
1261
+ * Idempotent — calling it multiple times is safe. Required because the
1262
+ * previously-coded `releaseAll()` was the only path that removed the
1263
+ * abort listener: if the host never called it (unhandled error path,
1264
+ * host crash, GC of the session without explicit close), the listener
1265
+ * pinned `this` (terminals Map, output buffers) for the lifetime of the
1266
+ * signal. With `dispose()` this is no longer leak-prone.
1267
+ *
1268
+ * Also exposed as `[Symbol.dispose]` for `using` blocks in Node ≥ 22.
1269
+ *
1270
+ * RAM-leak audit 2026-08-11, MEDIUM (Finding 2).
1271
+ */
1272
+ dispose() {
1273
+ if (this.disposed) return;
1274
+ if (DEBUG_DISPOSE) {
1275
+ const activeChildren = this.terminals.size;
1276
+ console.debug(
1277
+ JSON.stringify({
1278
+ event: "terminal_server.disposed",
1279
+ instanceId: this.instanceId,
1280
+ activeChildren,
1281
+ hadSignal: this.abortSignal !== void 0,
1282
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1283
+ })
1284
+ );
1285
+ }
1286
+ this.disposed = true;
1287
+ this.abortSignal?.removeEventListener("abort", this.abortHandler);
1288
+ for (const id of [...this.terminals.keys()]) {
1289
+ this.release(id);
1290
+ }
1291
+ }
1292
+ /** Alias for `dispose()` — enables `using new TerminalServer(...)`. */
1293
+ [Symbol.dispose]() {
1294
+ this.dispose();
1295
+ }
1296
+ /**
1297
+ * Kill all active terminals. Used on session close.
1298
+ *
1299
+ * @deprecated Prefer `dispose()` (or `using { … }` via `Symbol.dispose`).
1300
+ * `releaseAll` is retained as a delegated wrapper for callers that still
1301
+ * reference it; new code should call `dispose()` directly so the
1302
+ * host-signal listener is removed unconditionally.
1303
+ */
1304
+ releaseAll() {
1305
+ this.dispose();
1351
1306
  }
1352
- }
1353
- async function runPermissionWithDeadline(permissionPolicy, call, callbackOptions) {
1354
- const timeoutMs = resolvePermissionDeadline(callbackOptions.permissionTimeoutMs);
1355
- const controller = new AbortController();
1356
- const abort = () => controller.abort();
1357
- const timer = timeoutMs === null ? null : setTimeout(abort, timeoutMs);
1358
- let removeAbort;
1359
- if (callbackOptions.signal) {
1360
- if (callbackOptions.signal.aborted) {
1361
- abort();
1362
- } else {
1363
- callbackOptions.signal.addEventListener("abort", abort, { once: true });
1364
- removeAbort = () => callbackOptions.signal?.removeEventListener("abort", abort);
1307
+ resolveCwd(cwd) {
1308
+ if (!cwd) return this.projectRoot;
1309
+ const resolved = path2.resolve(cwd);
1310
+ const rootWithSep = this.projectRoot.endsWith(path2.sep) ? this.projectRoot : this.projectRoot + path2.sep;
1311
+ if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
1312
+ return this.projectRoot;
1313
+ }
1314
+ try {
1315
+ const realRoot = realpathSync2(this.projectRoot);
1316
+ const realCwd = realpathSync2(resolved);
1317
+ const realRootWithSep = realRoot.endsWith(path2.sep) ? realRoot : realRoot + path2.sep;
1318
+ if (realCwd !== realRoot && !realCwd.startsWith(realRootWithSep)) {
1319
+ return realRoot;
1320
+ }
1321
+ return realCwd;
1322
+ } catch {
1323
+ return this.projectRoot;
1365
1324
  }
1366
1325
  }
1367
- try {
1368
- return await Promise.race([
1369
- permissionPolicy({
1370
- toolCall: call.toolCall,
1371
- options: call.options,
1372
- signal: controller.signal
1373
- }),
1374
- rejectOnAbort(controller.signal)
1375
- ]);
1376
- } finally {
1377
- if (timer !== null) clearTimeout(timer);
1378
- removeAbort?.();
1379
- }
1380
- }
1381
- function rejectOnAbort(signal) {
1382
- return new Promise((_, reject) => {
1383
- const rejectAbort = () => reject(new Error("permission request cancelled or timed out"));
1384
- if (signal.aborted) {
1385
- rejectAbort();
1386
- return;
1326
+ buildEnv(agentEnv) {
1327
+ const env = buildChildEnv();
1328
+ if (agentEnv) {
1329
+ for (const { name, value } of agentEnv) {
1330
+ const upper = name.toUpperCase();
1331
+ if (DENIED_AGENT_ENV_KEYS.has(upper)) continue;
1332
+ env[name] = value;
1333
+ }
1387
1334
  }
1388
- signal.addEventListener("abort", rejectAbort, { once: true });
1389
- });
1390
- }
1391
- function isAbortLikeError(err) {
1392
- return err instanceof Error && /cancelled|canceled|timed out|aborted/i.test(err.message);
1393
- }
1394
- function resolvePermissionDeadline(value) {
1395
- if (value === Number.POSITIVE_INFINITY) return null;
1396
- if (value !== void 0 && Number.isFinite(value) && value > 0) return Math.trunc(value);
1397
- return DEFAULT_PERMISSION_TIMEOUT_MS;
1398
- }
1399
-
1400
- // src/client/acp-message-routing.ts
1401
- function isBestEffortAckMethod(method) {
1402
- return method === "mcp/connect" || method === "mcp/message" || method === "mcp/disconnect" || method === "elicitation/create" || method === "elicitation/complete";
1403
- }
1404
-
1405
- // src/client/acp-session-ops.ts
1406
- function filterMcpServers(agentCapabilities, servers) {
1407
- if (!servers || servers.length === 0) return [];
1408
- const mcpCaps = agentCapabilities.mcpCapabilities ?? {};
1409
- return servers.filter((s) => {
1410
- if ("type" in s && s.type === "http") return mcpCaps.http === true;
1411
- if ("type" in s && s.type === "sse") return mcpCaps.sse === true;
1412
- return true;
1413
- });
1414
- }
1415
- async function executeLoadSession(ctx, sessionId, mcpServers, cwd) {
1416
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1417
- if (!ctx.agentCapabilities.loadSession) {
1418
- throw new ACPSessionError(
1419
- "unsupported_capability",
1420
- "agent does not support session/load (loadSession capability not advertised)"
1421
- );
1335
+ return env;
1422
1336
  }
1423
- if (ctx.sessionId) {
1424
- await ctx.closeSession();
1337
+ /**
1338
+ * Clamp an agent-supplied numeric to a finite positive safe integer, falling
1339
+ * back to `defaultValue` for undefined/NaN/non-finite values. Prevents
1340
+ * negative, NaN, or Infinity values from disabling output caps or causing
1341
+ * unbounded memory growth.
1342
+ */
1343
+ clampFiniteInt(value, defaultValue) {
1344
+ if (value === void 0 || !Number.isFinite(value) || value < 1) {
1345
+ return defaultValue;
1346
+ }
1347
+ return Math.trunc(value);
1425
1348
  }
1426
- ctx.resetScratch();
1427
- const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
1428
- const id = ctx.allocId();
1429
- const result = await ctx.sendRequest(id, "session/load", {
1430
- sessionId,
1431
- cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
1432
- mcpServers: servers
1433
- });
1434
- if (isJsonRpcError(result)) {
1435
- throw new ACPSessionError("prompt_failed", `session/load failed: ${result.message}`, result);
1349
+ };
1350
+ var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
1351
+ "NODE_OPTIONS",
1352
+ "LD_PRELOAD",
1353
+ "LD_LIBRARY_PATH",
1354
+ "DYLD_INSERT_LIBRARIES",
1355
+ "DYLD_LIBRARY_PATH",
1356
+ "DYLD_FALLBACK_LIBRARY_PATH",
1357
+ "PATH",
1358
+ "PYTHONPATH",
1359
+ "PYTHONSTARTUP",
1360
+ "PERL5OPT",
1361
+ "PERLLIB",
1362
+ "RUBYOPT",
1363
+ "RUBYLIB"
1364
+ ]);
1365
+
1366
+ // src/client/trust-boundary-permission.ts
1367
+ function pickOption(options, allowed) {
1368
+ const kinds = allowed ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
1369
+ for (const kind of kinds) {
1370
+ const option = options.find((candidate) => candidate.kind === kind);
1371
+ if (option) return { outcome: "selected", optionId: option.optionId };
1436
1372
  }
1437
- ctx.setSessionId(sessionId);
1373
+ return { outcome: "cancelled" };
1438
1374
  }
1439
- async function executeResumeSession(ctx, sessionId, mcpServers, cwd) {
1440
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1441
- if (!ctx.agentCapabilities.sessionCapabilities?.resume) {
1442
- throw new ACPSessionError(
1443
- "unsupported_capability",
1444
- "agent does not support session/resume (sessionCapabilities.resume not advertised)"
1445
- );
1446
- }
1447
- if (ctx.sessionId) {
1448
- await ctx.closeSession();
1449
- }
1450
- const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
1451
- const id = ctx.allocId();
1452
- const result = await ctx.sendRequest(id, "session/resume", {
1453
- sessionId,
1454
- cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
1455
- mcpServers: servers
1456
- });
1457
- if (isJsonRpcError(result)) {
1458
- throw new ACPSessionError(
1459
- "prompt_failed",
1460
- `session/resume failed: ${result.message}`,
1461
- result
1462
- );
1375
+ function riskFor(kind) {
1376
+ if (kind === "read" || kind === "search" || kind === "fetch" || kind === "think") return "low";
1377
+ if (kind === "edit" || kind === "move") return "elevated";
1378
+ if (kind === "delete" || kind === "execute") return "high";
1379
+ return "elevated";
1380
+ }
1381
+ function capabilityFor(request) {
1382
+ const raw = request.toolCall.rawInput;
1383
+ if (typeof raw?.path === "string") {
1384
+ return request.toolCall.kind === "read" || request.toolCall.kind === "search" ? "filesystem.read" : "filesystem.write";
1463
1385
  }
1464
- ctx.setSessionId(sessionId);
1386
+ if (typeof raw?.command === "string" || request.toolCall.kind === "execute")
1387
+ return "process.spawn";
1388
+ if (request.toolCall.kind === "fetch") return "network.fetch";
1389
+ return `tool.${request.toolCall.kind ?? "unknown"}`;
1465
1390
  }
1466
- async function executeListSessions(ctx, cursor, cwd) {
1467
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1468
- if (!ctx.agentCapabilities.sessionCapabilities?.list) {
1469
- throw new ACPSessionError(
1470
- "unsupported_capability",
1471
- "agent does not support session/list (sessionCapabilities.list not advertised)"
1472
- );
1391
+ function subjectFor(request) {
1392
+ const raw = request.toolCall.rawInput;
1393
+ const title = request.toolCall.title ?? `ACP tool call ${String(request.toolCall.toolCallId)}`;
1394
+ if (typeof raw?.path === "string") {
1395
+ return { kind: "path", id: raw.path, attributes: { toolKind: request.toolCall.kind ?? null } };
1473
1396
  }
1474
- const id = ctx.allocId();
1475
- const params = {};
1476
- if (cursor !== void 0) params.cursor = cursor;
1477
- if (cwd !== void 0) params.cwd = cwd;
1478
- const result = await ctx.sendRequest(id, "session/list", params);
1479
- if (isJsonRpcError(result)) {
1480
- throw new ACPSessionError("prompt_failed", `session/list failed: ${result.message}`, result);
1397
+ if (typeof raw?.command === "string") {
1398
+ return {
1399
+ kind: "command",
1400
+ id: raw.command,
1401
+ attributes: { toolKind: request.toolCall.kind ?? null }
1402
+ };
1481
1403
  }
1482
- const r = result;
1483
1404
  return {
1484
- sessions: r.sessions ?? [],
1485
- nextCursor: r.nextCursor
1405
+ kind: "resource",
1406
+ id: title,
1407
+ attributes: { toolKind: request.toolCall.kind ?? null }
1486
1408
  };
1487
1409
  }
1488
- async function executeDeleteSession(ctx, sessionId) {
1489
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1490
- if (!ctx.agentCapabilities.sessionCapabilities?.delete) {
1491
- throw new ACPSessionError(
1492
- "unsupported_capability",
1493
- "agent does not support session/delete (sessionCapabilities.delete not advertised)"
1494
- );
1495
- }
1496
- const id = ctx.allocId();
1497
- const result = await ctx.sendRequest(id, "session/delete", { sessionId });
1498
- if (isJsonRpcError(result)) {
1499
- throw new ACPSessionError(
1500
- "prompt_failed",
1501
- `session/delete failed: ${result.message}`,
1502
- result
1503
- );
1504
- }
1505
- if (ctx.sessionId === sessionId) {
1506
- ctx.setSessionId(null);
1507
- }
1410
+ function isAllowed(decision) {
1411
+ return decision.kind === "allow" || decision.kind === "scoped-token";
1508
1412
  }
1509
- async function executeForkSession(ctx, sourceSessionId, cwd, mcpServers) {
1510
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1511
- const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
1512
- const id = ctx.allocId();
1513
- const result = await ctx.sendRequest(id, "session/fork", {
1514
- sessionId: sourceSessionId,
1515
- cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
1516
- ...servers.length > 0 ? { mcpServers: servers } : {}
1517
- });
1518
- if (isJsonRpcError(result)) {
1519
- throw new ACPSessionError("prompt_failed", `session/fork failed: ${result.message}`, result);
1520
- }
1521
- const newId = result.sessionId;
1522
- if (typeof newId !== "string" || !newId) {
1523
- throw new ACPSessionError("protocol_error", "session/fork returned no sessionId", result);
1524
- }
1525
- return newId;
1413
+ function toTrustBoundaryRequest(request, options) {
1414
+ const rawSessionId = request.toolCall.rawInput?.sessionId;
1415
+ const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
1416
+ return {
1417
+ version: 1,
1418
+ requestId: String(request.toolCall.toolCallId),
1419
+ actor: {
1420
+ ...options.actor ?? { kind: "agent" },
1421
+ ...sessionId ? { sessionId } : {}
1422
+ },
1423
+ surface: "acp",
1424
+ capability: capabilityFor(request),
1425
+ subject: subjectFor(request),
1426
+ risk: riskFor(request.toolCall.kind),
1427
+ scope: {
1428
+ ...options.scope ?? {},
1429
+ ...sessionId ? { sessionId } : {}
1430
+ },
1431
+ ...options.authContext ? { authContext: options.authContext } : {},
1432
+ metadata: {
1433
+ ...request.toolCall.title ? { title: request.toolCall.title } : {},
1434
+ toolKind: request.toolCall.kind ?? null
1435
+ }
1436
+ };
1526
1437
  }
1527
- async function executeSetMode(ctx, sessionId, modeId) {
1528
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1529
- const id = ctx.allocId();
1530
- const result = await ctx.sendRequest(id, "session/set_mode", { sessionId, modeId });
1531
- if (isJsonRpcError(result)) {
1532
- throw new ACPSessionError(
1533
- "prompt_failed",
1534
- `session/set_mode failed: ${result.message}`,
1535
- result
1536
- );
1537
- }
1438
+ function makeTrustBoundaryPermissionPolicy(options) {
1439
+ return async (request) => {
1440
+ if (request.signal.aborted) return { outcome: "cancelled" };
1441
+ const decision = await options.boundary.evaluate(toTrustBoundaryRequest(request, options));
1442
+ if (request.signal.aborted) return { outcome: "cancelled" };
1443
+ return pickOption(request.options, isAllowed(decision));
1444
+ };
1538
1445
  }
1539
- async function executeSetConfigOption(ctx, sessionId, configId, value) {
1540
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1541
- const id = ctx.allocId();
1542
- const result = await ctx.sendRequest(id, "session/set_config_option", {
1543
- sessionId,
1544
- configId,
1545
- value
1546
- });
1547
- if (isJsonRpcError(result)) {
1548
- throw new ACPSessionError(
1549
- "prompt_failed",
1550
- `session/set_config_option failed: ${result.message}`,
1551
- result
1552
- );
1446
+
1447
+ // src/client/websocket-transport.ts
1448
+ var WebSocketClientTransport = class {
1449
+ ws = null;
1450
+ handlers = /* @__PURE__ */ new Set();
1451
+ closed = false;
1452
+ opts;
1453
+ maxBufferedBytes;
1454
+ maxMessageChars;
1455
+ constructor(opts) {
1456
+ this.opts = opts;
1457
+ this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
1458
+ this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
1553
1459
  }
1554
- }
1555
- async function executeListProviders(ctx) {
1556
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1557
- const id = ctx.allocId();
1558
- const result = await ctx.sendRequest(id, "providers/list", {});
1559
- if (isJsonRpcError(result)) {
1560
- throw new ACPSessionError(
1561
- "prompt_failed",
1562
- `providers/list failed: ${result.message}`,
1563
- result
1564
- );
1460
+ /** Pending start() promise resolve/reject — settled in stop() to avoid leaking. */
1461
+ pendingStart = null;
1462
+ start() {
1463
+ if (this.closed || this.ws !== null || this.pendingStart !== null) {
1464
+ return Promise.reject(new Error("WebSocket transport has already been started or stopped"));
1465
+ }
1466
+ const WS = globalThis.WebSocket;
1467
+ if (!WS) {
1468
+ return Promise.reject(
1469
+ new Error(
1470
+ "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
1471
+ )
1472
+ );
1473
+ }
1474
+ const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
1475
+ return new Promise((resolve3, reject) => {
1476
+ const ws = new WS(this.opts.url, this.opts.protocols);
1477
+ this.ws = ws;
1478
+ const timer = setTimeout(() => {
1479
+ const pending = this.pendingStart;
1480
+ if (pending === null) return;
1481
+ this.pendingStart = null;
1482
+ this.closed = true;
1483
+ if (this.ws === ws) this.ws = null;
1484
+ this.handlers.clear();
1485
+ try {
1486
+ ws.close();
1487
+ } catch {
1488
+ }
1489
+ pending.reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
1490
+ }, timeoutMs);
1491
+ this.pendingStart = { resolve: resolve3, reject, timer };
1492
+ ws.addEventListener("open", () => {
1493
+ const pending = this.pendingStart;
1494
+ if (pending === null) return;
1495
+ this.pendingStart = null;
1496
+ clearTimeout(pending.timer);
1497
+ pending.resolve();
1498
+ });
1499
+ ws.addEventListener("error", (ev) => {
1500
+ const pending = this.pendingStart;
1501
+ if (pending === null) {
1502
+ this.stop();
1503
+ return;
1504
+ }
1505
+ this.pendingStart = null;
1506
+ this.closed = true;
1507
+ if (this.ws === ws) this.ws = null;
1508
+ this.handlers.clear();
1509
+ clearTimeout(pending.timer);
1510
+ const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
1511
+ pending.reject(new Error(message));
1512
+ });
1513
+ ws.addEventListener("close", () => {
1514
+ this.closed = true;
1515
+ if (this.ws === ws) this.ws = null;
1516
+ this.handlers.clear();
1517
+ const pending = this.pendingStart;
1518
+ if (pending !== null) {
1519
+ this.pendingStart = null;
1520
+ clearTimeout(pending.timer);
1521
+ pending.reject(new Error("WebSocket closed before the connection opened"));
1522
+ }
1523
+ });
1524
+ ws.addEventListener("message", (ev) => {
1525
+ this.onData(ev.data);
1526
+ });
1527
+ });
1565
1528
  }
1566
- const r = result;
1567
- return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
1568
- }
1569
- async function executeSetProvider(ctx, providerId, config) {
1570
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1571
- const id = ctx.allocId();
1572
- const result = await ctx.sendRequest(id, "providers/set", { providerId, ...config ?? {} });
1573
- if (isJsonRpcError(result)) {
1574
- throw new ACPSessionError("prompt_failed", `providers/set failed: ${result.message}`, result);
1529
+ send(msg) {
1530
+ if (this.closed || !this.ws) {
1531
+ return Promise.reject(new Error("WebSocket transport is not open"));
1532
+ }
1533
+ try {
1534
+ const serialized = JSON.stringify(msg);
1535
+ const buffered = Number.isFinite(this.ws.bufferedAmount) ? this.ws.bufferedAmount : 0;
1536
+ if (buffered + Buffer.byteLength(serialized, "utf8") > this.maxBufferedBytes) {
1537
+ this.stop();
1538
+ return Promise.reject(new Error("WebSocket transport send buffer limit exceeded"));
1539
+ }
1540
+ this.ws.send(serialized);
1541
+ return Promise.resolve();
1542
+ } catch (err) {
1543
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1544
+ }
1575
1545
  }
1576
- }
1577
- async function executeDisableProvider(ctx) {
1578
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1579
- const id = ctx.allocId();
1580
- const result = await ctx.sendRequest(id, "providers/disable", {});
1581
- if (isJsonRpcError(result)) {
1582
- throw new ACPSessionError(
1583
- "prompt_failed",
1584
- `providers/disable failed: ${result.message}`,
1585
- result
1586
- );
1546
+ onMessage(handler) {
1547
+ this.handlers.add(handler);
1548
+ return () => this.handlers.delete(handler);
1587
1549
  }
1588
- }
1589
- async function executeMcpMessage(ctx, connectionId, message) {
1590
- if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
1591
- const id = ctx.allocId();
1592
- const result = await ctx.sendRequest(id, "mcp/message", { connectionId, message });
1593
- if (isJsonRpcError(result)) {
1594
- throw new ACPSessionError("prompt_failed", `mcp/message failed: ${result.message}`, result);
1550
+ stop() {
1551
+ this.closed = true;
1552
+ this.handlers.clear();
1553
+ if (this.pendingStart !== null) {
1554
+ const pending = this.pendingStart;
1555
+ this.pendingStart = null;
1556
+ clearTimeout(pending.timer);
1557
+ try {
1558
+ pending.reject(new Error("WebSocket transport stopped while connecting"));
1559
+ } catch {
1560
+ }
1561
+ }
1562
+ if (this.ws) {
1563
+ try {
1564
+ this.ws.close();
1565
+ } catch {
1566
+ }
1567
+ this.ws = null;
1568
+ }
1595
1569
  }
1596
- return result;
1597
- }
1598
- async function executeCreateSession(ctx) {
1599
- const servers = filterMcpServers(ctx.agentCapabilities, ctx.opts.mcpServers);
1600
- const id = ctx.allocId();
1601
- const result = await ctx.sendRequest(id, "session/new", {
1602
- cwd: ctx.opts.cwd ?? ctx.opts.projectRoot,
1603
- mcpServers: servers
1604
- });
1605
- if (isJsonRpcError(result)) {
1606
- throw new ACPSessionError(
1607
- "session_create_failed",
1608
- `session/new failed: ${result.message}`,
1609
- result
1610
- );
1570
+ onData(data) {
1571
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
1572
+ if (text.length > this.maxMessageChars) {
1573
+ this.stop();
1574
+ return;
1575
+ }
1576
+ if (!text.trim()) return;
1577
+ let msg;
1578
+ try {
1579
+ msg = JSON.parse(text);
1580
+ } catch {
1581
+ for (const line of text.split("\n")) {
1582
+ if (!line.trim()) continue;
1583
+ try {
1584
+ this.dispatch(JSON.parse(line));
1585
+ } catch {
1586
+ }
1587
+ }
1588
+ return;
1589
+ }
1590
+ this.dispatch(msg);
1611
1591
  }
1612
- const sessionId = result.sessionId;
1613
- if (typeof sessionId !== "string" || sessionId.length === 0) {
1614
- throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
1592
+ dispatch(msg) {
1593
+ for (const handler of [...this.handlers]) {
1594
+ try {
1595
+ handler(msg);
1596
+ } catch {
1597
+ }
1598
+ }
1615
1599
  }
1616
- return sessionId;
1600
+ };
1601
+ function finitePositiveLimit(value, fallback) {
1602
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
1617
1603
  }
1618
1604
 
1619
1605
  // src/client/acp-session.ts
@@ -2199,11 +2185,7 @@ async function makeACPSubagentRunnerWithStop(options) {
2199
2185
  options.onProgress?.(event);
2200
2186
  };
2201
2187
  try {
2202
- const result = await session.prompt(
2203
- [textContent(task.description)],
2204
- ctx.signal,
2205
- onProgress
2206
- );
2188
+ const result = await session.prompt([textContent(task.description)], ctx.signal, onProgress);
2207
2189
  return {
2208
2190
  result: result.text,
2209
2191
  iterations: 1,