1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5
6import * as Cause from "effect/Cause";
7import * as Clock from "effect/Clock";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Queue from "effect/Queue";
18import * as Ref from "effect/Ref";
19import * as Schema from "effect/Schema";
20import * as Scope from "effect/Scope";
21import * as Semaphore from "effect/Semaphore";
22import * as Stream from "effect/Stream";
23import * as ChildProcess from "effect/process/ChildProcess";
24import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
25import * as EffectAcpClient from "effect-acp/client";
26import * as EffectAcpErrors from "effect-acp/errors";
27import type * as EffectAcpSchema from "effect-acp/compat";
28import type * as EffectAcpProtocol from "effect-acp/protocol";
29import { resolveSpawnCommand } from "@t3tools/shared/shell";
30import { AgentScope, AgentScopeThreadId, RAISE_OOM_SCORE_LINE } from "@t3tools/shared/AgentScope";
31import * as HostProcess from "@t3tools/shared/HostProcess";
32
33import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
34import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./stderr.ts";
35import {
36 collectSessionConfigOptionValues,
37 decideToolCallUpdateEmission,
38 extractModelConfigId,
39 findSessionConfigOption,
40 mergeToolCallState,
41 toolCallProgressLength,
42 parseSessionModeState,
43 parseSessionUpdateEvent,
44 sessionUpdateCountsAsLoadReplayActivity,
45 sessionUpdateIsReplay,
46 waitForSessionLoadReplayIdle,
47 type SessionLoadGate,
48 type AcpParsedSessionEvent,
49 type AcpSessionModeState,
50 type AcpToolCallState,
51} from "./runtimeModel.ts";
52
53const MAX_SHOWN_TOOL_CALL_IDS = 256;
54
55interface AcpToolCallTrackedState {
56 readonly state: AcpToolCallState;
57 readonly lastEmittedDetailLength: number | undefined;
58 readonly skippedSinceEmit: number;
59}
60
61function formatConfigOptionValue(value: string | boolean): string {
62 return JSON.stringify(value);
63}
64
65export interface AcpSessionEventStreamBarrier {
66 readonly _tag: "EventStreamBarrier";
67 readonly acknowledge: Deferred.Deferred<void>;
68}
69
70export type AcpSessionRuntimeEvent =
71 | AcpParsedSessionEvent
72 | AcpSessionEventStreamBarrier
73 | {
74 readonly _tag: "ConnectionTerminated";
75 readonly error: EffectAcpErrors.AcpError;
76 };
77
78const defaultSessionLoadTimeout = Duration.seconds(90);
79const defaultSessionLoadReplayIdleGap = Duration.seconds(2);
80const defaultCancelTimeout = Duration.seconds(15);
81const maxStartupMetadataUpdates = 32;
82// Antigravity can emit an accepted 16 KiB Google authorization URL on stderr.
83const maxStderrChunkLength = 32_768;
84
85export interface AcpSpawnInput {
86 readonly command: string;
87 readonly args: ReadonlyArray<string>;
88 readonly cwd?: string;
89 readonly env?: NodeJS.ProcessEnv;
90 readonly extendEnv?: boolean;
91 readonly shell?: false;
92}
93
94export interface AcpSessionRuntimeOptions {
95 readonly spawn: AcpSpawnInput;
96 readonly cwd: string;
97 readonly resumeSessionId?: string;
98 readonly resumeMethod?: "load" | "resume";
99 readonly sessionLoadTimeout?: Duration.Input;
100 readonly sessionLoadReplayIdleGap?: Duration.Input;
101 readonly interruptPromptOnCancel?: boolean;
102 /** Optional provider metadata forwarded on `session/cancel`. */
103 readonly cancelMeta?: EffectAcpSchema.CancelNotification["_meta"];
104 /** Optional provider metadata forwarded on `initialize`. */
105 readonly initializeMeta?: EffectAcpSchema.InitializeRequest["_meta"];
106 readonly ownDetachedProcessGroup?: boolean;
107 readonly ownDescendantProcessGroups?: boolean;
108 readonly processGroupPlatform?: NodeJS.Platform;
109 readonly processGroupTerminationGrace?: Duration.Input;
110 readonly windowsProcessTreeTerminator?: (
111 pid: number,
112 ) => Effect.Effect<void, AcpProcessGroupTerminationError>;
113 readonly posixProcessTreeController?: AcpPosixProcessTreeController;
114 readonly linuxCgroupController?: AcpLinuxCgroupController | null;
115 /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */
116 readonly cancelBehavior?: "interrupt" | "wait-for-prompt";
117 readonly cancelTimeout?: Duration.Input;
118 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];
119 readonly clientInfo: {
120 readonly name: string;
121 readonly version: string;
122 };
123 readonly authMethodId?: string;
124 /**
125 * Call `authenticate` right after `initialize` instead of waiting for an
126 * auth-required error. Antigravity selects its credential source (Google
127 * account vs API key) from the method, so the session must not open first.
128 */
129 readonly authenticateEagerly?: boolean;
130 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
131 /** Extra workspace roots the agent may read and write besides `cwd`. */
132 readonly additionalDirectories?: ReadonlyArray<string>;
133 /** Transforms provider stdout before protocol parsing and protocol logging. */
134 readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"];
135 /** Normalizes provider-specific fields before notification queues or runtime state retain them. */
136 readonly transformSessionUpdate?: (
137 notification: EffectAcpSchema.SessionNotification,
138 ) => EffectAcpSchema.SessionNotification;
139 /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */
140 readonly onStderr?: (text: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
141 /** Disable only for non-interactive discovery that must surface auth-required immediately. */
142 readonly authenticateOnAuthRequired?: boolean;
143 /** Observes the normalized handshake before any session setup or authentication retry. */
144 readonly onInitialized?: (
145 result: EffectAcpSchema.InitializeResponse,
146 ) => Effect.Effect<void, never>;
147 readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
148 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
149 readonly protocolLogging?: {
150 readonly logIncoming?: boolean;
151 readonly logOutgoing?: boolean;
152 readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
153 };
154 readonly onIncomingRequest?: EffectAcpClient.AcpClientOptions["onIncomingRequest"];
155 readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect<void>;
156 readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"];
157 readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"];
158 /**
159 * Model ids the user configured for this instance. The model config option
160 * accepts them even when the agent does not advertise them, and the agent
161 * validates them instead.
162 */
163 readonly customModels?: ReadonlyArray<string>;
164}
165
166export interface AcpSessionRequestLogEvent {
167 readonly method: string;
168 readonly payload: unknown;
169 readonly status: "started" | "succeeded" | "failed";
170 readonly result?: unknown;
171 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;
172}
173
174export class AcpProcessGroupTerminationError extends Schema.TaggedError<AcpProcessGroupTerminationError>()(
175 "AcpProcessGroupTerminationError",
176 {
177 detail: Schema.String,
178 pid: Schema.optionalKey(Schema.Int),
179 exitCode: Schema.optionalKey(Schema.Number),
180 cause: Schema.optional(Schema.Defect()),
181 },
182) {
183 override get message(): string {
184 return this.detail;
185 }
186}
187
188const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError);
189
190export interface AcpPosixProcessIdentity {
191 readonly executable: string | undefined;
192 readonly pgid: number;
193 readonly pid: number;
194 readonly ppid: number;
195 readonly sid: number;
196 /**
197 * Linux `/proc/<pid>/stat` state when known (`R`/`S`/`D`/`Z`/…). Omitted on
198 * platforms or fixtures that do not surface it. Zombies (`Z`) are already
199 * dead and must not count as residual teardown survivors.
200 */
201 readonly state?: string;
202 readonly startTime: string;
203}
204
205export interface AcpPosixProcessTreeController {
206 readonly childPidsOf: (pid: number) => ReadonlyArray<number>;
207 readonly childrenOf: (pid: number) => ReadonlyArray<AcpPosixProcessIdentity>;
208 readonly identity: (pid: number) => AcpPosixProcessIdentity | undefined;
209 readonly snapshot: () => ReadonlyArray<AcpPosixProcessIdentity>;
210 readonly signalProcess: (pid: number, signal: NodeJS.Signals) => void;
211}
212
213export interface AcpLinuxCgroupLease {
214 readonly contains: (pid: number) => boolean;
215 readonly exists: () => boolean;
216 readonly path: string;
217 readonly relativePath: string;
218 readonly kill: () => void;
219 readonly populated: () => boolean;
220 readonly remove: () => void;
221}
222
223export interface AcpLinuxCgroupController {
224 readonly create: () => AcpLinuxCgroupLease | undefined;
225}
226
227export interface AcpPosixOwnershipRoot {
228 captureAttempted?: boolean;
229 value: AcpOwnedPosixProcess | undefined;
230}
231
232function decodeMountInfoPath(value: string): string {
233 return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
234 String.fromCharCode(Number.parseInt(octal, 8)),
235 );
236}
237
238export function parseUnifiedCgroupPath(contents: string): string | undefined {
239 const matches = [...contents.matchAll(/^0::(\/.*)$/gm)];
240 return matches.length === 1 ? matches[0]![1] : undefined;
241}
242
243export function isSafeCgroupPath(value: string): boolean {
244 return (
245 value.startsWith("/") &&
246 !value.includes("(deleted)") &&
247 NodePath.posix.normalize(value) === value
248 );
249}
250
251export function parseCgroup2Mounts(
252 contents: string,
253): ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> {
254 const mounts: Array<{ readonly mountPoint: string; readonly root: string }> = [];
255 for (const line of contents.split("\n")) {
256 const [mount, filesystem] = line.split(" - ");
257 if (mount === undefined || filesystem?.split(" ")[0] !== "cgroup2") continue;
258 const fields = mount.split(" ");
259 if (fields.length < 5) continue;
260 mounts.push({
261 mountPoint: decodeMountInfoPath(fields[4]!),
262 root: decodeMountInfoPath(fields[3]!),
263 });
264 }
265 return mounts;
266}
267
268export function parseCgroup2Mount(
269 contents: string,
270): { readonly mountPoint: string; readonly root: string } | undefined {
271 return parseCgroup2Mounts(contents)[0];
272}
273
274export function wrapCommandForLinuxCgroup(
275 lease: AcpLinuxCgroupLease,
276 command: string,
277 args: ReadonlyArray<string>,
278): { readonly command: string; readonly args: ReadonlyArray<string> } {
279 return {
280 command: "/bin/sh",
281 args: [
282 "-c",
283 [
284 "lease_path=$1; expected=$2; shift 2",
285 RAISE_OOM_SCORE_LINE,
286 'printf "%s\\n" "$$" > "$lease_path/cgroup.procs" || exit 125',
287 "actual=",
288 "while IFS= read -r line; do",
289 ' case "$line" in 0::*) [ -z "$actual" ] || exit 126; actual=${line#0::};; esac',
290 "done < /proc/self/cgroup || exit 125",
291 '[ "$actual" = "$expected" ] || exit 126',
292 "unset ELECTRON_RUN_AS_NODE T3_ACP_CGROUP_WRAPPER",
293 "trap 'exit 125' 0",
294 // The trap alone does not cover a failed `exec`: bash — which is
295 // /bin/sh on Arch derivatives — exits 127 without running the EXIT
296 // trap, so a missing agent binary reported "killed by the wrapper"
297 // instead of "never launched". The caller always hands us an absolute
298 // target, so test it as an executable file rather than asking the
299 // shell to resolve it.
300 '[ -f "$1" ] && [ -x "$1" ] || exit 125',
301 'exec "$@"',
302 ].join("\n"),
303 "t3-acp-cgroup-wrapper",
304 lease.path,
305 lease.relativePath,
306 command,
307 ...args,
308 ],
309 };
310}
311
312export function resolveLinuxCgroupTargetCommand(
313 command: string,
314 cwd: string,
315 environment: NodeJS.ProcessEnv,
316): string | undefined {
317 // Match Node spawn PATH fallback when PATH is undefined. An explicitly empty
318 // PATH stays empty (lookup only via empty-segment → cwd).
319 const pathEnv = environment.PATH === undefined ? "/usr/bin:/bin" : environment.PATH;
320 const candidates = command.includes(NodePath.sep)
321 ? [NodePath.resolve(cwd, command)]
322 : pathEnv
323 .split(NodePath.delimiter)
324 .map((entry) =>
325 NodePath.join(
326 entry.length === 0
327 ? cwd
328 : NodePath.isAbsolute(entry)
329 ? entry
330 : NodePath.resolve(cwd, entry),
331 command,
332 ),
333 );
334 for (const candidate of candidates) {
335 try {
336 if (!NodeFS.statSync(candidate).isFile()) continue;
337 NodeFS.accessSync(candidate, NodeFS.constants.X_OK);
338 return candidate;
339 } catch {
340 // Try the next PATH entry.
341 }
342 }
343 return undefined;
344}
345
346const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/;
347
348export interface SweepStaleLinuxCgroupSiblingsOptions {
349 readonly currentPid?: number;
350 readonly isProcessAlive?: (pid: number) => boolean;
351 readonly readPopulated?: (siblingPath: string) => "0" | "1" | undefined;
352 readonly remove?: (siblingPath: string) => void;
353}
354
355function defaultCgroupOwnerIsAlive(pid: number): boolean {
356 try {
357 process.kill(pid, 0);
358 return true;
359 } catch (cause) {
360 return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH";
361 }
362}
363
364function defaultReadCgroupPopulated(siblingPath: string): "0" | "1" | undefined {
365 const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec(
366 NodeFS.readFileSync(NodePath.join(siblingPath, "cgroup.events"), "utf8"),
367 )?.[1];
368 return state === "0" || state === "1" ? state : undefined;
369}
370
371/** Best-effort removal of empty `t3-acp-<dead-pid>-*` sibling leases under a parent cgroup. */
372export function sweepStaleLinuxCgroupSiblings(
373 parentPath: string,
374 options: SweepStaleLinuxCgroupSiblingsOptions = {},
375): void {
376 const currentPid = options.currentPid ?? process.pid;
377 const isProcessAlive = options.isProcessAlive ?? defaultCgroupOwnerIsAlive;
378 const readPopulated = options.readPopulated ?? defaultReadCgroupPopulated;
379 const remove = options.remove ?? ((siblingPath: string) => NodeFS.rmdirSync(siblingPath));
380 try {
381 for (const entry of NodeFS.readdirSync(parentPath, { withFileTypes: true })) {
382 try {
383 if (!entry.isDirectory()) continue;
384 const match = STALE_ACP_CGROUP_SIBLING.exec(entry.name);
385 if (match === null) continue;
386 const ownerPid = Number(match[1]);
387 if (!Number.isSafeInteger(ownerPid) || ownerPid <= 1 || ownerPid === currentPid) continue;
388 if (isProcessAlive(ownerPid)) continue;
389 const siblingPath = NodePath.join(parentPath, entry.name);
390 if (NodePath.dirname(siblingPath) !== parentPath) continue;
391 if (readPopulated(siblingPath) !== "0") continue;
392 remove(siblingPath);
393 } catch {
394 // Ignore unreadable or busy siblings; never fail lease creation.
395 }
396 }
397 } catch {
398 // Ignore readdir failures on the parent path.
399 }
400}
401
402function tryCreateLinuxCgroupLease(
403 currentRelative: string,
404 mount: { readonly mountPoint: string; readonly root: string },
405): AcpLinuxCgroupLease | undefined {
406 if (
407 !isSafeCgroupPath(mount.root) ||
408 !isSafeCgroupPath(mount.mountPoint) ||
409 !(
410 mount.root === "/" ||
411 currentRelative === mount.root ||
412 currentRelative.startsWith(`${mount.root}/`)
413 )
414 ) {
415 return undefined;
416 }
417 const mountRelative =
418 mount.root === "/" ? currentRelative : currentRelative.slice(mount.root.length);
419 const currentPath = NodePath.resolve(mount.mountPoint, `.${mountRelative}`);
420 const resolvedMount = NodePath.resolve(mount.mountPoint);
421 if (currentPath !== resolvedMount && !currentPath.startsWith(`${resolvedMount}${NodePath.sep}`)) {
422 return undefined;
423 }
424 sweepStaleLinuxCgroupSiblings(currentPath);
425 const childName = `t3-acp-${process.pid}-${NodeCrypto.randomUUID().replaceAll("-", "")}`;
426 const childPath = NodePath.join(currentPath, childName);
427 const childRelative = NodePath.posix.join(currentRelative, childName);
428 if (NodePath.dirname(childPath) !== currentPath) return undefined;
429 try {
430 NodeFS.mkdirSync(childPath, { mode: 0o700 });
431 NodeFS.accessSync(NodePath.join(childPath, "cgroup.procs"), NodeFS.constants.W_OK);
432 NodeFS.accessSync(NodePath.join(childPath, "cgroup.kill"), NodeFS.constants.W_OK);
433 NodeFS.accessSync(NodePath.join(childPath, "cgroup.events"), NodeFS.constants.R_OK);
434 if (NodeFS.readFileSync(NodePath.join(childPath, "cgroup.type"), "utf8").trim() !== "domain") {
435 throw new Error("ACP cgroup is not a domain cgroup");
436 }
437 if (typeof process.execve !== "function") throw new Error("process.execve is unavailable");
438 } catch {
439 try {
440 NodeFS.rmdirSync(childPath);
441 } catch {
442 // The unavailable cgroup was never used for a child process.
443 }
444 return undefined;
445 }
446 return {
447 contains: (pid) =>
448 parseUnifiedCgroupPath(NodeFS.readFileSync(`/proc/${pid}/cgroup`, "utf8")) === childRelative,
449 exists: () => NodeFS.existsSync(childPath),
450 path: childPath,
451 relativePath: childRelative,
452 kill: () => NodeFS.writeFileSync(NodePath.join(childPath, "cgroup.kill"), "1\n"),
453 populated: () => {
454 const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec(
455 NodeFS.readFileSync(NodePath.join(childPath, "cgroup.events"), "utf8"),
456 )?.[1];
457 if (state === undefined) throw new Error("ACP cgroup.events has no populated state");
458 return state === "1";
459 },
460 remove: () => {
461 const directories = (path: string): ReadonlyArray<string> =>
462 NodeFS.readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
463 entry.isDirectory()
464 ? [...directories(NodePath.join(path, entry.name)), NodePath.join(path, entry.name)]
465 : [],
466 );
467 for (const directory of directories(childPath)) NodeFS.rmdirSync(directory);
468 NodeFS.rmdirSync(childPath);
469 },
470 };
471}
472
473export function makeLinuxCgroupController(): AcpLinuxCgroupController {
474 return {
475 create: () => {
476 let currentRelative: string | undefined;
477 let mounts: ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> = [];
478 try {
479 currentRelative = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8"));
480 mounts = parseCgroup2Mounts(NodeFS.readFileSync("/proc/self/mountinfo", "utf8"));
481 } catch {
482 return undefined;
483 }
484 if (
485 currentRelative === undefined ||
486 !currentRelative.startsWith("/") ||
487 mounts.length === 0
488 ) {
489 return undefined;
490 }
491 if (!isSafeCgroupPath(currentRelative)) return undefined;
492 // Prefer the first mount that successfully creates a writable child cgroup.
493 // Multi-mount hosts often list a read-only view before the delegated one.
494 for (const mount of mounts) {
495 const lease = tryCreateLinuxCgroupLease(currentRelative, mount);
496 if (lease !== undefined) return lease;
497 }
498 return undefined;
499 },
500 };
501}
502
503// OS-state polls (cgroup emptiness, process trees) must use wall time. Under
504// TestClock, Effect.sleep never resolves unless the test advances the clock.
505const wallClock = Clock.Clock.defaultValue();
506const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
507 Effect.provideService(effect, Clock.Clock, wallClock);
508
509export function terminateLinuxCgroupLease(
510 lease: AcpLinuxCgroupLease,
511): Effect.Effect<void, AcpProcessGroupTerminationError> {
512 const failure = (name: string, cause: unknown) =>
513 new AcpProcessGroupTerminationError({
514 cause,
515 detail: `Failed to ${name} ACP cgroup ${lease.path}`,
516 });
517 return Effect.gen(function* () {
518 let lastRemovalFailure: { readonly cause: unknown } | null = null;
519 for (let attempt = 0; attempt < 200; attempt += 1) {
520 if (!lease.exists()) return;
521 try {
522 lease.kill();
523 } catch (cause) {
524 const code = (cause as NodeJS.ErrnoException | undefined)?.code;
525 if (code !== "ENOENT") return yield* failure("kill", cause);
526 if (!lease.exists()) return;
527 yield* Effect.sleep("10 millis");
528 continue;
529 }
530 let populated: boolean;
531 try {
532 populated = lease.populated();
533 } catch (cause) {
534 const code = (cause as NodeJS.ErrnoException | undefined)?.code;
535 if (code === "ENOENT" && !lease.exists()) return;
536 if (code === "ENOENT") {
537 yield* Effect.sleep("10 millis");
538 continue;
539 }
540 return yield* failure("read state for", cause);
541 }
542 if (populated) {
543 lastRemovalFailure = null;
544 yield* Effect.sleep("10 millis");
545 continue;
546 }
547 try {
548 lease.remove();
549 lastRemovalFailure = null;
550 } catch (cause) {
551 const code = (cause as NodeJS.ErrnoException | undefined)?.code;
552 if (code === "ENOENT" && !lease.exists()) return;
553 if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "ENOENT") {
554 return yield* failure("remove", cause);
555 }
556 lastRemovalFailure = { cause };
557 }
558 if (!lease.exists()) return;
559 yield* Effect.sleep("10 millis");
560 }
561 if (lastRemovalFailure !== null) {
562 return yield* failure("remove", lastRemovalFailure.cause);
563 }
564 return yield* new AcpProcessGroupTerminationError({
565 detail: `ACP cgroup ${lease.path} remained present after cgroup cleanup`,
566 });
567 }).pipe(withWallClock);
568}
569
570export function signalLinuxCgroupRootTerm(input: {
571 readonly controller: AcpPosixProcessTreeController;
572 readonly lease: AcpLinuxCgroupLease;
573 readonly root: AcpPosixOwnershipRoot;
574}): void {
575 const root = input.root.value;
576 if (root === undefined) return;
577 try {
578 const observed = input.controller.identity(root.pid);
579 if (!samePosixProcessIdentity(root, observed) || !input.lease.contains(root.pid)) return;
580 input.controller.signalProcess(root.pid, "SIGTERM");
581 } catch {
582 // TERM is optional. cgroup.kill remains the authoritative teardown.
583 }
584}
585
586function readLinuxProcessIdentity(pid: number): AcpPosixProcessIdentity | undefined {
587 try {
588 const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8");
589 const commandEnd = stat.lastIndexOf(")");
590 if (commandEnd < 0) return undefined;
591 const fields = stat
592 .slice(commandEnd + 2)
593 .trim()
594 .split(/\s+/);
595 // After comm: state, ppid, pgrp, session, … starttime (field 22 / index 19).
596 const state = fields[0];
597 const ppid = Number(fields[1]);
598 const pgid = Number(fields[2]);
599 const sid = Number(fields[3]);
600 const startTime = fields[19];
601 if (
602 state === undefined ||
603 !Number.isSafeInteger(ppid) ||
604 !Number.isSafeInteger(pgid) ||
605 !Number.isSafeInteger(sid) ||
606 startTime === undefined
607 ) {
608 return undefined;
609 }
610 let executable: string | undefined;
611 try {
612 executable = NodeFS.readlinkSync(`/proc/${pid}/exe`);
613 } catch {
614 executable = undefined;
615 }
616 return { executable, pgid, pid, ppid, sid, startTime, state };
617 } catch {
618 return undefined;
619 }
620}
621
622/** Linux zombies keep pid/starttime until reaped; they are not live survivors. */
623export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean {
624 return process.state === "Z";
625}
626
627function readLinuxChildPids(pid: number): ReadonlyArray<number> {
628 const childPids = new Set<number>();
629 let taskIds: ReadonlyArray<string> = [];
630 try {
631 taskIds = NodeFS.readdirSync(`/proc/${pid}/task`).filter((entry) =>
632 /^[1-9][0-9]*$/.test(entry),
633 );
634 } catch {
635 return [];
636 }
637 for (const taskId of taskIds) {
638 try {
639 for (const childPid of NodeFS.readFileSync(`/proc/${pid}/task/${taskId}/children`, "utf8")
640 .trim()
641 .split(/\s+/)
642 .filter(Boolean)
643 .map(Number)) {
644 childPids.add(childPid);
645 }
646 } catch {
647 // The task exited while its children were being read.
648 }
649 }
650 return [...childPids];
651}
652
653function makeLinuxProcessTreeController(): AcpPosixProcessTreeController {
654 return {
655 childPidsOf: readLinuxChildPids,
656 childrenOf: (pid) =>
657 readLinuxChildPids(pid).flatMap((childPid) => {
658 const identity = readLinuxProcessIdentity(childPid);
659 return identity === undefined ? [] : [identity];
660 }),
661 identity: readLinuxProcessIdentity,
662 snapshot: () =>
663 NodeFS.readdirSync("/proc", { withFileTypes: true }).flatMap((entry) => {
664 if (!entry.isDirectory() || !/^[1-9][0-9]*$/.test(entry.name)) return [];
665 const identity = readLinuxProcessIdentity(Number(entry.name));
666 return identity === undefined ? [] : [identity];
667 }),
668 signalProcess: (pid, signal) => process.kill(pid, signal),
669 };
670}
671
672export function observePosixOwnershipLedger(input: {
673 readonly childQueues: Map<number, Array<number>>;
674 readonly controller: AcpPosixProcessTreeController;
675 readonly frontier: Map<number, AcpOwnedPosixProcess>;
676 readonly ledger: Map<string, AcpOwnedPosixProcess>;
677 readonly maxProcesses?: number;
678 readonly root: AcpPosixOwnershipRoot;
679 readonly rootPid: number;
680}): void {
681 let rootIdentity: AcpPosixProcessIdentity | undefined = input.root.value;
682 if (rootIdentity === undefined) {
683 if (input.root.captureAttempted === true) {
684 throw new AcpProcessGroupTerminationError({
685 detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`,
686 });
687 }
688 input.root.captureAttempted = true;
689 rootIdentity = input.controller.identity(input.rootPid);
690 if (rootIdentity === undefined) {
691 throw new AcpProcessGroupTerminationError({
692 detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`,
693 });
694 }
695 const parent = input.controller.identity(rootIdentity.ppid);
696 const ownedRoot = {
697 ...rootIdentity,
698 parentExecutable: parent?.executable,
699 parentStartTime: parent?.startTime ?? "",
700 };
701 input.ledger.set(processIdentityKey(rootIdentity), ownedRoot);
702 input.frontier.set(rootIdentity.pid, ownedRoot);
703 input.root.value = ownedRoot;
704 }
705 const maxProcesses = Math.max(1, input.maxProcesses ?? Number.POSITIVE_INFINITY);
706 let remainingBudget = maxProcesses;
707 const pending = [...input.frontier.entries()];
708 const visited = new Set<number>();
709 while (pending.length > 0 && remainingBudget > 0) {
710 const entry = pending.shift();
711 if (entry === undefined) continue;
712 const [parentPid, parent] = entry;
713 input.frontier.delete(parentPid);
714 if (visited.has(parent.pid)) continue;
715 visited.add(parent.pid);
716 remainingBudget -= 1;
717 const observedParent = input.controller.identity(parent.pid);
718 if (!samePosixProcessIdentity(parent, observedParent)) {
719 input.childQueues.delete(parent.pid);
720 continue;
721 }
722 const refreshedParent = { ...parent, ...observedParent };
723 input.ledger.set(processIdentityKey(refreshedParent), refreshedParent);
724 input.frontier.set(refreshedParent.pid, refreshedParent);
725 let queuedChildren = input.childQueues.get(refreshedParent.pid);
726 if (queuedChildren === undefined) {
727 if (remainingBudget === 0) continue;
728 remainingBudget -= 1;
729 queuedChildren = [...input.controller.childPidsOf(refreshedParent.pid)];
730 }
731 while (queuedChildren.length > 0 && remainingBudget > 0) {
732 const childPid = queuedChildren.shift();
733 if (childPid === undefined) continue;
734 remainingBudget -= 1;
735 const child = input.controller.identity(childPid);
736 if (child === undefined) continue;
737 if (child.ppid !== parent.pid) continue;
738 let reservedPid = input.frontier.get(child.pid);
739 if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) {
740 // PID reused under an owned parent; drop the stale reservation.
741 input.frontier.delete(child.pid);
742 for (const [key, owned] of input.ledger.entries()) {
743 if (owned.pid === child.pid) input.ledger.delete(key);
744 }
745 reservedPid = undefined;
746 }
747 const childKey = processIdentityKey(child);
748 const owned =
749 reservedPid === undefined
750 ? {
751 ...child,
752 parentExecutable: refreshedParent.executable,
753 parentStartTime: refreshedParent.startTime,
754 }
755 : { ...reservedPid, ...child };
756 input.ledger.set(childKey, owned);
757 input.frontier.set(child.pid, owned);
758 }
759 if (queuedChildren.length === 0) input.childQueues.delete(refreshedParent.pid);
760 else input.childQueues.set(refreshedParent.pid, queuedChildren);
761 }
762}
763
764export function observePosixOwnershipLedgerContinuously(input: {
765 readonly childQueues: Map<number, Array<number>>;
766 readonly controller: AcpPosixProcessTreeController;
767 readonly frontier: Map<number, AcpOwnedPosixProcess>;
768 readonly ledger: Map<string, AcpOwnedPosixProcess>;
769 readonly root: AcpPosixOwnershipRoot;
770 readonly rootPid: number;
771}): Effect.Effect<never> {
772 return Effect.forever(
773 Effect.gen(function* () {
774 const startedAt = yield* Clock.currentTimeMillis;
775 yield* Effect.sync(() => observePosixOwnershipLedger({ ...input, maxProcesses: 64 })).pipe(
776 Effect.exit,
777 );
778 const targetInterval = input.frontier.size <= 32 ? 25 : input.frontier.size <= 128 ? 50 : 100;
779 const completedAt = yield* Clock.currentTimeMillis;
780 yield* Effect.sleep(`${Math.max(5, targetInterval - (completedAt - startedAt))} millis`);
781 }),
782 ).pipe(withWallClock);
783}
784
785export function makePosixProcessTreeController(
786 platform: NodeJS.Platform,
787): AcpPosixProcessTreeController {
788 if (platform === "linux") return makeLinuxProcessTreeController();
789 throw new AcpProcessGroupTerminationError({
790 detail: `Detached ACP descendant ownership is unsupported on ${platform}`,
791 });
792}
793
794function samePosixProcessIdentity(
795 expected: AcpPosixProcessIdentity,
796 observed: AcpPosixProcessIdentity | undefined,
797): boolean {
798 return (
799 observed !== undefined &&
800 observed.pid === expected.pid &&
801 observed.startTime === expected.startTime
802 );
803}
804
805export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity {
806 readonly parentExecutable: string | undefined;
807 readonly parentStartTime: string;
808}
809
810function processIdentityKey(process: AcpPosixProcessIdentity): string {
811 return `${process.pid}:${process.startTime}`;
812}
813
814function processDepth(
815 process: AcpPosixProcessIdentity,
816 ledgerByPid: ReadonlyMap<number, AcpOwnedPosixProcess>,
817): number {
818 let depth = 0;
819 let current = process;
820 const seen = new Set<number>();
821 while (!seen.has(current.pid)) {
822 seen.add(current.pid);
823 const parent = ledgerByPid.get(current.ppid);
824 if (parent === undefined) break;
825 depth += 1;
826 current = parent;
827 }
828 return depth;
829}
830
831export function capturePosixOwnershipLedger(input: {
832 readonly controller: AcpPosixProcessTreeController;
833 readonly ledger: Map<string, AcpOwnedPosixProcess>;
834 readonly root?: AcpPosixOwnershipRoot;
835 readonly rootPid: number;
836 readonly table?: ReadonlyArray<AcpPosixProcessIdentity>;
837}): ReadonlyArray<AcpPosixProcessIdentity> {
838 let table: ReadonlyArray<AcpPosixProcessIdentity>;
839 if (input.table === undefined) {
840 try {
841 table = input.controller.snapshot();
842 } catch (cause) {
843 throw new AcpProcessGroupTerminationError({
844 cause,
845 detail: `Failed to snapshot ACP process tree ${input.rootPid}`,
846 });
847 }
848 } else {
849 table = input.table;
850 }
851 const byPid = new Map(table.map((process) => [process.pid, process]));
852 let rootIdentity: AcpPosixProcessIdentity | undefined =
853 input.root?.value ??
854 [...input.ledger.values()].find((process) => process.pid === input.rootPid);
855 if (rootIdentity === undefined) {
856 rootIdentity = byPid.get(input.rootPid);
857 if (rootIdentity === undefined) {
858 throw new AcpProcessGroupTerminationError({
859 detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`,
860 });
861 }
862 const ownedRoot = {
863 ...rootIdentity,
864 parentExecutable: byPid.get(rootIdentity.ppid)?.executable,
865 parentStartTime: byPid.get(rootIdentity.ppid)?.startTime ?? "",
866 };
867 input.ledger.set(processIdentityKey(rootIdentity), ownedRoot);
868 if (input.root !== undefined) input.root.value = ownedRoot;
869 }
870 // Drop non-root ledger entries whose PID is free or whose identity no longer
871 // matches the live process at that PID. Either case can block admission of a
872 // legitimate child after PID reuse. Keep the root reservation even if the
873 // root briefly disappears mid-teardown (finalizer retry still needs it).
874 for (const [key, owned] of input.ledger.entries()) {
875 if (owned.pid === input.rootPid) continue;
876 const live = byPid.get(owned.pid);
877 if (live === undefined || !samePosixProcessIdentity(owned, live)) {
878 input.ledger.delete(key);
879 }
880 }
881 const children = new Map<number, Array<AcpPosixProcessIdentity>>();
882 const retainedByPid = new Map(
883 [...input.ledger.values()]
884 .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid)))
885 .map((owned) => [owned.pid, owned]),
886 );
887 for (const process of table) {
888 const siblings = children.get(process.ppid) ?? [];
889 siblings.push(process);
890 children.set(process.ppid, siblings);
891 }
892 const pending = [...input.ledger.values()].filter((owned) =>
893 samePosixProcessIdentity(owned, byPid.get(owned.pid)),
894 );
895 const visited = new Set<number>();
896 while (pending.length > 0) {
897 const parent = pending.shift();
898 if (parent === undefined || visited.has(parent.pid)) continue;
899 visited.add(parent.pid);
900 const observedParent = byPid.get(parent.pid);
901 if (!samePosixProcessIdentity(parent, observedParent)) continue;
902 const refreshedParent = { ...parent, ...observedParent };
903 input.ledger.set(processIdentityKey(parent), refreshedParent);
904 for (const process of children.get(refreshedParent.pid) ?? []) {
905 const reservedPid = retainedByPid.get(process.pid);
906 if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, process)) continue;
907 const owned =
908 reservedPid === undefined
909 ? {
910 ...process,
911 parentExecutable: refreshedParent.executable,
912 parentStartTime: refreshedParent.startTime,
913 }
914 : { ...reservedPid, ...process };
915 input.ledger.set(processIdentityKey(process), owned);
916 retainedByPid.set(process.pid, owned);
917 pending.push(owned);
918 }
919 }
920 return table;
921}
922
923export function terminatePosixOwnedProcessTree(input: {
924 readonly controller: AcpPosixProcessTreeController;
925 readonly discoveryPasses?: number;
926 readonly grace?: Duration.Input;
927 readonly ledger?: Map<string, AcpOwnedPosixProcess>;
928 readonly root?: AcpPosixOwnershipRoot;
929 readonly rootPid: number;
930}): Effect.Effect<void, AcpProcessGroupTerminationError> {
931 // This portable fallback can only admit processes whose exact parent chain is
932 // visible before it breaks. A descendant that double-forks before discovery
933 // requires pre-exec cgroup containment for a hard ownership guarantee.
934 const ledger = input.ledger ?? new Map<string, AcpOwnedPosixProcess>();
935
936 const fail = (detail: string, cause?: unknown) =>
937 new AcpProcessGroupTerminationError({ detail, ...(cause === undefined ? {} : { cause }) });
938 const snapshot = () => {
939 try {
940 return input.controller.snapshot();
941 } catch (cause) {
942 throw fail(`Failed to snapshot ACP process tree ${input.rootPid}`, cause);
943 }
944 };
945 const discover = (table: ReadonlyArray<AcpPosixProcessIdentity>) =>
946 capturePosixOwnershipLedger({
947 controller: input.controller,
948 ledger,
949 ...(input.root === undefined ? {} : { root: input.root }),
950 rootPid: input.rootPid,
951 table,
952 });
953 const signalPhase = (signal: NodeJS.Signals, includeRoot: boolean) => {
954 const table = snapshot();
955 discover(table);
956 const byPid = new Map(table.map((entry) => [entry.pid, entry]));
957 const current = input.controller.identity(process.pid);
958 if (current === undefined) throw fail("Cannot identify the current T3 process group");
959 const ledgerByPid = new Map(
960 [...ledger.values()].map((process) => [process.pid, process] as const),
961 );
962 const pending = [...ledger.values()].filter(
963 (candidate) =>
964 (includeRoot || candidate.pid !== input.rootPid) &&
965 samePosixProcessIdentity(candidate, byPid.get(candidate.pid)),
966 );
967 const retainedByPid = new Map(
968 [...ledger.values()]
969 .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid)))
970 .map((owned) => [owned.pid, owned]),
971 );
972 const signalled = new Set<number>();
973 while (pending.length > 0) {
974 pending.sort(
975 (left, right) => processDepth(right, ledgerByPid) - processDepth(left, ledgerByPid),
976 );
977 const candidate = pending.shift();
978 if (candidate === undefined || signalled.has(candidate.pid)) continue;
979 const observed = input.controller.identity(candidate.pid);
980 if (
981 candidate.pid <= 1 ||
982 candidate.pid === process.pid ||
983 observed === undefined ||
984 observed.pgid === current.pgid ||
985 observed.sid === current.sid ||
986 !samePosixProcessIdentity(candidate, observed)
987 ) {
988 continue;
989 }
990 for (const child of input.controller.childrenOf(candidate.pid)) {
991 if (child.ppid !== candidate.pid) continue;
992 const reservedPid = retainedByPid.get(child.pid);
993 if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) continue;
994 const owned =
995 reservedPid === undefined
996 ? {
997 ...child,
998 parentExecutable: observed.executable,
999 parentStartTime: observed.startTime,
1000 }
1001 : { ...reservedPid, ...child };
1002 ledger.set(processIdentityKey(child), owned);
1003 ledgerByPid.set(child.pid, owned);
1004 retainedByPid.set(child.pid, owned);
1005 // Signal newly discovered children in this same pass (fork-during-kill).
1006 if (
1007 (includeRoot || child.pid !== input.rootPid) &&
1008 !signalled.has(child.pid) &&
1009 samePosixProcessIdentity(owned, input.controller.identity(child.pid))
1010 ) {
1011 pending.push(owned);
1012 }
1013 }
1014 try {
1015 // Linux starttime prevents PID reuse across observations, but Node does
1016 // not expose pidfd_send_signal. A final read-to-kill race remains.
1017 input.controller.signalProcess(candidate.pid, signal);
1018 signalled.add(candidate.pid);
1019 } catch (cause) {
1020 if (samePosixProcessIdentity(candidate, input.controller.identity(candidate.pid))) {
1021 throw fail(`Failed to signal ACP process ${candidate.pid}`, cause);
1022 }
1023 }
1024 }
1025 };
1026
1027 return Effect.gen(function* () {
1028 const passes = Math.max(2, Math.min(input.discoveryPasses ?? 4, 8));
1029 for (let pass = 0; pass < passes; pass += 1) {
1030 yield* Effect.try({
1031 try: () => signalPhase("SIGTERM", false),
1032 catch: (cause) => cause as AcpProcessGroupTerminationError,
1033 });
1034 yield* Effect.sleep("10 millis");
1035 }
1036 yield* Effect.try({
1037 try: () => signalPhase("SIGTERM", true),
1038 catch: (cause) => cause as AcpProcessGroupTerminationError,
1039 });
1040 const grace = input.grace ?? "1 second";
1041 if (grace !== 0) yield* Effect.sleep(grace);
1042 for (let pass = 0; pass < passes; pass += 1) {
1043 yield* Effect.try({
1044 try: () => signalPhase("SIGKILL", true),
1045 catch: (cause) => cause as AcpProcessGroupTerminationError,
1046 });
1047 yield* Effect.sleep("10 millis");
1048 }
1049 const finalTable = snapshot();
1050 discover(finalTable);
1051 const survivors = new Map(finalTable.map((entry) => [entry.pid, entry]));
1052 const residual = [...ledger.values()].filter((owned) => {
1053 const observed = survivors.get(owned.pid);
1054 if (!samePosixProcessIdentity(owned, observed) || observed === undefined) {
1055 return false;
1056 }
1057 // Already-killed zombies still appear in /proc until reaped; not live.
1058 return !posixProcessIsZombie(observed);
1059 });
1060 if (residual.length > 0) {
1061 return yield* fail(
1062 `ACP process tree ${input.rootPid} retained owned processes: ${residual.map((entry) => entry.pid).join(", ")}`,
1063 );
1064 }
1065 }).pipe(withWallClock);
1066}
1067
1068export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean {
1069 return exitCode === 0;
1070}
1071
1072export const terminateWindowsProcessTreeWithTaskkill = (
1073 pid: number,
1074): Effect.Effect<void, AcpProcessGroupTerminationError, ChildProcessSpawner.ChildProcessSpawner> =>
1075 Effect.scoped(
1076 Effect.gen(function* () {
1077 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1078 const taskkill = yield* spawner.spawn(
1079 ChildProcess.make("taskkill", ["/PID", String(pid), "/T", "/F"]),
1080 );
1081 const outputFiber = yield* Stream.decodeText(taskkill.all).pipe(
1082 Stream.mkString,
1083 Effect.forkScoped,
1084 );
1085 const exitCode = Number(yield* taskkill.exitCode);
1086 const output = yield* Fiber.join(outputFiber);
1087 // A missing leader cannot prove that inherited descendants exited.
1088 if (windowsTaskkillResultIsSuccess(exitCode, output)) return;
1089 const trimmedOutput = output.trim();
1090 return yield* new AcpProcessGroupTerminationError({
1091 detail: `taskkill exited ${exitCode} for ACP process tree ${pid}`,
1092 pid,
1093 exitCode,
1094 ...(trimmedOutput.length === 0
1095 ? {}
1096 : { cause: trimmedOutput.length > 500 ? trimmedOutput.slice(0, 500) : trimmedOutput }),
1097 });
1098 }),
1099 ).pipe(
1100 Effect.mapError((cause) =>
1101 isAcpProcessGroupTerminationError(cause)
1102 ? cause
1103 : new AcpProcessGroupTerminationError({
1104 cause,
1105 detail: `Failed to run taskkill for ACP process tree ${pid}`,
1106 pid,
1107 }),
1108 ),
1109 );
1110
1111export function selectAcpAgentAuthMethod(
1112 authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined,
1113 preferredMethodId?: string,
1114): EffectAcpSchema.AuthMethod | undefined {
1115 const preferred = preferredMethodId?.trim();
1116 if (preferred) {
1117 return authMethods?.find((method) => method.id === preferred);
1118 }
1119 return authMethods?.find((method) => method.type === undefined || method.type === "agent");
1120}
1121
1122function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean {
1123 return error._tag === "AcpRequestError" && error.code === -32000;
1124}
1125
1126export interface AcpSessionRuntimeStartResult {
1127 readonly sessionId: string;
1128 readonly initializeResult: EffectAcpSchema.InitializeResponse;
1129 readonly sessionSetupResult:
1130 | EffectAcpSchema.ForkSessionResponse
1131 | EffectAcpSchema.LoadSessionResponse
1132 | EffectAcpSchema.NewSessionResponse
1133 | EffectAcpSchema.ResumeSessionResponse;
1134 readonly modelConfigId: string | undefined;
1135}
1136
1137export interface AcpSessionActivationOptions {
1138 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
1139 readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
1140}
1141
1142export class AcpSessionRuntime extends Context.Service<
1143 AcpSessionRuntime,
1144 {
1145 /**
1146 * Registers a handler for `session/request_permission`.
1147 * @see https://agentclientprotocol.com/protocol/schema#session/request_permission
1148 */
1149 readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"];
1150 /**
1151 * Registers a handler for `session/elicitation`.
1152 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation
1153 */
1154 readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"];
1155 readonly handleMcpConnect: EffectAcpClient.AcpClient["Service"]["handleMcpConnect"];
1156 readonly handleMcpMessage: EffectAcpClient.AcpClient["Service"]["handleMcpMessage"];
1157 readonly handleMcpDisconnect: EffectAcpClient.AcpClient["Service"]["handleMcpDisconnect"];
1158 readonly handleMcpNotification: EffectAcpClient.AcpClient["Service"]["handleMcpNotification"];
1159 /**
1160 * Registers a handler for `fs/read_text_file`.
1161 * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file
1162 */
1163 readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"];
1164 /**
1165 * Registers a handler for `fs/write_text_file`.
1166 * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file
1167 */
1168 readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"];
1169 /**
1170 * Registers a handler for `terminal/create`.
1171 * @see https://agentclientprotocol.com/protocol/schema#terminal/create
1172 */
1173 readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"];
1174 /**
1175 * Registers a handler for `terminal/output`.
1176 * @see https://agentclientprotocol.com/protocol/schema#terminal/output
1177 */
1178 readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"];
1179 /**
1180 * Registers a handler for `terminal/wait_for_exit`.
1181 * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit
1182 */
1183 readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"];
1184 /**
1185 * Registers a handler for `terminal/kill`.
1186 * @see https://agentclientprotocol.com/protocol/schema#terminal/kill
1187 */
1188 readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"];
1189 /**
1190 * Registers a handler for `terminal/release`.
1191 * @see https://agentclientprotocol.com/protocol/schema#terminal/release
1192 */
1193 readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"];
1194 /**
1195 * Registers a handler for `session/update`.
1196 * @see https://agentclientprotocol.com/protocol/schema#session/update
1197 */
1198 readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"];
1199 /**
1200 * Registers a handler for `session/elicitation/complete`.
1201 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete
1202 */
1203 readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"];
1204 /**
1205 * Registers a fallback extension request handler.
1206 * @see https://agentclientprotocol.com/protocol/extensibility
1207 */
1208 readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"];
1209 /**
1210 * Registers a fallback extension notification handler.
1211 * @see https://agentclientprotocol.com/protocol/extensibility
1212 */
1213 readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"];
1214 /**
1215 * Registers a typed extension request handler.
1216 * @see https://agentclientprotocol.com/protocol/extensibility
1217 */
1218 readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"];
1219 /**
1220 * Registers a typed extension notification handler.
1221 * @see https://agentclientprotocol.com/protocol/extensibility
1222 */
1223 readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"];
1224 /**
1225 * Sends only `initialize` and returns the agent's response. Health probes use this to read
1226 * advertised capabilities without authenticating or opening a session, so a probe can never
1227 * start an interactive login or boot MCP servers.
1228 * @see https://agentclientprotocol.com/protocol/schema#initialize
1229 */
1230 readonly initialize: () => Effect.Effect<
1231 EffectAcpSchema.InitializeResponse,
1232 EffectAcpErrors.AcpError
1233 >;
1234 /** Explicit login uses the negotiated v1 authenticate / v2 auth/login operation. */
1235 readonly authenticate?: (methodId: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
1236 /**
1237 * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session.
1238 * Concurrent calls share the same in-flight startup and a failed startup may be retried.
1239 */
1240 readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
1241 /** Stream of parsed root-session events and connection failures. */
1242 readonly getEvents: () => Stream.Stream<AcpSessionRuntimeEvent, never>;
1243 /** Waits for queued events to be processed, or for the runtime scope to close. */
1244 readonly drainEvents: Effect.Effect<void>;
1245 /** Latest mode state observed from session setup and `session/update` notifications. */
1246 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;
1247 /** Latest configuration options observed from session setup and configuration writes. */
1248 readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
1249 readonly loadSession: (
1250 sessionId: string,
1251 options?: AcpSessionActivationOptions,
1252 ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
1253 readonly resumeSession: (
1254 sessionId: string,
1255 options?: AcpSessionActivationOptions,
1256 ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
1257 readonly forkSession: (
1258 sessionId: string,
1259 options?: AcpSessionActivationOptions,
1260 ) => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
1261 readonly listSessions: (
1262 cursor?: string,
1263 ) => Effect.Effect<EffectAcpSchema.ListSessionsResponse, EffectAcpErrors.AcpError>;
1264 readonly closeSession: (
1265 sessionId?: string,
1266 ) => Effect.Effect<EffectAcpSchema.CloseSessionResponse, EffectAcpErrors.AcpError>;
1267 readonly deleteSession: (
1268 sessionId: string,
1269 ) => Effect.Effect<EffectAcpSchema.DeleteSessionResponse, EffectAcpErrors.AcpError>;
1270 readonly listProviders: Effect.Effect<
1271 EffectAcpSchema.ListProvidersResponse,
1272 EffectAcpErrors.AcpError
1273 >;
1274 readonly setProvider: (
1275 provider: EffectAcpSchema.SetProviderRequest,
1276 ) => Effect.Effect<EffectAcpSchema.SetProviderResponse, EffectAcpErrors.AcpError>;
1277 readonly disableProvider: (
1278 providerId: string,
1279 ) => Effect.Effect<EffectAcpSchema.DisableProviderResponse, EffectAcpErrors.AcpError>;
1280 /** Logs out the current ACP identity when the agent advertises support. */
1281 readonly logout: Effect.Effect<EffectAcpSchema.LogoutResponse, EffectAcpErrors.AcpError>;
1282 /**
1283 * Sends a prompt turn to the active session. `options.dispatched` settles once the
1284 * `session/prompt` RPC is registered as the active prompt, so a caller that forks this
1285 * effect knows when a later `cancel` will target this prompt.
1286 * @see https://agentclientprotocol.com/protocol/schema#session/prompt
1287 */
1288 readonly prompt: (
1289 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
1290 options?: { readonly dispatched?: Deferred.Deferred<void> },
1291 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
1292 /**
1293 * Sends a real ACP `session/cancel` notification for the active session.
1294 * @see https://agentclientprotocol.com/protocol/schema#session/cancel
1295 */
1296 readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>;
1297 readonly processContainment:
1298 | "cgroup-v2"
1299 | "process-ledger-reduced-guarantee"
1300 | "process-group"
1301 | "none";
1302 /** Terminates the opt-in detached ACP process group and all inherited work. */
1303 readonly terminateProcessGroup?: Effect.Effect<void, AcpProcessGroupTerminationError>;
1304 /**
1305 * Selects the active mode through the negotiated `mode` configuration option.
1306 * This is a no-op when the requested mode is already active.
1307 * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option
1308 */
1309 readonly setMode: (
1310 modeId: string,
1311 ) => Effect.Effect<EffectAcpSchema.SetSessionModeResponse, EffectAcpErrors.AcpError>;
1312 /**
1313 * Updates a session configuration option and the runtime configuration snapshot.
1314 * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option
1315 */
1316 readonly setSessionModel: (
1317 modelId: string,
1318 meta?: EffectAcpSchema.SetSessionModelRequest["_meta"],
1319 ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>;
1320 readonly setConfigOption: (
1321 configId: string,
1322 value: string | boolean,
1323 ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>;
1324 /**
1325 * Selects the base model through the negotiated model configuration option.
1326 * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option
1327 */
1328 readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
1329 /**
1330
1331 * Sends a generic ACP extension request and records it through the request logger.
1332 * @see https://agentclientprotocol.com/protocol/extensibility
1333 */
1334 readonly request: (
1335 method: string,
1336 payload: unknown,
1337 ) => Effect.Effect<unknown, EffectAcpErrors.AcpError>;
1338 /**
1339 * Sends a generic ACP extension notification.
1340 * @see https://agentclientprotocol.com/protocol/extensibility
1341 */
1342 readonly notify: (
1343 method: string,
1344 payload: unknown,
1345 ) => Effect.Effect<void, EffectAcpErrors.AcpError>;
1346 }
1347>()("@t3tools/provider-acp/server/AcpSessionRuntime") {}
1348
1349interface AcpStartedState extends AcpSessionRuntimeStartResult {}
1350
1351type AcpStartState =
1352 | { readonly _tag: "NotStarted" }
1353 | {
1354 readonly _tag: "Starting";
1355 readonly deferred: Deferred.Deferred<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
1356 }
1357 | { readonly _tag: "Started"; readonly result: AcpStartedState };
1358
1359type AcpInitializeState =
1360 | { readonly _tag: "NotStarted" }
1361 | {
1362 readonly _tag: "Starting";
1363 readonly deferred: Deferred.Deferred<
1364 EffectAcpSchema.InitializeResponse,
1365 EffectAcpErrors.AcpError
1366 >;
1367 }
1368 | { readonly _tag: "Started"; readonly result: EffectAcpSchema.InitializeResponse };
1369
1370interface AcpAssistantSegmentState {
1371 readonly nextSegmentIndex: number;
1372 readonly activeItemId?: string;
1373}
1374
1375interface EnsureActiveAssistantSegmentResult {
1376 readonly itemId: string;
1377 readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;
1378}
1379
1380interface AcpActivePrompt {
1381 readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
1382 readonly completed: Deferred.Deferred<void>;
1383}
1384
1385export const make = (
1386 options: AcpSessionRuntimeOptions,
1387): Effect.Effect<
1388 AcpSessionRuntime["Service"],
1389 EffectAcpErrors.AcpError,
1390 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope
1391> =>
1392 Effect.gen(function* () {
1393 const crypto = yield* Crypto.Crypto;
1394 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1395 // A child of the caller's scope, so termination can close the runtime without closing the caller's.
1396 const runtimeScope = yield* Scope.fork(yield* Scope.Scope);
1397 const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>();
1398 const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined);
1399 const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallTrackedState>());
1400 // Recently shown tool calls. A late update to a finished call is not a new
1401 // boundary in the answer, although its progress state is gone.
1402 const shownToolCallIds = new Set<string>();
1403 const assistantItemRuntimeId = yield* crypto.randomUUIDv4().pipe(
1404 Effect.mapError(
1405 (cause) =>
1406 new EffectAcpErrors.AcpTransportError({
1407 detail: "Failed to generate an ACP assistant item runtime identifier.",
1408 cause,
1409 }),
1410 ),
1411 );
1412 const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 });
1413 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
1414 const initializeStateRef = yield* Ref.make<AcpInitializeState>({ _tag: "NotStarted" });
1415 const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });
1416 const startupMetadataRef = yield* Ref.make<ReadonlyArray<EffectAcpSchema.SessionNotification>>(
1417 [],
1418 );
1419 const notificationSemaphore = yield* Semaphore.make(1);
1420 const terminationErrorRef = yield* Ref.make<Option.Option<EffectAcpErrors.AcpError>>(
1421 Option.none(),
1422 );
1423 const stoppingRef = yield* Ref.make(false);
1424 const stderrFailure = yield* Deferred.make<never, EffectAcpErrors.AcpError>();
1425 const stderrTailRef = yield* Ref.make("");
1426 const homeDirectory = yield* HostProcess.HomeDirectory;
1427 const stderrDrained = yield* Deferred.make<void>();
1428 const runtimeClosed = yield* Deferred.make<void>();
1429 const promptSerializationSemaphore = yield* Semaphore.make(1);
1430 const sessionLoadSemaphore = yield* Semaphore.make(1);
1431 const promptDispatchSemaphore = yield* Semaphore.make(1);
1432 const activePromptRef = yield* Ref.make<Option.Option<AcpActivePrompt>>(Option.none());
1433 const assistantUpdatesOpenRef = yield* Ref.make(true);
1434 const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none());
1435
1436 const ensureConnected = Effect.gen(function* () {
1437 const error = yield* Ref.get(terminationErrorRef);
1438 if (Option.isSome(error)) {
1439 return yield* error.value;
1440 }
1441 if (yield* Ref.get(stoppingRef)) {
1442 return yield* new EffectAcpErrors.AcpTransportError({
1443 detail: "The ACP session runtime is closed.",
1444 cause: undefined,
1445 });
1446 }
1447 });
1448
1449 const enrichProcessExitWithStderr = (
1450 error: EffectAcpErrors.AcpError,
1451 ): Effect.Effect<EffectAcpErrors.AcpError> =>
1452 error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0
1453 ? Effect.succeed(error)
1454 : Deferred.await(stderrDrained).pipe(
1455 Effect.timeout("250 millis"),
1456 Effect.ignore,
1457 Effect.andThen(Ref.get(stderrTailRef)),
1458 Effect.map((tail) => {
1459 const stderr = sanitizeAcpStderrExcerpt(tail, homeDirectory);
1460 return stderr.length === 0
1461 ? error
1462 : new EffectAcpErrors.AcpProcessExitedError({
1463 ...(error.code !== undefined ? { code: error.code } : {}),
1464 ...(error.pid !== undefined ? { pid: error.pid } : {}),
1465 stderr,
1466 ...(error.cause !== undefined ? { cause: error.cause } : {}),
1467 });
1468 }),
1469 );
1470
1471 const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* (
1472 error: EffectAcpErrors.AcpError,
1473 ) {
1474 if (yield* Ref.get(stoppingRef)) {
1475 return;
1476 }
1477 const enriched = yield* enrichProcessExitWithStderr(error);
1478 const firstTermination = yield* Ref.modify(terminationErrorRef, (current) =>
1479 Option.isSome(current)
1480 ? ([false, current] as const)
1481 : ([true, Option.some(enriched)] as const),
1482 );
1483 if (!firstTermination) {
1484 return;
1485 }
1486 yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef });
1487 yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched });
1488 });
1489
1490 const logRequest = (event: AcpSessionRequestLogEvent) =>
1491 options.requestLogger ? options.requestLogger(event) : Effect.void;
1492
1493 const runLoggedRequest = <A>(
1494 method: string,
1495 payload: unknown,
1496 effect: Effect.Effect<A, EffectAcpErrors.AcpError>,
1497 ): Effect.Effect<A, EffectAcpErrors.AcpError> =>
1498 logRequest({ method, payload, status: "started" }).pipe(
1499 Effect.flatMap(() =>
1500 (options.onStderr
1501 ? Effect.raceFirst(effect, Deferred.await(stderrFailure))
1502 : effect
1503 ).pipe(
1504 Effect.catch((error) =>
1505 enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)),
1506 ),
1507 Effect.tap((result) =>
1508 logRequest({
1509 method,
1510 payload,
1511 status: "succeeded",
1512 result,
1513 }),
1514 ),
1515 Effect.onError((cause) =>
1516 logRequest({
1517 method,
1518 payload,
1519 status: "failed",
1520 cause,
1521 }),
1522 ),
1523 ),
1524 ),
1525 );
1526
1527 const spawnCommand =
1528 options.spawn.shell === false
1529 ? { command: options.spawn.command, args: options.spawn.args, shell: false }
1530 : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, {
1531 ...(options.spawn.env ? { env: options.spawn.env } : {}),
1532 extendEnv: options.spawn.extendEnv ?? true,
1533 });
1534 const linuxCgroupLease =
1535 options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux"
1536 ? yield* Effect.sync(() => {
1537 try {
1538 if (options.linuxCgroupController === null) return undefined;
1539 return (options.linuxCgroupController ?? makeLinuxCgroupController()).create();
1540 } catch {
1541 return undefined;
1542 }
1543 })
1544 : undefined;
1545 // This is lifecycle containment for inherited provider work. It is not a
1546 // security boundary against a provider that can modify its delegated cgroup.
1547 if (linuxCgroupLease !== undefined) {
1548 yield* Scope.addFinalizer(
1549 runtimeScope,
1550 Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe(
1551 Effect.ignoreCause({ log: true }),
1552 ),
1553 );
1554 }
1555 const containedTargetCommand =
1556 linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command)
1557 ? spawnCommand.command
1558 : yield* Effect.gen(function* () {
1559 const resolved = resolveLinuxCgroupTargetCommand(
1560 spawnCommand.command,
1561 options.spawn.cwd ?? options.cwd,
1562 { ...process.env, ...options.spawn.env },
1563 );
1564 if (resolved !== undefined) return resolved;
1565 return yield* new EffectAcpErrors.AcpSpawnError({
1566 command: options.spawn.command,
1567 cause: new Error("Contained ACP command was not found on PATH"),
1568 });
1569 });
1570 // A cgroup lease is already its own leaf cgroup, so it only needs the
1571 // raised OOM score (in its wrapper). Other agents get their own scope.
1572 const scopedSpawnCommand =
1573 linuxCgroupLease !== undefined || spawnCommand.shell
1574 ? spawnCommand
1575 : {
1576 ...(yield* (yield* AgentScope).wrap({
1577 command: spawnCommand.command,
1578 args: spawnCommand.args,
1579 name: "acp",
1580 threadId: yield* AgentScopeThreadId,
1581 env: { ...process.env, ...options.spawn.env },
1582 })),
1583 shell: false,
1584 };
1585 const containedSpawnCommand =
1586 linuxCgroupLease === undefined
1587 ? scopedSpawnCommand
1588 : {
1589 ...wrapCommandForLinuxCgroup(
1590 linuxCgroupLease,
1591 containedTargetCommand,
1592 spawnCommand.args,
1593 ),
1594 shell: false,
1595 };
1596 const spawnEnvironment =
1597 linuxCgroupLease === undefined
1598 ? options.spawn.env
1599 : {
1600 ...options.spawn.env,
1601 ELECTRON_RUN_AS_NODE: "1",
1602 T3_ACP_CGROUP_WRAPPER: "1",
1603 };
1604 const child = yield* spawner
1605 .spawn(
1606 ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, {
1607 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),
1608 ...(spawnEnvironment ? { env: spawnEnvironment } : {}),
1609 extendEnv: options.spawn.extendEnv ?? true,
1610 ...(options.ownDetachedProcessGroup === undefined
1611 ? {}
1612 : { detached: options.ownDetachedProcessGroup }),
1613 shell: containedSpawnCommand.shell,
1614 }),
1615 )
1616 .pipe(
1617 Effect.provideService(Scope.Scope, runtimeScope),
1618 Effect.mapError(
1619 (cause) =>
1620 new EffectAcpErrors.AcpSpawnError({
1621 command: options.spawn.command,
1622 cause,
1623 }),
1624 ),
1625 );
1626
1627 const posixOwnershipLedger = new Map<string, AcpOwnedPosixProcess>();
1628 const posixOwnershipFrontier = new Map<number, AcpOwnedPosixProcess>();
1629 const posixOwnershipChildQueues = new Map<number, Array<number>>();
1630 const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined };
1631 const posixController =
1632 options.ownDescendantProcessGroups === true
1633 ? yield* Effect.try({
1634 try: () => {
1635 if (options.posixProcessTreeController !== undefined) {
1636 return options.posixProcessTreeController;
1637 }
1638 if (options.processGroupPlatform === undefined) {
1639 throw new AcpProcessGroupTerminationError({
1640 detail: "POSIX ACP descendant ownership requires a supported host platform",
1641 });
1642 }
1643 return makePosixProcessTreeController(options.processGroupPlatform);
1644 },
1645 catch: (cause) =>
1646 new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }),
1647 })
1648 : undefined;
1649 if (posixController !== undefined) {
1650 const observeOwnership = () =>
1651 observePosixOwnershipLedger({
1652 childQueues: posixOwnershipChildQueues,
1653 controller: posixController,
1654 frontier: posixOwnershipFrontier,
1655 ledger: posixOwnershipLedger,
1656 root: posixOwnershipRoot,
1657 rootPid: Number(child.pid),
1658 });
1659 yield* Effect.try({
1660 try: observeOwnership,
1661 catch: (cause) =>
1662 new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }),
1663 });
1664 yield* observePosixOwnershipLedgerContinuously({
1665 childQueues: posixOwnershipChildQueues,
1666 controller: posixController,
1667 frontier: posixOwnershipFrontier,
1668 ledger: posixOwnershipLedger,
1669 root: posixOwnershipRoot,
1670 rootPid: Number(child.pid),
1671 }).pipe(Effect.forkIn(runtimeScope));
1672 }
1673
1674 const signalOwnedProcessGroup = (signal: NodeJS.Signals) =>
1675 Effect.try({
1676 try: () => {
1677 signalProcessGroup(Number(child.pid), signal);
1678 return true;
1679 },
1680 catch: (cause) =>
1681 new AcpProcessGroupTerminationError({
1682 cause,
1683 detail: `Failed to signal ACP process group ${child.pid} with ${signal}`,
1684 }),
1685 }).pipe(
1686 Effect.catch((error) => {
1687 const cause = error.cause as NodeJS.ErrnoException | undefined;
1688 return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error);
1689 }),
1690 );
1691 const terminateWindowsProcessTree =
1692 options.windowsProcessTreeTerminator ??
1693 ((pid: number) =>
1694 terminateWindowsProcessTreeWithTaskkill(pid).pipe(
1695 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1696 ));
1697 const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) =>
1698 Effect.gen(function* () {
1699 const platform = platformOverride ?? options.processGroupPlatform;
1700 if (platform === undefined || platform === "win32") {
1701 return yield* new AcpProcessGroupTerminationError({
1702 detail: "POSIX ACP descendant ownership requires a supported host platform",
1703 });
1704 }
1705 const controller = yield* Effect.try({
1706 try: () => posixController ?? makePosixProcessTreeController(platform),
1707 catch: (cause) =>
1708 isAcpProcessGroupTerminationError(cause)
1709 ? cause
1710 : new AcpProcessGroupTerminationError({
1711 cause,
1712 detail: `Failed to initialize ACP descendant ownership on ${platform}`,
1713 }),
1714 });
1715 yield* terminatePosixOwnedProcessTree({
1716 controller,
1717 grace,
1718 ledger: posixOwnershipLedger,
1719 root: posixOwnershipRoot,
1720 rootPid: Number(child.pid),
1721 });
1722 });
1723 const terminateOwnedProcessGroupImpl = Effect.gen(function* () {
1724 if (options.ownDetachedProcessGroup !== true) return;
1725 if (options.processGroupPlatform === undefined) {
1726 return yield* new AcpProcessGroupTerminationError({
1727 detail: "Detached ACP process-group ownership requires a host platform",
1728 });
1729 }
1730 if (options.processGroupPlatform === "win32") {
1731 return yield* terminateWindowsProcessTree(Number(child.pid));
1732 }
1733 if (linuxCgroupLease !== undefined) {
1734 if (posixController !== undefined) {
1735 yield* Effect.sync(() =>
1736 signalLinuxCgroupRootTerm({
1737 controller: posixController,
1738 lease: linuxCgroupLease,
1739 root: posixOwnershipRoot,
1740 }),
1741 );
1742 }
1743 const grace = options.processGroupTerminationGrace ?? "1 second";
1744 if (grace !== 0) yield* Effect.sleep(grace);
1745 return yield* terminateLinuxCgroupLease(linuxCgroupLease);
1746 }
1747 if (options.ownDescendantProcessGroups === true) {
1748 return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second");
1749 }
1750 const groupExisted = yield* signalOwnedProcessGroup("SIGTERM");
1751 if (!groupExisted) return;
1752 const grace = options.processGroupTerminationGrace ?? "1 second";
1753 if (grace !== 0) {
1754 yield* Effect.sleep(grace);
1755 }
1756 yield* signalOwnedProcessGroup("SIGKILL");
1757 }).pipe(withWallClock);
1758 const terminateProcessGroup = yield* Effect.cached(
1759 Effect.uninterruptible(terminateOwnedProcessGroupImpl),
1760 );
1761 if (options.ownDetachedProcessGroup === true) {
1762 const hostPlatform = yield* HostProcess.Platform;
1763 const forceTerminateOwnedProcessGroup =
1764 hostPlatform === "win32"
1765 ? terminateWindowsProcessTreeWithTaskkill(Number(child.pid)).pipe(
1766 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1767 )
1768 : linuxCgroupLease !== undefined
1769 ? terminateLinuxCgroupLease(linuxCgroupLease)
1770 : options.ownDescendantProcessGroups === true
1771 ? terminatePosixProcessTree(0, hostPlatform)
1772 : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid);
1773 yield* Scope.addFinalizer(
1774 runtimeScope,
1775 Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe(
1776 Effect.ignoreCause({ log: true }),
1777 ),
1778 );
1779 }
1780 yield* child.stderr.pipe(
1781 Stream.decodeText(),
1782 Stream.runForEach((chunk) =>
1783 Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe(
1784 Effect.andThen(
1785 options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void,
1786 ),
1787 Effect.catch((error) =>
1788 Effect.gen(function* () {
1789 yield* Deferred.fail(stderrFailure, error);
1790 yield* recordTermination(error);
1791 yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore);
1792 }),
1793 ),
1794 ),
1795 ),
1796 Effect.ensuring(Deferred.succeed(stderrDrained, undefined)),
1797 Effect.ignore,
1798 Effect.forkIn(runtimeScope),
1799 );
1800
1801 const acpContext = yield* Layer.build(
1802 EffectAcpClient.layerChildProcess(child, {
1803 ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}),
1804 ...(options.transformSessionUpdate
1805 ? { transformSessionUpdate: options.transformSessionUpdate }
1806 : {}),
1807 ...(options.protocolLogging?.logIncoming !== undefined
1808 ? { logIncoming: options.protocolLogging.logIncoming }
1809 : {}),
1810 ...(options.protocolLogging?.logOutgoing !== undefined
1811 ? { logOutgoing: options.protocolLogging.logOutgoing }
1812 : {}),
1813 ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}),
1814 ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}),
1815 onTermination: (error) =>
1816 recordTermination(error).pipe(
1817 Effect.andThen(options.onTermination?.(error) ?? Effect.void),
1818 Effect.ensuring(
1819 Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid),
1820 ),
1821 ),
1822 ...(options.onOutgoingResponseFailure
1823 ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure }
1824 : {}),
1825 ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}),
1826 }),
1827 ).pipe(Effect.provideService(Scope.Scope, runtimeScope));
1828
1829 const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext));
1830
1831 const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) =>
1832 handleSessionUpdate({
1833 queue: eventQueue,
1834 modeStateRef,
1835 configOptionsRef,
1836 toolCallsRef,
1837 shownToolCallIds,
1838 assistantSegmentRef,
1839 assistantItemRuntimeId,
1840 params: notification,
1841 });
1842
1843 yield* acp.handleSessionUpdate((notification) =>
1844 notificationSemaphore.withPermit(
1845 Effect.gen(function* () {
1846 if (Option.isSome(yield* Ref.get(terminationErrorRef))) {
1847 return;
1848 }
1849 const gate = yield* Ref.get(sessionLoadGateRef);
1850 // A different session can still have an in-flight prompt while this
1851 // load replays history, so quarantine only the loading session.
1852 const loadingThisSession =
1853 Option.isSome(gate) &&
1854 gate.value.active &&
1855 notification.sessionId === gate.value.sessionId;
1856 if (
1857 loadingThisSession &&
1858 sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId)
1859 ) {
1860 const lastActivityAtMillis = yield* Clock.currentTimeMillis;
1861 yield* Ref.set(
1862 sessionLoadGateRef,
1863 Option.some({
1864 ...gate.value,
1865 lastActivityAtMillis,
1866 }),
1867 );
1868 }
1869 if (sessionUpdateIsReplay(notification)) {
1870 return;
1871 }
1872 const startState = yield* Ref.get(startStateRef);
1873 if (startState._tag === "Starting") {
1874 if (isStartupMetadataUpdate(notification)) {
1875 yield* Ref.update(startupMetadataRef, (current) =>
1876 [
1877 ...current.filter(
1878 (previous) =>
1879 previous.sessionId !== notification.sessionId ||
1880 previous.update.sessionUpdate !== notification.update.sessionUpdate,
1881 ),
1882 notification,
1883 ].slice(-maxStartupMetadataUpdates),
1884 );
1885 }
1886 return;
1887 }
1888 if (loadingThisSession) {
1889 if (notification.update.sessionUpdate === "config_option_update") {
1890 const candidateConfigOptions = notification.update.configOptions;
1891 yield* Ref.update(sessionLoadGateRef, (current) =>
1892 Option.isSome(current)
1893 ? Option.some({ ...current.value, candidateConfigOptions })
1894 : current,
1895 );
1896 }
1897 return;
1898 }
1899 // One runtime projects one root ACP session. Child-session updates need
1900 // explicit lineage routing and must never be flattened into this stream.
1901 if (
1902 startState._tag !== "Started" ||
1903 notification.sessionId !== startState.result.sessionId
1904 ) {
1905 return;
1906 }
1907 if (
1908 !(yield* Ref.get(assistantUpdatesOpenRef)) &&
1909 (notification.update.sessionUpdate === "agent_message_chunk" ||
1910 notification.update.sessionUpdate === "agent_thought_chunk")
1911 ) {
1912 return;
1913 }
1914 yield* processSessionUpdate(notification);
1915 }),
1916 ),
1917 );
1918 yield* Scope.addFinalizer(
1919 runtimeScope,
1920 Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))),
1921 );
1922 const initializeClientCapabilities = {
1923 fs: {
1924 readTextFile: false,
1925 writeTextFile: false,
1926 ...options.clientCapabilities?.fs,
1927 },
1928 terminal: options.clientCapabilities?.terminal ?? false,
1929 ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}),
1930 ...(options.clientCapabilities?.elicitation
1931 ? { elicitation: options.clientCapabilities.elicitation }
1932 : {}),
1933 ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}),
1934 } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
1935
1936 const getStartedState = Effect.gen(function* () {
1937 yield* ensureConnected;
1938 const state = yield* Ref.get(startStateRef);
1939 if (state._tag === "Started") {
1940 return state.result;
1941 }
1942 return yield* new EffectAcpErrors.AcpTransportError({
1943 detail: "ACP session runtime has not been started",
1944 cause: "ACP session runtime has not been started",
1945 });
1946 });
1947
1948 const validateConfigOptionValue = (
1949 configId: string,
1950 value: string | boolean,
1951 ): Effect.Effect<void, EffectAcpErrors.AcpError> =>
1952 Effect.gen(function* () {
1953 const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId);
1954 if (!configOption) {
1955 return;
1956 }
1957 if (configOption.type === "boolean") {
1958 if (typeof value === "boolean") {
1959 return;
1960 }
1961 return yield* new EffectAcpErrors.AcpRequestError({
1962 code: -32602,
1963 errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`,
1964 data: {
1965 configId: configOption.id,
1966 expectedType: "boolean",
1967 receivedValue: value,
1968 },
1969 });
1970 }
1971 if (typeof value !== "string") {
1972 return yield* new EffectAcpErrors.AcpRequestError({
1973 code: -32602,
1974 errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`,
1975 data: {
1976 configId: configOption.id,
1977 expectedType: "string",
1978 receivedValue: value,
1979 },
1980 });
1981 }
1982 const allowedValues = collectSessionConfigOptionValues(configOption);
1983 if (allowedValues.includes(value)) {
1984 return;
1985 }
1986 if (configOption.category === "model" && options.customModels?.includes(value)) {
1987 return;
1988 }
1989 return yield* new EffectAcpErrors.AcpRequestError({
1990 code: -32602,
1991 errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`,
1992 data: {
1993 configId: configOption.id,
1994 allowedValues,
1995 receivedValue: value,
1996 },
1997 });
1998 });
1999
2000 const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* (
2001 response:
2002 | EffectAcpSchema.SetSessionConfigOptionResponse
2003 | EffectAcpSchema.ForkSessionResponse
2004 | EffectAcpSchema.LoadSessionResponse
2005 | EffectAcpSchema.NewSessionResponse
2006 | EffectAcpSchema.ResumeSessionResponse,
2007 ) {
2008 const configOptions = sessionConfigOptionsFromSetup(response);
2009 yield* Ref.set(configOptionsRef, configOptions);
2010 yield* Queue.offer(eventQueue, {
2011 _tag: "ConfigOptionsUpdated",
2012 configOptions,
2013 rawPayload: response,
2014 });
2015 });
2016
2017 const updateCurrentModeId = (modeId: string): Effect.Effect<void> =>
2018 Ref.update(modeStateRef, (current) =>
2019 current ? { ...current, currentModeId: modeId } : current,
2020 );
2021
2022 const adoptSession = (
2023 sessionId: string,
2024 sessionSetupResult:
2025 | EffectAcpSchema.ForkSessionResponse
2026 | EffectAcpSchema.LoadSessionResponse
2027 | EffectAcpSchema.NewSessionResponse
2028 | EffectAcpSchema.ResumeSessionResponse,
2029 ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> =>
2030 Effect.gen(function* () {
2031 const current = yield* getStartedState;
2032 const meta = sessionSetupResult._meta;
2033 const syntheticReplayIdle =
2034 meta !== null &&
2035 typeof meta === "object" &&
2036 !Array.isArray(meta) &&
2037 (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle";
2038 const extractedModelConfigId = extractModelConfigId(sessionSetupResult);
2039 const nextModelConfigId =
2040 extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined);
2041 const nextModeState = parseSessionModeState(sessionSetupResult);
2042 if (nextModeState !== undefined) {
2043 yield* Ref.set(modeStateRef, nextModeState);
2044 } else if (!syntheticReplayIdle) {
2045 yield* Ref.set(modeStateRef, undefined);
2046 }
2047 // Synthetic replay-idle load responses only carry initialize model/mode
2048 // meta; preserve live configOptions so setConfigOption still validates.
2049 if (
2050 sessionSetupResult.configOptions !== undefined &&
2051 sessionSetupResult.configOptions !== null
2052 ) {
2053 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
2054 } else if (!syntheticReplayIdle) {
2055 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
2056 }
2057 const nextState = {
2058 sessionId,
2059 initializeResult: current.initializeResult,
2060 sessionSetupResult,
2061 modelConfigId: nextModelConfigId,
2062 } satisfies AcpStartedState;
2063 yield* Ref.set(toolCallsRef, new Map());
2064 yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 });
2065 yield* Ref.set(startStateRef, { _tag: "Started", result: nextState });
2066 return nextState;
2067 });
2068
2069 const runLoadSessionWithReplayIdle = (
2070 loadPayload: EffectAcpSchema.LoadSessionRequest,
2071 initializeResult: EffectAcpSchema.InitializeResponse,
2072 ): Effect.Effect<EffectAcpSchema.LoadSessionResponse, EffectAcpErrors.AcpError> =>
2073 sessionLoadSemaphore.withPermits(1)(
2074 Effect.gen(function* () {
2075 const sessionLoadTimeout = Duration.fromInputUnsafe(
2076 options.sessionLoadTimeout ?? defaultSessionLoadTimeout,
2077 );
2078 const sessionLoadReplayIdleGap = Duration.fromInputUnsafe(
2079 options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap,
2080 );
2081
2082 yield* Ref.set(
2083 sessionLoadGateRef,
2084 Option.some({
2085 active: true,
2086 sessionId: loadPayload.sessionId,
2087 lastActivityAtMillis: undefined,
2088 idleGap: sessionLoadReplayIdleGap,
2089 initializeResult,
2090 }),
2091 );
2092
2093 return yield* Effect.gen(function* () {
2094 yield* logRequest({
2095 method: "session/load",
2096 payload: loadPayload,
2097 status: "started",
2098 });
2099
2100 const idleFiber = yield* waitForSessionLoadReplayIdle({
2101 gateRef: sessionLoadGateRef,
2102 }).pipe(Effect.forkIn(runtimeScope));
2103 const loaded = yield* Effect.raceFirst(
2104 acp.agent.loadSession(loadPayload),
2105 Fiber.join(idleFiber),
2106 ).pipe(
2107 Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)),
2108 Effect.timeoutOption(sessionLoadTimeout),
2109 Effect.flatMap((result) =>
2110 Option.match(result, {
2111 onNone: () =>
2112 Effect.fail(
2113 new EffectAcpErrors.AcpTransportError({
2114 operation: "call-rpc",
2115 method: "session/load",
2116 detail:
2117 "session/load timed out waiting for RPC response or replay idle gap",
2118 cause: undefined,
2119 }),
2120 ),
2121 onSome: Effect.succeed,
2122 }),
2123 ),
2124 Effect.tap((result) =>
2125 logRequest({
2126 method: "session/load",
2127 payload: loadPayload,
2128 status: "succeeded",
2129 result,
2130 }),
2131 ),
2132 Effect.onError((cause) =>
2133 logRequest({
2134 method: "session/load",
2135 payload: loadPayload,
2136 status: "failed",
2137 cause,
2138 }),
2139 ),
2140 );
2141
2142 const gate = yield* Ref.get(sessionLoadGateRef);
2143 const candidateConfigOptions = Option.isSome(gate)
2144 ? gate.value.candidateConfigOptions
2145 : undefined;
2146 return loaded.configOptions == null && candidateConfigOptions !== undefined
2147 ? { ...loaded, configOptions: candidateConfigOptions }
2148 : loaded;
2149 }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none())));
2150 }),
2151 );
2152
2153 const setConfigOption = (
2154 configId: string,
2155 value: string | boolean,
2156 ): Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError> =>
2157 validateConfigOptionValue(configId, value).pipe(
2158 Effect.flatMap(() => getStartedState),
2159 Effect.flatMap((started) =>
2160 Ref.get(configOptionsRef).pipe(
2161 Effect.flatMap((configOptions) => {
2162 const existing = findSessionConfigOption(configOptions, configId);
2163 if (existing && configOptionCurrentValueMatches(existing, value)) {
2164 return Effect.succeed({
2165 configOptions,
2166 } satisfies EffectAcpSchema.SetSessionConfigOptionResponse);
2167 }
2168 const requestPayload =
2169 typeof value === "boolean"
2170 ? ({
2171 sessionId: started.sessionId,
2172 configId,
2173 type: "boolean",
2174 value,
2175 } satisfies EffectAcpSchema.SetSessionConfigOptionRequest)
2176 : ({
2177 sessionId: started.sessionId,
2178 configId,
2179 value: String(value),
2180 } satisfies EffectAcpSchema.SetSessionConfigOptionRequest);
2181 return runLoggedRequest(
2182 "session/set_config_option",
2183 requestPayload,
2184 acp.agent.setSessionConfigOption(requestPayload),
2185 ).pipe(Effect.tap((response) => updateConfigOptions(response)));
2186 }),
2187 ),
2188 ),
2189 );
2190
2191 const initializeOnce = Effect.gen(function* () {
2192 const initializePayload = {
2193 protocolVersion: 2,
2194 clientCapabilities: initializeClientCapabilities,
2195 clientInfo: options.clientInfo,
2196 ...(options.initializeMeta === undefined ? {} : { _meta: options.initializeMeta }),
2197 } satisfies EffectAcpSchema.InitializeRequest;
2198
2199 const initializeResult = yield* runLoggedRequest(
2200 "initialize",
2201 initializePayload,
2202 acp.agent.initialize(initializePayload),
2203 );
2204 yield* options.onInitialized?.(initializeResult) ?? Effect.void;
2205 return initializeResult;
2206 });
2207
2208 const initialize = Effect.gen(function* () {
2209 yield* ensureConnected;
2210 const deferred = yield* Deferred.make<
2211 EffectAcpSchema.InitializeResponse,
2212 EffectAcpErrors.AcpError
2213 >();
2214 const effect = yield* Ref.modify(initializeStateRef, (state) => {
2215 switch (state._tag) {
2216 case "Started":
2217 return [Effect.succeed(state.result), state] as const;
2218 case "Starting":
2219 return [Deferred.await(state.deferred), state] as const;
2220 case "NotStarted":
2221 return [
2222 initializeOnce.pipe(
2223 Effect.tap((result) =>
2224 Ref.set(initializeStateRef, { _tag: "Started", result }).pipe(
2225 Effect.andThen(Deferred.succeed(deferred, result)),
2226 ),
2227 ),
2228 Effect.onError((cause) =>
2229 Deferred.failCause(deferred, cause).pipe(
2230 Effect.andThen(Ref.set(initializeStateRef, { _tag: "NotStarted" })),
2231 ),
2232 ),
2233 ),
2234 { _tag: "Starting", deferred } satisfies AcpInitializeState,
2235 ] as const;
2236 }
2237 });
2238 return yield* effect;
2239 });
2240
2241 const sessionMcpServers = (
2242 initializeResult: EffectAcpSchema.InitializeResponse,
2243 activationOptions?: AcpSessionActivationOptions,
2244 ): ReadonlyArray<EffectAcpSchema.McpServer> => {
2245 const acpServers = activationOptions?.acpMcpServers ?? options.acpMcpServers ?? [];
2246 if (
2247 initializeResult.agentCapabilities?.mcpCapabilities?.acp === true &&
2248 acpServers.length > 0
2249 ) {
2250 return acpServers;
2251 }
2252 return activationOptions?.mcpServers ?? options.mcpServers ?? [];
2253 };
2254
2255 const startOnce = Effect.gen(function* () {
2256 const initializeResult = yield* initialize;
2257
2258 const authenticateAfterRequired = (
2259 authRequiredError: EffectAcpErrors.AcpError,
2260 ): Effect.Effect<void, EffectAcpErrors.AcpError> =>
2261 Effect.gen(function* () {
2262 const configuredAuthMethodId = options.authMethodId?.trim();
2263 const authMethod = selectAcpAgentAuthMethod(
2264 initializeResult.authMethods,
2265 configuredAuthMethodId,
2266 );
2267 if (
2268 configuredAuthMethodId &&
2269 initializeResult.authMethods !== undefined &&
2270 authMethod === undefined
2271 ) {
2272 return yield* new EffectAcpErrors.AcpTransportError({
2273 detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`,
2274 cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods },
2275 });
2276 }
2277 if (
2278 authMethod !== undefined &&
2279 authMethod.type !== undefined &&
2280 authMethod.type !== "agent"
2281 ) {
2282 return yield* new EffectAcpErrors.AcpTransportError({
2283 detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`,
2284 cause: authMethod,
2285 });
2286 }
2287
2288 const authMethodId = authMethod?.id ?? configuredAuthMethodId;
2289 if (!authMethodId) {
2290 return yield* authRequiredError;
2291 }
2292
2293 const authenticatePayload = {
2294 methodId: authMethodId,
2295 } satisfies EffectAcpSchema.AuthenticateRequest;
2296 yield* runLoggedRequest(
2297 "authenticate",
2298 authenticatePayload,
2299 acp.agent.authenticate(authenticatePayload),
2300 );
2301 });
2302
2303 const setupSession = Effect.gen(function* () {
2304 let sessionId: string;
2305 let sessionSetupResult:
2306 | EffectAcpSchema.LoadSessionResponse
2307 | EffectAcpSchema.NewSessionResponse
2308 | EffectAcpSchema.ResumeSessionResponse;
2309 const additionalDirectories = options.additionalDirectories?.length
2310 ? { additionalDirectories: options.additionalDirectories }
2311 : {};
2312 if (options.resumeSessionId) {
2313 sessionId = options.resumeSessionId;
2314 if (
2315 initializeResult.agentCapabilities?.loadSession === true &&
2316 options.resumeMethod !== "resume"
2317 ) {
2318 const loadPayload = {
2319 sessionId,
2320 cwd: options.cwd,
2321 ...additionalDirectories,
2322 mcpServers: sessionMcpServers(initializeResult),
2323 } satisfies EffectAcpSchema.LoadSessionRequest;
2324 sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult);
2325 } else if (initializeResult.agentCapabilities?.sessionCapabilities?.resume != null) {
2326 const resumePayload = {
2327 sessionId,
2328 cwd: options.cwd,
2329 ...additionalDirectories,
2330 mcpServers: sessionMcpServers(initializeResult),
2331 } satisfies EffectAcpSchema.ResumeSessionRequest;
2332 sessionSetupResult = yield* runLoggedRequest(
2333 "session/resume",
2334 resumePayload,
2335 acp.agent.resumeSession(resumePayload),
2336 );
2337 } else {
2338 return yield* new EffectAcpErrors.AcpRequestError({
2339 code: -32601,
2340 errorMessage: "ACP agent does not advertise session/load or session/resume support",
2341 });
2342 }
2343 } else {
2344 const createPayload = {
2345 cwd: options.cwd,
2346 mcpServers: sessionMcpServers(initializeResult),
2347 ...additionalDirectories,
2348 } satisfies EffectAcpSchema.NewSessionRequest;
2349 const created = yield* runLoggedRequest(
2350 "session/new",
2351 createPayload,
2352 acp.agent.createSession(createPayload),
2353 );
2354 sessionId = created.sessionId;
2355 sessionSetupResult = created;
2356 }
2357
2358 return { sessionId, sessionSetupResult };
2359 });
2360
2361 if (options.authenticateEagerly === true) {
2362 // The configured method is authoritative here: Antigravity accepts
2363 // methods (API key, Vertex) it does not list in `initialize`.
2364 const authenticatePayload = {
2365 methodId: options.authMethodId ?? "",
2366 } satisfies EffectAcpSchema.AuthenticateRequest;
2367 yield* runLoggedRequest(
2368 "authenticate",
2369 authenticatePayload,
2370 acp.agent.authenticate(authenticatePayload),
2371 );
2372 }
2373 const { sessionId, sessionSetupResult } = yield* setupSession.pipe(
2374 Effect.catch((error) =>
2375 !isAcpAuthenticationRequired(error) || options.authenticateOnAuthRequired === false
2376 ? Effect.fail(error)
2377 : authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)),
2378 ),
2379 );
2380
2381 yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));
2382 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
2383
2384 const nextState = {
2385 sessionId,
2386 initializeResult,
2387 sessionSetupResult,
2388 modelConfigId: extractModelConfigId(sessionSetupResult),
2389 } satisfies AcpStartedState;
2390 return nextState;
2391 });
2392
2393 const start = Effect.gen(function* () {
2394 yield* ensureConnected;
2395 const deferred = yield* Deferred.make<
2396 AcpSessionRuntimeStartResult,
2397 EffectAcpErrors.AcpError
2398 >();
2399 const effect = yield* Ref.modify(startStateRef, (state) => {
2400 switch (state._tag) {
2401 case "Started":
2402 return [Effect.succeed(state.result), state] as const;
2403 case "Starting":
2404 return [Deferred.await(state.deferred), state] as const;
2405 case "NotStarted":
2406 return [
2407 startOnce.pipe(
2408 Effect.tap((result) =>
2409 notificationSemaphore.withPermit(
2410 Effect.gen(function* () {
2411 const error = yield* Ref.get(terminationErrorRef);
2412 if (Option.isSome(error)) {
2413 return yield* error.value;
2414 }
2415 yield* Ref.set(startStateRef, { _tag: "Started", result });
2416 const metadata = yield* Ref.getAndSet(startupMetadataRef, []);
2417 for (const notification of metadata) {
2418 if (notification.sessionId === result.sessionId) {
2419 yield* processSessionUpdate(notification);
2420 }
2421 }
2422 yield* Deferred.succeed(deferred, result);
2423 }),
2424 ),
2425 ),
2426 Effect.onError((cause) =>
2427 Deferred.failCause(deferred, cause).pipe(
2428 Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })),
2429 Effect.andThen(Ref.set(startupMetadataRef, [])),
2430 ),
2431 ),
2432 ),
2433 { _tag: "Starting", deferred } satisfies AcpStartState,
2434 ] as const;
2435 }
2436 });
2437 return yield* effect;
2438 });
2439
2440 const drainEvents = Effect.gen(function* () {
2441 if (yield* Ref.get(stoppingRef)) {
2442 return;
2443 }
2444 const acknowledge = yield* Deferred.make<void>();
2445 yield* notificationSemaphore.withPermit(
2446 Effect.gen(function* () {
2447 // Keep a provider's final flushed chunks together until the adapter settles the turn.
2448 if (Option.isNone(yield* Ref.get(activePromptRef))) {
2449 yield* Ref.set(assistantUpdatesOpenRef, false);
2450 yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef });
2451 }
2452 yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge });
2453 }),
2454 );
2455 yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed));
2456 });
2457
2458 const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* (
2459 error: EffectAcpErrors.AcpError,
2460 ) {
2461 yield* recordTermination(error);
2462 yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore);
2463 });
2464
2465 const cancel = Effect.gen(function* () {
2466 const started = yield* getStartedState;
2467 const activePrompt = yield* Ref.get(activePromptRef);
2468 const cancelPayload = {
2469 sessionId: started.sessionId,
2470 ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }),
2471 } satisfies EffectAcpSchema.CancelNotification;
2472 if (options.cancelBehavior !== "wait-for-prompt") {
2473 if (options.interruptPromptOnCancel === false) {
2474 return yield* acp.agent.cancel(cancelPayload);
2475 }
2476 if (Option.isSome(activePrompt)) {
2477 yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore);
2478 }
2479 // Write cancel before a replacement prompt can reach the agent.
2480 yield* acp.agent.cancel(cancelPayload).pipe(Effect.ignore);
2481 return;
2482 }
2483
2484 yield* acp.agent.cancel(cancelPayload);
2485 if (Option.isNone(activePrompt)) {
2486 return;
2487 }
2488 const completed = yield* Effect.gen(function* () {
2489 const result = yield* Fiber.await(activePrompt.value.fiber);
2490 yield* Deferred.await(activePrompt.value.completed);
2491 if (Option.isNone(yield* Ref.get(terminationErrorRef))) {
2492 yield* drainEvents;
2493 }
2494 return result;
2495 }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout));
2496 if (Option.isNone(completed)) {
2497 const error = new EffectAcpErrors.AcpTransportError({
2498 operation: "call-rpc",
2499 method: "session/cancel",
2500 detail: "The ACP agent did not finish cancellation. Its process was stopped.",
2501 cause: undefined,
2502 });
2503 yield* retireRuntime(error);
2504 return yield* error;
2505 }
2506 if (Exit.isFailure(completed.value)) {
2507 return yield* Effect.failCause(completed.value.cause);
2508 }
2509 });
2510
2511 return {
2512 processContainment:
2513 linuxCgroupLease !== undefined
2514 ? "cgroup-v2"
2515 : options.ownDescendantProcessGroups === true
2516 ? "process-ledger-reduced-guarantee"
2517 : options.ownDetachedProcessGroup === true
2518 ? "process-group"
2519 : "none",
2520 handleRequestPermission: acp.handleRequestPermission,
2521 handleElicitation: acp.handleElicitation,
2522 handleMcpConnect: acp.handleMcpConnect,
2523 handleMcpMessage: acp.handleMcpMessage,
2524 handleMcpDisconnect: acp.handleMcpDisconnect,
2525 handleMcpNotification: acp.handleMcpNotification,
2526 handleReadTextFile: acp.handleReadTextFile,
2527 handleWriteTextFile: acp.handleWriteTextFile,
2528 handleCreateTerminal: acp.handleCreateTerminal,
2529 handleTerminalOutput: acp.handleTerminalOutput,
2530 handleTerminalWaitForExit: acp.handleTerminalWaitForExit,
2531 handleTerminalKill: acp.handleTerminalKill,
2532 handleTerminalRelease: acp.handleTerminalRelease,
2533 handleSessionUpdate: acp.handleSessionUpdate,
2534 handleElicitationComplete: acp.handleElicitationComplete,
2535 handleUnknownExtRequest: acp.handleUnknownExtRequest,
2536 handleUnknownExtNotification: acp.handleUnknownExtNotification,
2537 handleExtRequest: acp.handleExtRequest,
2538 handleExtNotification: acp.handleExtNotification,
2539 initialize: () => initialize,
2540 authenticate: (methodId) =>
2541 initialize.pipe(
2542 Effect.andThen(
2543 runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })),
2544 ),
2545 Effect.asVoid,
2546 ),
2547 start: () => start,
2548 getEvents: () => Stream.fromQueue(eventQueue),
2549 drainEvents,
2550 getModeState: Ref.get(modeStateRef),
2551 getConfigOptions: Ref.get(configOptionsRef),
2552 loadSession: (sessionId, activationOptions) =>
2553 start.pipe(
2554 Effect.flatMap((started) => {
2555 const requestPayload = {
2556 sessionId,
2557 cwd: options.cwd,
2558 mcpServers: sessionMcpServers(started.initializeResult, activationOptions),
2559 } satisfies EffectAcpSchema.LoadSessionRequest;
2560 return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult);
2561 }),
2562 Effect.flatMap((response) => adoptSession(sessionId, response)),
2563 ),
2564 resumeSession: (sessionId, activationOptions) =>
2565 start.pipe(
2566 Effect.flatMap((started) => {
2567 const requestPayload = {
2568 sessionId,
2569 cwd: options.cwd,
2570 mcpServers: sessionMcpServers(started.initializeResult, activationOptions),
2571 } satisfies EffectAcpSchema.ResumeSessionRequest;
2572 return runLoggedRequest(
2573 "session/resume",
2574 requestPayload,
2575 acp.agent.resumeSession(requestPayload),
2576 );
2577 }),
2578 Effect.flatMap((response) => adoptSession(sessionId, response)),
2579 ),
2580 forkSession: (sessionId, activationOptions) =>
2581 start.pipe(
2582 Effect.flatMap((started) => {
2583 const requestPayload = {
2584 sessionId,
2585 cwd: options.cwd,
2586 mcpServers: sessionMcpServers(started.initializeResult, activationOptions),
2587 } satisfies EffectAcpSchema.ForkSessionRequest;
2588 return runLoggedRequest(
2589 "session/fork",
2590 requestPayload,
2591 acp.agent.forkSession(requestPayload),
2592 );
2593 }),
2594 Effect.flatMap((response) => adoptSession(response.sessionId, response)),
2595 ),
2596 listSessions: (cursor) => {
2597 const requestPayload = {
2598 cwd: options.cwd,
2599 ...(cursor === undefined ? {} : { cursor }),
2600 } satisfies EffectAcpSchema.ListSessionsRequest;
2601 return initialize.pipe(
2602 Effect.filterOrFail(
2603 (initialized) => initialized.agentCapabilities?.sessionCapabilities?.list != null,
2604 () =>
2605 new EffectAcpErrors.AcpRequestError({
2606 code: -32601,
2607 errorMessage: "ACP agent does not advertise session/list support",
2608 }),
2609 ),
2610 Effect.andThen(
2611 runLoggedRequest(
2612 "session/list",
2613 requestPayload,
2614 acp.agent.listSessions(requestPayload),
2615 ),
2616 ),
2617 );
2618 },
2619 closeSession: (sessionId) =>
2620 start.pipe(
2621 Effect.flatMap((started) => {
2622 const requestPayload = {
2623 sessionId: sessionId ?? started.sessionId,
2624 } satisfies EffectAcpSchema.CloseSessionRequest;
2625 return runLoggedRequest(
2626 "session/close",
2627 requestPayload,
2628 acp.agent.closeSession(requestPayload),
2629 );
2630 }),
2631 ),
2632 deleteSession: (sessionId) =>
2633 initialize.pipe(
2634 Effect.filterOrFail(
2635 (initialized) => initialized.agentCapabilities?.sessionCapabilities?.delete != null,
2636 () =>
2637 new EffectAcpErrors.AcpRequestError({
2638 code: -32601,
2639 errorMessage: "ACP agent does not advertise session/delete support",
2640 }),
2641 ),
2642 Effect.andThen(
2643 runLoggedRequest(
2644 "session/delete",
2645 { sessionId },
2646 acp.agent.deleteSession({ sessionId }),
2647 ),
2648 ),
2649 ),
2650 listProviders: initialize.pipe(
2651 Effect.filterOrFail(
2652 (initialized) => initialized.agentCapabilities?.providers != null,
2653 () =>
2654 new EffectAcpErrors.AcpRequestError({
2655 code: -32601,
2656 errorMessage: "ACP agent does not advertise provider configuration support",
2657 }),
2658 ),
2659 Effect.andThen(runLoggedRequest("providers/list", {}, acp.agent.listProviders({}))),
2660 ),
2661 setProvider: (provider) =>
2662 initialize.pipe(
2663 Effect.filterOrFail(
2664 (initialized) => initialized.agentCapabilities?.providers != null,
2665 () =>
2666 new EffectAcpErrors.AcpRequestError({
2667 code: -32601,
2668 errorMessage: "ACP agent does not advertise provider configuration support",
2669 }),
2670 ),
2671 Effect.andThen(
2672 runLoggedRequest(
2673 "providers/set",
2674 {
2675 ...provider,
2676 ...(provider.headers === undefined
2677 ? {}
2678 : {
2679 headers: Object.fromEntries(
2680 Object.keys(provider.headers).map((name) => [name, "[redacted]"]),
2681 ),
2682 }),
2683 },
2684 acp.agent.setProvider(provider),
2685 ),
2686 ),
2687 ),
2688 disableProvider: (providerId) => {
2689 const request = { providerId } satisfies EffectAcpSchema.DisableProviderRequest;
2690 return initialize.pipe(
2691 Effect.filterOrFail(
2692 (initialized) => initialized.agentCapabilities?.providers != null,
2693 () =>
2694 new EffectAcpErrors.AcpRequestError({
2695 code: -32601,
2696 errorMessage: "ACP agent does not advertise provider configuration support",
2697 }),
2698 ),
2699 Effect.andThen(
2700 runLoggedRequest("providers/disable", request, acp.agent.disableProvider(request)),
2701 ),
2702 );
2703 },
2704 logout: initialize.pipe(
2705 Effect.filterOrFail(
2706 (initialized) => initialized.agentCapabilities?.auth?.logout != null,
2707 () =>
2708 new EffectAcpErrors.AcpRequestError({
2709 code: -32601,
2710 errorMessage: "ACP agent does not advertise logout support",
2711 }),
2712 ),
2713 Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))),
2714 ),
2715 prompt: (payload, promptOptions?) =>
2716 promptSerializationSemaphore.withPermit(
2717 Effect.acquireUseRelease(
2718 promptDispatchSemaphore.withPermit(
2719 Effect.gen(function* () {
2720 const started = yield* getStartedState;
2721 yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef });
2722 yield* Ref.set(assistantUpdatesOpenRef, true);
2723 const requestPayload = {
2724 sessionId: started.sessionId,
2725 ...payload,
2726 } satisfies EffectAcpSchema.PromptRequest;
2727 const completed = yield* Deferred.make<void>();
2728 const fiber = yield* runLoggedRequest(
2729 "session/prompt",
2730 requestPayload,
2731 acp.agent.prompt(requestPayload),
2732 ).pipe(Effect.forkIn(runtimeScope));
2733 const active = { fiber, completed } satisfies AcpActivePrompt;
2734 yield* Ref.set(activePromptRef, Option.some(active));
2735 if (promptOptions?.dispatched) {
2736 yield* Deferred.succeed(promptOptions.dispatched, undefined);
2737 }
2738 return active;
2739 }).pipe(notificationSemaphore.withPermit),
2740 ),
2741 (activePrompt) =>
2742 Fiber.join(activePrompt.fiber).pipe(
2743 Effect.catchCause((cause) =>
2744 options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause)
2745 ? Effect.succeed({
2746 stopReason: "cancelled",
2747 } satisfies EffectAcpSchema.PromptResponse)
2748 : Effect.failCause(cause),
2749 ),
2750 Effect.tap(() =>
2751 closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }),
2752 ),
2753 ),
2754 (activePrompt, result) =>
2755 Effect.gen(function* () {
2756 if (
2757 options.cancelBehavior === "wait-for-prompt" &&
2758 Exit.isFailure(result) &&
2759 Cause.hasInterrupts(result.cause)
2760 ) {
2761 yield* retireRuntime(
2762 new EffectAcpErrors.AcpTransportError({
2763 method: "session/prompt",
2764 detail: "The ACP prompt stopped before the agent confirmed completion.",
2765 cause: undefined,
2766 }),
2767 );
2768 }
2769 yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore);
2770 yield* Ref.set(activePromptRef, Option.none());
2771 yield* Deferred.succeed(activePrompt.completed, undefined);
2772 }),
2773 ),
2774 ),
2775 cancel:
2776 options.cancelBehavior === "wait-for-prompt"
2777 ? promptDispatchSemaphore.withPermit(cancel)
2778 : cancel,
2779 ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}),
2780 // A session's mode is its `category: "mode"` config option. ACP v1 agents
2781 // that only advertise `modes` (gemini-cli) take `session/set_mode`
2782 // instead, which ACP v2 removed.
2783 setMode: (modeId) =>
2784 Effect.gen(function* () {
2785 const modeState = yield* Ref.get(modeStateRef);
2786 if (modeState?.currentModeId === modeId) {
2787 return {} satisfies EffectAcpSchema.SetSessionModeResponse;
2788 }
2789 const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find(
2790 (option) => option.category === "mode" && option.type === "select",
2791 );
2792 if (modeConfigOption === undefined && modeState !== undefined) {
2793 const started = yield* getStartedState;
2794 const payload = { sessionId: started.sessionId, modeId };
2795 yield* runLoggedRequest("session/set_mode", payload, acp.agent.setSessionMode(payload));
2796 yield* updateCurrentModeId(modeId);
2797 return {} satisfies EffectAcpSchema.SetSessionModeResponse;
2798 }
2799 const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId);
2800 // The agent answers with its config options, so the mode it reports
2801 // is the mode it runs in, even when it kept another one.
2802 const reported = parseSessionModeState({ configOptions: response.configOptions });
2803 if (reported === undefined) {
2804 yield* updateCurrentModeId(modeId);
2805 } else {
2806 yield* updateCurrentModeId(reported.currentModeId);
2807 }
2808 return {} satisfies EffectAcpSchema.SetSessionModeResponse;
2809 }),
2810 setSessionModel: (modelId, meta) =>
2811 getStartedState.pipe(
2812 Effect.flatMap((started) => {
2813 const payload = {
2814 sessionId: started.sessionId,
2815 modelId,
2816 ...(meta === undefined ? {} : { _meta: meta }),
2817 };
2818 return runLoggedRequest(
2819 "session/set_model",
2820 payload,
2821 acp.agent.setSessionModel(payload),
2822 );
2823 }),
2824 ),
2825 setConfigOption,
2826 setModel: (model) =>
2827 getStartedState.pipe(
2828 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),
2829 Effect.asVoid,
2830 ),
2831
2832 request: (method, payload) =>
2833 ensureConnected.pipe(
2834 Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))),
2835 ),
2836 notify: (method, payload) =>
2837 ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))),
2838 } satisfies AcpSessionRuntime["Service"];
2839 });
2840
2841export const layer = (
2842 options: AcpSessionRuntimeOptions,
2843): Layer.Layer<
2844 AcpSessionRuntime,
2845 EffectAcpErrors.AcpError,
2846 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
2847> => Layer.effect(AcpSessionRuntime, make(options));
2848
2849function sessionConfigOptionsFromSetup(
2850 response:
2851 | {
2852 readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null;
2853 }
2854 | undefined,
2855): ReadonlyArray<EffectAcpSchema.SessionConfigOption> {
2856 return response?.configOptions ?? [];
2857}
2858
2859function configOptionCurrentValueMatches(
2860 configOption: EffectAcpSchema.SessionConfigOption,
2861 value: string | boolean,
2862): boolean {
2863 const currentValue = configOption.currentValue;
2864 if (configOption.type === "boolean") {
2865 return currentValue === value;
2866 }
2867 if (typeof currentValue !== "string") {
2868 return false;
2869 }
2870 return currentValue.trim() === String(value).trim();
2871}
2872
2873function isStartupMetadataUpdate(notification: EffectAcpSchema.SessionNotification): boolean {
2874 switch (notification.update.sessionUpdate) {
2875 case "current_mode_update":
2876 case "config_option_update":
2877 case "available_commands_update":
2878 return true;
2879 default:
2880 return false;
2881 }
2882}
2883
2884const handleSessionUpdate = ({
2885 queue,
2886 modeStateRef,
2887 configOptionsRef,
2888 toolCallsRef,
2889 shownToolCallIds,
2890 assistantSegmentRef,
2891 assistantItemRuntimeId,
2892 params,
2893}: {
2894 readonly queue: Queue.Queue<AcpSessionRuntimeEvent>;
2895 readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
2896 readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
2897 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>;
2898 readonly shownToolCallIds: Set<string>;
2899 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
2900 readonly assistantItemRuntimeId: string;
2901 readonly params: EffectAcpSchema.SessionNotification;
2902}): Effect.Effect<void> =>
2903 Effect.gen(function* () {
2904 if (params.update.sessionUpdate === "config_option_update") {
2905 const configOptions = params.update.configOptions;
2906 yield* Ref.set(configOptionsRef, configOptions);
2907 yield* Ref.update(
2908 modeStateRef,
2909 (current) => parseSessionModeState({ configOptions }) ?? current,
2910 );
2911 }
2912 const parsed = parseSessionUpdateEvent(params);
2913 if (parsed.modeId) {
2914 yield* Ref.update(modeStateRef, (current) =>
2915 current === undefined ? current : updateModeState(current, parsed.modeId!),
2916 );
2917 }
2918 for (const event of parsed.events) {
2919 if (event._tag === "ToolCallUpdated") {
2920 const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => {
2921 const tracked = current.get(event.toolCall.toolCallId);
2922 const previous = tracked?.state;
2923 const nextToolCall = mergeToolCallState(previous, event.toolCall);
2924 const decision = decideToolCallUpdateEmission({
2925 previous,
2926 next: nextToolCall,
2927 lastEmittedDetailLength: tracked?.lastEmittedDetailLength,
2928 skippedSinceEmit: tracked?.skippedSinceEmit ?? 0,
2929 });
2930 const next = new Map(current);
2931 if (nextToolCall.status === "completed" || nextToolCall.status === "failed") {
2932 next.delete(nextToolCall.toolCallId);
2933 } else {
2934 next.set(nextToolCall.toolCallId, {
2935 state: nextToolCall,
2936 lastEmittedDetailLength: decision.emit
2937 ? toolCallProgressLength(nextToolCall)
2938 : tracked?.lastEmittedDetailLength,
2939 skippedSinceEmit: decision.skippedSinceEmit,
2940 });
2941 }
2942 return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const;
2943 });
2944 if (!decision.emit) {
2945 continue;
2946 }
2947 // A new tool call is a boundary in the prose. Progress on a call that
2948 // is already shown, such as a background command finishing, is not.
2949 if (!shownToolCallIds.has(merged.toolCallId)) {
2950 shownToolCallIds.add(merged.toolCallId);
2951 // Only recent calls get late updates; keep a long session bounded.
2952 if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) {
2953 shownToolCallIds.delete(shownToolCallIds.values().next().value!);
2954 }
2955 // A call still running is already on screen, even if it aged out.
2956 if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef });
2957 }
2958 yield* Queue.offer(queue, {
2959 _tag: "ToolCallUpdated",
2960 toolCall: merged,
2961 rawPayload: event.rawPayload,
2962 });
2963 continue;
2964 }
2965 if (event._tag === "ContentDelta") {
2966 if (event.text.trim().length === 0) {
2967 const assistantSegmentState = yield* Ref.get(assistantSegmentRef);
2968 if (!assistantSegmentState.activeItemId) {
2969 continue;
2970 }
2971 }
2972 const itemId = yield* ensureActiveAssistantSegment({
2973 queue,
2974 assistantSegmentRef,
2975 sessionId: params.sessionId,
2976 assistantItemRuntimeId,
2977 });
2978 yield* Queue.offer(queue, {
2979 ...event,
2980 itemId,
2981 });
2982 continue;
2983 }
2984 yield* Queue.offer(queue, event);
2985 }
2986 });
2987
2988function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState {
2989 const normalized = nextModeId.trim();
2990 if (!normalized) {
2991 return modeState;
2992 }
2993 return modeState.availableModes.some((mode) => mode.id === normalized)
2994 ? {
2995 ...modeState,
2996 currentModeId: normalized,
2997 }
2998 : modeState;
2999}
3000
3001const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) =>
3002 `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`;
3003
3004const ensureActiveAssistantSegment = ({
3005 queue,
3006 assistantSegmentRef,
3007 sessionId,
3008 assistantItemRuntimeId,
3009}: {
3010 readonly queue: Queue.Queue<AcpSessionRuntimeEvent>;
3011 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
3012 readonly sessionId: string;
3013 readonly assistantItemRuntimeId: string;
3014}) =>
3015 Ref.modify<AcpAssistantSegmentState, EnsureActiveAssistantSegmentResult>(
3016 assistantSegmentRef,
3017 (current) => {
3018 if (current.activeItemId) {
3019 return [{ itemId: current.activeItemId }, current] as const;
3020 }
3021 const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex);
3022 return [
3023 {
3024 itemId,
3025 startedEvent: {
3026 _tag: "AssistantItemStarted",
3027 itemId,
3028 } satisfies Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>,
3029 },
3030 {
3031 nextSegmentIndex: current.nextSegmentIndex + 1,
3032 activeItemId: itemId,
3033 } satisfies AcpAssistantSegmentState,
3034 ] as const;
3035 },
3036 ).pipe(
3037 Effect.flatMap((result) =>
3038 result.startedEvent
3039 ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId))
3040 : Effect.succeed(result.itemId),
3041 ),
3042 );
3043
3044const closeActiveAssistantSegment = ({
3045 queue,
3046 assistantSegmentRef,
3047}: {
3048 readonly queue: Queue.Queue<AcpSessionRuntimeEvent>;
3049 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
3050}) =>
3051 Ref.modify(assistantSegmentRef, (current) => {
3052 if (!current.activeItemId) {
3053 return [undefined, current] as const;
3054 }
3055 return [
3056 {
3057 _tag: "AssistantItemCompleted",
3058 itemId: current.activeItemId,
3059 } satisfies AcpParsedSessionEvent,
3060 {
3061 nextSegmentIndex: current.nextSegmentIndex,
3062 } satisfies AcpAssistantSegmentState,
3063 ] as const;
3064 }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void)));