import { EventEmitter } from "node:events";import fs from "node:fs";import path from "node:path"; import { DEFAULT_TERMINAL_ID, TerminalClearInput, TerminalCloseInput, TerminalOpenInput, TerminalResizeInput, TerminalWriteInput, type ServerRuntimeEnvironment, type TerminalEvent, type TerminalSessionSnapshot,} from "@t3tools/contracts";import { Effect, Encoding, Layer, Path, Schema } from "effect"; import { createLogger } from "../../logger";import { detectServerRuntimeEnvironment } from "../../runtimeEnvironment";import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";import { runProcess } from "../../processRunner";import { ServerConfig } from "../../config";import { ShellCandidate, TerminalError, TerminalManager, TerminalManagerShape, TerminalSessionState, TerminalStartInput,} from "../Services/Manager"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000;const DEFAULT_PERSIST_DEBOUNCE_MS = 40;const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;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;const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput);const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; function defaultShellResolver(runtimeEnvironment: ServerRuntimeEnvironment): string { if (runtimeEnvironment.platform === "windows") { return process.env.ComSpec ?? "cmd.exe"; } return process.env.SHELL ?? "bash";} function normalizeShellCommand( value: string | undefined, runtimeEnvironment: ServerRuntimeEnvironment,): string | null { if (!value) return null; const trimmed = value.trim(); if (trimmed.length === 0) return null; if (runtimeEnvironment.platform === "windows") { return trimmed; } const firstToken = trimmed.split(/\s+/g)[0]?.trim(); if (!firstToken) return null; return firstToken.replace(/^['"]|['"]$/g, "");} function shellCandidateFromCommand( command: string | null, runtimeEnvironment: ServerRuntimeEnvironment,): ShellCandidate | null { if (!command || command.length === 0) return null; const shellName = path.basename(command).toLowerCase(); if (runtimeEnvironment.platform !== "windows" && shellName === "zsh") { return { shell: command, args: ["-o", "nopromptsp"] }; } return { shell: command };} 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 | null>): ShellCandidate[] { const seen = new Set<string>(); 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;} export function resolveShellCandidates( shellResolver: () => string, runtimeEnvironment: ServerRuntimeEnvironment = detectServerRuntimeEnvironment(),): ShellCandidate[] { const requested = shellCandidateFromCommand( normalizeShellCommand(shellResolver(), runtimeEnvironment), runtimeEnvironment, ); if (runtimeEnvironment.platform === "windows") { return uniqueShellCandidates([ requested, shellCandidateFromCommand(process.env.ComSpec ?? null, runtimeEnvironment), shellCandidateFromCommand("powershell.exe", runtimeEnvironment), shellCandidateFromCommand("cmd.exe", runtimeEnvironment), ]); } return uniqueShellCandidates([ requested, shellCandidateFromCommand( normalizeShellCommand(process.env.SHELL, runtimeEnvironment), runtimeEnvironment, ), shellCandidateFromCommand("/bin/zsh", runtimeEnvironment), shellCandidateFromCommand("/bin/bash", runtimeEnvironment), shellCandidateFromCommand("/bin/sh", runtimeEnvironment), shellCandidateFromCommand("zsh", runtimeEnvironment), shellCandidateFromCommand("bash", runtimeEnvironment), shellCandidateFromCommand("sh", runtimeEnvironment), ]);} function isRetryableShellSpawnError(error: unknown): boolean { const queue: unknown[] = [error]; const seen = new Set<unknown>(); 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); const cause = (current as { cause?: unknown }).cause; if (cause) { queue.push(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") );} async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> { const command = [ `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, "if ($children) { exit 0 }", "exit 1", ].join("; "); try { const result = await runProcess( "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { timeoutMs: 1_500, allowNonZeroExit: true, maxBufferBytes: 32_768, outputMode: "truncate", runtimeEnvironment: { platform: "windows", pathStyle: "windows", isWsl: false, windowsInteropMode: "windows-native", wslDistroName: null, }, }, ); return result.code === 0; } catch { return false; }} async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> { try { const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { timeoutMs: 1_000, allowNonZeroExit: true, maxBufferBytes: 32_768, outputMode: "truncate", }); if (pgrepResult.code === 0) { return pgrepResult.stdout.trim().length > 0; } if (pgrepResult.code === 1) { return false; } } catch { // Fall back to ps when pgrep is unavailable. } try { const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { timeoutMs: 1_000, allowNonZeroExit: true, maxBufferBytes: 262_144, outputMode: "truncate", }); if (psResult.code !== 0) { return false; } for (const line of psResult.stdout.split(/\r?\n/g)) { const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); const pid = Number(pidRaw); const ppid = Number(ppidRaw); if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; if (ppid === terminalPid) { return true; } } return false; } catch { return false; }} function createDefaultSubprocessChecker(runtimeEnvironment: ServerRuntimeEnvironment) { return async (terminalPid: number): Promise<boolean> => { if (!Number.isInteger(terminalPid) || terminalPid <= 0) { return false; } if (runtimeEnvironment.platform === "windows") { return checkWindowsSubprocessActivity(terminalPid); } return checkPosixSubprocessActivity(terminalPid); };} function isWindowsPath(value: string): boolean { return WINDOWS_ABSOLUTE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value);} export function validateTerminalCwdForRuntime( cwd: string, runtimeEnvironment: ServerRuntimeEnvironment = detectServerRuntimeEnvironment(),): string | null { if (runtimeEnvironment.pathStyle === "windows") { if (cwd.startsWith("/")) { return `Terminal cwd does not match Windows runtime path style: ${cwd}`; } return null; } if (isWindowsPath(cwd)) { return `Terminal cwd does not match POSIX runtime path style: ${cwd}`; } return null;} function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; const hasTrailingNewline = history.endsWith("\n"); const lines = history.split("\n"); if (hasTrailingNewline) { lines.pop(); } if (lines.length <= maxLines) return history; const capped = lines.slice(lines.length - maxLines).join("\n"); return hasTrailingNewline ? `${capped}\n` : capped;} function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");} function toSafeThreadId(threadId: string): string { return `terminal_${Encoding.encodeBase64Url(threadId)}`;} function toSafeTerminalId(terminalId: string): string { return Encoding.encodeBase64Url(terminalId);} 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);} function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv?: Record<string, string> | null,): 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)) { spawnEnv[key] = value; } } return spawnEnv;} function normalizedRuntimeEnv( env: Record<string, string> | undefined,): Record<string, string> | 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 TerminalManagerEvents { event: [event: TerminalEvent];} interface TerminalManagerOptions { logsDir?: string; historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; subprocessChecker?: TerminalSubprocessChecker; runtimeEnvironment?: ServerRuntimeEnvironment; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number;} export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { private readonly sessions = new Map<string, TerminalSessionState>(); private readonly logsDir: string; private readonly historyLineLimit: number; private readonly ptyAdapter: PtyAdapterShape; private readonly shellResolver: () => string; private readonly persistQueues = new Map<string, Promise<void>>(); private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); private readonly pendingPersistHistory = new Map<string, string>(); private readonly threadLocks = new Map<string, Promise<void>>(); private readonly persistDebounceMs: number; private readonly subprocessChecker: TerminalSubprocessChecker; private readonly runtimeEnvironment: ServerRuntimeEnvironment; private readonly subprocessPollIntervalMs: number; private readonly processKillGraceMs: number; private readonly maxRetainedInactiveSessions: number; private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; private subprocessPollInFlight = false; private readonly killEscalationTimers = new Map<PtyProcess, ReturnType<typeof setTimeout>>(); private readonly logger = createLogger("terminal"); constructor(options: TerminalManagerOptions) { super(); this.runtimeEnvironment = options.runtimeEnvironment ?? detectServerRuntimeEnvironment(); this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; this.ptyAdapter = options.ptyAdapter; this.shellResolver = options.shellResolver ?? (() => defaultShellResolver(this.runtimeEnvironment)); this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; this.subprocessChecker = options.subprocessChecker ?? createDefaultSubprocessChecker(this.runtimeEnvironment); this.subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; this.processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; this.maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; fs.mkdirSync(this.logsDir, { recursive: true }); } async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { const input = decodeTerminalOpenInput(raw); return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); const sessionKey = toSessionKey(input.threadId, input.terminalId); const existing = this.sessions.get(sessionKey); if (!existing) { await this.flushPersistQueue(input.threadId, input.terminalId); const history = await this.readHistory(input.threadId, input.terminalId); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { threadId: input.threadId, terminalId: input.terminalId, cwd: input.cwd, status: "starting", pid: null, history, exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), cols, rows, process: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, runtimeEnv: normalizedRuntimeEnv(input.env), }; this.sessions.set(sessionKey, session); this.evictInactiveSessionsIfNeeded(); await this.startSession(session, { ...input, cols, rows }, "started"); return this.snapshot(session); } const nextRuntimeEnv = normalizedRuntimeEnv(input.env); const currentRuntimeEnv = existing.runtimeEnv; const targetCols = input.cols ?? existing.cols; const targetRows = input.rows ?? existing.rows; const runtimeEnvChanged = JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); if (existing.cwd !== input.cwd || runtimeEnvChanged) { this.stopProcess(existing); existing.cwd = input.cwd; existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (existing.status === "exited" || existing.status === "error") { existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (currentRuntimeEnv !== nextRuntimeEnv) { existing.runtimeEnv = nextRuntimeEnv; } if (!existing.process) { await this.startSession( existing, { ...input, cols: targetCols, rows: targetRows }, "started", ); return this.snapshot(existing); } if (existing.cols !== targetCols || existing.rows !== targetRows) { existing.cols = targetCols; existing.rows = targetRows; existing.process.resize(targetCols, targetRows); existing.updatedAt = new Date().toISOString(); } return this.snapshot(existing); }); } async write(raw: TerminalWriteInput): Promise<void> { const input = decodeTerminalWriteInput(raw); const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { if (session.status === "exited") { return; } throw new Error( `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, );Lines 1–500 of 1284. Load more
import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; import { DEFAULT_TERMINAL_ID, TerminalClearInput, TerminalCloseInput, TerminalOpenInput, TerminalResizeInput, TerminalWriteInput, type ServerRuntimeEnvironment, type TerminalEvent, type TerminalSessionSnapshot, } from "@t3tools/contracts"; import { Effect, Encoding, Layer, Path, Schema } from "effect"; import { createLogger } from "../../logger"; import { detectServerRuntimeEnvironment } from "../../runtimeEnvironment"; import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY"; import { runProcess } from "../../processRunner"; import { ServerConfig } from "../../config"; import { ShellCandidate, TerminalError, TerminalManager, TerminalManagerShape, TerminalSessionState, TerminalStartInput, } from "../Services/Manager"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; 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; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; function defaultShellResolver(runtimeEnvironment: ServerRuntimeEnvironment): string { if (runtimeEnvironment.platform === "windows") { return process.env.ComSpec ?? "cmd.exe"; } return process.env.SHELL ?? "bash"; } function normalizeShellCommand( value: string | undefined, runtimeEnvironment: ServerRuntimeEnvironment, ): string | null { if (!value) return null; const trimmed = value.trim(); if (trimmed.length === 0) return null; if (runtimeEnvironment.platform === "windows") { return trimmed; } const firstToken = trimmed.split(/\s+/g)[0]?.trim(); if (!firstToken) return null; return firstToken.replace(/^['"]|['"]$/g, ""); } function shellCandidateFromCommand( command: string | null, runtimeEnvironment: ServerRuntimeEnvironment, ): ShellCandidate | null { if (!command || command.length === 0) return null; const shellName = path.basename(command).toLowerCase(); if (runtimeEnvironment.platform !== "windows" && shellName === "zsh") { return { shell: command, args: ["-o", "nopromptsp"] }; } return { shell: command }; } 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 | null>): ShellCandidate[] { const seen = new Set<string>(); 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; } export function resolveShellCandidates( shellResolver: () => string, runtimeEnvironment: ServerRuntimeEnvironment = detectServerRuntimeEnvironment(), ): ShellCandidate[] { const requested = shellCandidateFromCommand( normalizeShellCommand(shellResolver(), runtimeEnvironment), runtimeEnvironment, ); if (runtimeEnvironment.platform === "windows") { return uniqueShellCandidates([ requested, shellCandidateFromCommand(process.env.ComSpec ?? null, runtimeEnvironment), shellCandidateFromCommand("powershell.exe", runtimeEnvironment), shellCandidateFromCommand("cmd.exe", runtimeEnvironment), ]); } return uniqueShellCandidates([ requested, shellCandidateFromCommand( normalizeShellCommand(process.env.SHELL, runtimeEnvironment), runtimeEnvironment, ), shellCandidateFromCommand("/bin/zsh", runtimeEnvironment), shellCandidateFromCommand("/bin/bash", runtimeEnvironment), shellCandidateFromCommand("/bin/sh", runtimeEnvironment), shellCandidateFromCommand("zsh", runtimeEnvironment), shellCandidateFromCommand("bash", runtimeEnvironment), shellCandidateFromCommand("sh", runtimeEnvironment), ]); } function isRetryableShellSpawnError(error: unknown): boolean { const queue: unknown[] = [error]; const seen = new Set<unknown>(); 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); const cause = (current as { cause?: unknown }).cause; if (cause) { queue.push(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") ); } async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> { const command = [ `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, "if ($children) { exit 0 }", "exit 1", ].join("; "); try { const result = await runProcess( "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { timeoutMs: 1_500, allowNonZeroExit: true, maxBufferBytes: 32_768, outputMode: "truncate", runtimeEnvironment: { platform: "windows", pathStyle: "windows", isWsl: false, windowsInteropMode: "windows-native", wslDistroName: null, }, }, ); return result.code === 0; } catch { return false; } } async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> { try { const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { timeoutMs: 1_000, allowNonZeroExit: true, maxBufferBytes: 32_768, outputMode: "truncate", }); if (pgrepResult.code === 0) { return pgrepResult.stdout.trim().length > 0; } if (pgrepResult.code === 1) { return false; } } catch { // Fall back to ps when pgrep is unavailable. } try { const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { timeoutMs: 1_000, allowNonZeroExit: true, maxBufferBytes: 262_144, outputMode: "truncate", }); if (psResult.code !== 0) { return false; } for (const line of psResult.stdout.split(/\r?\n/g)) { const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); const pid = Number(pidRaw); const ppid = Number(ppidRaw); if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; if (ppid === terminalPid) { return true; } } return false; } catch { return false; } } function createDefaultSubprocessChecker(runtimeEnvironment: ServerRuntimeEnvironment) { return async (terminalPid: number): Promise<boolean> => { if (!Number.isInteger(terminalPid) || terminalPid <= 0) { return false; } if (runtimeEnvironment.platform === "windows") { return checkWindowsSubprocessActivity(terminalPid); } return checkPosixSubprocessActivity(terminalPid); }; } function isWindowsPath(value: string): boolean { return WINDOWS_ABSOLUTE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value); } export function validateTerminalCwdForRuntime( cwd: string, runtimeEnvironment: ServerRuntimeEnvironment = detectServerRuntimeEnvironment(), ): string | null { if (runtimeEnvironment.pathStyle === "windows") { if (cwd.startsWith("/")) { return `Terminal cwd does not match Windows runtime path style: ${cwd}`; } return null; } if (isWindowsPath(cwd)) { return `Terminal cwd does not match POSIX runtime path style: ${cwd}`; } return null; } function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; const hasTrailingNewline = history.endsWith("\n"); const lines = history.split("\n"); if (hasTrailingNewline) { lines.pop(); } if (lines.length <= maxLines) return history; const capped = lines.slice(lines.length - maxLines).join("\n"); return hasTrailingNewline ? `${capped}\n` : capped; } function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } function toSafeThreadId(threadId: string): string { return `terminal_${Encoding.encodeBase64Url(threadId)}`; } function toSafeTerminalId(terminalId: string): string { return Encoding.encodeBase64Url(terminalId); } 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); } function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv?: Record<string, string> | null, ): 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)) { spawnEnv[key] = value; } } return spawnEnv; } function normalizedRuntimeEnv( env: Record<string, string> | undefined, ): Record<string, string> | 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 TerminalManagerEvents { event: [event: TerminalEvent]; } interface TerminalManagerOptions { logsDir?: string; historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; subprocessChecker?: TerminalSubprocessChecker; runtimeEnvironment?: ServerRuntimeEnvironment; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; } export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { private readonly sessions = new Map<string, TerminalSessionState>(); private readonly logsDir: string; private readonly historyLineLimit: number; private readonly ptyAdapter: PtyAdapterShape; private readonly shellResolver: () => string; private readonly persistQueues = new Map<string, Promise<void>>(); private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); private readonly pendingPersistHistory = new Map<string, string>(); private readonly threadLocks = new Map<string, Promise<void>>(); private readonly persistDebounceMs: number; private readonly subprocessChecker: TerminalSubprocessChecker; private readonly runtimeEnvironment: ServerRuntimeEnvironment; private readonly subprocessPollIntervalMs: number; private readonly processKillGraceMs: number; private readonly maxRetainedInactiveSessions: number; private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; private subprocessPollInFlight = false; private readonly killEscalationTimers = new Map<PtyProcess, ReturnType<typeof setTimeout>>(); private readonly logger = createLogger("terminal"); constructor(options: TerminalManagerOptions) { super(); this.runtimeEnvironment = options.runtimeEnvironment ?? detectServerRuntimeEnvironment(); this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; this.ptyAdapter = options.ptyAdapter; this.shellResolver = options.shellResolver ?? (() => defaultShellResolver(this.runtimeEnvironment)); this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; this.subprocessChecker = options.subprocessChecker ?? createDefaultSubprocessChecker(this.runtimeEnvironment); this.subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; this.processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; this.maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; fs.mkdirSync(this.logsDir, { recursive: true }); } async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { const input = decodeTerminalOpenInput(raw); return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); const sessionKey = toSessionKey(input.threadId, input.terminalId); const existing = this.sessions.get(sessionKey); if (!existing) { await this.flushPersistQueue(input.threadId, input.terminalId); const history = await this.readHistory(input.threadId, input.terminalId); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { threadId: input.threadId, terminalId: input.terminalId, cwd: input.cwd, status: "starting", pid: null, history, exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), cols, rows, process: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, runtimeEnv: normalizedRuntimeEnv(input.env), }; this.sessions.set(sessionKey, session); this.evictInactiveSessionsIfNeeded(); await this.startSession(session, { ...input, cols, rows }, "started"); return this.snapshot(session); } const nextRuntimeEnv = normalizedRuntimeEnv(input.env); const currentRuntimeEnv = existing.runtimeEnv; const targetCols = input.cols ?? existing.cols; const targetRows = input.rows ?? existing.rows; const runtimeEnvChanged = JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); if (existing.cwd !== input.cwd || runtimeEnvChanged) { this.stopProcess(existing); existing.cwd = input.cwd; existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (existing.status === "exited" || existing.status === "error") { existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (currentRuntimeEnv !== nextRuntimeEnv) { existing.runtimeEnv = nextRuntimeEnv; } if (!existing.process) { await this.startSession( existing, { ...input, cols: targetCols, rows: targetRows }, "started", ); return this.snapshot(existing); } if (existing.cols !== targetCols || existing.rows !== targetRows) { existing.cols = targetCols; existing.rows = targetRows; existing.process.resize(targetCols, targetRows); existing.updatedAt = new Date().toISOString(); } return this.snapshot(existing); }); } async write(raw: TerminalWriteInput): Promise<void> { const input = decodeTerminalWriteInput(raw); const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { if (session.status === "exited") { return; } throw new Error( `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, );