3051 lines · 98.2 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,  TerminalProviderInstanceNotFoundError,  TerminalProviderEnvironmentError,  TerminalResizeError,  TerminalSessionLookupError,  TerminalWriteError,  type TerminalAttachInput,  type TerminalAttachStreamEvent,  type TerminalClearInput,  type TerminalCloseInput,  type TerminalEvent,  type TerminalMetadataStreamEvent,  type TerminalOpenInput,  type TerminalResizeInput,  type ResourceMonitorProcessTableEntry,  type TerminalRestartInput,  type TerminalSessionSnapshot,  type TerminalSessionStatus,  type TerminalSummary,  type TerminalWriteInput,  ClaudeSettings,  CodexSettings,  ProviderInstanceId,} 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 { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts";import * as ServerSettings from "../serverSettings.ts";import {  increment,  terminalRestartsTotal,  terminalSessionsTotal,} from "../observability/Metrics.ts";import { expandHomePath } from "../pathExpansion.ts";import * as ProcessRunner from "../processRunner.ts";import * as PortScanner from "../preview/PortScanner.ts";import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";import * as PtyAdapter from "./PtyAdapter.ts"; 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 = 40;const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_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;const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); class TerminalSubprocessCheckError extends Schema.TaggedError<TerminalSubprocessCheckError>()(  "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>()(  "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;} interface TerminalSessionState {  threadId: string;  terminalId: string;  cwd: string;  worktreePath: string | null;  status: TerminalSessionStatus;  pid: number | null;  history: BoundedTerminalHistory;  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: 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<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.value(),    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;} 

Lines 1–500 of 3051.