1863 lines · 60.1 KB · TypeScript
RawDownload
import path from "node:path"; import {  DEFAULT_TERMINAL_ID,  type TerminalEvent,  type TerminalSessionSnapshot,  type TerminalSessionStatus,} from "@t3tools/contracts";import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";import {  Data,  Effect,  Encoding,  Equal,  Exit,  Fiber,  FileSystem,  Layer,  Option,  Scope,  Semaphore,  SynchronizedRef,} from "effect"; import { ServerConfig } from "../../config";import {  increment,  terminalRestartsTotal,  terminalSessionsTotal,} from "../../observability/Metrics";import { runProcess } from "../../processRunner";import {  TerminalCwdError,  TerminalHistoryError,  TerminalManager,  TerminalNotRunningError,  TerminalSessionLookupError,  type TerminalManagerShape,} from "../Services/Manager";import {  PtyAdapter,  PtySpawnError,  type PtyAdapterShape,  type PtyExitEvent,  type PtyProcess,} from "../Services/PTY"; 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"]); type TerminalSubprocessChecker = (  terminalPid: number,) => Effect.Effect<boolean, TerminalSubprocessCheckError>; class TerminalSubprocessCheckError extends Data.TaggedError("TerminalSubprocessCheckError")<{  readonly message: string;  readonly cause?: unknown;  readonly terminalPid: number;  readonly command: "powershell" | "pgrep" | "ps";}> {} class TerminalProcessSignalError extends Data.TaggedError("TerminalProcessSignalError")<{  readonly message: string;  readonly cause?: unknown;  readonly signal: "SIGTERM" | "SIGKILL";}> {} interface ShellCandidate {  shell: string;  args?: string[];} interface TerminalStartInput {  threadId: string;  terminalId: string;  cwd: string;  worktreePath?: string | null;  cols: number;  rows: number;  env?: Record<string, string>;} interface TerminalSessionState {  threadId: string;  terminalId: string;  cwd: string;  worktreePath: string | null;  status: TerminalSessionStatus;  pid: number | null;  history: string;  pendingHistoryControlSequence: string;  pendingProcessEvents: Array<PendingProcessEvent>;  pendingProcessEventIndex: number;  processEventDrainRunning: boolean;  exitCode: number | null;  exitSignal: number | null;  updatedAt: string;  cols: number;  rows: number;  process: PtyProcess | null;  unsubscribeData: (() => void) | null;  unsubscribeExit: (() => void) | null;  hasRunningSubprocess: boolean;  runtimeEnv: Record<string, string> | null;} interface PersistHistoryRequest {  history: string;  immediate: boolean;} type PendingProcessEvent = { type: "output"; data: string } | { type: "exit"; event: PtyExitEvent }; type DrainProcessEventAction =  | { type: "idle" }  | {      type: "output";      threadId: string;      terminalId: string;      history: string | null;      data: string;    }  | {      type: "exit";      process: PtyProcess | null;      threadId: string;      terminalId: string;      exitCode: number | null;      exitSignal: number | null;    }; interface TerminalManagerState {  sessions: Map<string, TerminalSessionState>;  killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;} 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,    exitCode: session.exitCode,    exitSignal: session.exitSignal,    updatedAt: session.updatedAt,  };} 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(): string {  if (process.platform === "win32") {    return process.env.ComSpec ?? "cmd.exe";  }  return process.env.SHELL ?? "bash";} function normalizeShellCommand(value: string | undefined): string | null {  if (!value) return null;  const trimmed = value.trim();  if (trimmed.length === 0) return null;   if (process.platform === "win32") {    return trimmed;  }   const firstToken = trimmed.split(/\s+/g)[0]?.trim();  if (!firstToken) return null;  return firstToken.replace(/^['"]|['"]$/g, "");} function shellCandidateFromCommand(command: string | null): ShellCandidate | null {  if (!command || command.length === 0) return null;  const shellName = path.basename(command).toLowerCase();  if (process.platform !== "win32" && 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;} function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {  const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver()));   if (process.platform === "win32") {    return uniqueShellCandidates([      requested,      shellCandidateFromCommand(process.env.ComSpec ?? null),      shellCandidateFromCommand("powershell.exe"),      shellCandidateFromCommand("cmd.exe"),    ]);  }   return uniqueShellCandidates([    requested,    shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)),    shellCandidateFromCommand("/bin/zsh"),    shellCandidateFromCommand("/bin/bash"),    shellCandidateFromCommand("/bin/sh"),    shellCandidateFromCommand("zsh"),    shellCandidateFromCommand("bash"),    shellCandidateFromCommand("sh"),  ]);} function isRetryableShellSpawnError(error: PtySpawnError): 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")  );} function checkWindowsSubprocessActivity(  terminalPid: number,): Effect.Effect<boolean, TerminalSubprocessCheckError> {  const command = [    `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,    "if ($children) { exit 0 }",    "exit 1",  ].join("; ");  return Effect.tryPromise({    try: () =>      runProcess("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {        timeoutMs: 1_500,        allowNonZeroExit: true,        maxBufferBytes: 32_768,        outputMode: "truncate",      }),    catch: (cause) =>      new TerminalSubprocessCheckError({        message: "Failed to check Windows terminal subprocess activity.",        cause,        terminalPid,        command: "powershell",      }),  }).pipe(Effect.map((result) => result.code === 0));} const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (  terminalPid: number,): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {  const runPgrep = Effect.tryPromise({    try: () =>      runProcess("pgrep", ["-P", String(terminalPid)], {        timeoutMs: 1_000,        allowNonZeroExit: true,        maxBufferBytes: 32_768,        outputMode: "truncate",      }),    catch: (cause) =>      new TerminalSubprocessCheckError({        message: "Failed to inspect terminal subprocesses with pgrep.",        cause,        terminalPid,        command: "pgrep",      }),  });   const runPs = Effect.tryPromise({    try: () =>      runProcess("ps", ["-eo", "pid=,ppid="], {        timeoutMs: 1_000,        allowNonZeroExit: true,        maxBufferBytes: 262_144,        outputMode: "truncate",      }),    catch: (cause) =>      new TerminalSubprocessCheckError({        message: "Failed to inspect terminal subprocesses with ps.",        cause,        terminalPid,        command: "ps",      }),  });   const pgrepResult = yield* Effect.exit(runPgrep);  if (pgrepResult._tag === "Success") {    if (pgrepResult.value.code === 0) {      return pgrepResult.value.stdout.trim().length > 0;    }    if (pgrepResult.value.code === 1) {      return false;    }  }   const psResult = yield* Effect.exit(runPs);  if (psResult._tag === "Failure" || psResult.value.code !== 0) {    return false;  }   for (const line of psResult.value.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;}); const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* (  terminalPid: number,): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {  if (!Number.isInteger(terminalPid) || terminalPid <= 0) {    return false;  }  if (process.platform === "win32") {    return yield* checkWindowsSubprocessActivity(terminalPid);  }  return yield* checkPosixSubprocessActivity(terminalPid);}); 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 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;  }  return false;} 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;} function sanitizeTerminalHistoryChunk(  pendingControlSequence: string,  data: string,): { visibleText: string; pendingControlSequence: string } {  const input = `${pendingControlSequence}${data}`;  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) };      } 

Lines 1–500 of 1863.