diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 4bbf5ca214996eec1f346519c3f41b36c240f245..7d8a24069a3229b0b5382b93b2131a345be93e9a 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor, @@ -39,7 +39,7 @@ export const logCleanupCauseUnlessInterrupted = ({ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const terminalManager = yield* TerminalManager; + const terminalManager = yield* TerminalManager.TerminalManager; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 051a7d20de002ed4c2e2441e0dc51cb8b71ac92a..91d39a3c1eaccd23023fbaeb536468a43bc29ff9 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import { describe, expect, it, vi } from "vite-plus/test"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; @@ -52,7 +52,7 @@ describe("ProjectSetupScriptRunner", () => { ProjectSetupScriptRunnerLive.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge( - Layer.succeed(TerminalManager, { + Layer.succeed(TerminalManager.TerminalManager, { open, attachStream: () => Effect.die(new Error("unused")), write, @@ -114,7 +114,7 @@ describe("ProjectSetupScriptRunner", () => { ProjectSetupScriptRunnerLive.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge( - Layer.succeed(TerminalManager, { + Layer.succeed(TerminalManager.TerminalManager, { open, attachStream: () => Effect.die(new Error("unused")), write, diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index 61cd043b43b057f7f3c9ead65d461b52d8b4ba05..3c8772641be01ef6f25c9e6f562b7bc39a6aa788 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; import { type ProjectSetupScriptRunnerShape, ProjectSetupScriptRunner, @@ -14,7 +14,7 @@ import { const makeProjectSetupScriptRunner = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const terminalManager = yield* TerminalManager; + const terminalManager = yield* TerminalManager.TerminalManager; const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => Effect.gen(function* () { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4b92b34cc62c5406913cbd9c7d6e94cecc333e00..a1213880f144d9d01d174772538bdcdc474caaa0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -85,7 +85,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; -import * as TerminalManager from "./terminal/Services/Manager.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f3cbe764264ad1e8031a67c2e8019a775ff453b3..4e0cd792f2bd89a0bef343fbbb616d469984278b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,7 +33,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -101,11 +101,11 @@ const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { - const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts")); - return BunPTY.layer; + const BunPtyAdapter = yield* Effect.promise(() => import("./terminal/BunPtyAdapter.ts")); + return BunPtyAdapter.layer; } else { - const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts")); - return NodePTY.layer; + const NodePtyAdapter = yield* Effect.promise(() => import("./terminal/NodePtyAdapter.ts")); + return NodePtyAdapter.layer; } }), ); @@ -238,7 +238,7 @@ const CheckpointingLayerLive = Layer.empty.pipe( const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); -const TerminalLayerLive = TerminalManagerLive.pipe( +const TerminalLayerLive = TerminalManager.layer.pipe( Layer.provide(PtyAdapterLive), Layer.provide(PortScannerLayerLive), ); diff --git a/apps/server/src/terminal/BunPtyAdapter.ts b/apps/server/src/terminal/BunPtyAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..045da058cf507559bf4f316279880ffa034cb154 --- /dev/null +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -0,0 +1,133 @@ +/// + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as PtyAdapter from "./PtyAdapter.ts"; + +class BunPtyProcess implements PtyAdapter.PtyProcess { + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + private readonly decoder = new TextDecoder(); + private readonly process: Bun.Subprocess; + private didExit = false; + + constructor(process: Bun.Subprocess) { + this.process = process; + void this.process.exited + .then((exitCode) => { + this.emitExit({ + exitCode: Number.isInteger(exitCode) ? exitCode : 0, + signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, + }); + }) + .catch(() => { + this.emitExit({ exitCode: 1, signal: null }); + }); + } + + get pid(): number { + return this.process.pid; + } + + write(data: string): void { + if (!this.process.terminal) { + throw new Error("Bun PTY terminal handle is unavailable"); + } + this.process.terminal.write(data); + } + + resize(cols: number, rows: number): void { + if (!this.process.terminal?.resize) { + throw new Error("Bun PTY resize is unavailable"); + } + this.process.terminal.resize(cols, rows); + } + + kill(signal?: string): void { + if (!signal) { + this.process.kill(); + return; + } + this.process.kill(signal as NodeJS.Signals); + } + + onData(callback: (data: string) => void): () => void { + this.dataListeners.add(callback); + return () => { + this.dataListeners.delete(callback); + }; + } + + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { + this.exitListeners.add(callback); + return () => { + this.exitListeners.delete(callback); + }; + } + + emitData(data: Uint8Array): void { + if (this.didExit) return; + const text = this.decoder.decode(data, { stream: true }); + if (text.length === 0) return; + for (const listener of this.dataListeners) { + listener(text); + } + } + + private emitExit(event: PtyAdapter.PtyExitEvent): void { + if (this.didExit) return; + this.didExit = true; + + const remainder = this.decoder.decode(); + if (remainder.length > 0) { + for (const listener of this.dataListeners) { + listener(remainder); + } + } + + for (const listener of this.exitListeners) { + listener(event); + } + } +} + +export const make = Effect.fn("BunPtyAdapter.make")(function* () { + const platform = yield* HostProcessPlatform; + if (platform === "win32") { + return yield* Effect.die( + "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", + ); + } + return PtyAdapter.PtyAdapter.of({ + spawn: (input) => + Effect.try({ + try: () => { + let processHandle: BunPtyProcess | null = null; + const command = [input.shell, ...(input.args ?? [])]; + const subprocess = Bun.spawn(command, { + cwd: input.cwd, + env: input.env, + terminal: { + cols: input.cols, + rows: input.rows, + data: (_terminal, data) => { + processHandle?.emitData(data); + }, + }, + }); + processHandle = new BunPtyProcess(subprocess); + return processHandle; + }, + catch: (cause) => + new PtyAdapter.PtySpawnError({ + adapter: "bun", + shell: input.shell, + cause, + }), + }), + }); +}); + +export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts deleted file mode 100644 index 82ea1dcb9b9ac9f34269cd38fd218338dc0f4fb6..0000000000000000000000000000000000000000 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ /dev/null @@ -1,126 +0,0 @@ -/// - -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { PtyAdapter } from "../Services/PTY.ts"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; - -class BunPtyProcess implements PtyProcess { - private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); - private readonly decoder = new TextDecoder(); - private readonly process: Bun.Subprocess; - private didExit = false; - - constructor(process: Bun.Subprocess) { - this.process = process; - void this.process.exited - .then((exitCode) => { - this.emitExit({ - exitCode: Number.isInteger(exitCode) ? exitCode : 0, - signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, - }); - }) - .catch(() => { - this.emitExit({ exitCode: 1, signal: null }); - }); - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - if (!this.process.terminal) { - throw new Error("Bun PTY terminal handle is unavailable"); - } - this.process.terminal.write(data); - } - - resize(cols: number, rows: number): void { - if (!this.process.terminal?.resize) { - throw new Error("Bun PTY resize is unavailable"); - } - this.process.terminal.resize(cols, rows); - } - - kill(signal?: string): void { - if (!signal) { - this.process.kill(); - return; - } - this.process.kill(signal as NodeJS.Signals); - } - - onData(callback: (data: string) => void): () => void { - this.dataListeners.add(callback); - return () => { - this.dataListeners.delete(callback); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - this.exitListeners.add(callback); - return () => { - this.exitListeners.delete(callback); - }; - } - - emitData(data: Uint8Array): void { - if (this.didExit) return; - const text = this.decoder.decode(data, { stream: true }); - if (text.length === 0) return; - for (const listener of this.dataListeners) { - listener(text); - } - } - - private emitExit(event: PtyExitEvent): void { - if (this.didExit) return; - this.didExit = true; - - const remainder = this.decoder.decode(); - if (remainder.length > 0) { - for (const listener of this.dataListeners) { - listener(remainder); - } - } - - for (const listener of this.exitListeners) { - listener(event); - } - } -} - -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - if (platform === "win32") { - return yield* Effect.die( - "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", - ); - } - return { - spawn: (input) => - Effect.sync(() => { - let processHandle: BunPtyProcess | null = null; - const command = [input.shell, ...(input.args ?? [])]; - const subprocess = Bun.spawn(command, { - cwd: input.cwd, - env: input.env, - terminal: { - cols: input.cols, - rows: input.rows, - data: (_terminal, data) => { - processHandle?.emitData(data); - }, - }, - }); - processHandle = new BunPtyProcess(subprocess); - return processHandle; - }), - } satisfies PtyAdapterShape; - }), -); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts deleted file mode 100644 index a55e5244893f7f5e5d2ccfa3f30752c89bce2015..0000000000000000000000000000000000000000 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ /dev/null @@ -1,1555 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { - DEFAULT_TERMINAL_ID, - type TerminalAttachStreamEvent, - type TerminalEvent, - type TerminalMetadataStreamEvent, - type TerminalOpenInput, - type TerminalRestartInput, -} from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -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 PlatformError from "effect/PlatformError"; -import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; -import * as Scope from "effect/Scope"; -import { TestClock } from "effect/testing"; -import { expect } from "vite-plus/test"; - -import * as ProcessRunner from "../../processRunner.ts"; -import type { TerminalManagerShape } from "../Services/Manager.ts"; -import { - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, - type PtySpawnInput, - PtySpawnError, -} from "../Services/PTY.ts"; -import { makeTerminalManagerWithOptions } from "./Manager.ts"; - -class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ - readonly message: string; -}> {} - -class FakePtyProcess implements PtyProcess { - readonly writes: string[] = []; - readonly resizeCalls: Array<{ cols: number; rows: number }> = []; - readonly killSignals: Array = []; - readonly pid: number; - private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); - killed = false; - - constructor(pid: number) { - this.pid = pid; - } - - write(data: string): void { - this.writes.push(data); - } - - resize(cols: number, rows: number): void { - this.resizeCalls.push({ cols, rows }); - } - - kill(signal?: string): void { - this.killed = true; - this.killSignals.push(signal); - } - - onData(callback: (data: string) => void): () => void { - this.dataListeners.add(callback); - return () => { - this.dataListeners.delete(callback); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - this.exitListeners.add(callback); - return () => { - this.exitListeners.delete(callback); - }; - } - - emitData(data: string): void { - for (const listener of this.dataListeners) { - listener(data); - } - } - - emitExit(event: PtyExitEvent): void { - for (const listener of this.exitListeners) { - listener(event); - } - } -} - -class FakePtyAdapter implements PtyAdapterShape { - readonly spawnInputs: PtySpawnInput[] = []; - readonly processes: FakePtyProcess[] = []; - readonly spawnFailures: Error[] = []; - private readonly mode: "sync" | "async"; - private nextPid = 9000; - - constructor(mode: "sync" | "async" = "sync") { - this.mode = mode; - } - - spawn(input: PtySpawnInput): Effect.Effect { - this.spawnInputs.push(input); - const failure = this.spawnFailures.shift(); - if (failure) { - return Effect.fail( - new PtySpawnError({ - adapter: "fake", - message: "Failed to spawn PTY process", - cause: failure, - }), - ); - } - const process = new FakePtyProcess(this.nextPid++); - this.processes.push(process); - if (this.mode === "async") { - return Effect.tryPromise({ - try: async () => process, - catch: (cause) => - new PtySpawnError({ - adapter: "fake", - message: "Failed to spawn PTY process", - cause, - }), - }); - } - return Effect.succeed(process); - } -} - -const waitFor = ( - predicate: Effect.Effect, - timeout: Duration.Input = 800, -): Effect.Effect => - predicate.pipe( - Effect.filterOrFail( - (done) => done, - () => new WaitForConditionError({ message: "Condition not met" }), - ), - Effect.retry(Schedule.spaced("15 millis")), - Effect.timeoutOption(timeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), - onSome: () => Effect.void, - }), - ), - ); - -function openInput(overrides: Partial = {}): TerminalOpenInput { - return { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cwd: process.cwd(), - cols: 100, - rows: 24, - ...overrides, - }; -} - -function restartInput(overrides: Partial = {}): TerminalRestartInput { - return { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cwd: process.cwd(), - cols: 100, - rows: 24, - ...overrides, - }; -} - -const historyLogPath = (logsDir: string, threadId = "thread-1") => - Effect.service(Path.Path).pipe( - Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), - ); - -const multiTerminalHistoryLogPath = ( - logsDir: string, - threadId = "thread-1", - terminalId = DEFAULT_TERMINAL_ID, -) => - Effect.service(Path.Path).pipe( - Effect.map(({ join }) => { - const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; - return join( - logsDir, - terminalId === DEFAULT_TERMINAL_ID - ? `${threadPart}.log` - : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, - ); - }), - ); - -interface CreateManagerOptions { - shellResolver?: () => string; - env?: NodeJS.ProcessEnv; - subprocessInspector?: (terminalPid: number) => Effect.Effect<{ - readonly hasRunningSubprocess: boolean; - readonly childCommand: string | null; - readonly processIds: ReadonlyArray; - }>; - subprocessPollIntervalMs?: number; - processKillGraceMs?: number; - maxRetainedInactiveSessions?: number; - ptyAdapter?: FakePtyAdapter; -} - -interface ManagerFixture { - readonly baseDir: string; - readonly logsDir: string; - readonly ptyAdapter: FakePtyAdapter; - readonly manager: TerminalManagerShape; - readonly getEvents: Effect.Effect>; -} - -const createManager = ( - historyLineLimit = 5, - options: CreateManagerOptions = {}, -): Effect.Effect< - ManagerFixture, - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner -> => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => - Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); - const logsDir = join(baseDir, "userdata", "logs", "terminals"); - const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); - - const manager = yield* makeTerminalManagerWithOptions({ - logsDir, - historyLineLimit, - ptyAdapter, - ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), - ...(options.env !== undefined ? { env: options.env } : {}), - ...(options.subprocessInspector !== undefined - ? { subprocessInspector: options.subprocessInspector } - : {}), - ...(options.subprocessPollIntervalMs !== undefined - ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } - : {}), - processKillGraceMs: options.processKillGraceMs ?? 1, - ...(options.maxRetainedInactiveSessions !== undefined - ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } - : {}), - }); - const eventsRef = yield* Ref.make>([]); - const unsubscribe = yield* manager.subscribe((event) => - Ref.update(eventsRef, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - return { - baseDir, - logsDir, - join, - ptyAdapter, - manager, - getEvents: Ref.get(eventsRef), - }; - }), - ); - -const withHostPlatform = (platform: NodeJS.Platform) => - Layer.succeed(HostProcessPlatform, platform); - -it.layer( - Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), - { excludeTestServices: true }, -)("TerminalManager", (it) => { - it.effect("spawns lazily and reuses running terminal per thread", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - const [first, second] = yield* Effect.all( - [manager.open(openInput()), manager.open(openInput())], - { concurrency: "unbounded" }, - ); - const third = yield* manager.open(openInput()); - - assert.equal(first.threadId, "thread-1"); - assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); - assert.equal(second.threadId, "thread-1"); - assert.equal(third.threadId, "thread-1"); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - }), - ); - - it.effect("attaches to running sessions without restarting them", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - - yield* manager.open(openInput()); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 100, - rows: 40, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.threadId, "thread-1"); - assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - }), - ); - - it.effect("keeps attach streams live when a terminal id is closed and reopened", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - yield* manager.close({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - deleteHistory: true, - }); - yield* manager.open(openInput()); - - const events = yield* Ref.get(attachEvents); - expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); - expect( - events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), - ).toEqual(["running", "running"]); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - }), - ); - - it.effect("attaches to exited sessions without restarting them", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - "1200 millis", - ); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - openInput({ - env: { - T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", - }, - worktreePath: "/tmp/should-not-restart", - }), - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.status, "exited"); - assert.equal(snapshot.snapshot.worktreePath, null); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - }), - ); - - it.effect("restarts inactive sessions from attach only when requested", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - "1200 millis", - ); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - ...openInput({ - env: { - T3CODE_WORKTREE_PATH: "/tmp/restart-requested", - }, - worktreePath: "/tmp/restart-requested", - }), - restartIfNotRunning: true, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.status, "running"); - assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - }), - ); - - const makeDirectory = (filePath: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => - fs.makeDirectory(filePath, { recursive: true }), - ); - - const chmod = (filePath: string, mode: number) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); - - const pathExists = (filePath: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); - - const readFileString = (filePath: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); - - const writeFileString = (filePath: string, contents: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => - fs.writeFileString(filePath, contents), - ); - - it.effect("preserves non-notFound cwd stat failures", () => - Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; - - const path = yield* Path.Path; - - const { manager, baseDir } = yield* createManager(); - const blockedRoot = path.join(baseDir, "blocked-root"); - const blockedCwd = path.join(blockedRoot, "cwd"); - yield* makeDirectory(blockedCwd); - yield* chmod(blockedRoot, 0o000); - - const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( - Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), - ); - - expect(error).toMatchObject({ - _tag: "TerminalCwdError", - cwd: blockedCwd, - reason: "statFailed", - }); - }), - ); - - it.effect("supports asynchronous PTY spawn effects", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - const snapshot = yield* manager.open(openInput()); - - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - expect(ptyAdapter.processes).toHaveLength(1); - }), - ); - - it.effect("forwards write and resize to active pty process", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "ls\n", - }); - yield* manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 120, - rows: 30, - }); - - expect(process.writes).toEqual(["ls\n"]); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); - }), - ); - - it.effect("ignores delayed resize requests after a terminal closes", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - yield* manager.close({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - deleteHistory: true, - }); - yield* manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 120, - rows: 30, - }); - - expect(process.resizeCalls).toEqual([]); - }), - ); - - it.effect("resizes running terminal on open when a different size is requested", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput({ cols: 100, rows: 24 })); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); - - assert.equal(reopened.status, "running"); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); - }), - ); - - it.effect("supports multiple terminals per thread independently", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "term-2" })); - - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; - - yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); - yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); - - expect(first.writes).toEqual(["pwd\n"]); - expect(second.writes).toEqual(["ls\n"]); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - }), - ); - - it.effect("clears transcript and emits cleared event", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); - const path = yield* Path.Path; - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello\n"); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - Effect.map((text) => text === ""), - ), - ); - - const events = yield* getEvents; - expect(events.some((event) => event.type === "cleared")).toBe(true); - expect( - events.some( - (event) => - event.type === "cleared" && - event.threadId === "thread-1" && - event.terminalId === DEFAULT_TERMINAL_ID, - ), - ).toBe(true); - }), - ); - - it.effect("restarts terminal with empty transcript and respawns pty", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput()); - const firstProcess = ptyAdapter.processes[0]; - expect(firstProcess).toBeDefined(); - if (!firstProcess) return; - firstProcess.emitData("before restart\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - const snapshot = yield* manager.restart(restartInput()); - assert.equal(snapshot.history, ""); - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - Effect.map((text) => text === ""), - ), - ); - }), - ); - - it.effect("restarts a running session when open is called with a different cwd", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const originalCwd = path.join(baseDir, "original"); - const differentCwd = path.join(baseDir, "different"); - yield* makeDirectory(originalCwd); - yield* makeDirectory(differentCwd); - - yield* manager.open(openInput({ cwd: originalCwd })); - const firstProcess = ptyAdapter.processes[0]; - expect(firstProcess).toBeDefined(); - if (!firstProcess) return; - - firstProcess.emitData("before reopen\n"); - const logPath = yield* historyLogPath(logsDir); - yield* waitFor(pathExists(logPath)); - - const reopened = yield* manager.open(openInput({ cwd: differentCwd })); - - expect(ptyAdapter.spawnInputs).toHaveLength(2); - assert.equal(firstProcess.killed, true); - assert.equal(reopened.cwd, differentCwd); - assert.equal(reopened.history, ""); - yield* waitFor(Effect.map(readFileString(logPath), (text) => text === "")); - }), - ); - - it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => - Effect.gen(function* () { - const { manager, getEvents, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); - const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); - yield* makeDirectory(firstWorktreePath); - yield* makeDirectory(secondWorktreePath); - const startedSnapshot = yield* manager.open( - openInput({ - cwd: firstWorktreePath, - worktreePath: firstWorktreePath, - }), - ); - const restartedSnapshot = yield* manager.restart( - restartInput({ - cwd: secondWorktreePath, - worktreePath: secondWorktreePath, - }), - ); - - assert.equal(startedSnapshot.worktreePath, firstWorktreePath); - assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); - - const events = yield* getEvents; - const startedEvent = events.find( - (event): event is Extract => event.type === "started", - ); - const restartedEvent = events.find( - (event): event is Extract => - event.type === "restarted", - ); - - assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); - assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); - }), - ); - - it.effect("preserves worktree metadata when reopening an exited session", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const worktreePath = path.join(baseDir, "worktrees", "feature-a"); - yield* makeDirectory(worktreePath); - - yield* manager.open( - openInput({ - cwd: worktreePath, - worktreePath, - }), - ); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - ); - - const reopenedSnapshot = yield* manager.open( - openInput({ - cwd: worktreePath, - worktreePath, - }), - ); - - assert.equal(reopenedSnapshot.worktreePath, worktreePath); - - const events = yield* getEvents; - const reopenedEvent = events - .toReversed() - .find( - (event): event is Extract => event.type === "started", - ); - - assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); - }), - ); - - it.effect("emits exited event and reopens with clean transcript after exit", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); - const path = yield* Path.Path; - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("old data\n"); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - ); - const reopened = yield* manager.open(openInput()); - - assert.equal(reopened.history, ""); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - expect( - yield* historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - ), - ).toBe(""); - }), - ); - - it.effect("ignores trailing writes after terminal exit", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "\r", - }); - expect(process.writes).toEqual([]); - }), - ); - - it.effect("emits subprocess activity events when child-process state changes", () => - Effect.gen(function* () { - let inspect: { - readonly hasRunningSubprocess: boolean; - readonly childCommand: string | null; - readonly processIds: ReadonlyArray; - } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - const { manager, getEvents } = yield* createManager(5, { - subprocessInspector: () => Effect.succeed(inspect), - subprocessPollIntervalMs: 20, - }); - - yield* manager.open(openInput()); - expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); - - inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "vim", - ), - ), - "1200 millis", - ); - - inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === false && - event.label === "Terminal 1", - ), - ), - "1200 millis", - ); - }), - ); - - it.effect("does not invoke subprocess polling until a terminal session is running", () => - Effect.gen(function* () { - let checks = 0; - const { manager } = yield* createManager(5, { - subprocessInspector: () => { - checks += 1; - return Effect.succeed({ - hasRunningSubprocess: false, - childCommand: null, - processIds: [], - }); - }, - subprocessPollIntervalMs: 20, - }); - - yield* Effect.sleep("80 millis"); - assert.equal(checks, 0); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.sync(() => checks > 0), - "1200 millis", - ); - }), - ); - - it.effect("caps persisted history to configured line limit", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(3); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("line1\nline2\nline3\nline4\n"); - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); - expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); - }), - ); - - it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("prompt "); - process.emitData("\u001b[32mok\u001b[0m "); - process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); - process.emitData("\u001b[1;1R"); - process.emitData("done\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n"); - }), - ); - - it.effect( - "preserves clear and style control sequences while dropping chunk-split query traffic", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before clear\n"); - process.emitData("\u001b[H\u001b[2J"); - process.emitData("prompt "); - process.emitData("\u001b]11;"); - process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); - process.emitData("R\u001b[36mdone\u001b[0m\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal( - reopened.history, - "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", - ); - }), - ); - - it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before "); - process.emitData("\u001b(B"); - process.emitData("after\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before \u001b(Bafter\n"); - }), - ); - - it.effect( - "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before "); - process.emitData("\u001b("); - process.emitData("Bafter\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before \u001b(Bafter\n"); - }), - ); - - it.effect("deletes history file when close(deleteHistory=true)", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("bye\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - expect( - yield* historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - }), - ); - - it.effect("closes all terminals for a thread when close omits terminalId", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "sidecar" })); - const defaultProcess = ptyAdapter.processes[0]; - const sidecarProcess = ptyAdapter.processes[1]; - expect(defaultProcess).toBeDefined(); - expect(sidecarProcess).toBeDefined(); - if (!defaultProcess || !sidecarProcess) return; - - defaultProcess.emitData("default\n"); - sidecarProcess.emitData("sidecar\n"); - const path = yield* Path.Path; - yield* waitFor( - multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - yield* waitFor( - multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - - assert.equal(defaultProcess.killed, true); - assert.equal(sidecarProcess.killed, true); - expect( - yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - expect( - yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - }), - ); - - it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - yield* Fiber.join(closeFiber); - - assert.equal(process.killSignals[0], "SIGTERM"); - expect(process.killSignals).toContain("SIGKILL"); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("publishes closed events when terminals are explicitly closed", () => - Effect.gen(function* () { - const { manager, getEvents } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "sidecar" })); - - yield* manager.close({ threadId: "thread-1" }); - - const closedEvents = (yield* getEvents).filter( - (event): event is Extract => event.type === "closed", - ); - expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); - }), - ); - - it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { - maxRetainedInactiveSessions: 1, - }); - - yield* manager.open(openInput({ threadId: "thread-1" })); - yield* manager.open(openInput({ threadId: "thread-2" })); - - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; - - first.emitData("first-history\n"); - second.emitData("second-history\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir, "thread-1").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - first.emitExit({ exitCode: 0, signal: 0 }); - yield* Effect.sleep(Duration.millis(5)); - second.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map( - getEvents, - (events) => events.filter((event) => event.type === "exited").length === 2, - ), - ); - - const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" })); - const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" })); - - assert.equal(reopenedFirst.history, "first-history\n"); - assert.equal(reopenedSecond.history, ""); - }), - ); - - it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => - Effect.gen(function* () { - const { manager, logsDir } = yield* createManager(); - const path = yield* Path.Path; - const legacyPath = path.join(logsDir, "thread-1.log"); - const nextPath = yield* historyLogPath(logsDir); - yield* writeFileString(legacyPath, "legacy-line\n"); - - const snapshot = yield* manager.open(openInput()); - - assert.equal(snapshot.history, "legacy-line\n"); - expect(yield* pathExists(nextPath)).toBe(true); - expect(yield* readFileString(nextPath)).toBe("legacy-line\n"); - expect(yield* pathExists(legacyPath)).toBe(false); - }), - ); - - it.effect("retries with fallback shells when preferred shell spawn fails", () => - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - const missingShell = - platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; - const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => missingShell, - }); - ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); - - const snapshot = yield* manager.open(openInput()); - - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); - expect(ptyAdapter.spawnInputs[0]?.shell).toBe( - platform === "win32" ? missingShell : "/definitely/missing-shell", - ); - - if (platform === "win32") { - expect( - ptyAdapter.spawnInputs.some( - (input) => - input.shell === "pwsh.exe" || - input.shell === "powershell.exe" || - input.shell === "cmd.exe", - ), - ).toBe(true); - } else { - expect( - ptyAdapter.spawnInputs - .slice(1) - .some((input) => input.shell !== "/definitely/missing-shell"), - ).toBe(true); - } - }), - ); - - it.effect("prefers PowerShell over ComSpec for Windows terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - }).pipe(Effect.provide(withHostPlatform("win32"))); - - yield* manager.open(openInput()); - - expect(ptyAdapter.spawnInputs[0]).toEqual( - expect.objectContaining({ - shell: "pwsh.exe", - args: ["-NoLogo"], - }), - ); - }), - ); - - it.effect("falls back to built-in PowerShell by absolute path on Windows", () => - Effect.gen(function* () { - const ptyAdapter = new FakePtyAdapter(); - const { manager } = yield* createManager(5, { - ptyAdapter, - shellResolver: () => "C:\\missing\\custom-shell.exe", - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - }).pipe(Effect.provide(withHostPlatform("win32"))); - ptyAdapter.spawnFailures.push( - new Error("spawn custom-shell.exe ENOENT"), - new Error("spawn pwsh.exe ENOENT"), - ); - - yield* manager.open(openInput()); - - expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ - "C:\\missing\\custom-shell.exe", - "pwsh.exe", - "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - ]); - expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); - expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); - }), - ); - - it.effect("filters app runtime env variables from terminal sessions", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - env: { - PORT: "5173", - T3CODE_PORT: "3773", - VITE_DEV_SERVER_URL: "http://localhost:5173", - TEST_TERMINAL_KEEP: "keep-me", - }, - }); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.PORT).toBeUndefined(); - expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); - expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); - // Arbitrary host env vars must pass through — terminals inherit the - // user's environment apart from the explicit blocklist. - expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); - }), - ); - - it.effect("injects runtime env overrides into spawned terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open( - openInput({ - env: { - T3CODE_PROJECT_ROOT: "/repo", - T3CODE_WORKTREE_PATH: "/repo/worktree-a", - CUSTOM_FLAG: "1", - }, - }), - ); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo"); - assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a"); - assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); - }), - ); - - it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => - Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; - const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/bin/zsh", - }); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); - }), - ); - - it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello from callback\n"); - - yield* waitFor( - Effect.map(getEvents, (events) => - events.some((event) => event.type === "output" && event.data === "hello from callback\n"), - ), - "1200 millis", - ); - }), - ); - - it.effect("pushes PTY callbacks to direct event subscribers", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - const subscriberEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.subscribe((event) => - Ref.update(subscriberEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello from subscriber\n"); - - yield* waitFor( - Effect.map(Ref.get(subscriberEvents), (events) => - events.some( - (event) => event.type === "output" && event.data === "hello from subscriber\n", - ), - ), - "1200 millis", - ); - }), - ); - - it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () => - Effect.gen(function* () { - const { manager } = yield* createManager(); - yield* manager.open(openInput({ threadId: "existing-thread" })); - - const metadataEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.subscribeMetadata((event) => - Ref.update(metadataEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const initialEvents = yield* Ref.get(metadataEvents); - expect(initialEvents[0]).toMatchObject({ - type: "snapshot", - terminals: [ - { - threadId: "existing-thread", - terminalId: DEFAULT_TERMINAL_ID, - }, - ], - }); - - yield* manager.open(openInput({ threadId: "new-thread" })); - - yield* waitFor( - Effect.map(Ref.get(metadataEvents), (events) => - events.some( - (event) => - event.type === "upsert" && - event.terminal.threadId === "new-thread" && - event.terminal.terminalId === DEFAULT_TERMINAL_ID, - ), - ), - "1200 millis", - ); - - yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID }); - - yield* waitFor( - Effect.map(Ref.get(metadataEvents), (events) => - events.some( - (event) => - event.type === "remove" && - event.threadId === "new-thread" && - event.terminalId === DEFAULT_TERMINAL_ID, - ), - ), - "1200 millis", - ); - }), - ); - - it.effect("removes terminal metadata subscriptions when initial delivery fails", () => - Effect.gen(function* () { - const { manager } = yield* createManager(); - yield* manager.open(openInput({ threadId: "existing-thread" })); - - const leakedLiveEvents = yield* Ref.make(0); - const exit = yield* Effect.exit( - manager.subscribeMetadata((event) => - event.type === "snapshot" - ? Effect.die("snapshot listener failed") - : Ref.update(leakedLiveEvents, (count) => count + 1), - ), - ); - - expect(Exit.isFailure(exit)).toBe(true); - - yield* manager.open(openInput({ threadId: "new-thread" })); - expect(yield* Ref.get(leakedLiveEvents)).toBe(0); - }), - ); - - it.effect( - "streams attach snapshots followed by live events without duplicate start snapshots", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - expect(yield* Ref.get(attachEvents)).toMatchObject([ - { - type: "snapshot", - snapshot: { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - }, - }, - ]); - - process.emitData("hello from attach\n"); - - yield* waitFor( - Effect.map(Ref.get(attachEvents), (events) => - events.some((event) => event.type === "output" && event.data === "hello from attach\n"), - ), - "1200 millis", - ); - - const events = yield* Ref.get(attachEvents); - expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1); - }), - ); - - it.effect("buffers attach output delivered during the initial snapshot callback", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - yield* manager.open(openInput()); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Effect.gen(function* () { - yield* Ref.update(attachEvents, (events) => [...events, event]); - if (event.type === "snapshot") { - yield* Effect.sync(() => process.emitData("during snapshot\n")); - yield* Effect.yieldNow; - } - }), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - yield* waitFor( - Effect.map(Ref.get(attachEvents), (events) => - events.some((event) => event.type === "output" && event.data === "during snapshot\n"), - ), - "1200 millis", - ); - - expect(yield* Ref.get(attachEvents)).toMatchObject([ - { type: "snapshot" }, - { type: "output", data: "during snapshot\n" }, - ]); - }), - ); - - it.effect("preserves queued PTY output ordering through exit callbacks", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("first\n"); - process.emitData("second\n"); - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => { - const relevant = events.filter( - (event) => event.type === "output" || event.type === "exited", - ); - return relevant.length >= 3; - }), - "1200 millis", - ); - - const relevant = (yield* getEvents).filter( - (event) => event.type === "output" || event.type === "exited", - ); - expect(relevant).toEqual([ - expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), - expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), - expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), - ]); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - expect(snapshot.snapshot.sequence).toBe(4); - }), - ); - - it.effect("scoped runtime shutdown stops active terminals cleanly", () => - Effect.gen(function* () { - const scope = yield* Scope.make("sequential"); - const { manager, ptyAdapter } = yield* createManager(5, { - processKillGraceMs: 10, - }).pipe(Effect.provideService(Scope.Scope, scope)); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - yield* Fiber.join(closeScope); - - assert.equal(process.killSignals[0], "SIGTERM"); - expect(process.killSignals).toContain("SIGKILL"); - }).pipe(Effect.provide(TestClock.layer())), - ); -}); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts deleted file mode 100644 index 6d528f02aa9832457a2438678668e705ab0d3517..0000000000000000000000000000000000000000 --- a/apps/server/src/terminal/Layers/Manager.ts +++ /dev/null @@ -1,2488 +0,0 @@ -import { - DEFAULT_TERMINAL_ID, - type TerminalAttachInput, - type TerminalAttachStreamEvent, - type TerminalEvent, - type TerminalMetadataStreamEvent, - type TerminalOpenInput, - type TerminalResizeInput, - type TerminalSessionSnapshot, - type TerminalSessionStatus, - type TerminalSummary, -} 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 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 { 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 { - TerminalCwdError, - TerminalHistoryError, - TerminalManager, - TerminalNotRunningError, - TerminalSessionLookupError, - type TerminalManagerShape, -} from "../Services/Manager.ts"; -import { - PtyAdapter, - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; - -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", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - terminalPid: Schema.Number, - command: Schema.Literals(["powershell", "pgrep", "ps"]), - }, -) {} - -class TerminalProcessSignalError extends Schema.TaggedErrorClass()( - "TerminalProcessSignalError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - signal: Schema.Literals(["SIGTERM", "SIGKILL"]), - }, -) {} - -interface TerminalSubprocessInspectResult { - readonly hasRunningSubprocess: boolean; - readonly childCommand: string | null; - readonly processIds: ReadonlyArray; -} - -interface TerminalSubprocessInspector { - ( - terminalPid: number, - ): Effect.Effect; -} - -interface ShellCandidate { - shell: string; - args?: string[]; -} - -interface TerminalStartInput { - threadId: string; - terminalId: string; - cwd: string; - worktreePath?: string | null; - cols: number; - rows: number; - env?: Record; -} - -interface TerminalSessionState { - threadId: string; - terminalId: string; - cwd: string; - worktreePath: string | null; - status: TerminalSessionStatus; - pid: number | null; - history: string; - pendingHistoryControlSequence: string; - pendingProcessEvents: Array; - pendingProcessEventIndex: number; - processEventDrainRunning: boolean; - exitCode: number | null; - exitSignal: number | null; - updatedAt: string; - eventSequence: number; - cols: number; - rows: number; - process: PtyProcess | null; - unsubscribeData: (() => void) | null; - unsubscribeExit: (() => void) | null; - hasRunningSubprocess: boolean; - /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ - childCommandLabel: string | null; - runtimeEnv: Record | 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; - sequence: number; - history: string | null; - data: string; - } - | { - type: "exit"; - process: 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, - 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 { - 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: 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") - ); -} - -function parseFirstChildPidFromPgrep(stdout: string): number | null { - for (const line of stdout.split(/\r?\n/g)) { - const n = Number.parseInt(line.trim(), 10); - if (Number.isInteger(n) && n > 0) { - return n; - } - } - return null; -} - -function windowsInspectSubprocess( - terminalPid: number, - platform: NodeJS.Platform, -): Effect.Effect< - TerminalSubprocessInspectResult, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner -> { - const command = - 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; - return Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; - return yield* processRunner.run({ - // powershell.exe is a real executable — never spawn it through cmd.exe - // shell mode, which would re-tokenize the `-Command` payload (pipes, - // semicolons) before PowerShell ever sees it. - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: "1500 millis", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - }).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processNameById = new Map(); - const childrenByParent = new Map(); - for (const line of result.stdout.split(/\r?\n/g)) { - const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); - const pid = Number(pidRaw); - const parentPid = Number(parentPidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; - processNameById.set(pid, nameRaw?.trim() ?? ""); - const children = childrenByParent.get(parentPid) ?? []; - children.push(pid); - childrenByParent.set(parentPid, children); - } - const directChildren = childrenByParent.get(terminalPid) ?? []; - const childPid = directChildren[0]; - if (childPid === undefined) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processIds = new Set([terminalPid]); - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const pid of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(pid)) continue; - processIds.add(pid); - pending.push(pid); - } - } - const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - } as const; - }), - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect Windows terminal subprocesses.", - cause, - terminalPid, - command: "powershell", - }), - ), - ); -} - -const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( - terminalPid: number, - platform: NodeJS.Platform, -): Effect.fn.Return< - TerminalSubprocessInspectResult, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner -> { - const processRunner = yield* ProcessRunner.ProcessRunner; - const runPgrep = processRunner - .run({ - command: "pgrep", - args: ["-P", String(terminalPid)], - timeout: "1 second", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with pgrep.", - cause, - terminalPid, - command: "pgrep", - }), - ), - ); - - const runPs = processRunner - .run({ - command: "ps", - args: ["-eo", "pid=,ppid="], - timeout: "1 second", - maxOutputBytes: 262_144, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with ps.", - cause, - terminalPid, - command: "ps", - }), - ), - ); - - let childPid: number | null = null; - - const pgrepResult = yield* Effect.exit(runPgrep); - if (pgrepResult._tag === "Success") { - if (pgrepResult.value.code === 0) { - childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); - } else if (pgrepResult.value.code === 1) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - } - - if (childPid === null) { - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - 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) { - childPid = pid; - break; - } - } - } - - if (childPid === null) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - - const runComm = processRunner.run({ - command: "ps", - args: ["-p", String(childPid), "-o", "comm="], - timeout: "1 second", - maxOutputBytes: 8_192, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - - const commResult = yield* Effect.exit(runComm); - let rawComm: string | null = null; - if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { - rawComm = commResult.value.stdout.trim(); - } - - if (!rawComm || rawComm.length === 0) { - const runArgs = processRunner.run({ - command: "ps", - args: ["-p", String(childPid), "-o", "args="], - timeout: "1 second", - maxOutputBytes: 16_384, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - const argsResult = yield* Effect.exit(runArgs); - if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { - const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; - rawComm = first.length > 0 ? first : null; - } - } - - const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; - const processIds = new Set([terminalPid]); - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Success" && psResult.value.code === 0) { - const childrenByParent = new Map(); - 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; - const children = childrenByParent.get(ppid) ?? []; - children.push(pid); - childrenByParent.set(ppid, children); - } - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const child of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(child)) continue; - processIds.add(child); - pending.push(child); - } - } - } else { - processIds.add(childPid); - } - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - }; -}); - -function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { - return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - if (platform === "win32") { - return yield* windowsInspectSubprocess(terminalPid, platform); - } - return yield* posixInspectSubprocess(terminalPid, platform); - }); -} - -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) }; - } - - 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, index + 2); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); - if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { - 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, index + 1); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); - if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { - append(sequence); - } - index = terminatorIndex; - continue; - } - - append(input[index] ?? ""); - index += 1; - } - - return { visibleText, pendingControlSequence: "" }; -} - -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 | 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 | 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; - ptyAdapter: PtyAdapterShape; - shellResolver?: () => string; - env?: NodeJS.ProcessEnv; - subprocessInspector?: TerminalSubprocessInspector; - 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; -} - -const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { - const { terminalLogsDir } = yield* ServerConfig; - const ptyAdapter = yield* PtyAdapter; - const portDiscovery = yield* PortScanner.PortDiscovery; - return yield* makeTerminalManagerWithOptions({ - logsDir: terminalLogsDir, - ptyAdapter, - registerTerminalProcesses: portDiscovery.registerTerminalProcesses, - unregisterTerminal: portDiscovery.unregisterTerminal, - }); -}); - -export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( - 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 platform = yield* HostProcessPlatform; - // Terminals must inherit the user's full environment (minus the blocklist - // applied in createTerminalSpawnEnv) — an allowlist here silently strips - // things like PSModulePath, DISPLAY, proxies, and toolchain variables. - // `options.env` is the test seam. - const baseEnv = options.env ?? process.env; - const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); - const processRunner = yield* ProcessRunner.ProcessRunner; - const subprocessInspector = - options.subprocessInspector ?? - ((terminalPid) => - defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - )); - 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(), - }); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); - const workerScope = yield* Scope.make("sequential"); - yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); - - const publishEvent = (event: TerminalEvent) => - Effect.gen(function* () { - for (const listener of terminalEventListeners) { - yield* listener(event).pipe(Effect.ignoreCause({ log: true })); - } - }); - - const historyPath = (threadId: string, terminalId: string) => { - const threadPart = toSafeThreadId(threadId); - if (terminalId === DEFAULT_TERMINAL_ID) { - return path.join(logsDir, `${threadPart}.log`); - } - return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); - }; - - const legacyHistoryPath = (threadId: string) => - path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); - - const toTerminalHistoryError = - (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => - (cause: unknown) => - new TerminalHistoryError({ - operation, - threadId, - terminalId, - cause, - }); - - const readManagerState = SynchronizedRef.get(managerStateRef); - - const modifyManagerState = ( - f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], - ) => SynchronizedRef.modify(managerStateRef, f); - - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - - const withThreadLock = ( - threadId: string, - effect: Effect.Effect, - ): Effect.Effect => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); - - const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( - process: 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: PtyProcess, - fiber: Fiber.Fiber, - ) { - yield* modifyManagerState((state) => { - const killFibers = new Map(state.killFibers); - killFibers.set(process, fiber); - return [undefined, { ...state, killFibers }] as const; - }); - }); - - const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* ( - process: PtyProcess, - threadId: string, - terminalId: string, - ) { - const terminated = yield* Effect.try({ - try: () => process.kill("SIGTERM"), - catch: (cause) => - new TerminalProcessSignalError({ - message: "Failed to send SIGTERM to terminal process.", - cause, - signal: "SIGTERM", - }), - }).pipe( - Effect.as(true), - Effect.catch((error) => - Effect.logWarning("failed to kill terminal process", { - threadId, - terminalId, - signal: "SIGTERM", - error: error.message, - }).pipe(Effect.as(false)), - ), - ); - if (!terminated) { - return; - } - - yield* Effect.sleep(processKillGraceMs); - - yield* Effect.try({ - try: () => process.kill("SIGKILL"), - catch: (cause) => - new TerminalProcessSignalError({ - message: "Failed to send SIGKILL to terminal process.", - cause, - signal: "SIGKILL", - }), - }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to force-kill terminal process", { - threadId, - terminalId, - signal: "SIGKILL", - error: error.message, - }), - ), - ); - }); - - const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( - process: 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).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: string, - ) { - 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: string, - ) { - yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, - immediate: true, - }); - yield* flushPersist(threadId, terminalId); - }); - - const readHistory = Effect.fn("terminal.readHistory")(function* ( - threadId: string, - terminalId: string, - ) { - const nextPath = historyPath(threadId, terminalId); - if ( - yield* fileSystem - .exists(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) - ) { - const raw = yield* fileSystem - .readFileString(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); - const capped = capHistory(raw, historyLineLimit); - if (capped !== raw) { - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); - } - return capped; - } - - if (terminalId !== DEFAULT_TERMINAL_ID) { - return ""; - } - - const legacyPath = legacyHistoryPath(threadId); - if ( - !(yield* fileSystem - .exists(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) - ) { - return ""; - } - - const raw = yield* fileSystem - .readFileString(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); - const capped = capHistory(raw, historyLineLimit); - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); - yield* fileSystem.remove(legacyPath, { force: true }).pipe( - Effect.catch((cleanupError) => - Effect.logWarning("failed to remove legacy terminal history", { - threadId, - error: cleanupError, - }), - ), - ); - return capped; - }); - - 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) { - 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 threadPrefix = `${toSafeThreadId(threadId)}_`; - const entries = yield* fileSystem - .readDirectory(logsDir, { recursive: false }) - .pipe(Effect.orElseSucceed(() => [] as Array)); - yield* Effect.forEach( - entries.filter( - (name) => - name === `${toSafeThreadId(threadId)}.log` || - name === `${legacySafeThreadId(threadId)}.log` || - name.startsWith(threadPrefix), - ), - (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.mapError( - (cause) => - new TerminalCwdError({ - cwd, - reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", - cause, - }), - ), - ); - if (stats.type !== "Directory") { - return yield* new TerminalCwdError({ - cwd, - reason: "notDirectory", - }); - } - }); - - 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) => - Option.match(session, { - onNone: () => - Effect.fail( - new TerminalSessionLookupError({ - threadId, - terminalId, - }), - ), - onSome: Effect.succeed, - }), - ); - }); - - 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); - } - - 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 = capHistory( - `${session.history}${sanitized.visibleText}`, - historyLineLimit, - ); - } - 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: PtySpawnError | null = null, - ): Effect.fn.Return<{ process: PtyProcess; shellLabel: string }, PtySpawnError> { - if (index >= shellCandidates.length) { - const detail = lastError?.message ?? "Failed to spawn PTY process"; - const tried = - shellCandidates.length > 0 - ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` - : ""; - return yield* new PtySpawnError({ - adapter: "terminal-manager", - message: `${detail}.${tried}`.trim(), - ...(lastError ? { cause: lastError } : {}), - }); - } - - const candidate = shellCandidates[index]; - if (!candidate) { - return yield* ( - lastError ?? - new PtySpawnError({ - adapter: "terminal-manager", - message: "No shell candidate available for PTY spawn.", - }) - ); - } - - const attempt = yield* Effect.result( - options.ptyAdapter.spawn({ - shell: candidate.shell, - ...(candidate.args ? { args: candidate.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: 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); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); - ptyProcess = spawnResult.process; - startedShell = spawnResult.shellLabel; - - const processPid = ptyProcess.pid; - const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - - let eventStamp: ReturnType = { - updatedAt: session.updatedAt, - sequence: session.eventSequence, - }; - yield* modifyManagerState((state) => { - session.process = ptyProcess; - session.pid = processPid; - session.status = "running"; - session.unsubscribeData = unsubscribeData; - session.unsubscribeExit = unsubscribeExit; - eventStamp = advanceEventSequence(session); - return [undefined, state] as const; - }); - - yield* publishEvent({ - type: eventType, - threadId: session.threadId, - terminalId: session.terminalId, - sequence: eventStamp.sequence, - snapshot: snapshot(session), - }); - }), - ), - ), - ); - - if (startResult._tag === "Success") { - return; - } - - { - const error = startResult.failure; - if (ptyProcess) { - yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); - } - - yield* modifyManagerState((state) => { - session.status = "error"; - session.pid = null; - session.process = null; - session.unsubscribeData = null; - session.unsubscribeExit = 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, - error: message, - ...(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) => { - 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); - } - }); - - 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), - ); - - if (runningSessions.length === 0) { - return; - } - - const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( - session: TerminalSessionState & { pid: number }, - ) { - const terminalPid = session.pid; - const inspectResult = yield* subprocessInspector(terminalPid).pipe( - Effect.map(Option.some), - 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)) { - 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.status !== "running" || - liveSession.value.pid !== terminalPid || - (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); - - 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); - } - }); - - yield* Effect.forEach(runningSessions, checkSubprocessActivity, { - concurrency: "unbounded", - discard: true, - }); - }); - - const hasRunningSessions = readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()].some((session) => session.status === "running"), - ), - ); - - yield* Effect.forever( - hasRunningSessions.pipe( - Effect.flatMap((active) => - active - ? pollSubprocessActivity().pipe( - Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), - ) - : 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 openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existing = yield* getSession(input.threadId, terminalId); - if (Option.isNone(existing)) { - yield* flushPersist(input.threadId, terminalId); - const history = yield* readHistory(input.threadId, terminalId); - 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, - 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); - return [undefined, { ...state, sessions }] as const; - }); - - yield* evictInactiveSessionsIfNeeded(); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "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 = ""; - 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 = ""; - liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; - yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); - } - - if (!liveSession.process) { - yield* startSession( - liveSession, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: liveSession.worktreePath, - cols: targetCols, - rows: targetRows, - ...(input.env ? { env: input.env } : {}), - }, - "started", - ); - return snapshot(liveSession); - } - - if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { - liveSession.cols = targetCols; - liveSession.rows = targetRows; - liveSession.updatedAt = yield* nowIso; - liveSession.process.resize(targetCols, targetRows); - } - - return snapshot(liveSession); - }); - - const open: TerminalManagerShape["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); - - const openOrAttachForStream = (input: TerminalAttachInput) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const terminalId = input.terminalId; - const existing = yield* getSession(input.threadId, terminalId); - - if (Option.isNone(existing)) { - if (!input.cwd) { - return yield* new TerminalSessionLookupError({ - threadId: input.threadId, - terminalId, - }); - } - - return yield* openLocked({ - ...input, - terminalId, - cwd: input.cwd, - }); - } - - const session = existing.value; - const targetCols = input.cols ?? session.cols; - const targetRows = input.rows ?? session.rows; - - if (!session.process && input.cwd && input.restartIfNotRunning === true) { - return yield* openLocked({ - ...input, - terminalId, - cwd: input.cwd, - }); - } - - if ( - session.process && - session.status === "running" && - (session.cols !== targetCols || session.rows !== targetRows) - ) { - session.cols = targetCols; - session.rows = targetRows; - session.updatedAt = yield* nowIso; - yield* Effect.sync(() => session.process?.resize(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: TerminalManagerShape["subscribe"] = (listener) => - Effect.sync(() => { - terminalEventListeners.add(listener); - return () => { - terminalEventListeners.delete(listener); - }; - }); - - const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => { - let unsubscribe: (() => void) | null = null; - - return Effect.gen(function* () { - const bufferedEvents: TerminalEvent[] = []; - let deliverLive = false; - - unsubscribe = yield* subscribe((event) => { - if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { - return Effect.void; - } - - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; - } - - const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent) : Effect.void; - }); - - const initialSnapshot = yield* openOrAttachForStream(input); - - yield* listener({ - type: "snapshot", - snapshot: initialSnapshot, - }); - - for (const event of bufferedEvents) { - if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { - continue; - } - - const attachEvent = terminalEventToAttachEvent(event); - if (attachEvent) { - yield* listener(attachEvent); - } - } - - deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; - }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), - ), - ), - ); - }; - - 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: TerminalManagerShape["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: TerminalManagerShape["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, - }); - } - yield* Effect.sync(() => process.write(input.data)); - }); - - const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { - const session = yield* getSession(input.threadId, input.terminalId); - // ResizeObserver traffic can already be in flight when the UI closes the session. - if (Option.isNone(session)) { - return; - } - const process = session.value.process; - if (!process || session.value.status !== "running") { - return; - } - session.value.cols = input.cols; - session.value.rows = input.rows; - session.value.updatedAt = yield* nowIso; - yield* Effect.sync(() => process.resize(input.cols, input.rows)); - }); - - const resize: TerminalManagerShape["resize"] = (input) => - withThreadLock(input.threadId, resizeLocked(input)); - - const clear: TerminalManagerShape["clear"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const terminalId = input.terminalId; - const session = yield* requireSession(input.threadId, terminalId); - session.history = ""; - 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 restart: TerminalManagerShape["restart"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - 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, - 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); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history = ""; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), - ); - - const close: TerminalManagerShape["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); - } - }), - ); - - return { - open, - attachStream, - write, - resize, - clear, - restart, - close, - subscribe, - subscribeMetadata, - } satisfies TerminalManagerShape; - }, -); - -export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe( - Layer.provide(ProcessRunner.layer), -); diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts deleted file mode 100644 index 46840214b66bf43d31a1584bd19b3bf58830fe29..0000000000000000000000000000000000000000 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; - -import { PtyAdapter } from "../Services/PTY.ts"; -import { layer } from "./NodePTY.ts"; - -const spawn = vi.fn(() => ({ - pid: 42, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - onData: vi.fn(() => ({ dispose: vi.fn() })), - onExit: vi.fn(() => ({ dispose: vi.fn() })), -})); - -vi.mock("node-pty", () => ({ spawn })); - -const testLayer = layer.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(HostProcessPlatform, "win32"), - Layer.succeed(HostProcessArchitecture, "x64"), - ), - ), -); - -it.effect("spawns through the public adapter with the provided host references", () => - Effect.gen(function* () { - const adapter = yield* PtyAdapter; - const process = yield* adapter.spawn({ - shell: "powershell.exe", - args: ["-NoLogo"], - cwd: "C:\\workspace", - cols: 120, - rows: 40, - env: {}, - }); - - assert.equal(process.pid, 42); - assert.equal(spawn.mock.calls.length, 1); - assert.deepEqual(spawn.mock.calls[0], [ - "powershell.exe", - ["-NoLogo"], - { - cwd: "C:\\workspace", - cols: 120, - rows: 40, - env: {}, - name: "xterm-color", - }, - ]); - }).pipe(Effect.provide(testLayer)), -); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts deleted file mode 100644 index 2b19fe4ac5198cf0af4cfeee40e7cfab0ec8dc06..0000000000000000000000000000000000000000 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createRequire } from "node:module"; - -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { PtyAdapter } from "../Services/PTY.ts"; -import { - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; - -let didEnsureSpawnHelperExecutable = false; - -const resolveNodePtySpawnHelperPath = Effect.gen(function* () { - const requireForNodePty = createRequire(import.meta.url); - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; - - const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); - const packageDir = path.dirname(packageJsonPath); - const candidates = [ - path.join(packageDir, "build", "Release", "spawn-helper"), - path.join(packageDir, "build", "Debug", "spawn-helper"), - path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), - ]; - - for (const candidate of candidates) { - if (yield* fs.exists(candidate)) { - return candidate; - } - } - return null; -}).pipe(Effect.orElseSucceed(() => null)); - -const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { - const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; - if (platform === "win32") return; - if (didEnsureSpawnHelperExecutable) return; - - const helperPath = yield* resolveNodePtySpawnHelperPath; - if (!helperPath) return; - didEnsureSpawnHelperExecutable = true; - - if (!(yield* fs.exists(helperPath))) { - return; - } - - // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. - yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); -}); - -class NodePtyProcess implements PtyProcess { - private readonly process: import("node-pty").IPty; - - constructor(process: import("node-pty").IPty) { - this.process = process; - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - this.process.write(data); - } - - resize(cols: number, rows: number): void { - this.process.resize(cols, rows); - } - - kill(signal?: string): void { - this.process.kill(signal); - } - - onData(callback: (data: string) => void): () => void { - const disposable = this.process.onData(callback); - return () => { - disposable.dispose(); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - const disposable = this.process.onExit((event) => { - callback({ - exitCode: event.exitCode, - signal: event.signal ?? null, - }); - }); - return () => { - disposable.dispose(); - }; - } -} - -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; - - const nodePty = yield* Effect.promise(() => import("node-pty")); - - const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( - ensureNodePtySpawnHelperExecutable().pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HostProcessArchitecture, architecture), - Effect.orElseSucceed(() => undefined), - ), - ); - - return { - spawn: Effect.fn(function* (input) { - yield* ensureNodePtySpawnHelperExecutableCached; - const ptyProcess = yield* Effect.try({ - try: () => - nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: platform === "win32" ? "xterm-color" : "xterm-256color", - }), - catch: (cause) => - new PtySpawnError({ - adapter: "node-pty", - message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", - cause, - }), - }); - return new NodePtyProcess(ptyProcess); - }), - } satisfies PtyAdapterShape; - }), -); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4c73ea7489625eae30b798edc0dc7ce14c98ab0 --- /dev/null +++ b/apps/server/src/terminal/Manager.test.ts @@ -0,0 +1,1550 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + DEFAULT_TERMINAL_ID, + type TerminalAttachStreamEvent, + type TerminalEvent, + type TerminalMetadataStreamEvent, + type TerminalOpenInput, + type TerminalRestartInput, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +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 PlatformError from "effect/PlatformError"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; +import { expect } from "vite-plus/test"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as TerminalManager from "./Manager.ts"; +import * as PtyAdapter from "./PtyAdapter.ts"; + +class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ + readonly message: string; +}> {} + +class FakePtyProcess implements PtyAdapter.PtyProcess { + readonly writes: string[] = []; + readonly resizeCalls: Array<{ cols: number; rows: number }> = []; + readonly killSignals: Array = []; + readonly pid: number; + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + killed = false; + + constructor(pid: number) { + this.pid = pid; + } + + write(data: string): void { + this.writes.push(data); + } + + resize(cols: number, rows: number): void { + this.resizeCalls.push({ cols, rows }); + } + + kill(signal?: string): void { + this.killed = true; + this.killSignals.push(signal); + } + + onData(callback: (data: string) => void): () => void { + this.dataListeners.add(callback); + return () => { + this.dataListeners.delete(callback); + }; + } + + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { + this.exitListeners.add(callback); + return () => { + this.exitListeners.delete(callback); + }; + } + + emitData(data: string): void { + for (const listener of this.dataListeners) { + listener(data); + } + } + + emitExit(event: PtyAdapter.PtyExitEvent): void { + for (const listener of this.exitListeners) { + listener(event); + } + } +} + +class FakePtyAdapter { + readonly spawnInputs: PtyAdapter.PtySpawnInput[] = []; + readonly processes: FakePtyProcess[] = []; + readonly spawnFailures: Error[] = []; + private readonly mode: "sync" | "async"; + private nextPid = 9000; + + constructor(mode: "sync" | "async" = "sync") { + this.mode = mode; + } + + spawn( + input: PtyAdapter.PtySpawnInput, + ): Effect.Effect { + this.spawnInputs.push(input); + const failure = this.spawnFailures.shift(); + if (failure) { + return Effect.fail( + new PtyAdapter.PtySpawnError({ + adapter: "fake", + shell: input.shell, + cause: failure, + }), + ); + } + const process = new FakePtyProcess(this.nextPid++); + this.processes.push(process); + if (this.mode === "async") { + return Effect.tryPromise({ + try: async () => process, + catch: (cause) => + new PtyAdapter.PtySpawnError({ + adapter: "fake", + shell: input.shell, + cause, + }), + }); + } + return Effect.succeed(process); + } +} + +const waitFor = ( + predicate: Effect.Effect, + timeout: Duration.Input = 800, +): Effect.Effect => + predicate.pipe( + Effect.filterOrFail( + (done) => done, + () => new WaitForConditionError({ message: "Condition not met" }), + ), + Effect.retry(Schedule.spaced("15 millis")), + Effect.timeoutOption(timeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), + onSome: () => Effect.void, + }), + ), + ); + +function openInput(overrides: Partial = {}): TerminalOpenInput { + return { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: process.cwd(), + cols: 100, + rows: 24, + ...overrides, + }; +} + +function restartInput(overrides: Partial = {}): TerminalRestartInput { + return { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: process.cwd(), + cols: 100, + rows: 24, + ...overrides, + }; +} + +const historyLogPath = (logsDir: string, threadId = "thread-1") => + Effect.service(Path.Path).pipe( + Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), + ); + +const multiTerminalHistoryLogPath = ( + logsDir: string, + threadId = "thread-1", + terminalId = DEFAULT_TERMINAL_ID, +) => + Effect.service(Path.Path).pipe( + Effect.map(({ join }) => { + const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; + return join( + logsDir, + terminalId === DEFAULT_TERMINAL_ID + ? `${threadPart}.log` + : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, + ); + }), + ); + +interface CreateManagerOptions { + shellResolver?: () => string; + env?: NodeJS.ProcessEnv; + subprocessInspector?: (terminalPid: number) => Effect.Effect<{ + readonly hasRunningSubprocess: boolean; + readonly childCommand: string | null; + readonly processIds: ReadonlyArray; + }>; + subprocessPollIntervalMs?: number; + processKillGraceMs?: number; + maxRetainedInactiveSessions?: number; + ptyAdapter?: FakePtyAdapter; +} + +interface ManagerFixture { + readonly baseDir: string; + readonly logsDir: string; + readonly ptyAdapter: FakePtyAdapter; + readonly manager: TerminalManager.TerminalManager["Service"]; + readonly getEvents: Effect.Effect>; +} + +const createManager = ( + historyLineLimit = 5, + options: CreateManagerOptions = {}, +): Effect.Effect< + ManagerFixture, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner +> => + Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); + const logsDir = join(baseDir, "userdata", "logs", "terminals"); + const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); + + const manager = yield* TerminalManager.makeWithOptions({ + logsDir, + historyLineLimit, + ptyAdapter, + ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), + ...(options.subprocessInspector !== undefined + ? { subprocessInspector: options.subprocessInspector } + : {}), + ...(options.subprocessPollIntervalMs !== undefined + ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } + : {}), + processKillGraceMs: options.processKillGraceMs ?? 1, + ...(options.maxRetainedInactiveSessions !== undefined + ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } + : {}), + }); + const eventsRef = yield* Ref.make>([]); + const unsubscribe = yield* manager.subscribe((event) => + Ref.update(eventsRef, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + return { + baseDir, + logsDir, + join, + ptyAdapter, + manager, + getEvents: Ref.get(eventsRef), + }; + }), + ); + +const withHostPlatform = (platform: NodeJS.Platform) => + Layer.succeed(HostProcessPlatform, platform); + +it.layer( + Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), + { excludeTestServices: true }, +)("TerminalManager", (it) => { + it.effect("spawns lazily and reuses running terminal per thread", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const [first, second] = yield* Effect.all( + [manager.open(openInput()), manager.open(openInput())], + { concurrency: "unbounded" }, + ); + const third = yield* manager.open(openInput()); + + assert.equal(first.threadId, "thread-1"); + assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); + assert.equal(second.threadId, "thread-1"); + assert.equal(third.threadId, "thread-1"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("attaches to running sessions without restarting them", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + + yield* manager.open(openInput()); + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 100, + rows: 40, + }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); + expect(snapshot).toBeDefined(); + if (!snapshot || snapshot.type !== "snapshot") return; + assert.equal(snapshot.snapshot.threadId, "thread-1"); + assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("keeps attach streams live when a terminal id is closed and reopened", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* manager.close({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + deleteHistory: true, + }); + yield* manager.open(openInput()); + + const events = yield* Ref.get(attachEvents); + expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); + expect( + events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), + ).toEqual(["running", "running"]); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + }), + ); + + it.effect("attaches to exited sessions without restarting them", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + openInput({ + env: { + T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", + }, + worktreePath: "/tmp/should-not-restart", + }), + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); + expect(snapshot).toBeDefined(); + if (!snapshot || snapshot.type !== "snapshot") return; + assert.equal(snapshot.snapshot.status, "exited"); + assert.equal(snapshot.snapshot.worktreePath, null); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("restarts inactive sessions from attach only when requested", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + { + ...openInput({ + env: { + T3CODE_WORKTREE_PATH: "/tmp/restart-requested", + }, + worktreePath: "/tmp/restart-requested", + }), + restartIfNotRunning: true, + }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); + expect(snapshot).toBeDefined(); + if (!snapshot || snapshot.type !== "snapshot") return; + assert.equal(snapshot.snapshot.status, "running"); + assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + }), + ); + + const makeDirectory = (filePath: string) => + Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => + fs.makeDirectory(filePath, { recursive: true }), + ); + + const chmod = (filePath: string, mode: number) => + Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); + + const pathExists = (filePath: string) => + Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); + + const readFileString = (filePath: string) => + Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); + + const writeFileString = (filePath: string, contents: string) => + Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => + fs.writeFileString(filePath, contents), + ); + + it.effect("preserves non-notFound cwd stat failures", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const blockedRoot = path.join(baseDir, "blocked-root"); + const blockedCwd = path.join(blockedRoot, "cwd"); + yield* makeDirectory(blockedCwd); + yield* chmod(blockedRoot, 0o000); + + const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( + Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), + ); + + expect(error).toMatchObject({ + _tag: "TerminalCwdError", + cwd: blockedCwd, + reason: "statFailed", + }); + }), + ); + + it.effect("supports asynchronous PTY spawn effects", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + + const snapshot = yield* manager.open(openInput()); + + assert.equal(snapshot.status, "running"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes).toHaveLength(1); + }), + ); + + it.effect("forwards write and resize to active pty process", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + yield* manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "ls\n", + }); + yield* manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 120, + rows: 30, + }); + + expect(process.writes).toEqual(["ls\n"]); + expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); + }), + ); + + it.effect("ignores delayed resize requests after a terminal closes", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + yield* manager.close({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + deleteHistory: true, + }); + yield* manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 120, + rows: 30, + }); + + expect(process.resizeCalls).toEqual([]); + }), + ); + + it.effect("resizes running terminal on open when a different size is requested", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput({ cols: 100, rows: 24 })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); + + assert.equal(reopened.status, "running"); + expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); + }), + ); + + it.effect("supports multiple terminals per thread independently", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput({ terminalId: "default" })); + yield* manager.open(openInput({ terminalId: "term-2" })); + + const first = ptyAdapter.processes[0]; + const second = ptyAdapter.processes[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second) return; + + yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); + yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); + + expect(first.writes).toEqual(["pwd\n"]); + expect(second.writes).toEqual(["ls\n"]); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + }), + ); + + it.effect("clears transcript and emits cleared event", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); + const path = yield* Path.Path; + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("hello\n"); + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); + yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(readFileString), + Effect.map((text) => text === ""), + ), + ); + + const events = yield* getEvents; + expect(events.some((event) => event.type === "cleared")).toBe(true); + expect( + events.some( + (event) => + event.type === "cleared" && + event.threadId === "thread-1" && + event.terminalId === DEFAULT_TERMINAL_ID, + ), + ).toBe(true); + }), + ); + + it.effect("restarts terminal with empty transcript and respawns pty", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager(); + yield* manager.open(openInput()); + const firstProcess = ptyAdapter.processes[0]; + expect(firstProcess).toBeDefined(); + if (!firstProcess) return; + firstProcess.emitData("before restart\n"); + const path = yield* Path.Path; + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); + + const snapshot = yield* manager.restart(restartInput()); + assert.equal(snapshot.history, ""); + assert.equal(snapshot.status, "running"); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(readFileString), + Effect.map((text) => text === ""), + ), + ); + }), + ); + + it.effect("restarts a running session when open is called with a different cwd", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); + const path = yield* Path.Path; + const originalCwd = path.join(baseDir, "original"); + const differentCwd = path.join(baseDir, "different"); + yield* makeDirectory(originalCwd); + yield* makeDirectory(differentCwd); + + yield* manager.open(openInput({ cwd: originalCwd })); + const firstProcess = ptyAdapter.processes[0]; + expect(firstProcess).toBeDefined(); + if (!firstProcess) return; + + firstProcess.emitData("before reopen\n"); + const logPath = yield* historyLogPath(logsDir); + yield* waitFor(pathExists(logPath)); + + const reopened = yield* manager.open(openInput({ cwd: differentCwd })); + + expect(ptyAdapter.spawnInputs).toHaveLength(2); + assert.equal(firstProcess.killed, true); + assert.equal(reopened.cwd, differentCwd); + assert.equal(reopened.history, ""); + yield* waitFor(Effect.map(readFileString(logPath), (text) => text === "")); + }), + ); + + it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => + Effect.gen(function* () { + const { manager, getEvents, baseDir } = yield* createManager(); + const path = yield* Path.Path; + const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); + const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); + yield* makeDirectory(firstWorktreePath); + yield* makeDirectory(secondWorktreePath); + const startedSnapshot = yield* manager.open( + openInput({ + cwd: firstWorktreePath, + worktreePath: firstWorktreePath, + }), + ); + const restartedSnapshot = yield* manager.restart( + restartInput({ + cwd: secondWorktreePath, + worktreePath: secondWorktreePath, + }), + ); + + assert.equal(startedSnapshot.worktreePath, firstWorktreePath); + assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); + + const events = yield* getEvents; + const startedEvent = events.find( + (event): event is Extract => event.type === "started", + ); + const restartedEvent = events.find( + (event): event is Extract => + event.type === "restarted", + ); + + assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); + assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); + }), + ); + + it.effect("preserves worktree metadata when reopening an exited session", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); + const path = yield* Path.Path; + const worktreePath = path.join(baseDir, "worktrees", "feature-a"); + yield* makeDirectory(worktreePath); + + yield* manager.open( + openInput({ + cwd: worktreePath, + worktreePath, + }), + ); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + ); + + const reopenedSnapshot = yield* manager.open( + openInput({ + cwd: worktreePath, + worktreePath, + }), + ); + + assert.equal(reopenedSnapshot.worktreePath, worktreePath); + + const events = yield* getEvents; + const reopenedEvent = events + .toReversed() + .find( + (event): event is Extract => event.type === "started", + ); + + assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); + }), + ); + + it.effect("emits exited event and reopens with clean transcript after exit", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); + const path = yield* Path.Path; + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + process.emitData("old data\n"); + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + ); + const reopened = yield* manager.open(openInput()); + + assert.equal(reopened.history, ""); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect( + yield* historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(readFileString), + ), + ).toBe(""); + }), + ); + + it.effect("ignores trailing writes after terminal exit", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "\r", + }); + expect(process.writes).toEqual([]); + }), + ); + + it.effect("emits subprocess activity events when child-process state changes", () => + Effect.gen(function* () { + let inspect: { + readonly hasRunningSubprocess: boolean; + readonly childCommand: string | null; + readonly processIds: ReadonlyArray; + } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + const { manager, getEvents } = yield* createManager(5, { + subprocessInspector: () => Effect.succeed(inspect), + subprocessPollIntervalMs: 20, + }); + + yield* manager.open(openInput()); + expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); + + inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "vim", + ), + ), + "1200 millis", + ); + + inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === false && + event.label === "Terminal 1", + ), + ), + "1200 millis", + ); + }), + ); + + it.effect("does not invoke subprocess polling until a terminal session is running", () => + Effect.gen(function* () { + let checks = 0; + const { manager } = yield* createManager(5, { + subprocessInspector: () => { + checks += 1; + return Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }); + }, + subprocessPollIntervalMs: 20, + }); + + yield* Effect.sleep("80 millis"); + assert.equal(checks, 0); + + yield* manager.open(openInput()); + yield* waitFor( + Effect.sync(() => checks > 0), + "1200 millis", + ); + }), + ); + + it.effect("caps persisted history to configured line limit", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(3); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("line1\nline2\nline3\nline4\n"); + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); + expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); + }), + ); + + it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("prompt "); + process.emitData("\u001b[32mok\u001b[0m "); + process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); + process.emitData("\u001b[1;1R"); + process.emitData("done\n"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n"); + }), + ); + + it.effect( + "preserves clear and style control sequences while dropping chunk-split query traffic", + () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before clear\n"); + process.emitData("\u001b[H\u001b[2J"); + process.emitData("prompt "); + process.emitData("\u001b]11;"); + process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); + process.emitData("R\u001b[36mdone\u001b[0m\n"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal( + reopened.history, + "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", + ); + }), + ); + + it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before "); + process.emitData("\u001b(B"); + process.emitData("after\n"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal(reopened.history, "before \u001b(Bafter\n"); + }), + ); + + it.effect( + "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", + () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before "); + process.emitData("\u001b("); + process.emitData("Bafter\n"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal(reopened.history, "before \u001b(Bafter\n"); + }), + ); + + it.effect("deletes history file when close(deleteHistory=true)", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + process.emitData("bye\n"); + const path = yield* Path.Path; + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); + + yield* manager.close({ threadId: "thread-1", deleteHistory: true }); + expect( + yield* historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ).toBe(false); + }), + ); + + it.effect("closes all terminals for a thread when close omits terminalId", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager(); + yield* manager.open(openInput({ terminalId: "default" })); + yield* manager.open(openInput({ terminalId: "sidecar" })); + const defaultProcess = ptyAdapter.processes[0]; + const sidecarProcess = ptyAdapter.processes[1]; + expect(defaultProcess).toBeDefined(); + expect(sidecarProcess).toBeDefined(); + if (!defaultProcess || !sidecarProcess) return; + + defaultProcess.emitData("default\n"); + sidecarProcess.emitData("sidecar\n"); + const path = yield* Path.Path; + yield* waitFor( + multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); + yield* waitFor( + multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); + + yield* manager.close({ threadId: "thread-1", deleteHistory: true }); + + assert.equal(defaultProcess.killed, true); + assert.equal(sidecarProcess.killed, true); + expect( + yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ).toBe(false); + expect( + yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ).toBe(false); + }), + ); + + it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("10 millis"); + yield* Fiber.join(closeFiber); + + assert.equal(process.killSignals[0], "SIGTERM"); + expect(process.killSignals).toContain("SIGKILL"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("publishes closed events when terminals are explicitly closed", () => + Effect.gen(function* () { + const { manager, getEvents } = yield* createManager(); + yield* manager.open(openInput({ terminalId: "default" })); + yield* manager.open(openInput({ terminalId: "sidecar" })); + + yield* manager.close({ threadId: "thread-1" }); + + const closedEvents = (yield* getEvents).filter( + (event): event is Extract => event.type === "closed", + ); + expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); + }), + ); + + it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { + maxRetainedInactiveSessions: 1, + }); + + yield* manager.open(openInput({ threadId: "thread-1" })); + yield* manager.open(openInput({ threadId: "thread-2" })); + + const first = ptyAdapter.processes[0]; + const second = ptyAdapter.processes[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second) return; + + first.emitData("first-history\n"); + second.emitData("second-history\n"); + const path = yield* Path.Path; + yield* waitFor( + historyLogPath(logsDir, "thread-1").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); + first.emitExit({ exitCode: 0, signal: 0 }); + yield* Effect.sleep(Duration.millis(5)); + second.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map( + getEvents, + (events) => events.filter((event) => event.type === "exited").length === 2, + ), + ); + + const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" })); + const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" })); + + assert.equal(reopenedFirst.history, "first-history\n"); + assert.equal(reopenedSecond.history, ""); + }), + ); + + it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager(); + const path = yield* Path.Path; + const legacyPath = path.join(logsDir, "thread-1.log"); + const nextPath = yield* historyLogPath(logsDir); + yield* writeFileString(legacyPath, "legacy-line\n"); + + const snapshot = yield* manager.open(openInput()); + + assert.equal(snapshot.history, "legacy-line\n"); + expect(yield* pathExists(nextPath)).toBe(true); + expect(yield* readFileString(nextPath)).toBe("legacy-line\n"); + expect(yield* pathExists(legacyPath)).toBe(false); + }), + ); + + it.effect("retries with fallback shells when preferred shell spawn fails", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const missingShell = + platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => missingShell, + }); + ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); + + const snapshot = yield* manager.open(openInput()); + + assert.equal(snapshot.status, "running"); + expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); + expect(ptyAdapter.spawnInputs[0]?.shell).toBe( + platform === "win32" ? missingShell : "/definitely/missing-shell", + ); + + if (platform === "win32") { + expect( + ptyAdapter.spawnInputs.some( + (input) => + input.shell === "pwsh.exe" || + input.shell === "powershell.exe" || + input.shell === "cmd.exe", + ), + ).toBe(true); + } else { + expect( + ptyAdapter.spawnInputs + .slice(1) + .some((input) => input.shell !== "/definitely/missing-shell"), + ).toBe(true); + } + }), + ); + + it.effect("prefers PowerShell over ComSpec for Windows terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }).pipe(Effect.provide(withHostPlatform("win32"))); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs[0]).toEqual( + expect.objectContaining({ + shell: "pwsh.exe", + args: ["-NoLogo"], + }), + ); + }), + ); + + it.effect("falls back to built-in PowerShell by absolute path on Windows", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + const { manager } = yield* createManager(5, { + ptyAdapter, + shellResolver: () => "C:\\missing\\custom-shell.exe", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }).pipe(Effect.provide(withHostPlatform("win32"))); + ptyAdapter.spawnFailures.push( + new Error("spawn custom-shell.exe ENOENT"), + new Error("spawn pwsh.exe ENOENT"), + ); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "C:\\missing\\custom-shell.exe", + "pwsh.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ]); + expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); + expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); + }), + ); + + it.effect("filters app runtime env variables from terminal sessions", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PORT: "5173", + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://localhost:5173", + TEST_TERMINAL_KEEP: "keep-me", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.PORT).toBeUndefined(); + expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); + expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); + // Arbitrary host env vars must pass through — terminals inherit the + // user's environment apart from the explicit blocklist. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); + }), + ); + + it.effect("injects runtime env overrides into spawned terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open( + openInput({ + env: { + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + CUSTOM_FLAG: "1", + }, + }), + ); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo"); + assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a"); + assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); + }), + ); + + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => "/bin/zsh", + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); + }), + ); + + it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("hello from callback\n"); + + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data === "hello from callback\n"), + ), + "1200 millis", + ); + }), + ); + + it.effect("pushes PTY callbacks to direct event subscribers", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + const subscriberEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.subscribe((event) => + Ref.update(subscriberEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("hello from subscriber\n"); + + yield* waitFor( + Effect.map(Ref.get(subscriberEvents), (events) => + events.some( + (event) => event.type === "output" && event.data === "hello from subscriber\n", + ), + ), + "1200 millis", + ); + }), + ); + + it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () => + Effect.gen(function* () { + const { manager } = yield* createManager(); + yield* manager.open(openInput({ threadId: "existing-thread" })); + + const metadataEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.subscribeMetadata((event) => + Ref.update(metadataEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + const initialEvents = yield* Ref.get(metadataEvents); + expect(initialEvents[0]).toMatchObject({ + type: "snapshot", + terminals: [ + { + threadId: "existing-thread", + terminalId: DEFAULT_TERMINAL_ID, + }, + ], + }); + + yield* manager.open(openInput({ threadId: "new-thread" })); + + yield* waitFor( + Effect.map(Ref.get(metadataEvents), (events) => + events.some( + (event) => + event.type === "upsert" && + event.terminal.threadId === "new-thread" && + event.terminal.terminalId === DEFAULT_TERMINAL_ID, + ), + ), + "1200 millis", + ); + + yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID }); + + yield* waitFor( + Effect.map(Ref.get(metadataEvents), (events) => + events.some( + (event) => + event.type === "remove" && + event.threadId === "new-thread" && + event.terminalId === DEFAULT_TERMINAL_ID, + ), + ), + "1200 millis", + ); + }), + ); + + it.effect("removes terminal metadata subscriptions when initial delivery fails", () => + Effect.gen(function* () { + const { manager } = yield* createManager(); + yield* manager.open(openInput({ threadId: "existing-thread" })); + + const leakedLiveEvents = yield* Ref.make(0); + const exit = yield* Effect.exit( + manager.subscribeMetadata((event) => + event.type === "snapshot" + ? Effect.die("snapshot listener failed") + : Ref.update(leakedLiveEvents, (count) => count + 1), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + + yield* manager.open(openInput({ threadId: "new-thread" })); + expect(yield* Ref.get(leakedLiveEvents)).toBe(0); + }), + ); + + it.effect( + "streams attach snapshots followed by live events without duplicate start snapshots", + () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + expect(yield* Ref.get(attachEvents)).toMatchObject([ + { + type: "snapshot", + snapshot: { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }, + }, + ]); + + process.emitData("hello from attach\n"); + + yield* waitFor( + Effect.map(Ref.get(attachEvents), (events) => + events.some((event) => event.type === "output" && event.data === "hello from attach\n"), + ), + "1200 millis", + ); + + const events = yield* Ref.get(attachEvents); + expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1); + }), + ); + + it.effect("buffers attach output delivered during the initial snapshot callback", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + yield* manager.open(openInput()); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Effect.gen(function* () { + yield* Ref.update(attachEvents, (events) => [...events, event]); + if (event.type === "snapshot") { + yield* Effect.sync(() => process.emitData("during snapshot\n")); + yield* Effect.yieldNow; + } + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* waitFor( + Effect.map(Ref.get(attachEvents), (events) => + events.some((event) => event.type === "output" && event.data === "during snapshot\n"), + ), + "1200 millis", + ); + + expect(yield* Ref.get(attachEvents)).toMatchObject([ + { type: "snapshot" }, + { type: "output", data: "during snapshot\n" }, + ]); + }), + ); + + it.effect("preserves queued PTY output ordering through exit callbacks", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("first\n"); + process.emitData("second\n"); + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => { + const relevant = events.filter( + (event) => event.type === "output" || event.type === "exited", + ); + return relevant.length >= 3; + }), + "1200 millis", + ); + + const relevant = (yield* getEvents).filter( + (event) => event.type === "output" || event.type === "exited", + ); + expect(relevant).toEqual([ + expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), + expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), + expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), + ]); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); + expect(snapshot).toBeDefined(); + if (!snapshot || snapshot.type !== "snapshot") return; + expect(snapshot.snapshot.sequence).toBe(4); + }), + ); + + it.effect("scoped runtime shutdown stops active terminals cleanly", () => + Effect.gen(function* () { + const scope = yield* Scope.make("sequential"); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 10, + }).pipe(Effect.provideService(Scope.Scope, scope)); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("10 millis"); + yield* Fiber.join(closeScope); + + assert.equal(process.killSignals[0], "SIGTERM"); + expect(process.killSignals).toContain("SIGKILL"); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fa9d07ebc978896841b3757ec0133ae40266882 --- /dev/null +++ b/apps/server/src/terminal/Manager.ts @@ -0,0 +1,2571 @@ +/** + * 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, + TerminalError, + TerminalHistoryError, + TerminalNotRunningError, + TerminalSessionLookupError, + 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, + TerminalError, + TerminalHistoryError, + TerminalNotRunningError, + TerminalSessionLookupError, +}; + +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", + { + 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", + { + 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; + + /** + * 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, + ) => Effect.Effect<() => void, TerminalError>; + + /** + * Write input bytes to a terminal session. + */ + readonly write: (input: TerminalWriteInput) => Effect.Effect; + + /** + * Resize the PTY backing a terminal session. + */ + readonly resize: (input: TerminalResizeInput) => Effect.Effect; + + /** + * Clear terminal output history. + */ + readonly clear: (input: TerminalClearInput) => Effect.Effect; + + /** + * Restart a terminal session in place. + * + * Always resets history before spawning the new process. + */ + readonly restart: ( + input: TerminalRestartInput, + ) => Effect.Effect; + + /** + * Close an active terminal session. + * + * When `terminalId` is omitted, closes all sessions for the thread. + */ + readonly close: (input: TerminalCloseInput) => Effect.Effect; + + /** + * Subscribe to terminal runtime events with a direct callback. + * + * Returns an unsubscribe function. + */ + readonly subscribe: ( + listener: (event: TerminalEvent) => Effect.Effect, + ) => Effect.Effect<() => void>; + + /** + * Subscribe to lightweight terminal metadata with an initial full snapshot. + * + * Returns an unsubscribe function. + */ + 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; +} + +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; + 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 | 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; + 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, + 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 { + 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") + ); +} + +function parseFirstChildPidFromPgrep(stdout: string): number | null { + for (const line of stdout.split(/\r?\n/g)) { + const n = Number.parseInt(line.trim(), 10); + if (Number.isInteger(n) && n > 0) { + return n; + } + } + return null; +} + +function windowsInspectSubprocess( + terminalPid: number, + platform: NodeJS.Platform, +): Effect.Effect< + TerminalSubprocessInspectResult, + TerminalSubprocessCheckError, + ProcessRunner.ProcessRunner +> { + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; + return Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner.run({ + // powershell.exe is a real executable — never spawn it through cmd.exe + // shell mode, which would re-tokenize the `-Command` payload (pipes, + // semicolons) before PowerShell ever sees it. + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: "1500 millis", + maxOutputBytes: 32_768, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }); + }).pipe( + Effect.map((result) => { + if (result.code !== 0) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; + } + const processNameById = new Map(); + const childrenByParent = new Map(); + for (const line of result.stdout.split(/\r?\n/g)) { + const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const parentPid = Number(parentPidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; + processNameById.set(pid, nameRaw?.trim() ?? ""); + const children = childrenByParent.get(parentPid) ?? []; + children.push(pid); + childrenByParent.set(parentPid, children); + } + const directChildren = childrenByParent.get(terminalPid) ?? []; + const childPid = directChildren[0]; + if (childPid === undefined) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; + } + const processIds = new Set([terminalPid]); + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const pid of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(pid)) continue; + processIds.add(pid); + pending.push(pid); + } + } + const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], + } as const; + }), + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + cause, + terminalPid, + command: "powershell", + }), + ), + ); +} + +const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( + terminalPid: number, + platform: NodeJS.Platform, +): Effect.fn.Return< + TerminalSubprocessInspectResult, + TerminalSubprocessCheckError, + ProcessRunner.ProcessRunner +> { + const processRunner = yield* ProcessRunner.ProcessRunner; + const runPgrep = processRunner + .run({ + command: "pgrep", + args: ["-P", String(terminalPid)], + timeout: "1 second", + maxOutputBytes: 32_768, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + cause, + terminalPid, + command: "pgrep", + }), + ), + ); + + const runPs = processRunner + .run({ + command: "ps", + args: ["-eo", "pid=,ppid="], + timeout: "1 second", + maxOutputBytes: 262_144, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + cause, + terminalPid, + command: "ps", + }), + ), + ); + + let childPid: number | null = null; + + const pgrepResult = yield* Effect.exit(runPgrep); + if (pgrepResult._tag === "Success") { + if (pgrepResult.value.code === 0) { + childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); + } else if (pgrepResult.value.code === 1) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + } + + if (childPid === null) { + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Failure" || psResult.value.code !== 0) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + 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) { + childPid = pid; + break; + } + } + } + + if (childPid === null) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + + const runComm = processRunner.run({ + command: "ps", + args: ["-p", String(childPid), "-o", "comm="], + timeout: "1 second", + maxOutputBytes: 8_192, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }); + + const commResult = yield* Effect.exit(runComm); + let rawComm: string | null = null; + if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { + rawComm = commResult.value.stdout.trim(); + } + + if (!rawComm || rawComm.length === 0) { + const runArgs = processRunner.run({ + command: "ps", + args: ["-p", String(childPid), "-o", "args="], + timeout: "1 second", + maxOutputBytes: 16_384, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }); + const argsResult = yield* Effect.exit(runArgs); + if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { + const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; + rawComm = first.length > 0 ? first : null; + } + } + + const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; + const processIds = new Set([terminalPid]); + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Success" && psResult.value.code === 0) { + const childrenByParent = new Map(); + 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; + const children = childrenByParent.get(ppid) ?? []; + children.push(pid); + childrenByParent.set(ppid, children); + } + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const child of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(child)) continue; + processIds.add(child); + pending.push(child); + } + } + } else { + processIds.add(childPid); + } + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], + }; +}); + +function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { + return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { + if (!Number.isInteger(terminalPid) || terminalPid <= 0) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + if (platform === "win32") { + return yield* windowsInspectSubprocess(terminalPid, platform); + } + return yield* posixInspectSubprocess(terminalPid, platform); + }); +} + +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) }; + } + + 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, index + 2); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); + if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { + 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, index + 1); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); + if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { + append(sequence); + } + index = terminatorIndex; + continue; + } + + append(input[index] ?? ""); + index += 1; + } + + return { visibleText, pendingControlSequence: "" }; +} + +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 | 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 | 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; + ptyAdapter: PtyAdapter.PtyAdapter["Service"]; + shellResolver?: () => string; + env?: NodeJS.ProcessEnv; + subprocessInspector?: TerminalSubprocessInspector; + 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; +} + +export const make = Effect.fn("TerminalManager.make")(function* () { + const { terminalLogsDir } = yield* ServerConfig.ServerConfig; + const ptyAdapter = yield* PtyAdapter.PtyAdapter; + const portDiscovery = yield* PortScanner.PortDiscovery; + return yield* makeWithOptions({ + logsDir: terminalLogsDir, + ptyAdapter, + registerTerminalProcesses: portDiscovery.registerTerminalProcesses, + unregisterTerminal: portDiscovery.unregisterTerminal, + }); +}); + +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 platform = yield* HostProcessPlatform; + // Terminals must inherit the user's full environment (minus the blocklist + // applied in createTerminalSpawnEnv) — an allowlist here silently strips + // things like PSModulePath, DISPLAY, proxies, and toolchain variables. + // `options.env` is the test seam. + const baseEnv = options.env ?? process.env; + const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); + const processRunner = yield* ProcessRunner.ProcessRunner; + const subprocessInspector = + options.subprocessInspector ?? + ((terminalPid) => + defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + )); + 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(), + }); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const workerScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); + + const publishEvent = (event: TerminalEvent) => + Effect.gen(function* () { + for (const listener of terminalEventListeners) { + yield* listener(event).pipe(Effect.ignoreCause({ log: true })); + } + }); + + const historyPath = (threadId: string, terminalId: string) => { + const threadPart = toSafeThreadId(threadId); + if (terminalId === DEFAULT_TERMINAL_ID) { + return path.join(logsDir, `${threadPart}.log`); + } + return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); + }; + + const legacyHistoryPath = (threadId: string) => + path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); + + const toTerminalHistoryError = + (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => + (cause: unknown) => + new TerminalHistoryError({ + operation, + threadId, + terminalId, + cause, + }); + + const readManagerState = SynchronizedRef.get(managerStateRef); + + const modifyManagerState = ( + f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], + ) => SynchronizedRef.modify(managerStateRef, f); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = ( + threadId: string, + effect: Effect.Effect, + ): Effect.Effect => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + 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) => { + 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, + ) { + 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", + error: error.message, + }).pipe(Effect.as(false)), + ), + ); + if (!terminated) { + return; + } + + yield* Effect.sleep(processKillGraceMs); + + 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", + error: error.message, + }), + ), + ); + }); + + 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).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: string, + ) { + 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: string, + ) { + yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { + history, + immediate: true, + }); + yield* flushPersist(threadId, terminalId); + }); + + const readHistory = Effect.fn("terminal.readHistory")(function* ( + threadId: string, + terminalId: string, + ) { + const nextPath = historyPath(threadId, terminalId); + if ( + yield* fileSystem + .exists(nextPath) + .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) + ) { + const raw = yield* fileSystem + .readFileString(nextPath) + .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); + const capped = capHistory(raw, historyLineLimit); + if (capped !== raw) { + yield* fileSystem + .writeFileString(nextPath, capped) + .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); + } + return capped; + } + + if (terminalId !== DEFAULT_TERMINAL_ID) { + return ""; + } + + const legacyPath = legacyHistoryPath(threadId); + if ( + !(yield* fileSystem + .exists(legacyPath) + .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) + ) { + return ""; + } + + const raw = yield* fileSystem + .readFileString(legacyPath) + .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + const capped = capHistory(raw, historyLineLimit); + yield* fileSystem + .writeFileString(nextPath, capped) + .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + yield* fileSystem.remove(legacyPath, { force: true }).pipe( + Effect.catch((cleanupError) => + Effect.logWarning("failed to remove legacy terminal history", { + threadId, + error: cleanupError, + }), + ), + ); + return capped; + }); + + 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) { + 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 threadPrefix = `${toSafeThreadId(threadId)}_`; + const entries = yield* fileSystem + .readDirectory(logsDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + yield* Effect.forEach( + entries.filter( + (name) => + name === `${toSafeThreadId(threadId)}.log` || + name === `${legacySafeThreadId(threadId)}.log` || + name.startsWith(threadPrefix), + ), + (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.mapError( + (cause) => + new TerminalCwdError({ + cwd, + reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", + cause, + }), + ), + ); + if (stats.type !== "Directory") { + return yield* new TerminalCwdError({ + cwd, + reason: "notDirectory", + }); + } + }); + + 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) => + Option.match(session, { + onNone: () => + Effect.fail( + new TerminalSessionLookupError({ + threadId, + terminalId, + }), + ), + onSome: Effect.succeed, + }), + ); + }); + + 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); + } + + 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 = capHistory( + `${session.history}${sanitized.visibleText}`, + historyLineLimit, + ); + } + 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 attempt = yield* Effect.result( + options.ptyAdapter.spawn({ + shell: candidate.shell, + ...(candidate.args ? { args: candidate.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); + const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); + ptyProcess = spawnResult.process; + startedShell = spawnResult.shellLabel; + + const processPid = ptyProcess.pid; + const unsubscribeData = ptyProcess.onData((data) => { + if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { + return; + } + runFork(drainProcessEvents(session, processPid)); + }); + const unsubscribeExit = ptyProcess.onExit((event) => { + if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { + return; + } + runFork(drainProcessEvents(session, processPid)); + }); + + let eventStamp: ReturnType = { + updatedAt: session.updatedAt, + sequence: session.eventSequence, + }; + yield* modifyManagerState((state) => { + session.process = ptyProcess; + session.pid = processPid; + session.status = "running"; + session.unsubscribeData = unsubscribeData; + session.unsubscribeExit = unsubscribeExit; + eventStamp = advanceEventSequence(session); + return [undefined, state] as const; + }); + + yield* publishEvent({ + type: eventType, + threadId: session.threadId, + terminalId: session.terminalId, + sequence: eventStamp.sequence, + snapshot: snapshot(session), + }); + }), + ), + ), + ); + + 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, + error: message, + ...(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) => { + 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); + } + }); + + 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), + ); + + if (runningSessions.length === 0) { + return; + } + + const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( + session: TerminalSessionState & { pid: number }, + ) { + const terminalPid = session.pid; + const inspectResult = yield* subprocessInspector(terminalPid).pipe( + Effect.map(Option.some), + 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)) { + 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.status !== "running" || + liveSession.value.pid !== terminalPid || + (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); + + 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); + } + }); + + yield* Effect.forEach(runningSessions, checkSubprocessActivity, { + concurrency: "unbounded", + discard: true, + }); + }); + + const hasRunningSessions = readManagerState.pipe( + Effect.map((state) => + [...state.sessions.values()].some((session) => session.status === "running"), + ), + ); + + yield* Effect.forever( + hasRunningSessions.pipe( + Effect.flatMap((active) => + active + ? pollSubprocessActivity().pipe( + Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), + ) + : 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 openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existing = yield* getSession(input.threadId, terminalId); + if (Option.isNone(existing)) { + yield* flushPersist(input.threadId, terminalId); + const history = yield* readHistory(input.threadId, terminalId); + 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, + 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); + return [undefined, { ...state, sessions }] as const; + }); + + yield* evictInactiveSessionsIfNeeded(); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "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 = ""; + 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 = ""; + liveSession.pendingHistoryControlSequence = ""; + liveSession.pendingProcessEvents = []; + liveSession.pendingProcessEventIndex = 0; + liveSession.processEventDrainRunning = false; + yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); + } + + if (!liveSession.process) { + yield* startSession( + liveSession, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: liveSession.worktreePath, + cols: targetCols, + rows: targetRows, + ...(input.env ? { env: input.env } : {}), + }, + "started", + ); + return snapshot(liveSession); + } + + if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { + liveSession.cols = targetCols; + liveSession.rows = targetRows; + liveSession.updatedAt = yield* nowIso; + liveSession.process.resize(targetCols, targetRows); + } + + return snapshot(liveSession); + }); + + const open: TerminalManager["Service"]["open"] = (input) => + withThreadLock(input.threadId, openLocked(input)); + + const openOrAttachForStream = (input: TerminalAttachInput) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const terminalId = input.terminalId; + const existing = yield* getSession(input.threadId, terminalId); + + if (Option.isNone(existing)) { + if (!input.cwd) { + return yield* new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId, + }); + } + + return yield* openLocked({ + ...input, + terminalId, + cwd: input.cwd, + }); + } + + const session = existing.value; + const targetCols = input.cols ?? session.cols; + const targetRows = input.rows ?? session.rows; + + if (!session.process && input.cwd && input.restartIfNotRunning === true) { + return yield* openLocked({ + ...input, + terminalId, + cwd: input.cwd, + }); + } + + if ( + session.process && + session.status === "running" && + (session.cols !== targetCols || session.rows !== targetRows) + ) { + session.cols = targetCols; + session.rows = targetRows; + session.updatedAt = yield* nowIso; + yield* Effect.sync(() => session.process?.resize(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) => + Effect.sync(() => { + terminalEventListeners.add(listener); + return () => { + terminalEventListeners.delete(listener); + }; + }); + + const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => { + let unsubscribe: (() => void) | null = null; + + return Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; + + unsubscribe = yield* subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } + + const attachEvent = terminalEventToAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }); + + const initialSnapshot = yield* openOrAttachForStream(input); + + yield* listener({ + type: "snapshot", + snapshot: initialSnapshot, + }); + + for (const event of bufferedEvents) { + if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { + continue; + } + + const attachEvent = terminalEventToAttachEvent(event); + if (attachEvent) { + yield* listener(attachEvent); + } + } + + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), + ), + ), + ); + }; + + 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, + }); + } + yield* Effect.sync(() => process.write(input.data)); + }); + + const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { + const session = yield* getSession(input.threadId, input.terminalId); + // ResizeObserver traffic can already be in flight when the UI closes the session. + if (Option.isNone(session)) { + return; + } + const process = session.value.process; + if (!process || session.value.status !== "running") { + return; + } + session.value.cols = input.cols; + session.value.rows = input.rows; + session.value.updatedAt = yield* nowIso; + yield* Effect.sync(() => process.resize(input.cols, input.rows)); + }); + + const resize: TerminalManager["Service"]["resize"] = (input) => + withThreadLock(input.threadId, resizeLocked(input)); + + 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 = ""; + 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 restart: TerminalManager["Service"]["restart"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + 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, + 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); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history = ""; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }), + ); + + 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); + } + }), + ); + + return TerminalManager.of({ + open, + attachStream, + write, + resize, + clear, + restart, + close, + subscribe, + subscribeMetadata, + }); +}); + +export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..798e96e3a26c3b26f4287e9cd4e51a4de7f2742e --- /dev/null +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -0,0 +1,58 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import * as NodePtyAdapter from "./NodePtyAdapter.ts"; +import * as PtyAdapter from "./PtyAdapter.ts"; + +const spawn = vi.fn(() => ({ + pid: 42, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("node-pty", () => ({ spawn })); + +const testLayer = NodePtyAdapter.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), +); + +it.effect("spawns through the public adapter with the provided host references", () => + Effect.gen(function* () { + const adapter = yield* PtyAdapter.PtyAdapter; + const process = yield* adapter.spawn({ + shell: "powershell.exe", + args: ["-NoLogo"], + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + }); + + assert.equal(process.pid, 42); + assert.equal(spawn.mock.calls.length, 1); + assert.deepEqual(spawn.mock.calls[0], [ + "powershell.exe", + ["-NoLogo"], + { + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + name: "xterm-color", + }, + ]); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7b5406e7b9e06c61e5d152661d73fd0d2193934 --- /dev/null +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -0,0 +1,139 @@ +import { createRequire } from "node:module"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as PtyAdapter from "./PtyAdapter.ts"; + +let didEnsureSpawnHelperExecutable = false; + +const resolveNodePtySpawnHelperPath = Effect.gen(function* () { + const requireForNodePty = createRequire(import.meta.url); + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + + const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); + const packageDir = path.dirname(packageJsonPath); + const candidates = [ + path.join(packageDir, "build", "Release", "spawn-helper"), + path.join(packageDir, "build", "Debug", "spawn-helper"), + path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), + ]; + + for (const candidate of candidates) { + if (yield* fs.exists(candidate)) { + return candidate; + } + } + return null; +}).pipe(Effect.orElseSucceed(() => null)); + +const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + if (platform === "win32") return; + if (didEnsureSpawnHelperExecutable) return; + + const helperPath = yield* resolveNodePtySpawnHelperPath; + if (!helperPath) return; + didEnsureSpawnHelperExecutable = true; + + if (!(yield* fs.exists(helperPath))) { + return; + } + + // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. + yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); +}); + +class NodePtyProcess implements PtyAdapter.PtyProcess { + private readonly process: import("node-pty").IPty; + + constructor(process: import("node-pty").IPty) { + this.process = process; + } + + get pid(): number { + return this.process.pid; + } + + write(data: string): void { + this.process.write(data); + } + + resize(cols: number, rows: number): void { + this.process.resize(cols, rows); + } + + kill(signal?: string): void { + this.process.kill(signal); + } + + onData(callback: (data: string) => void): () => void { + const disposable = this.process.onData(callback); + return () => { + disposable.dispose(); + }; + } + + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { + const disposable = this.process.onExit((event) => { + callback({ + exitCode: event.exitCode, + signal: event.signal ?? null, + }); + }); + return () => { + disposable.dispose(); + }; + } +} + +export const make = Effect.fn("NodePtyAdapter.make")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + + const nodePty = yield* Effect.promise(() => import("node-pty")); + + const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( + ensureNodePtySpawnHelperExecutable().pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, architecture), + Effect.orElseSucceed(() => undefined), + ), + ); + + return PtyAdapter.PtyAdapter.of({ + spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { + yield* ensureNodePtySpawnHelperExecutableCached; + const ptyProcess = yield* Effect.try({ + try: () => + nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: platform === "win32" ? "xterm-color" : "xterm-256color", + }), + catch: (cause) => + new PtyAdapter.PtySpawnError({ + adapter: "node-pty", + shell: input.shell, + cause, + }), + }); + return new NodePtyProcess(ptyProcess); + }), + }); +}); + +export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/PtyAdapter.ts b/apps/server/src/terminal/PtyAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..dafb6f12f4fe2f8d76fcdd7c5b8ad267b0234f58 --- /dev/null +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -0,0 +1,68 @@ +/** + * PtyAdapter - Terminal PTY adapter service contract. + * + * Defines the process primitives required by terminal session management + * without binding to a specific PTY implementation. + * + * @module PtyAdapter + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +/** + * PtySpawnError - Error type for PTY spawn failures. + */ +export class PtySpawnError extends Schema.TaggedErrorClass()("PtySpawnError", { + adapter: Schema.String, + shell: Schema.optional(Schema.String), + attemptedShells: Schema.optional(Schema.Array(Schema.String)), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + const shell = this.shell === undefined ? "" : ` '${this.shell}'`; + const attemptedShells = + this.attemptedShells === undefined || this.attemptedShells.length === 0 + ? "" + : ` Tried shells: ${this.attemptedShells.join(", ")}.`; + const causeMessage = + this.cause instanceof Error && this.cause.message.length > 0 ? ` ${this.cause.message}` : ""; + return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}${causeMessage}`; + } +} + +export interface PtyExitEvent { + exitCode: number; + signal: number | null; +} + +export interface PtyProcess { + readonly pid: number; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(signal?: string): void; + onData(callback: (data: string) => void): () => void; + onExit(callback: (event: PtyExitEvent) => void): () => void; +} + +export interface PtySpawnInput { + shell: string; + args?: string[]; + cwd: string; + cols: number; + rows: number; + env: NodeJS.ProcessEnv; +} + +/** + * PtyAdapter - Service tag for PTY process integration. + */ +export class PtyAdapter extends Context.Service< + PtyAdapter, + { + /** + * Spawn a PTY process for a terminal session. + */ + readonly spawn: (input: PtySpawnInput) => Effect.Effect; + } +>()("t3/terminal/PtyAdapter") {} diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts deleted file mode 100644 index 51c66f49f7cc09260e09ed0bb2049d30d9be4284..0000000000000000000000000000000000000000 --- a/apps/server/src/terminal/Services/Manager.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * TerminalManager - Terminal session orchestration service interface. - * - * Owns terminal lifecycle operations, output fanout, and session state - * transitions for thread-scoped terminals. - * - * @module TerminalManager - */ -import { - TerminalAttachInput, - TerminalAttachStreamEvent, - TerminalClearInput, - TerminalCloseInput, - TerminalEvent, - TerminalCwdError, - TerminalError, - TerminalHistoryError, - TerminalMetadataStreamEvent, - TerminalNotRunningError, - TerminalOpenInput, - TerminalResizeInput, - TerminalRestartInput, - TerminalSessionSnapshot, - TerminalSessionLookupError, - TerminalSessionStatus, - TerminalWriteInput, -} from "@t3tools/contracts"; -import type { PtyProcess } from "./PTY.ts"; -import * as Effect from "effect/Effect"; -import * as Context from "effect/Context"; - -export { - TerminalCwdError, - TerminalError, - TerminalHistoryError, - TerminalNotRunningError, - TerminalSessionLookupError, -}; - -export interface TerminalSessionState { - threadId: string; - terminalId: string; - cwd: string; - worktreePath: string | null; - status: TerminalSessionStatus; - pid: number | null; - history: string; - pendingHistoryControlSequence: string; - 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 | null; -} - -export interface ShellCandidate { - shell: string; - args?: string[]; -} - -export interface TerminalStartInput extends TerminalOpenInput { - cols: number; - rows: number; -} - -/** - * TerminalManagerShape - Service API for terminal session lifecycle operations. - */ -export interface TerminalManagerShape { - /** - * 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; - - /** - * 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, - ) => Effect.Effect<() => void, TerminalError>; - - /** - * Write input bytes to a terminal session. - */ - readonly write: (input: TerminalWriteInput) => Effect.Effect; - - /** - * Resize the PTY backing a terminal session. - */ - readonly resize: (input: TerminalResizeInput) => Effect.Effect; - - /** - * Clear terminal output history. - */ - readonly clear: (input: TerminalClearInput) => Effect.Effect; - - /** - * Restart a terminal session in place. - * - * Always resets history before spawning the new process. - */ - readonly restart: ( - input: TerminalRestartInput, - ) => Effect.Effect; - - /** - * Close an active terminal session. - * - * When `terminalId` is omitted, closes all sessions for the thread. - */ - readonly close: (input: TerminalCloseInput) => Effect.Effect; - - /** - * Subscribe to terminal runtime events with a direct callback. - * - * Returns an unsubscribe function. - */ - readonly subscribe: ( - listener: (event: TerminalEvent) => Effect.Effect, - ) => Effect.Effect<() => void>; - - /** - * Subscribe to lightweight terminal metadata with an initial full snapshot. - * - * Returns an unsubscribe function. - */ - readonly subscribeMetadata: ( - listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, - ) => Effect.Effect<() => void>; -} - -/** - * TerminalManager - Service tag for terminal session orchestration. - */ -export class TerminalManager extends Context.Service()( - "t3/terminal/Services/Manager/TerminalManager", -) {} diff --git a/apps/server/src/terminal/Services/PTY.ts b/apps/server/src/terminal/Services/PTY.ts deleted file mode 100644 index 7af78810efa7a470f40c2487be7bd8f0591cf444..0000000000000000000000000000000000000000 --- a/apps/server/src/terminal/Services/PTY.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * PtyAdapter - Terminal PTY adapter service contract. - * - * Defines the process primitives required by terminal session management - * without binding to a specific PTY implementation. - * - * @module PtyAdapter - */ -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; - -/** - * PtyError - Error type for PTY adapter operations. - */ -export class PtySpawnError extends Schema.TaggedErrorClass()("PtySpawnError", { - adapter: Schema.String, - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} - -export interface PtyExitEvent { - exitCode: number; - signal: number | null; -} - -export interface PtyProcess { - readonly pid: number; - write(data: string): void; - resize(cols: number, rows: number): void; - kill(signal?: string): void; - onData(callback: (data: string) => void): () => void; - onExit(callback: (event: PtyExitEvent) => void): () => void; -} - -export interface PtySpawnInput { - shell: string; - args?: string[]; - cwd: string; - cols: number; - rows: number; - env: NodeJS.ProcessEnv; -} - -/** - * PtyAdapterShape - Service API for spawning and controlling PTY processes. - */ -export interface PtyAdapterShape { - /** - * Spawn a PTY process for a terminal session. - */ - spawn(input: PtySpawnInput): Effect.Effect; -} - -/** - * PtyAdapter - Service tag for PTY process integration. - */ -export class PtyAdapter extends Context.Service()( - "t3/terminal/Services/PTY/PtyAdapter", -) {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 29ade95d3953ea59270dbbab2ff130a4d3579a07..01337541cd1905d7bca78649d46452a66ea5ebbc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,45 +56,44 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; -import { ServerConfig } from "./config.ts"; -import { Keybindings } from "./keybindings.ts"; +import * as CheckpointDiffQuery from "./checkpointing/Services/CheckpointDiffQuery.ts"; +import * as ServerConfig from "./config.ts"; +import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; -import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; -import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; -import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; -import { TerminalManager } from "./terminal/Services/Manager.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; -import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; -import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; -import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; -import { GitWorkflowService } from "./git/GitWorkflowService.ts"; -import { ReviewService } from "./review/ReviewService.ts"; -import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; -import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import * as WorkspaceFileSystem from "./workspace/Services/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./workspace/Services/WorkspacePaths.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as ReviewService from "./review/ReviewService.ts"; +import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts"; +import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; +import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; -import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; @@ -109,7 +108,7 @@ import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); -const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); +const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -248,35 +247,36 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = (currentSession: AuthenticatedSession) => +const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; - const checkpointDiffQuery = yield* CheckpointDiffQuery; - const keybindings = yield* Keybindings; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; - const gitWorkflow = yield* GitWorkflowService; - const review = yield* ReviewService; - const vcsProvisioning = yield* VcsProvisioningService; - const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; - const terminalManager = yield* TerminalManager; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const review = yield* ReviewService.ReviewService; + const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const terminalManager = yield* TerminalManager.TerminalManager; const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; - const providerRegistry = yield* ProviderRegistry; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; - const config = yield* ServerConfig; - const lifecycleEvents = yield* ServerLifecycleEvents; - const serverSettings = yield* ServerSettingsService; - const startup = yield* ServerRuntimeStartup; + const config = yield* ServerConfig.ServerConfig; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const repositoryIdentityResolver = yield* RepositoryIdentityResolver; - const serverEnvironment = yield* ServerEnvironment; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const repositoryIdentityResolver = + yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; const automaticGitFetchInterval = serverSettings.getSettings.pipe( @@ -287,7 +287,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), ), ); - const sourceControlRepositories = yield* SourceControlRepositoryService; + const sourceControlRepositories = + yield* SourceControlRepositoryService.SourceControlRepositoryService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -766,7 +767,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; - const settings = redactServerSettingsForClient(yield* serverSettings.getSettings); + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -1068,7 +1071,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.serverGetSettings]: (_input) => observeRpcEffect( WS_METHODS.serverGetSettings, - serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)), + serverSettings.getSettings.pipe( + Effect.map(ServerSettings.redactServerSettingsForClient), + ), { "rpc.aggregate": "server", }, @@ -1076,7 +1081,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.serverUpdateSettings]: ({ patch }) => observeRpcEffect( WS_METHODS.serverUpdateSettings, - serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)), + serverSettings + .updateSettings(patch) + .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), { "rpc.aggregate": "server", }, @@ -1559,7 +1566,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => redactServerSettingsForClient(settings)), + Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const,