// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto digest is effectful; a sync hash keeps historyPath a plain function. import * as NodeCrypto from "node:crypto"; import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { DEFAULT_TERMINAL_ID, DEFAULT_MAX_TERMINAL_BUFFER_BYTES, TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, TerminalCwdStatError, TerminalError, TerminalHistoryError, TerminalNotRunningError, TerminalProviderInstanceNotFoundError, TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalClearInput, type TerminalCloseInput, type TerminalEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalObserveInput, type TerminalResizeInput, type ResourceMonitorProcessTableEntry, type TerminalSessionSnapshot, type TerminalSessionStatus, type TerminalSummary, type TerminalWriteInput, ClaudeSettings, CodexSettings, ProviderInstanceId, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { mergePathEntries } from "@t3tools/shared/shell"; import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ServerSettings from "../serverSettings.ts"; import { increment, terminalSessionsTotal } from "../observability/Metrics.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; export { TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, TerminalCwdStatError, TerminalError, TerminalHistoryError, TerminalNotRunningError, TerminalProviderInstanceNotFoundError, TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, }; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024; const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 250; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000; // Discover children started without terminal input or output. const QUIET_SUBPROCESS_SNAPSHOT_EVERY_TICKS = 30; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; // The tmux/screen/Zellij keys name the multiplexer pane that launched the server, // so inheriting them points multiplexer-aware tools at that unrelated pane. const TERMINAL_ENV_BLOCKLIST = new Set([ "PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE", "TMUX", "TMUX_PANE", "STY", "WINDOW", "ZELLIJ", "ZELLIJ_SESSION_NAME", "ZELLIJ_PANE_ID", ]); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); class TerminalSubprocessCheckError extends Schema.TaggedError()( "TerminalSubprocessCheckError", { cause: Schema.optional(Schema.Defect()), command: Schema.Literals(["powershell", "ps", "resource-monitor"]), exitCode: Schema.optional(Schema.NullOr(Schema.Number)), timedOut: Schema.optional(Schema.Boolean), stdoutTruncated: Schema.optional(Schema.Boolean), }, ) { override get message(): string { const details = [ this.exitCode !== undefined && this.exitCode !== null ? `exit code ${this.exitCode}` : null, this.timedOut ? "timed out" : null, this.stdoutTruncated ? "output truncated" : null, ] .filter((detail) => detail !== null) .join(", "); return `Failed to inspect terminal subprocesses with ${this.command}${details.length > 0 ? ` (${details})` : ""}`; } } class TerminalProcessSignalError extends Schema.TaggedError()( "TerminalProcessSignalError", { cause: Schema.optional(Schema.Defect()), signal: Schema.Literals(["SIGTERM", "SIGKILL"]), terminalPid: Schema.Number, }, ) { override get message(): string { return `Failed to send ${this.signal} to terminal process ${this.terminalPid}`; } } export class TerminalManager extends Context.Service< TerminalManager, { readonly open: ( input: TerminalOpenInput, ) => Effect.Effect; /** * Attached viewers share one PTY sized to the smallest of them, so a narrow client * still sees the whole TUI. Streams and resizes with the same `viewerId` (one client * connection) count as one viewer; streams without one are each their own viewer. */ readonly attachStream: ( input: TerminalAttachInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, registerCleanup?: (stop: () => void) => void, viewerId?: string, ) => Effect.Effect<() => void, TerminalError>; /** * The `terminal.attach` RPC stream. Overflow recovery swaps the inner subscription, so * `viewerId` and the size it reported stay attached until the whole stream ends. */ readonly attachOutputStream: ( input: TerminalAttachInput, viewerId: string, ) => Stream.Stream; readonly observeStream: ( input: TerminalObserveInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void, TerminalError>; readonly resyncStream: ( input: TerminalObserveInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void, TerminalError>; readonly write: (input: TerminalWriteInput) => Effect.Effect; readonly resize: ( input: TerminalResizeInput, viewerId?: string, ) => Effect.Effect; readonly clear: (input: TerminalClearInput) => Effect.Effect; /** Omit terminalId to close all sessions for the thread. */ readonly close: (input: TerminalCloseInput) => Effect.Effect; readonly closeIdle: (input: { readonly threadId: string; readonly terminalId?: string; }) => Effect.Effect; readonly subscribe: ( listener: (event: TerminalEvent) => Effect.Effect, registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void>; readonly subscribeMetadata: ( listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, ) => Effect.Effect<() => void>; } >()("t3/terminal/Manager/TerminalManager") {} interface TerminalSubprocessInspectResult { readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; readonly processIds: ReadonlyArray; } interface TerminalSubprocessInspector { ( terminalPid: number, ): Effect.Effect; } const resizePtyProcess = ( session: TerminalSessionState, process: PtyAdapter.PtyProcess, cols: number, rows: number, ) => Effect.try({ try: () => process.resize(cols, rows), catch: (cause) => new TerminalResizeError({ threadId: session.threadId, terminalId: session.terminalId, terminalPid: process.pid, cols, rows, cause, }), }); export interface ShellCandidate { shell: string; args?: string[]; } interface TerminalStartInput { readonly cwd: string; readonly worktreePath?: string | null; readonly cols: number; readonly rows: number; } interface TerminalSessionState { threadId: string; terminalId: string; cwd: string; worktreePath: string | null; status: TerminalSessionStatus; pid: number | null; history: BoundedTerminalHistory; pendingHistoryControlSequence: string; pendingProcessEvents: Array; pendingProcessEventIndex: number; processEventDrainRunning: boolean; exitCode: number | null; exitSignal: number | null; updatedAt: string; eventSequence: number; inputCount: number; cols: number; rows: number; process: PtyAdapter.PtyProcess | null; unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; childCommandLabel: string | null; runtimeEnv: Record | null; } interface TerminalGrid { readonly cols: number; readonly rows: number; } /** One client connection attached to a terminal, possibly through several streams. */ interface TerminalViewer { streams: number; grid: TerminalGrid | null; } interface PersistHistoryRequest { history: BoundedTerminalHistory; immediate: boolean; } type PendingProcessEvent = | { type: "output"; data: string } | { type: "exit"; event: PtyAdapter.PtyExitEvent }; type DrainProcessEventAction = | { type: "idle" } | { type: "output"; threadId: string; terminalId: string; sequence: number; history: BoundedTerminalHistory | null; data: string; } | { type: "exit"; process: PtyAdapter.PtyProcess | null; threadId: string; terminalId: string; sequence: number; exitCode: number | null; exitSignal: number | null; }; interface TerminalManagerState { sessions: Map; killFibers: Map>; } function truncateTerminalWireLabel(value: string): string { if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); } function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { let trimmed = raw.trim(); if (trimmed.length === 0) return null; if ( (trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("(") && trimmed.endsWith(")")) ) { trimmed = trimmed.slice(1, -1).trim(); } const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); if (firstToken.length === 0) return null; const separators = platform === "win32" ? /[\\/]/ : /\//; const base = firstToken.split(separators).at(-1) ?? firstToken; const withoutExe = platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; return withoutExe.length > 0 ? withoutExe : null; } function terminalWireLabel(session: TerminalSessionState): string { if (session.hasRunningSubprocess && session.childCommandLabel) { const trimmed = session.childCommandLabel.trim(); if (trimmed.length > 0) { return truncateTerminalWireLabel(trimmed); } } return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); } function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { return { threadId: session.threadId, terminalId: session.terminalId, cwd: session.cwd, worktreePath: session.worktreePath, status: session.status, pid: session.pid, history: session.history.value(), exitCode: session.exitCode, exitSignal: session.exitSignal, label: terminalWireLabel(session), updatedAt: session.updatedAt, sequence: session.eventSequence, cols: session.cols, rows: session.rows, }; } function summary(session: TerminalSessionState): TerminalSummary { return { threadId: session.threadId, terminalId: session.terminalId, cwd: session.cwd, worktreePath: session.worktreePath, status: session.status, pid: session.pid, exitCode: session.exitCode, exitSignal: session.exitSignal, hasRunningSubprocess: session.hasRunningSubprocess, label: terminalWireLabel(session), updatedAt: session.updatedAt, }; } function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { switch (event.type) { case "started": case "restarted": case "exited": case "closed": case "error": case "activity": return true; case "output": case "cleared": return false; } } function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent { switch (event.type) { case "started": return { type: "snapshot", snapshot: event.snapshot, }; case "output": case "exited": case "closed": case "error": case "cleared": case "restarted": case "activity": return event; } } function isDuplicateAttachSnapshotEvent( event: TerminalEvent, initialSnapshot: TerminalSessionSnapshot, ) { return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" ? event.sequence <= initialSnapshot.sequence : event.type === "started" && event.snapshot.threadId === initialSnapshot.threadId && event.snapshot.terminalId === initialSnapshot.terminalId && event.snapshot.updatedAt <= initialSnapshot.updatedAt; } function advanceEventSequence(session: TerminalSessionState): { readonly updatedAt: string; readonly sequence: number; } { const updatedAt = DateTime.formatIso(DateTime.nowUnsafe()); session.eventSequence += 1; session.updatedAt = updatedAt; return { updatedAt, sequence: session.eventSequence }; } function terminalActivityMark(session: TerminalSessionState): number { return session.eventSequence + session.inputCount; } interface QuietTerminalSession { readonly process: PtyAdapter.PtyProcess | null; readonly mark: number; readonly idleSnapshots: number; } function cleanupProcessHandles(session: TerminalSessionState): void { session.unsubscribeData?.(); session.unsubscribeData = null; session.unsubscribeExit?.(); session.unsubscribeExit = null; } function enqueueProcessEvent( session: TerminalSessionState, expectedPid: number, event: PendingProcessEvent, ): boolean { if (!session.process || session.status !== "running" || session.pid !== expectedPid) { return false; } session.pendingProcessEvents.push(event); if (session.processEventDrainRunning) { return false; } session.processEventDrainRunning = true; return true; } function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { if (platform === "win32") { return "pwsh.exe"; } return env.SHELL ?? "bash"; } function normalizeShellCommand( value: string | undefined, platform: NodeJS.Platform, ): string | null { if (!value) return null; const trimmed = value.trim(); if (trimmed.length === 0) return null; if (platform === "win32") { return trimmed; } const firstToken = trimmed.split(/\s+/g)[0]?.trim(); if (!firstToken) return null; return firstToken.replace(/^['"]|['"]$/g, ""); } function basenameForPlatform(command: string, platform: NodeJS.Platform): string { const normalized = platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); const parts = normalized .split(platform === "win32" ? /\\+/ : /\/+/) .filter((part) => part.length > 0); return parts.at(-1) ?? normalized; } function joinWindowsPath(...parts: ReadonlyArray): string { return parts .map((part, index) => { if (index === 0) return part.replace(/[\\/]+$/g, ""); return part.replace(/^[\\/]+|[\\/]+$/g, ""); }) .filter((part) => part.length > 0) .join("\\"); } function shellCandidateFromCommand( command: string | null, platform: NodeJS.Platform, ): ShellCandidate | null { if (!command || command.length === 0) return null; const shellName = basenameForPlatform(command, platform).toLowerCase(); if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { return { shell: command, args: ["-NoLogo"] }; } if (platform !== "win32" && shellName === "zsh") { return { shell: command, args: ["-o", "nopromptsp"] }; } return { shell: command }; } function windowsSystemRoot(env: NodeJS.ProcessEnv): string { return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; } function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { return joinWindowsPath( windowsSystemRoot(env), "System32", "WindowsPowerShell", "v1.0", "powershell.exe", ); } function windowsCmdPath(env: NodeJS.ProcessEnv): string { return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); } function formatShellCandidate(candidate: ShellCandidate): string { if (!candidate.args || candidate.args.length === 0) return candidate.shell; return `${candidate.shell} ${candidate.args.join(" ")}`; } function uniqueShellCandidates(candidates: Array): ShellCandidate[] { const seen = new Set(); const ordered: ShellCandidate[] = []; for (const candidate of candidates) { if (!candidate) continue; const key = formatShellCandidate(candidate); if (seen.has(key)) continue; seen.add(key); ordered.push(candidate); } return ordered; } function resolveShellCandidates( shellResolver: () => string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ): ShellCandidate[] { const requested = shellCandidateFromCommand( normalizeShellCommand(shellResolver(), platform), platform, ); if (platform === "win32") { return uniqueShellCandidates([ requested, shellCandidateFromCommand("pwsh.exe", platform), shellCandidateFromCommand(windowsPowerShellPath(env), platform), shellCandidateFromCommand("powershell.exe", platform), shellCandidateFromCommand(env.ComSpec ?? null, platform), shellCandidateFromCommand(windowsCmdPath(env), platform), shellCandidateFromCommand("cmd.exe", platform), ]); } return uniqueShellCandidates([ requested, shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), shellCandidateFromCommand("/bin/zsh", platform), shellCandidateFromCommand("/bin/bash", platform), shellCandidateFromCommand("/bin/sh", platform), shellCandidateFromCommand("zsh", platform), shellCandidateFromCommand("bash", platform), shellCandidateFromCommand("sh", platform), ]); } function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean { const queue: unknown[] = [error]; const seen = new Set(); const messages: string[] = []; while (queue.length > 0) { const current = queue.shift(); if (!current || seen.has(current)) { continue; } seen.add(current); if (typeof current === "string") { messages.push(current); continue; } if (current instanceof Error) { messages.push(current.message); if (current.cause) { queue.push(current.cause); } continue; } if (typeof current === "object") { const value = current as { message?: unknown; cause?: unknown }; if (typeof value.message === "string") { messages.push(value.message); } if (value.cause) { queue.push(value.cause); } } } const message = messages.join(" ").toLowerCase(); return ( message.includes("posix_spawnp failed") || message.includes("enoent") || message.includes("not found") || message.includes("file not found") || message.includes("no such file") ); } interface TerminalProcessTableSnapshot { readonly childrenByParent: ReadonlyMap>; readonly commandById: ReadonlyMap; } function subprocessSnapshotPollDelayMs(pollIntervalMs: number, failureCount: number): number { return Math.min(pollIntervalMs * 2 ** failureCount, MAX_SUBPROCESS_POLL_INTERVAL_MS); } function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { const childrenByParent = new Map(); const commandById = new Map(); for (const line of stdout.split(/\r?\n/g)) { // The final comm column can contain spaces; only the first two tokens are structural. const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line); if (!match) continue; const pid = Number(match[1]); const ppid = Number(match[2]); if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; commandById.set(pid, (match[3] ?? "").trim()); const children = childrenByParent.get(ppid) ?? []; children.push(pid); childrenByParent.set(ppid, children); } return { childrenByParent, commandById }; } function processTableSnapshotFromProcesses( processes: ReadonlyArray, ): TerminalProcessTableSnapshot { const childrenByParent = new Map(); const commandById = new Map(); for (const process of processes) { const { pid, ppid: parentPid, name } = process; if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; commandById.set(pid, name.trim()); const children = childrenByParent.get(parentPid) ?? []; children.push(pid); childrenByParent.set(parentPid, children); } return { childrenByParent, commandById }; } function deriveSubprocessInspectResult( snapshot: TerminalProcessTableSnapshot, terminalPid: number, platform: NodeJS.Platform, ): TerminalSubprocessInspectResult { const commandName = (pid: number) => normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform); const shellName = commandName(terminalPid); // Async prompt themes fork childless shell helpers that are not user commands. const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find( (pid) => shellName === null || commandName(pid) !== shellName || (snapshot.childrenByParent.get(pid)?.length ?? 0) > 0, ); if (childPid === undefined) { return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } const processIds = new Set([terminalPid]); const pending = [terminalPid]; while (pending.length > 0) { const parentPid = pending.pop(); if (parentPid === undefined) continue; for (const pid of snapshot.childrenByParent.get(parentPid) ?? []) { if (processIds.has(pid)) continue; processIds.add(pid); pending.push(pid); } } const normalized = commandName(childPid); return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, processIds: [...processIds], }; } const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const; // Resolve ps once; bare-name spawns repeatedly probe long PATHs during one-second polling. const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () { const fileSystem = yield* FileSystem.FileSystem; for (const candidate of POSIX_PS_ABSOLUTE_PATHS) { const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); if (exists) return candidate; } return "ps"; }); const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot")(function* ( psCommand: string, ): Effect.fn.Return< TerminalProcessTableSnapshot, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner > { const processRunner = yield* ProcessRunner.ProcessRunner; const result = yield* processRunner .run({ command: psCommand, args: ["-eo", "pid=,ppid=,comm="], timeout: "1 second", maxOutputBytes: 524_288, outputMode: "truncate", timeoutBehavior: "timedOutResult", }) .pipe( Effect.mapError( (cause) => new TerminalSubprocessCheckError({ cause, command: "ps", }), ), ); if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { // Reject partial tables; treating them as authoritative would clear process ids and mark terminals idle. return yield* new TerminalSubprocessCheckError({ command: "ps", exitCode: result.code, timedOut: result.timedOut, stdoutTruncated: result.stdoutTruncated, }); } return parsePosixProcessTable(result.stdout); }); const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")( function* (): Effect.fn.Return< TerminalProcessTableSnapshot, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner > { const processRunner = yield* ProcessRunner.ProcessRunner; const command = 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; const result = yield* processRunner .run({ command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: "1500 millis", maxOutputBytes: 262_144, outputMode: "truncate", timeoutBehavior: "timedOutResult", }) .pipe( Effect.mapError( (cause) => new TerminalSubprocessCheckError({ cause, command: "powershell" }), ), ); if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { return yield* new TerminalSubprocessCheckError({ command: "powershell", exitCode: result.code, timedOut: result.timedOut, stdoutTruncated: result.stdoutTruncated, }); } const processes = result.stdout.split(/\r?\n/g).flatMap((line) => { const [pidRaw, ppidRaw, name = ""] = line.trim().split("|", 3); const pid = Number(pidRaw); const ppid = Number(ppidRaw); return Number.isInteger(pid) && pid > 0 && Number.isInteger(ppid) ? [{ pid, ppid, name }] : []; }); return processTableSnapshotFromProcesses(processes); }, ); interface TerminalHistoryChunk { data: string; byteLength: number; lineBreaks: number; } export class BoundedTerminalHistory { private readonly maxLines: number; private readonly maxBytes: number; private chunks: Array = []; private start = 0; private byteLength = 0; private lineBreaks = 0; // Reading the old string's tail on each append can force chunk concatenation. private lastCodeUnit: number | undefined; private cachedValue: string | null = ""; constructor(maxLines: number, initial: string, maxBytes = DEFAULT_HISTORY_BYTE_LIMIT) { this.maxLines = maxLines; this.maxBytes = maxBytes; this.append(initial); } append(text: string): void { if (text.length === 0) return; this.cachedValue = null; if (this.maxBytes <= 0 || this.maxLines <= 0) { this.clear(); if (this.maxBytes > 0 && text.endsWith("\n")) this.appendChunk("\n"); return; } let offset = 0; const previous = this.chunks.at(-1); const lastCode = this.lastCodeUnit; const firstCode = text.charCodeAt(0); if ( previous && lastCode !== undefined && lastCode >= 0xd800 && lastCode <= 0xdbff && firstCode >= 0xdc00 && firstCode <= 0xdfff ) { // Joining a split surrogate changes its UTF-8 size from 3 to 4 bytes. previous.data += text[0]; previous.byteLength += 1; this.byteLength += 1; this.lastCodeUnit = firstCode; offset = 1; this.trim(); } while (offset < text.length) { let end = Math.min(offset + MAX_HISTORY_CHUNK_LENGTH, text.length); const before = text.charCodeAt(end - 1); const after = text.charCodeAt(end); if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) { end -= 1; } const data = text.slice(offset, end); // Detach small chunks from large input strings so evicted prefixes can be collected. this.appendChunk( text.length > MAX_HISTORY_CHUNK_LENGTH ? Buffer.from(data, "utf16le").toString("utf16le") : data, ); this.trim(); offset = end; } } private appendChunk(data: string): void { const byteLength = Buffer.byteLength(data); let lineBreaks = 0; for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) { lineBreaks += 1; } const previous = this.chunks.at(-1); if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) { previous.data += data; previous.byteLength += byteLength; previous.lineBreaks += lineBreaks; } else { this.chunks.push({ data, byteLength, lineBreaks }); } this.byteLength += byteLength; this.lineBreaks += lineBreaks; this.lastCodeUnit = data.charCodeAt(data.length - 1); this.cachedValue = null; } private discardChunk(): void { const first = this.chunks[this.start]!; this.byteLength -= first.byteLength; this.lineBreaks -= first.lineBreaks; this.chunks[this.start++] = undefined; } private trimChunk(offset: number, byteLength: number, lineBreaks: number): void { const first = this.chunks[this.start]!; if (offset === first.data.length) { this.discardChunk(); return; } first.data = first.data.slice(offset); first.byteLength -= byteLength; first.lineBreaks -= lineBreaks; this.byteLength -= byteLength; this.lineBreaks -= lineBreaks; } private trim(): void { const trailingNewline = this.lastCodeUnit === 10; let linesToDrop = this.lineBreaks + (trailingNewline ? 0 : 1) - this.maxLines; while (linesToDrop > 0) { const first = this.chunks[this.start]!; if (first.lineBreaks < linesToDrop) { linesToDrop -= first.lineBreaks; this.discardChunk(); continue; } let offset = 0; for (let line = 0; line < linesToDrop; line += 1) { offset = first.data.indexOf("\n", offset) + 1; } this.trimChunk(offset, Buffer.byteLength(first.data.slice(0, offset)), linesToDrop); linesToDrop = 0; } while (this.byteLength > this.maxBytes) { const first = this.chunks[this.start]!; const bytesToDrop = this.byteLength - this.maxBytes; if (first.byteLength <= bytesToDrop) { this.discardChunk(); continue; } if (first.byteLength === first.data.length && first.lineBreaks === 0) { this.trimChunk(bytesToDrop, bytesToDrop, 0); continue; } let offset = 0; let bytes = 0; let lineBreaks = 0; while (bytes < bytesToDrop) { const codePoint = first.data.codePointAt(offset)!; bytes += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; offset += codePoint <= 0xffff ? 1 : 2; if (codePoint === 10) lineBreaks += 1; } this.trimChunk(offset, bytes, lineBreaks); } if ( this.start === this.chunks.length || (this.start > 2_048 && this.start * 2 >= this.chunks.length) ) { this.chunks = this.chunks.slice(this.start); this.start = 0; if (this.chunks.length === 0) this.lastCodeUnit = undefined; } } clear(): void { this.chunks = []; this.start = 0; this.byteLength = 0; this.lineBreaks = 0; this.lastCodeUnit = undefined; this.cachedValue = ""; } value(): string { if (this.cachedValue !== null) return this.cachedValue; this.cachedValue = this.chunks .slice(this.start) .map((chunk) => chunk!.data) .join(""); return this.cachedValue; } } // Pop beyond Ghostty's eight-entry Kitty stacks on both screens before clearing // flags. A 1049 round trip preserves the main cursor, text, and scrollback. const INHERITED_INPUT_RESET = "\u001b[?1049h\u001b[<100u\u001b[=0;1u\u001b[?1049l" + "\u001b[?1;9;66;67;1000;1002;1003;1004;1005;1006;1015;2004l" + "\u001b[?25;1035;1036h\u001b[<100u\u001b[=0;1u\u001b[>4m"; function neutralizeInheritedHistory(history: string, maxLines: number, maxBytes: number): string { // Scan exactly the cold replay window, reserving the entire longest reset so // neither the server nor the client can trim away the entry after we choose its exit. const resetBytes = history.includes("\u001b") ? Buffer.byteLength(INHERITED_INPUT_RESET + "\u001b7\u001b[?1049l") : 0; history = new BoundedTerminalHistory( // The suffix occupies the empty line after a trailing newline, too. resetBytes > 0 && history.endsWith("\n") ? maxLines - 1 : maxLines, history, Math.max(0, maxBytes - resetBytes), ).value(); if (!history.includes("\u001b")) return history; // An unconditional 1049 reset restores a stale saved cursor on the main // screen. The exit must match the inherited screen's cursor-save behavior. let alternateScreen: number | undefined; for (const match of history.matchAll(/\u001b(?:\[\?([0-9;]+)([hl])|(c))/gu)) { if (match[3]) alternateScreen = undefined; for (const parameter of (match[1] ?? "").split(";")) { const mode = Number(parameter); if (mode === 47 || mode === 1047 || mode === 1049) { alternateScreen = match[2] === "h" ? (alternateScreen ?? mode) : undefined; } } } // A client may trim away the entry, leaving us on main: save its current // cursor before exiting. On alternate this saves only the alternate cursor. const leaveAlternateScreen = alternateScreen === undefined ? "" : `\u001b7\u001b[?${alternateScreen}l`; return history + leaveAlternateScreen + INHERITED_INPUT_RESET; } function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "n") { return true; } if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { return true; } if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { return true; } // Strip DECRQM/DECRPM query traffic to prevent echoed replies during replay; $ guards preserve setters. if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) { return true; } // XTVERSION query (>q). DECSCUSR (space-intermediate q) stays. if (finalByte === "q" && /^>[0-9;]*$/.test(body)) { return true; } // Kitty keyboard protocol query/reply (?u). Restore-cursor (bare u) stays. if (finalByte === "u" && body.startsWith("?")) { return true; } return false; } // Strip DECRQSS/XTGETTCAP query traffic; replaying queries triggers fresh replies without visual value. function shouldStripDcsSequence(content: string): boolean { return /^[01]?[$+][qr]/.test(content); } function shouldStripOscSequence(content: string): boolean { return /^(10|11|12);(?:\?|rgb:)/.test(content); } function stripStringTerminator(value: string): string { if (value.endsWith("\u001b\\")) { return value.slice(0, -2); } const lastCharacter = value.at(-1); if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { return value.slice(0, -1); } return value; } function findStringTerminatorIndex(input: string, start: number): number | null { for (let index = start; index < input.length; index += 1) { const codePoint = input.charCodeAt(index); if (codePoint === 0x07 || codePoint === 0x9c) { return index + 1; } if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { return index + 2; } } return null; } function isEscapeIntermediateByte(codePoint: number): boolean { return codePoint >= 0x20 && codePoint <= 0x2f; } function isEscapeFinalByte(codePoint: number): boolean { return codePoint >= 0x30 && codePoint <= 0x7e; } function findEscapeSequenceEndIndex(input: string, start: number): number | null { let cursor = start; while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { cursor += 1; } if (cursor >= input.length) { return null; } return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; } const HISTORY_SEQUENCE_START = /[\u001b\u0090\u009b\u009d\u009e\u009f]/; const NEXT_HISTORY_SEQUENCE_START = new RegExp(HISTORY_SEQUENCE_START.source, "g"); function sanitizeTerminalHistoryChunk( pendingControlSequence: string, data: string, ): { visibleText: string; pendingControlSequence: string } { if (pendingControlSequence.length === 0 && !HISTORY_SEQUENCE_START.test(data)) { return { visibleText: data, pendingControlSequence: "" }; } const input = `${pendingControlSequence}${data}`; // Only the pending prefix's final ESC can begin a terminator split across chunks. const pendingScanStart = Math.max(0, pendingControlSequence.length - 1); let visibleText = ""; let index = 0; const append = (value: string) => { visibleText += value; }; while (index < input.length) { const codePoint = input.charCodeAt(index); if (codePoint === 0x1b) { const nextCodePoint = input.charCodeAt(index + 1); if (Number.isNaN(nextCodePoint)) { return { visibleText, pendingControlSequence: input.slice(index) }; } if (nextCodePoint === 0x5b) { let cursor = index + 2; while (cursor < input.length) { if (isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 2, cursor); if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; break; } cursor += 1; } if (cursor >= input.length) { return { visibleText, pendingControlSequence: input.slice(index) }; } continue; } if ( nextCodePoint === 0x5d || nextCodePoint === 0x50 || nextCodePoint === 0x5e || nextCodePoint === 0x5f ) { const terminatorIndex = findStringTerminatorIndex( input, Math.max(index + 2, pendingScanStart), ); if (terminatorIndex === null) { return { visibleText, pendingControlSequence: input.slice(index) }; } const sequence = input.slice(index, terminatorIndex); const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); const strip = (nextCodePoint === 0x5d && shouldStripOscSequence(content)) || (nextCodePoint === 0x50 && shouldStripDcsSequence(content)); if (!strip) { append(sequence); } index = terminatorIndex; continue; } const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); if (escapeSequenceEndIndex === null) { return { visibleText, pendingControlSequence: input.slice(index) }; } append(input.slice(index, escapeSequenceEndIndex)); index = escapeSequenceEndIndex; continue; } if (codePoint === 0x9b) { let cursor = index + 1; while (cursor < input.length) { if (isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 1, cursor); if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; break; } cursor += 1; } if (cursor >= input.length) { return { visibleText, pendingControlSequence: input.slice(index) }; } continue; } if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { const terminatorIndex = findStringTerminatorIndex( input, Math.max(index + 1, pendingScanStart), ); if (terminatorIndex === null) { return { visibleText, pendingControlSequence: input.slice(index) }; } const sequence = input.slice(index, terminatorIndex); const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); const strip = (codePoint === 0x9d && shouldStripOscSequence(content)) || (codePoint === 0x90 && shouldStripDcsSequence(content)); if (!strip) { append(sequence); } index = terminatorIndex; continue; } // One slice per printable run: every chunk of coloured output reaches this loop. NEXT_HISTORY_SEQUENCE_START.lastIndex = index + 1; const nextSequenceStart = NEXT_HISTORY_SEQUENCE_START.exec(input)?.index ?? input.length; append(input.slice(index, nextSequenceStart)); index = nextSequenceStart; } return { visibleText, pendingControlSequence: "" }; } function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } const MAX_FILE_NAME_BYTES = 255; function encodedHistoryThreadPart(threadId: string): string { return `terminal_${Base64Url.encode(threadId)}`; } function hashId(id: string): string { return NodeCrypto.createHash("sha256").update(id).digest("base64url"); } // "." is outside the base64url alphabet, so a hashed name can never collide with an encoded one. function hashedHistoryThreadPart(threadId: string): string { return `terminal_sha256.${hashId(threadId)}`; } function joinHistoryFileName(threadPart: string, terminalPart: string | null): string { return terminalPart === null ? `${threadPart}.log` : `${threadPart}_${terminalPart}.log`; } function toSessionKey(threadId: string, terminalId: string): string { return `${threadId}\u0000${terminalId}`; } function shouldExcludeTerminalEnvKey(key: string): boolean { const normalizedKey = key.toUpperCase(); if (normalizedKey.startsWith("T3CODE_")) { return true; } if (normalizedKey.startsWith("VITE_")) { return true; } return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); } const APPIMAGE_RUNTIME_ENV_KEYS = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"] as const; const APPIMAGE_PATH_LIKE_ENV_KEYS = [ "PATH", "LD_LIBRARY_PATH", "XDG_DATA_DIRS", "GSETTINGS_SCHEMA_DIR", ] as const; function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { return segment === appDir || segment.startsWith(`${appDir}/`); } // AppImage mount paths would make PTY tools resolve against the mount instead of the user's system. function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env; const scrubbed: NodeJS.ProcessEnv = { ...env }; for (const key of APPIMAGE_RUNTIME_ENV_KEYS) { delete scrubbed[key]; } const appDir = env.APPDIR?.replace(/\/+$/, ""); if (appDir) { for (const key of APPIMAGE_PATH_LIKE_ENV_KEYS) { const value = scrubbed[key]; if (value === undefined) continue; const kept = value .split(":") .filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir)); if (kept.length > 0) { scrubbed[key] = kept.join(":"); } else { delete scrubbed[key]; } } } return scrubbed; } function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv: Record | null | undefined, platform: NodeJS.Platform, home: string, ): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { if (value === undefined) continue; if (shouldExcludeTerminalEnvKey(key)) continue; spawnEnv[key] = value; } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { const existingKey = platform === "win32" ? Object.keys(spawnEnv).find((candidate) => candidate.toLowerCase() === key.toLowerCase()) : undefined; spawnEnv[existingKey ?? key] = key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value, home) : value; } } if (!spawnEnv.COLORTERM && runtimeEnv?.COLORTERM === undefined) { spawnEnv.COLORTERM = "truecolor"; } // fish 4.1+ can wait 10s at startup for a DA1 reply before a renderer can answer. // Skip the query unless the user or provider already chose `query-term` or `no-query-term`. const fishFeaturesKey = platform === "win32" ? (Object.keys(spawnEnv).find((candidate) => candidate.toLowerCase() === "fish_features") ?? "fish_features") : "fish_features"; const fishFeatures = spawnEnv[fishFeaturesKey]; if (!fishFeatures?.includes("query-term")) { spawnEnv[fishFeaturesKey] = fishFeatures ? `${fishFeatures},no-query-term` : "no-query-term"; } return stripAppImageRuntimeEnv(spawnEnv); } function normalizedRuntimeEnv( env: Record | undefined, ): Record | null { if (!env) return null; const entries = Object.entries(env); if (entries.length === 0) return null; return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); } interface TerminalManagerOptions { logsDir: string; historyLineLimit?: number; historyByteLimit?: number; ptyAdapter: PtyAdapter.PtyAdapter["Service"]; shellResolver?: () => string; env?: NodeJS.ProcessEnv; managedBinaryCacheDir?: string; managedBinaryToolsDir?: string; subprocessInspector?: TerminalSubprocessInspector; processTable?: Effect.Effect< ReadonlyArray, TerminalSubprocessCheckError >; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; registerTerminalProcesses?: (input: { readonly threadId: string; readonly terminalId: string; readonly processIds: ReadonlyArray; }) => Effect.Effect; unregisterTerminal?: (input: { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; resolveProviderInstanceEnvironment?: ( providerInstanceId: string, env: Record | undefined, ) => Effect.Effect< Record, TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError >; } export const resolveProviderInstanceTerminalEnvironment = Effect.fn( "terminal.resolveProviderInstanceTerminalEnvironment", )(function* (input: { readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; readonly path: Path.Path; readonly rawProviderInstanceId: string; readonly env: Record | undefined; }) { const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); const settings = yield* input.serverSettings.getSettings.pipe( Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), ); const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; if (instance === undefined) { return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); } let resolved = yield* mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); if (instance.driver === "codex") { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isSome(config)) { const layout = yield* resolveCodexHomeLayout(config.value).pipe( Effect.provideService(Path.Path, input.path), ); if (layout.effectiveHomePath) resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; } } else if (instance.driver === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); if (Option.isSome(config)) { resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( Effect.provideService(Path.Path, input.path), ); } } return Object.fromEntries( Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), ); }); /** @public */ export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir, providerStatusCacheDir, baseDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient; const serverSettings = yield* ServerSettings.ServerSettingsService; const path = yield* Path.Path; const resolveProviderInstanceEnvironment = Effect.fn( "terminal.resolveProviderInstanceEnvironment", )((rawProviderInstanceId: string, env: Record | undefined) => resolveProviderInstanceTerminalEnvironment({ serverSettings, path, rawProviderInstanceId, env, }), ); return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, processTable: nativeTelemetry.processTable.pipe( Effect.mapError( (cause) => new TerminalSubprocessCheckError({ cause, command: "resource-monitor" }), ), ), managedBinaryCacheDir: providerStatusCacheDir, managedBinaryToolsDir: path.join(baseDir, "tools"), registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, resolveProviderInstanceEnvironment, }); }); export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(function* ( options: TerminalManagerOptions, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT; const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; const agentScope = yield* AgentScope; // Inherit the full environment minus createTerminalSpawnEnv's blocklist; allowlists silently drop user tooling variables. const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( function* ( input: Input, ): Effect.fn.Return< Input, TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError > { if (input.providerInstanceId === undefined) return input; const resolver = options.resolveProviderInstanceEnvironment; if (resolver === undefined) { return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), }); } const env = yield* resolver(input.providerInstanceId, input.env); return { ...input, env }; }, ); // Share one process table per poll; per-terminal scans can exhaust PIDs on busy hosts. const fallbackProcessTableSnapshot = ( platform === "win32" ? windowsProcessTableSnapshot() : posixProcessTableSnapshot(yield* resolvePosixPsCommand()) ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner)); const fetchProcessTableSnapshot: Effect.Effect< { readonly snapshot: TerminalProcessTableSnapshot; readonly snapshotSucceeded: boolean; }, TerminalSubprocessCheckError > = options.processTable ? options.processTable.pipe( Effect.map((entries) => ({ snapshot: processTableSnapshotFromProcesses(entries), snapshotSucceeded: true, })), Effect.catch(() => fallbackProcessTableSnapshot.pipe( Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: false })), ), ), ) : fallbackProcessTableSnapshot.pipe( Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: true })), ); const customSubprocessInspector = options.subprocessInspector; const acquireSubprocessInspector: Effect.Effect< { readonly inspector: TerminalSubprocessInspector; readonly snapshotSucceeded: boolean; }, TerminalSubprocessCheckError > = customSubprocessInspector !== undefined ? Effect.succeed({ inspector: customSubprocessInspector, snapshotSucceeded: true }) : Effect.map( fetchProcessTableSnapshot, ({ snapshot, snapshotSucceeded, }): { readonly inspector: TerminalSubprocessInspector; readonly snapshotSucceeded: boolean; } => ({ inspector: (terminalPid) => Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)), snapshotSucceeded, }), ); const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); const managerStateRef = yield* SynchronizedRef.make({ sessions: new Map(), killFibers: new Map(), }); // Keep close markers in memory so unknown IDs recover after a server restart. const closedSessionKeys = new Set(); const threadLocks = yield* KeyedLock.make(); const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const workerScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); const publishEvent = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced(function* (event: TerminalEvent) { for (const listener of terminalEventListeners) { yield* listener(event).pipe(Effect.ignoreCause({ log: true })); } }); // Nested subagent thread ids can encode past the filename limit. Only names that would not fit // switch to hashed ids, so every history file that already exists keeps its path. const historyPath = (threadId: string, terminalId: string) => { const isDefault = terminalId === DEFAULT_TERMINAL_ID; const encodedName = joinHistoryFileName( encodedHistoryThreadPart(threadId), isDefault ? null : Base64Url.encode(terminalId), ); if (encodedName.length <= MAX_FILE_NAME_BYTES) { return path.join(logsDir, encodedName); } return path.join( logsDir, joinHistoryFileName(hashedHistoryThreadPart(threadId), isDefault ? null : hashId(terminalId)), ); }; const legacyHistoryName = (threadId: string) => `${legacySafeThreadId(threadId)}.log`; const legacyHistoryPath = (threadId: string) => path.join(logsDir, legacyHistoryName(threadId)); // Ids this long could never have been written under the legacy name. const hasLegacyHistoryPath = (threadId: string) => Buffer.byteLength(legacyHistoryName(threadId)) <= MAX_FILE_NAME_BYTES; const readManagerState = SynchronizedRef.get(managerStateRef); const modifyManagerState = ( f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], ) => SynchronizedRef.modify(managerStateRef, f); const withThreadLock = ( threadId: string, effect: Effect.Effect, ): Effect.Effect => threadLocks.withLock(threadId, effect); const terminalViewers = new Map>(); let anonymousViewerCount = 0; const retainViewer = (sessionKey: string, viewerId: string, grid: TerminalGrid | null) => { let viewers = terminalViewers.get(sessionKey); if (!viewers) { viewers = new Map(); terminalViewers.set(sessionKey, viewers); } const viewer = viewers.get(viewerId); if (viewer) { viewer.streams += 1; // After a viewer's first size, resizes own it; attach retries resend the original size. viewer.grid ??= grid; } else { viewers.set(viewerId, { streams: 1, grid }); } }; /** Returns whether the viewer left, which can grow the shared grid. */ const releaseViewer = (sessionKey: string, viewerId: string) => { const viewers = terminalViewers.get(sessionKey); const viewer = viewers?.get(viewerId); if (!viewers || !viewer) return false; viewer.streams -= 1; if (viewer.streams > 0) return false; viewers.delete(viewerId); if (viewers.size === 0) terminalViewers.delete(sessionKey); return true; }; // The smallest attached viewer bounds each axis, so every viewer can draw the whole grid. const sharedViewerGrid = (sessionKey: string): TerminalGrid | null => { let shared: TerminalGrid | null = null; for (const viewer of terminalViewers.get(sessionKey)?.values() ?? []) { if (!viewer.grid) continue; shared = shared ? { cols: Math.min(shared.cols, viewer.grid.cols), rows: Math.min(shared.rows, viewer.grid.rows), } : viewer.grid; } return shared; }; /** Attached viewers own the size; a caller's own size only applies when none has reported one. */ const withSharedGrid = < A extends { readonly cols?: number | undefined; readonly rows?: number | undefined }, >( sessionKey: string, input: A, ): A => { const shared = sharedViewerGrid(sessionKey); return shared ? { ...input, cols: shared.cols, rows: shared.rows } : input; }; // Renderers draw the PTY grid, so every size change reaches them. It rides on activity // because older clients reject unknown event types. const resizeSession = Effect.fnUntraced(function* ( session: TerminalSessionState, process: PtyAdapter.PtyProcess, cols: number, rows: number, ) { yield* resizePtyProcess(session, process, cols, rows); session.cols = cols; session.rows = rows; session.updatedAt = yield* nowIso; const eventStamp = advanceEventSequence(session); yield* publishEvent({ type: "activity", threadId: session.threadId, terminalId: session.terminalId, sequence: eventStamp.sequence, hasRunningSubprocess: session.hasRunningSubprocess, label: terminalWireLabel(session), cols, rows, }); }); const applySharedGrid = Effect.fnUntraced(function* (threadId: string, terminalId: string) { const session = yield* getSession(threadId, terminalId); if (Option.isNone(session)) return; const process = session.value.process; const shared = sharedViewerGrid(toSessionKey(threadId, terminalId)); if ( !process || session.value.status !== "running" || !shared || (shared.cols === session.value.cols && shared.rows === session.value.rows) ) { return; } yield* resizeSession(session.value, process, shared.cols, shared.rows); }); const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( process: PtyAdapter.PtyProcess | null, ) { if (!process) return; const fiber: Option.Option> = yield* modifyManagerState< Option.Option> >((state) => { const existing: Option.Option> = Option.fromNullishOr( state.killFibers.get(process), ); if (Option.isNone(existing)) { return [Option.none>(), state] as const; } const killFibers = new Map(state.killFibers); killFibers.delete(process); return [existing, { ...state, killFibers }] as const; }); if (Option.isSome(fiber)) { yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore); } }); const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* ( process: PtyAdapter.PtyProcess, fiber: Fiber.Fiber, ) { yield* modifyManagerState((state) => { // Children may finalize before registration; check inside the synchronized update to avoid retaining finished fibers. if (fiber.pollUnsafe() !== undefined) { return [undefined, state] as const; } const killFibers = new Map(state.killFibers); killFibers.set(process, fiber); return [undefined, { ...state, killFibers }] as const; }); }); const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* ( process: PtyAdapter.PtyProcess, threadId: string, terminalId: string, ) { // Subscribe before SIGTERM to catch immediate exits; Effect.ensuring releases even on interruption, unlike JS finally. const exited = Deferred.makeUnsafe(); const unsubscribeExit = process.onExit(() => { Deferred.doneUnsafe(exited, Effect.void); }); yield* Effect.gen(function* () { const terminated = yield* Effect.try({ try: () => process.kill("SIGTERM"), catch: (cause) => new TerminalProcessSignalError({ cause, signal: "SIGTERM", terminalPid: process.pid, }), }).pipe( Effect.as(true), Effect.catch((error) => Effect.logWarning("failed to kill terminal process", { threadId, terminalId, signal: "SIGTERM", cause: error, }).pipe(Effect.as(false)), ), ); if (!terminated) { return; } const exitedBeforeGrace = yield* Deferred.await(exited).pipe( Effect.as(true), Effect.timeoutOrElse({ duration: processKillGraceMs, orElse: () => Effect.succeed(false), }), ); if (exitedBeforeGrace) { return; } yield* Effect.try({ try: () => process.kill("SIGKILL"), catch: (cause) => new TerminalProcessSignalError({ cause, signal: "SIGKILL", terminalPid: process.pid, }), }).pipe( Effect.catch((error) => Effect.logWarning("failed to force-kill terminal process", { threadId, terminalId, signal: "SIGKILL", cause: error, }), ), ); }).pipe(Effect.ensuring(Effect.sync(unsubscribeExit))); }); const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( process: PtyAdapter.PtyProcess, threadId: string, terminalId: string, ) { const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( Effect.ensuring( modifyManagerState((state) => { if (!state.killFibers.has(process)) { return [undefined, state] as const; } const killFibers = new Map(state.killFibers); killFibers.delete(process); return [undefined, { ...state, killFibers }] as const; }), ), Effect.forkIn(workerScope), ); yield* registerKillFiber(process, fiber); }); const persistWorker = yield* makeKeyedCoalescingWorker< string, PersistHistoryRequest, never, never >({ merge: (current, next) => ({ history: next.history, immediate: current.immediate || next.immediate, }), process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) { if (!request.immediate) { yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS); } const [threadId, terminalId] = sessionKey.split("\u0000"); if (!threadId || !terminalId) { return; } yield* fileSystem .writeFileString(historyPath(threadId, terminalId), request.history.value()) .pipe( Effect.catch((error) => Effect.logWarning("failed to persist terminal history", { threadId, terminalId, error, }), ), ); }), }); const queuePersist = Effect.fn("terminal.queuePersist")(function* ( threadId: string, terminalId: string, history: BoundedTerminalHistory, ) { yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { history, immediate: false, }); }); const flushPersist = Effect.fn("terminal.flushPersist")(function* ( threadId: string, terminalId: string, ) { yield* persistWorker.drainKey(toSessionKey(threadId, terminalId)); }); const persistHistory = Effect.fn("terminal.persistHistory")(function* ( threadId: string, terminalId: string, history: BoundedTerminalHistory, ) { yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { history, immediate: true, }); yield* flushPersist(threadId, terminalId); }); const readHistoryTail = Effect.fn("terminal.readHistoryTail")(function* (filePath: string) { const file = yield* fileSystem.open(filePath, { flag: "r" }); const info = yield* file.stat; const limit = BigInt(historyByteLimit); const offset = info.size > limit ? info.size - limit : 0n; yield* file.seek(offset, "start"); const bytes = new Uint8Array(Number(info.size - offset)); let length = 0; while (length < bytes.length) { const read = Number(yield* file.read(bytes.subarray(length))); if (read === 0) break; length += read; } let start = 0; if (offset > 0n) { // A tail read can start inside a UTF-8 code point. Skip its remaining bytes. while (start < length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1; } return { history: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(start, length)), truncated: offset > 0n, }; }); const readHistory = Effect.fn("terminal.readHistory")(function* ( threadId: string, terminalId: string, ) { const nextPath = historyPath(threadId, terminalId); if ( yield* fileSystem .exists(nextPath) .pipe( Effect.mapError( (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), ), ) ) { const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe( Effect.scoped, Effect.mapError( (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), ), ); const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); const capped = history.value(); if (truncated || capped !== raw) { yield* fileSystem .writeFileString(nextPath, capped) .pipe( Effect.mapError( (cause) => new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }), ), ); } return history; } if (terminalId !== DEFAULT_TERMINAL_ID || !hasLegacyHistoryPath(threadId)) { return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); } const legacyPath = legacyHistoryPath(threadId); if ( !(yield* fileSystem .exists(legacyPath) .pipe( Effect.mapError( (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), ), )) ) { return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); } const { history: raw } = yield* readHistoryTail(legacyPath).pipe( Effect.scoped, Effect.mapError( (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), ), ); const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); const capped = history.value(); yield* fileSystem .writeFileString(nextPath, capped) .pipe( Effect.mapError( (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), ), ); yield* fileSystem.remove(legacyPath, { force: true }).pipe( Effect.catch((cleanupError) => Effect.logWarning("failed to remove legacy terminal history", { threadId, error: cleanupError, }), ), ); return history; }); const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( threadId: string, terminalId: string, ) { yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( Effect.catch((error) => Effect.logWarning("failed to delete terminal history", { threadId, terminalId, error, }), ), ); if (terminalId === DEFAULT_TERMINAL_ID && hasLegacyHistoryPath(threadId)) { yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( Effect.catch((error) => Effect.logWarning("failed to delete terminal history", { threadId, terminalId, error, }), ), ); } }); const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( threadId: string, ) { const threadParts = [encodedHistoryThreadPart(threadId), hashedHistoryThreadPart(threadId)]; const entries = yield* fileSystem .readDirectory(logsDir, { recursive: false }) .pipe(Effect.orElseSucceed(() => [] as Array)); yield* Effect.forEach( entries.filter( (name) => name === `${legacySafeThreadId(threadId)}.log` || threadParts.some((part) => name === `${part}.log` || name.startsWith(`${part}_`)), ), (name) => fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( Effect.catch((error) => Effect.logWarning("failed to delete terminal histories for thread", { threadId, error, }), ), ), { discard: true }, ); }); const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { const stats = yield* fileSystem.stat(cwd).pipe( Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" ? new TerminalCwdNotFoundError({ cwd }) : new TerminalCwdStatError({ cwd, cause }), }), ); if (stats.type !== "Directory") { return yield* new TerminalCwdNotDirectoryError({ cwd }); } }); const getSession = Effect.fn("terminal.getSession")(function* ( threadId: string, terminalId: string, ): Effect.fn.Return> { return yield* Effect.map(readManagerState, (state) => Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))), ); }); const requireSession = Effect.fn("terminal.requireSession")(function* ( threadId: string, terminalId: string, ): Effect.fn.Return { return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => Effect.fromOption(session, () => new TerminalSessionLookupError({ threadId, terminalId })), ); }); const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) { return yield* readManagerState.pipe( Effect.map((state) => [...state.sessions.values()].filter((session) => session.threadId === threadId), ), ); }); const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")( function* () { yield* modifyManagerState((state) => { const inactiveSessions = [...state.sessions.values()].filter( (session) => session.status !== "running", ); if (inactiveSessions.length <= maxRetainedInactiveSessions) { return [undefined, state] as const; } inactiveSessions.sort( (left, right) => left.updatedAt.localeCompare(right.updatedAt) || left.threadId.localeCompare(right.threadId) || left.terminalId.localeCompare(right.terminalId), ); const sessions = new Map(state.sessions); const toEvict = inactiveSessions.length - maxRetainedInactiveSessions; for (const session of inactiveSessions.slice(0, toEvict)) { const key = toSessionKey(session.threadId, session.terminalId); sessions.delete(key); closedSessionKeys.add(key); } return [undefined, { ...state, sessions }] as const; }); }, ); const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( session: TerminalSessionState, expectedPid: number, ) { while (true) { const action: DrainProcessEventAction = yield* Effect.sync(() => { if (session.pid !== expectedPid || !session.process || session.status !== "running") { session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; return { type: "idle" } as const; } const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex]; if (!nextEvent) { session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; return { type: "idle" } as const; } session.pendingProcessEventIndex += 1; if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; } if (nextEvent.type === "output") { const sanitized = sanitizeTerminalHistoryChunk( session.pendingHistoryControlSequence, nextEvent.data, ); session.pendingHistoryControlSequence = sanitized.pendingControlSequence; if (sanitized.visibleText.length > 0) { session.history.append(sanitized.visibleText); } const eventStamp = advanceEventSequence(session); return { type: "output", threadId: session.threadId, terminalId: session.terminalId, sequence: eventStamp.sequence, history: sanitized.visibleText.length > 0 ? session.history : null, data: nextEvent.data, } as const; } const process = session.process; cleanupProcessHandles(session); session.process = null; session.pid = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; session.status = "exited"; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; session.exitCode = Number.isInteger(nextEvent.event.exitCode) ? nextEvent.event.exitCode : null; session.exitSignal = Number.isInteger(nextEvent.event.signal) ? nextEvent.event.signal : null; const eventStamp = advanceEventSequence(session); return { type: "exit", process, threadId: session.threadId, terminalId: session.terminalId, sequence: eventStamp.sequence, exitCode: session.exitCode, exitSignal: session.exitSignal, } as const; }); if (action.type === "idle") { return; } if (action.type === "output") { if (action.history !== null) { yield* queuePersist(action.threadId, action.terminalId, action.history); } yield* publishEvent({ type: "output", threadId: action.threadId, terminalId: action.terminalId, sequence: action.sequence, data: action.data, }); continue; } yield* clearKillFiber(action.process); yield* unregisterTerminal({ threadId: action.threadId, terminalId: action.terminalId, }); yield* publishEvent({ type: "exited", threadId: action.threadId, terminalId: action.terminalId, sequence: action.sequence, exitCode: action.exitCode, exitSignal: action.exitSignal, }); yield* evictInactiveSessionsIfNeeded(); return; } }); const stopProcess = Effect.fn("terminal.stopProcess")(function* (session: TerminalSessionState) { const process = session.process; if (!process) return; const updatedAt = yield* nowIso; yield* modifyManagerState((state) => { cleanupProcessHandles(session); session.process = null; session.pid = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; session.status = "exited"; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; session.updatedAt = updatedAt; return [undefined, state] as const; }); yield* clearKillFiber(process); yield* unregisterTerminal({ threadId: session.threadId, terminalId: session.terminalId, }); yield* startKillEscalation(process, session.threadId, session.terminalId); yield* evictInactiveSessionsIfNeeded(); }); const trySpawn = Effect.fn("terminal.trySpawn")(function* ( shellCandidates: ReadonlyArray, spawnEnv: NodeJS.ProcessEnv, session: TerminalSessionState, index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< { process: PtyAdapter.PtyProcess; shellLabel: string }, PtyAdapter.PtySpawnError > { if (index >= shellCandidates.length) { return yield* new PtyAdapter.PtySpawnError({ adapter: "terminal-manager", attemptedShells: shellCandidates.map((candidate) => formatShellCandidate(candidate)), ...(lastError ? { cause: lastError } : {}), }); } const candidate = shellCandidates[index]; if (!candidate) { return yield* ( lastError ?? new PtyAdapter.PtySpawnError({ adapter: "terminal-manager", attemptedShells: [], }) ); } const launch = yield* agentScope.wrap({ command: candidate.shell, args: candidate.args ?? [], name: "terminal", env: spawnEnv, survivesRestart: true, }); const attempt = yield* Effect.result( options.ptyAdapter.spawn({ shell: launch.command, ...(launch.args.length > 0 ? { args: [...launch.args] } : {}), cwd: session.cwd, cols: session.cols, rows: session.rows, env: spawnEnv, }), ); if (attempt._tag === "Success") { return { process: attempt.success, shellLabel: formatShellCandidate(candidate), }; } const spawnError = attempt.failure; if (!isRetryableShellSpawnError(spawnError)) { return yield* spawnError; } return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); }); const startSession = Effect.fn("terminal.startSession")(function* ( session: TerminalSessionState, input: TerminalStartInput, eventType: "started" | "restarted", ) { yield* stopProcess(session); yield* Effect.annotateCurrentSpan({ "terminal.thread_id": session.threadId, "terminal.id": session.terminalId, "terminal.event_type": eventType, "terminal.cwd": input.cwd, }); const startingAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "starting"; session.cwd = input.cwd; session.worktreePath = input.worktreePath ?? null; session.cols = input.cols; session.rows = input.rows; session.exitCode = null; session.exitSignal = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; session.updatedAt = startingAt; return [undefined, state] as const; }); let ptyProcess: PtyAdapter.PtyProcess | null = null; let startedShell: string | null = null; const startResult = yield* Effect.result( increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( Effect.andThen( Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); const terminalEnv = createTerminalSpawnEnv( baseEnv, session.runtimeEnv, platform, yield* HostProcess.HomeDirectory, ); // Append managed install directories so agent commands resolve without shadowing user/system tools. if ( options.managedBinaryCacheDir !== undefined && options.managedBinaryToolsDir !== undefined ) { const managedDirectories = yield* acpRegistryManagedBinaryDirectories({ cacheDir: options.managedBinaryCacheDir, toolsDir: options.managedBinaryToolsDir, platform, architecture, }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); if (managedDirectories.length > 0) { const delimiter = platform === "win32" ? ";" : ":"; const pathKey = platform === "win32" ? (Object.keys(terminalEnv).find( (candidate) => candidate.toLowerCase() === "path", ) ?? "PATH") : "PATH"; const merged = mergePathEntries( terminalEnv[pathKey], managedDirectories.join(delimiter), platform, ); if (merged !== undefined) { terminalEnv[pathKey] = merged; } } } const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; const processPid = ptyProcess.pid; let eventsActivated = false; let eventStamp: ReturnType = { updatedAt: session.updatedAt, sequence: session.eventSequence, }; yield* modifyManagerState((state) => { session.process = ptyProcess; session.pid = processPid; session.status = "running"; // onExit may replay an exit immediately; accept it before subscribing. session.unsubscribeData = spawnResult.process.onData((data) => { if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { return; } if (eventsActivated) runFork(drainProcessEvents(session, processPid)); }); session.unsubscribeExit = spawnResult.process.onExit((event) => { if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { return; } if (eventsActivated) runFork(drainProcessEvents(session, processPid)); }); eventStamp = advanceEventSequence(session); return [undefined, state] as const; }); yield* publishEvent({ type: eventType, threadId: session.threadId, terminalId: session.terminalId, sequence: eventStamp.sequence, snapshot: snapshot(session), }); // Publish startup before draining any events replayed during subscription. eventsActivated = true; if (session.processEventDrainRunning) runFork(drainProcessEvents(session, processPid)); }), ), ), ); if (startResult._tag === "Success") { return; } { const error = startResult.failure; if (ptyProcess) { yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); } yield* modifyManagerState((state) => { cleanupProcessHandles(session); session.status = "error"; session.pid = null; session.process = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; advanceEventSequence(session); return [undefined, state] as const; }); yield* unregisterTerminal({ threadId: session.threadId, terminalId: session.terminalId, }); yield* evictInactiveSessionsIfNeeded(); const message = error.message; yield* publishEvent({ type: "error", threadId: session.threadId, terminalId: session.terminalId, sequence: session.eventSequence, message, }); yield* Effect.logError("failed to start terminal", { threadId: session.threadId, terminalId: session.terminalId, cause: error, ...(startedShell ? { shell: startedShell } : {}), }); } }); const closeSession = Effect.fn("terminal.closeSession")(function* ( threadId: string, terminalId: string, deleteHistoryOnClose: boolean, ) { const key = toSessionKey(threadId, terminalId); const session = yield* getSession(threadId, terminalId); const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; if (Option.isSome(session)) { yield* stopProcess(session.value); yield* unregisterTerminal({ threadId, terminalId }); yield* persistHistory(threadId, terminalId, session.value.history); } yield* flushPersist(threadId, terminalId); const removed = yield* modifyManagerState((state) => { closedSessionKeys.add(key); if (!state.sessions.has(key)) { return [false, state] as const; } const sessions = new Map(state.sessions); sessions.delete(key); return [true, { ...state, sessions }] as const; }); if (removed) { yield* publishEvent({ type: "closed", threadId, terminalId, sequence: closedEventSequence, }); } if (deleteHistoryOnClose) { yield* deleteHistory(threadId, terminalId); } }); // Two unchanged snapshots cover children forked just after the first snapshot. // Process identity prevents a restarted shell from inheriting quiet state. let quietSessions = new WeakMap(); let skippedQuietTicks = 0; const isQuiet = (session: TerminalSessionState & { pid: number }) => { const quiet = quietSessions.get(session); return ( quiet !== undefined && quiet.idleSnapshots === 2 && quiet.process === session.process && quiet.mark === terminalActivityMark(session) && !session.hasRunningSubprocess ); }; const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { const state = yield* readManagerState; const runningSessions = [...state.sessions.values()] .filter( (session): session is TerminalSessionState & { pid: number } => session.status === "running" && Number.isInteger(session.pid), ) .map((session) => ({ session, terminalPid: session.pid, process: session.process, mark: terminalActivityMark(session), })); if (runningSessions.length === 0) { return true; } if ( runningSessions.every(({ session }) => isQuiet(session)) && skippedQuietTicks < QUIET_SUBPROCESS_SNAPSHOT_EVERY_TICKS - 1 ) { skippedQuietTicks += 1; return true; } skippedQuietTicks = 0; const inspectorOption = yield* acquireSubprocessInspector.pipe( Effect.asSome, Effect.catch((reason) => Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { reason, }).pipe( Effect.as( Option.none<{ readonly inspector: TerminalSubprocessInspector; readonly snapshotSucceeded: boolean; }>(), ), ), ), ); if (Option.isNone(inspectorOption)) { quietSessions = new WeakMap(); return false; } const { inspector: subprocessInspector, snapshotSucceeded } = inspectorOption.value; const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( candidate: (typeof runningSessions)[number], ) { const { session, terminalPid, process } = candidate; let mark = candidate.mark; const previous = quietSessions.get(session); quietSessions.delete(session); const inspectResult = yield* subprocessInspector(terminalPid).pipe( Effect.asSome, Effect.catch((reason) => Effect.logWarning("failed to check terminal subprocess activity", { threadId: session.threadId, terminalId: session.terminalId, terminalPid, reason, }).pipe(Effect.as(Option.none())), ), ); if ( Option.isNone(inspectResult) || session.status !== "running" || session.pid !== terminalPid || session.process !== process ) { return; } const next = inspectResult.value; yield* registerTerminalProcesses({ threadId: session.threadId, terminalId: session.terminalId, processIds: next.processIds, }); const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; const event = yield* modifyManagerState((state) => { const liveSession: Option.Option = Option.fromNullishOr( state.sessions.get(toSessionKey(session.threadId, session.terminalId)), ); if ( Option.isNone(liveSession) || liveSession.value !== session || liveSession.value.status !== "running" || liveSession.value.pid !== terminalPid || liveSession.value.process !== process || (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && liveSession.value.childCommandLabel === nextChildLabel) ) { return [Option.none(), state] as const; } liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; liveSession.value.childCommandLabel = nextChildLabel; const eventStamp = advanceEventSequence(liveSession.value); // Only this poll's own event may advance the mark without waking it. mark += 1; return [ Option.some({ type: "activity" as const, threadId: liveSession.value.threadId, terminalId: liveSession.value.terminalId, sequence: eventStamp.sequence, hasRunningSubprocess: next.hasRunningSubprocess, label: terminalWireLabel(liveSession.value), }), state, ] as const; }); if (Option.isSome(event)) { yield* publishEvent(event.value); } if ( snapshotSucceeded && !next.hasRunningSubprocess && session.status === "running" && session.pid === terminalPid && session.process === process && terminalActivityMark(session) === mark ) { quietSessions.set(session, { process, mark, idleSnapshots: previous?.process === process && previous.mark === mark ? 2 : 1, }); } }); yield* Effect.forEach(runningSessions, checkSubprocessActivity, { concurrency: "unbounded", discard: true, }); return snapshotSucceeded; }); const hasRunningSessions = readManagerState.pipe( Effect.map((state) => [...state.sessions.values()].some((session) => session.status === "running"), ), ); let subprocessSnapshotFailureCount = 0; yield* Effect.forever( hasRunningSessions.pipe( Effect.flatMap((active) => active ? pollSubprocessActivity().pipe( Effect.flatMap((snapshotSucceeded) => { subprocessSnapshotFailureCount = snapshotSucceeded ? 0 : Math.min(subprocessSnapshotFailureCount + 1, 30); const delayMs = subprocessSnapshotPollDelayMs( subprocessPollIntervalMs, subprocessSnapshotFailureCount, ); return Effect.sleep(delayMs); }), ) : Effect.sync(() => { subprocessSnapshotFailureCount = 0; }).pipe(Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs))), ), ), ).pipe(Effect.forkIn(workerScope)); yield* Effect.addFinalizer(() => Effect.gen(function* () { const sessions = yield* modifyManagerState( (state) => [ [...state.sessions.values()], { ...state, sessions: new Map(), }, ] as const, ); const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( session: TerminalSessionState, ) { cleanupProcessHandles(session); if (!session.process) return; yield* clearKillFiber(session.process); yield* runKillEscalation(session.process, session.threadId, session.terminalId); }); yield* Effect.forEach(sessions, cleanupSession, { concurrency: "unbounded", discard: true, }); }).pipe(Effect.ignoreCause({ log: true })), ); const openWithWorkspaceLease = Effect.fn("terminal.openLocked")(function* ( requestedInput: TerminalOpenInput, ) { const terminalId = requestedInput.terminalId; yield* assertValidCwd(requestedInput.cwd); const sessionKey = toSessionKey(requestedInput.threadId, terminalId); const input = withSharedGrid(sessionKey, requestedInput); const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { yield* flushPersist(input.threadId, terminalId); const inherited = yield* readHistory(input.threadId, terminalId); const history = new BoundedTerminalHistory( historyLineLimit, neutralizeInheritedHistory( inherited.value(), historyLineLimit, Math.min(historyByteLimit, DEFAULT_MAX_TERMINAL_BUFFER_BYTES), ), historyByteLimit, ); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { threadId: input.threadId, terminalId, cwd: input.cwd, worktreePath: input.worktreePath ?? null, status: "starting", pid: null, history, pendingHistoryControlSequence: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, processEventDrainRunning: false, exitCode: null, exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, inputCount: 0, cols, rows, process: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, childCommandLabel: null, runtimeEnv: normalizedRuntimeEnv(input.env), }; const createdSession = session; yield* modifyManagerState((state) => { const sessions = new Map(state.sessions); sessions.set(sessionKey, createdSession); closedSessionKeys.delete(sessionKey); return [undefined, { ...state, sessions }] as const; }); yield* evictInactiveSessionsIfNeeded(); yield* startSession( session, { cwd: input.cwd, ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, rows, }, "started", ); return snapshot(session); } const liveSession = existing.value; const nextRuntimeEnv = normalizedRuntimeEnv(input.env); const currentRuntimeEnv = liveSession.runtimeEnv; const targetCols = input.cols ?? liveSession.cols; const targetRows = input.rows ?? liveSession.rows; const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); const nextWorktreePath = input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; const launchContextChanged = liveSession.cwd !== input.cwd || runtimeEnvChanged || liveSession.worktreePath !== nextWorktreePath; if (launchContextChanged) { yield* stopProcess(liveSession); liveSession.cwd = input.cwd; liveSession.worktreePath = nextWorktreePath; liveSession.runtimeEnv = nextRuntimeEnv; liveSession.history.clear(); liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; liveSession.pendingProcessEventIndex = 0; liveSession.processEventDrainRunning = false; yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; liveSession.worktreePath = nextWorktreePath; liveSession.history.clear(); liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; liveSession.pendingProcessEventIndex = 0; liveSession.processEventDrainRunning = false; yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); } if (!liveSession.process) { yield* startSession( liveSession, { cwd: input.cwd, worktreePath: liveSession.worktreePath, cols: targetCols, rows: targetRows, }, "started", ); return snapshot(liveSession); } if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { yield* resizeSession(liveSession, liveSession.process, targetCols, targetRows); } return snapshot(liveSession); }); const openLocked = (input: TerminalOpenInput) => withWorkspaceLease( path.resolve(input.worktreePath ?? input.cwd), openWithWorkspaceLease(input), ); const open: TerminalManager["Service"]["open"] = (input) => withThreadLock( input.threadId, resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); const openOrAttachForStream = Effect.fnUntraced(function* (requestedInput: TerminalAttachInput) { const terminalId = requestedInput.terminalId; const input = withSharedGrid(toSessionKey(requestedInput.threadId, terminalId), requestedInput); const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { if (input.resume && closedSessionKeys.has(toSessionKey(input.threadId, terminalId))) { return null; } if (!input.cwd) { return yield* new TerminalSessionLookupError({ threadId: input.threadId, terminalId, }); } const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); return yield* Effect.uninterruptible(openLocked(resolvedInput)); } const session = existing.value; const targetCols = input.cols ?? session.cols; const targetRows = input.rows ?? session.rows; if (!input.resume && !session.process && input.cwd && input.restartIfNotRunning === true) { const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); return yield* Effect.uninterruptible(openLocked(resolvedInput)); } if ( session.process && session.status === "running" && (session.cols !== targetCols || session.rows !== targetRows) ) { yield* resizeSession(session, session.process, targetCols, targetRows); } return snapshot(session); }); const readAllTerminalMetadata = () => readManagerState.pipe( Effect.map((state) => [...state.sessions.values()] .map(summary) .sort( (left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.threadId.localeCompare(right.threadId) || left.terminalId.localeCompare(right.terminalId), ), ), ); const readTerminalMetadata = (input: { readonly threadId: string; readonly terminalId: string; }) => getSession(input.threadId, input.terminalId).pipe( Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), ); const subscribe: TerminalManager["Service"]["subscribe"] = (listener, registerCleanup) => Effect.sync(() => { terminalEventListeners.add(listener); const stop = () => { terminalEventListeners.delete(listener); }; // Transfer ownership before an interrupt can discard the returned handle. registerCleanup?.(stop); return stop; }); // Subscribe after taking the lock so contention cannot accumulate an unbounded replay. // Snapshot listeners run under that lock and must not block. const streamSession = ( input: TerminalObserveInput, initial: Effect.Effect, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, registerCleanup?: (stop: () => void) => void, ) => { let unsubscribe: (() => void) | null = null; const stop = () => { unsubscribe?.(); unsubscribe = null; }; return Effect.gen(function* () { const bufferedEvents: TerminalEvent[] = []; let deliverLive = false; let snapshotFloor: number | null = null; yield* subscribe( (event) => { if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { return Effect.void; } if (!deliverLive) { bufferedEvents.push(event); return Effect.void; } // Fanout can finish after a snapshot that already includes the event. // Keep only its sequence, not its history, until the session changes. if (snapshotFloor !== null) { if ( event.type === "started" || event.type === "restarted" || event.type === "error" || event.type === "closed" ) { snapshotFloor = null; } else if (typeof event.sequence === "number" && event.sequence <= snapshotFloor) { return Effect.void; } } const attachEvent = terminalEventToAttachEvent(event); return listener(attachEvent); }, (cleanup) => { unsubscribe = cleanup; registerCleanup?.(stop); }, ); const initialSnapshot = yield* initial; yield* listener( initialSnapshot === null ? { type: "closed", threadId: input.threadId, terminalId: input.terminalId } : { type: "snapshot", snapshot: initialSnapshot }, ); for (const event of bufferedEvents) { if (initialSnapshot !== null && isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { continue; } const attachEvent = terminalEventToAttachEvent(event); yield* listener(attachEvent); } // The live listener keeps this closure, so release replayed output. bufferedEvents.length = 0; snapshotFloor = initialSnapshot?.sequence ?? null; deliverLive = true; return stop; }).pipe( (effect) => withThreadLock(input.threadId, effect), Effect.onError(() => Effect.sync(stop)), ); }; /** Retains `viewer` and returns its release, which runs at most once. */ const retainViewerOnce = ( input: TerminalObserveInput, viewer: string, grid: TerminalGrid | null, ) => { const sessionKey = toSessionKey(input.threadId, input.terminalId); retainViewer(sessionKey, viewer, grid); let retained = true; return () => { if (!retained) return; retained = false; if (releaseViewer(sessionKey, viewer)) { runFork(withThreadLock(input.threadId, applySharedGrid(input.threadId, input.terminalId))); } }; }; const attachStream: TerminalManager["Service"]["attachStream"] = ( input, listener, registerCleanup, viewerId, ) => { const viewer = viewerId ?? `stream:${++anonymousViewerCount}`; let detach = () => {}; const initial = Effect.suspend(() => { detach = retainViewerOnce( input, viewer, input.cols !== undefined && input.rows !== undefined ? { cols: input.cols, rows: input.rows } : null, ); return openOrAttachForStream(input); }); return streamSession( input, initial, listener, registerCleanup && ((stop) => registerCleanup(() => { stop(); detach(); })), ).pipe( Effect.map((stop) => () => { stop(); detach(); }), Effect.onError(() => Effect.sync(detach)), ); }; const observeStream: TerminalManager["Service"]["observeStream"] = ( input, listener, registerCleanup, ) => streamSession( input, requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)), listener, registerCleanup, ); const resyncStream: TerminalManager["Service"]["resyncStream"] = ( input, listener, registerCleanup, ) => streamSession( input, getSession(input.threadId, input.terminalId).pipe( Effect.map((session) => (Option.isSome(session) ? snapshot(session.value) : null)), ), listener, registerCleanup, ); const attachOutputStream: TerminalManager["Service"]["attachOutputStream"] = (input, viewerId) => Stream.unwrap( Effect.acquireRelease( Effect.sync(() => retainViewerOnce(input, viewerId, null)), (release) => Effect.sync(release), ).pipe( Effect.as( terminalOutputStream( (listener, registerCleanup) => attachStream(input, listener, registerCleanup, viewerId), (listener, registerCleanup) => resyncStream(input, listener, registerCleanup), ), ), ), ); const metadataEventFromTerminalEvent = ( event: TerminalEvent, ): Effect.Effect => { if (!shouldPublishTerminalMetadataEvent(event)) { return Effect.succeed(null); } if (event.type === "closed") { return Effect.succeed({ type: "remove" as const, threadId: event.threadId, terminalId: event.terminalId, }); } return readTerminalMetadata({ threadId: event.threadId, terminalId: event.terminalId, }).pipe( Effect.map((terminal) => terminal ? { type: "upsert" as const, terminal, } : null, ), ); }; const offerMetadataEvent = ( listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, event: TerminalEvent, ) => metadataEventFromTerminalEvent(event).pipe( Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), ); const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => { let unsubscribe: (() => void) | null = null; return Effect.gen(function* () { const bufferedEvents: TerminalEvent[] = []; let deliverLive = false; unsubscribe = yield* subscribe((event) => { if (!deliverLive) { bufferedEvents.push(event); return Effect.void; } return offerMetadataEvent(listener, event); }); const terminals = yield* readAllTerminalMetadata(); yield* listener({ type: "snapshot", terminals, }); for (const event of bufferedEvents) { yield* offerMetadataEvent(listener, event); } deliverLive = true; return () => { unsubscribe?.(); unsubscribe = null; }; }).pipe( Effect.catchCause((cause) => Effect.flatMap( Effect.sync(() => { unsubscribe?.(); unsubscribe = null; }), () => Effect.failCause(cause), ), ), ); }; const write: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(function* (input) { const terminalId = input.terminalId; const session = yield* requireSession(input.threadId, terminalId); const process = session.process; if (!process || session.status !== "running") { if (session.status === "exited") return; return yield* new TerminalNotRunningError({ threadId: input.threadId, terminalId, }); } session.inputCount += 1; yield* Effect.try({ try: () => process.write(input.data), catch: (cause) => new TerminalWriteError({ threadId: input.threadId, terminalId, terminalPid: process.pid, cause, }), }); }); const resizeLocked = Effect.fn("terminal.resize")(function* ( input: TerminalResizeInput, viewerId: string | undefined, ) { const sessionKey = toSessionKey(input.threadId, input.terminalId); const viewer = viewerId === undefined ? undefined : terminalViewers.get(sessionKey)?.get(viewerId); if (viewer) { viewer.grid = { cols: input.cols, rows: input.rows }; return yield* applySharedGrid(input.threadId, input.terminalId); } // A caller that is not attached cannot override the viewers that are. if (sharedViewerGrid(sessionKey)) return; const session = yield* getSession(input.threadId, input.terminalId); if (Option.isNone(session)) { return; } const process = session.value.process; if ( !process || session.value.status !== "running" || (session.value.cols === input.cols && session.value.rows === input.rows) ) { return; } yield* resizeSession(session.value, process, input.cols, input.rows); }); const resize: TerminalManager["Service"]["resize"] = (input, viewerId) => withThreadLock(input.threadId, resizeLocked(input, viewerId)); const clear: TerminalManager["Service"]["clear"] = (input) => withThreadLock( input.threadId, Effect.gen(function* () { const terminalId = input.terminalId; const session = yield* requireSession(input.threadId, terminalId); session.history.clear(); session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; const eventStamp = advanceEventSequence(session); yield* persistHistory(input.threadId, terminalId, session.history); yield* publishEvent({ type: "cleared", threadId: input.threadId, terminalId, sequence: eventStamp.sequence, }); }), ); const close: TerminalManager["Service"]["close"] = (input) => withThreadLock( input.threadId, Effect.gen(function* () { if (input.terminalId) { yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); return; } const threadSessions = yield* sessionsForThread(input.threadId); yield* Effect.forEach( threadSessions, (session) => closeSession(input.threadId, session.terminalId, false), { discard: true }, ); if (input.deleteHistory) { yield* deleteAllHistoryForThread(input.threadId); } }), ); const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) => withThreadLock( input.threadId, Effect.gen(function* () { const running = (yield* sessionsForThread(input.threadId)).filter( (session): session is TerminalSessionState & { pid: number } => session.status === "running" && Number.isInteger(session.pid) && (input.terminalId === undefined || session.terminalId === input.terminalId), ); if (running.length === 0) return; const marks = new Map( running.map((session) => [session.terminalId, terminalActivityMark(session)]), ); // Inspect now so commands started after the last poll still protect their terminals. const { inspector } = yield* acquireSubprocessInspector; yield* Effect.forEach( running, (session) => inspector(session.pid).pipe( Effect.flatMap((result) => result.hasRunningSubprocess || terminalActivityMark(session) !== marks.get(session.terminalId) ? Effect.void : closeSession(input.threadId, session.terminalId, false), ), ), { discard: true }, ); }), ).pipe( Effect.catch((error) => Effect.logWarning("failed to close idle terminals", { threadId: input.threadId, error: error.message, }), ), ); return TerminalManager.of({ open, attachStream, attachOutputStream, observeStream, resyncStream, write, resize, clear, close, closeIdle, subscribe, subscribeMetadata, }); }); export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer));