2670 lines · 83.1 KB · TypeScript
RawDownload
/** * TerminalManager - Terminal session orchestration service interface. * * Owns terminal lifecycle operations, output fanout, and session state * transitions for thread-scoped terminals. * * @module TerminalManager */import {  DEFAULT_TERMINAL_ID,  TerminalCwdError,  TerminalCwdNotDirectoryError,  TerminalCwdNotFoundError,  TerminalCwdStatError,  TerminalError,  TerminalHistoryError,  TerminalNotRunningError,  TerminalResizeError,  TerminalSessionLookupError,  TerminalWriteError,  type TerminalAttachInput,  type TerminalAttachStreamEvent,  type TerminalClearInput,  type TerminalCloseInput,  type TerminalEvent,  type TerminalMetadataStreamEvent,  type TerminalOpenInput,  type TerminalResizeInput,  type TerminalRestartInput,  type TerminalSessionSnapshot,  type TerminalSessionStatus,  type TerminalSummary,  type TerminalWriteInput,} from "@t3tools/contracts";import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { getTerminalLabel } from "@t3tools/shared/terminalLabels";import * as DateTime from "effect/DateTime";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";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 Semaphore from "effect/Semaphore";import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts";import {  increment,  terminalRestartsTotal,  terminalSessionsTotal,} from "../observability/Metrics.ts";import * as ProcessRunner from "../processRunner.ts";import * as PortScanner from "../preview/PortScanner.ts";import * as PtyAdapter from "./PtyAdapter.ts"; export {  TerminalCwdError,  TerminalCwdNotDirectoryError,  TerminalCwdNotFoundError,  TerminalCwdStatError,  TerminalError,  TerminalHistoryError,  TerminalNotRunningError,  TerminalResizeError,  TerminalSessionLookupError,  TerminalWriteError,}; 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 nowIso = Effect.map(DateTime.now, DateTime.formatIso);const MAX_TERMINAL_LABEL_LENGTH = 128; class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(  "TerminalSubprocessCheckError",  {    cause: Schema.optional(Schema.Defect()),    terminalPid: Schema.Number,    command: Schema.Literals(["powershell", "pgrep", "ps"]),  },) {  override get message(): string {    return `Failed to inspect terminal subprocesses for PID ${this.terminalPid} with ${this.command}`;  }} class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcessSignalError>()(  "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}`;  }} /** * TerminalManager - Service tag for terminal session orchestration. */export class TerminalManager extends Context.Service<  TerminalManager,  {    /**     * Open or attach to a terminal session.     *     * Reuses an existing session for the same thread/terminal id and restores     * persisted history on first open.     */    readonly open: (      input: TerminalOpenInput,    ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;     /**     * Attach to a terminal and stream its initial snapshot followed by live events.     *     * Returns an unsubscribe function.     */    readonly attachStream: (      input: TerminalAttachInput,      listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,    ) => Effect.Effect<() => void, TerminalError>;     /**     * Write input bytes to a terminal session.     */    readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>;     /**     * Resize the PTY backing a terminal session.     */    readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>;     /**     * Clear terminal output history.     */    readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>;     /**     * Restart a terminal session in place.     *     * Always resets history before spawning the new process.     */    readonly restart: (      input: TerminalRestartInput,    ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;     /**     * Close an active terminal session.     *     * When `terminalId` is omitted, closes all sessions for the thread.     */    readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;     /**     * Subscribe to terminal runtime events with a direct callback.     *     * Returns an unsubscribe function.     */    readonly subscribe: (      listener: (event: TerminalEvent) => Effect.Effect<void>,    ) => Effect.Effect<() => void>;     /**     * Subscribe to lightweight terminal metadata with an initial full snapshot.     *     * Returns an unsubscribe function.     */    readonly subscribeMetadata: (      listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,    ) => Effect.Effect<() => void>;  }>()("t3/terminal/Manager/TerminalManager") {} interface TerminalSubprocessInspectResult {  readonly hasRunningSubprocess: boolean;  readonly childCommand: string | null;  readonly processIds: ReadonlyArray<number>;} interface TerminalSubprocessInspector {  (    terminalPid: number,  ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;} 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[];} export interface TerminalStartInput extends TerminalOpenInput {  cols: number;  rows: number;} export 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;  eventSequence: number;  cols: number;  rows: number;  process: PtyAdapter.PtyProcess | null;  unsubscribeData: (() => void) | null;  unsubscribeExit: (() => void) | null;  hasRunningSubprocess: boolean;  /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */  childCommandLabel: string | null;  runtimeEnv: Record<string, string> | null;} interface PersistHistoryRequest {  history: string;  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: string | 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<string, TerminalSessionState>;  killFibers: Map<PtyAdapter.PtyProcess, Fiber.Fiber<void, never>>;} 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,    exitCode: session.exitCode,    exitSignal: session.exitSignal,    label: terminalWireLabel(session),    updatedAt: session.updatedAt,    sequence: session.eventSequence,  };} 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 | null {  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 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>): 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";} 

Lines 1–500 of 2670.