| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | |
| 6 | import * as Cause from "effect/Cause"; |
| 7 | import * as Clock from "effect/Clock"; |
| 8 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; |
| 14 | import * as Fiber from "effect/Fiber"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import * as Queue from "effect/Queue"; |
| 18 | import * as Ref from "effect/Ref"; |
| 19 | import * as Schema from "effect/Schema"; |
| 20 | import * as Scope from "effect/Scope"; |
| 21 | import * as Semaphore from "effect/Semaphore"; |
| 22 | import * as Stream from "effect/Stream"; |
| 23 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 24 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 25 | import * as EffectAcpClient from "effect-acp/client"; |
| 26 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 27 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 28 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 29 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 30 | import { AgentScope, AgentScopeThreadId, RAISE_OOM_SCORE_LINE } from "@t3tools/shared/AgentScope"; |
| 31 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 32 | |
| 33 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; |
| 34 | import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./stderr.ts"; |
| 35 | import { |
| 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 | |
| 53 | const MAX_SHOWN_TOOL_CALL_IDS = 256; |
| 54 | |
| 55 | interface AcpToolCallTrackedState { |
| 56 | readonly state: AcpToolCallState; |
| 57 | readonly lastEmittedDetailLength: number | undefined; |
| 58 | readonly skippedSinceEmit: number; |
| 59 | } |
| 60 | |
| 61 | function formatConfigOptionValue(value: string | boolean): string { |
| 62 | return JSON.stringify(value); |
| 63 | } |
| 64 | |
| 65 | export interface AcpSessionEventStreamBarrier { |
| 66 | readonly _tag: "EventStreamBarrier"; |
| 67 | readonly acknowledge: Deferred.Deferred<void>; |
| 68 | } |
| 69 | |
| 70 | export type AcpSessionRuntimeEvent = |
| 71 | | AcpParsedSessionEvent |
| 72 | | AcpSessionEventStreamBarrier |
| 73 | | { |
| 74 | readonly _tag: "ConnectionTerminated"; |
| 75 | readonly error: EffectAcpErrors.AcpError; |
| 76 | }; |
| 77 | |
| 78 | const defaultSessionLoadTimeout = Duration.seconds(90); |
| 79 | const defaultSessionLoadReplayIdleGap = Duration.seconds(2); |
| 80 | const defaultCancelTimeout = Duration.seconds(15); |
| 81 | const maxStartupMetadataUpdates = 32; |
| 82 | // Antigravity can emit an accepted 16 KiB Google authorization URL on stderr. |
| 83 | const maxStderrChunkLength = 32_768; |
| 84 | |
| 85 | export 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 | |
| 94 | export 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 | |
| 166 | export 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 | |
| 174 | export 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 | |
| 188 | const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError); |
| 189 | |
| 190 | export 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 | |
| 205 | export 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 | |
| 213 | export 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 | |
| 223 | export interface AcpLinuxCgroupController { |
| 224 | readonly create: () => AcpLinuxCgroupLease | undefined; |
| 225 | } |
| 226 | |
| 227 | export interface AcpPosixOwnershipRoot { |
| 228 | captureAttempted?: boolean; |
| 229 | value: AcpOwnedPosixProcess | undefined; |
| 230 | } |
| 231 | |
| 232 | function 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 | |
| 238 | export function parseUnifiedCgroupPath(contents: string): string | undefined { |
| 239 | const matches = [...contents.matchAll(/^0::(\/.*)$/gm)]; |
| 240 | return matches.length === 1 ? matches[0]![1] : undefined; |
| 241 | } |
| 242 | |
| 243 | export function isSafeCgroupPath(value: string): boolean { |
| 244 | return ( |
| 245 | value.startsWith("/") && |
| 246 | !value.includes("(deleted)") && |
| 247 | NodePath.posix.normalize(value) === value |
| 248 | ); |
| 249 | } |
| 250 | |
| 251 | export 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 | |
| 268 | export function parseCgroup2Mount( |
| 269 | contents: string, |
| 270 | ): { readonly mountPoint: string; readonly root: string } | undefined { |
| 271 | return parseCgroup2Mounts(contents)[0]; |
| 272 | } |
| 273 | |
| 274 | export 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 | |
| 312 | export 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 | |
| 346 | const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/; |
| 347 | |
| 348 | export 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 | |
| 355 | function 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 | |
| 364 | function 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. */ |
| 372 | export 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 | |
| 402 | function 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 | |
| 473 | export 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. |
| 505 | const wallClock = Clock.Clock.defaultValue(); |
| 506 | const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => |
| 507 | Effect.provideService(effect, Clock.Clock, wallClock); |
| 508 | |
| 509 | export 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 | |
| 570 | export 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 | |
| 586 | function 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. */ |
| 623 | export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean { |
| 624 | return process.state === "Z"; |
| 625 | } |
| 626 | |
| 627 | function 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 | |
| 653 | function 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 | |
| 672 | export 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 | |
| 764 | export 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 | |
| 785 | export 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 | |
| 794 | function 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 | |
| 805 | export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity { |
| 806 | readonly parentExecutable: string | undefined; |
| 807 | readonly parentStartTime: string; |
| 808 | } |
| 809 | |
| 810 | function processIdentityKey(process: AcpPosixProcessIdentity): string { |
| 811 | return `${process.pid}:${process.startTime}`; |
| 812 | } |
| 813 | |
| 814 | function 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 | |
| 831 | export 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 | |
| 923 | export 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 | |
| 1068 | export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean { |
| 1069 | return exitCode === 0; |
| 1070 | } |
| 1071 | |
| 1072 | export 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 | |
| 1111 | export 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 | |
| 1122 | function isAcpAuthenticationRequired(error: EffectAcpErrors.AcpError): boolean { |
| 1123 | return error._tag === "AcpRequestError" && error.code === -32000; |
| 1124 | } |
| 1125 | |
| 1126 | export 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 | |
| 1137 | export interface AcpSessionActivationOptions { |
| 1138 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; |
| 1139 | readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; |
| 1140 | } |
| 1141 | |
| 1142 | export 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 | |
| 1349 | interface AcpStartedState extends AcpSessionRuntimeStartResult {} |
| 1350 | |
| 1351 | type 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 | |
| 1359 | type 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 | |
| 1370 | interface AcpAssistantSegmentState { |
| 1371 | readonly nextSegmentIndex: number; |
| 1372 | readonly activeItemId?: string; |
| 1373 | } |
| 1374 | |
| 1375 | interface EnsureActiveAssistantSegmentResult { |
| 1376 | readonly itemId: string; |
| 1377 | readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>; |
| 1378 | } |
| 1379 | |
| 1380 | interface AcpActivePrompt { |
| 1381 | readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; |
| 1382 | readonly completed: Deferred.Deferred<void>; |
| 1383 | } |
| 1384 | |
| 1385 | export 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 | |
| 2841 | export 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 | |
| 2849 | function sessionConfigOptionsFromSetup( |
| 2850 | response: |
| 2851 | | { |
| 2852 | readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null; |
| 2853 | } |
| 2854 | | undefined, |
| 2855 | ): ReadonlyArray<EffectAcpSchema.SessionConfigOption> { |
| 2856 | return response?.configOptions ?? []; |
| 2857 | } |
| 2858 | |
| 2859 | function 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 | |
| 2873 | function 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 | |
| 2884 | const 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 | |
| 2988 | function 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 | |
| 3001 | const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => |
| 3002 | `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; |
| 3003 | |
| 3004 | const 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 | |
| 3044 | const 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))); |