import fs from "node:fs";import os from "node:os";import path from "node:path"; import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput,} from "@t3tools/contracts";import { afterEach, describe, expect, it } from "vitest"; import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY";import { TerminalManagerRuntime } from "./Manager";import { Effect, Encoding } from "effect"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array<string | undefined> = []; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; constructor(readonly pid: number) {} 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 nextPid = 9000; spawn(input: PtySpawnInput): Effect.Effect<PtyProcess> { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { throw failure; } const process = new FakePtyProcess(this.nextPid++); this.processes.push(process); return Effect.succeed(process); }} function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> { const started = Date.now(); return new Promise((resolve, reject) => { const poll = () => { if (predicate()) { resolve(); return; } if (Date.now() - started > timeoutMs) { reject(new Error("Timed out waiting for condition")); return; } setTimeout(poll, 15); }; poll(); });} function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { return { threadId: "thread-1", cwd: process.cwd(), cols: 100, rows: 24, ...overrides, };} function historyLogName(threadId: string): string { return `terminal_${Encoding.encodeBase64Url(threadId)}.log`;} function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; if (terminalId === DEFAULT_TERMINAL_ID) { return `${threadPart}.log`; } return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`;} function historyLogPath(logsDir: string, threadId = "thread-1"): string { return path.join(logsDir, historyLogName(threadId));} function multiTerminalHistoryLogPath( logsDir: string, threadId = "thread-1", terminalId = "default",): string { return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));} describe("TerminalManager", () => { const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); } }); function makeManager( historyLineLimit = 5, options: { shellResolver?: () => string; subprocessChecker?: (terminalPid: number) => Promise<boolean>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; } = {}, ) { const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); tempDirs.push(logsDir); const ptyAdapter = new FakePtyAdapter(); const manager = new TerminalManagerRuntime({ logsDir, ptyAdapter, historyLineLimit, shellResolver: options.shellResolver ?? (() => "/bin/bash"), ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), ...(options.subprocessPollIntervalMs ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), ...(options.processKillGraceMs ? { processKillGraceMs: options.processKillGraceMs } : {}), ...(options.maxRetainedInactiveSessions ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), }); return { logsDir, ptyAdapter, manager }; } it("spawns lazily and reuses running terminal per thread", async () => { const { manager, ptyAdapter } = makeManager(); const [first, second] = await Promise.all([ manager.open(openInput()), manager.open(openInput()), ]); const third = await manager.open(openInput()); expect(first.threadId).toBe("thread-1"); expect(first.terminalId).toBe("default"); expect(second.threadId).toBe("thread-1"); expect(third.threadId).toBe("thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); manager.dispose(); }); it("forwards write and resize to active pty process", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; await manager.write({ threadId: "thread-1", data: "ls\n" }); await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 }); expect(process.writes).toEqual(["ls\n"]); expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); manager.dispose(); }); it("resizes running terminal on open when a different size is requested", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ cols: 100, rows: 24 })); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; await manager.open(openInput({ cols: 140, rows: 40 })); expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); manager.dispose(); }); it("preserves existing terminal size on open when size is omitted", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ cols: 100, rows: 24 })); const ptyProcess = ptyAdapter.processes[0]; expect(ptyProcess).toBeDefined(); if (!ptyProcess) return; await manager.open({ threadId: "thread-1", cwd: globalThis.process.cwd(), }); expect(ptyProcess.resizeCalls).toEqual([]); ptyProcess.emitExit({ exitCode: 0, signal: 0 }); await manager.open({ threadId: "thread-1", cwd: globalThis.process.cwd(), }); const resumedSpawn = ptyAdapter.spawnInputs[1]; expect(resumedSpawn).toBeDefined(); if (!resumedSpawn) return; expect(resumedSpawn.cols).toBe(100); expect(resumedSpawn.rows).toBe(24); manager.dispose(); }); it("uses default dimensions when opening a new terminal without size hints", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open({ threadId: "thread-1", cwd: process.cwd(), }); const spawned = ptyAdapter.spawnInputs[0]; expect(spawned).toBeDefined(); if (!spawned) return; expect(spawned.cols).toBe(120); expect(spawned.rows).toBe(30); manager.dispose(); }); it("supports multiple terminals per thread with isolated sessions", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ terminalId: "default" })); await 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; await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); await 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); manager.dispose(); }); it("clears transcript and emits cleared event", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); const events: TerminalEvent[] = []; manager.on("event", (event) => { events.push(event); }); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("hello\n"); await waitFor(() => fs.existsSync(historyLogPath(logsDir))); await manager.clear({ threadId: "thread-1" }); await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); expect(events.some((event) => event.type === "cleared")).toBe(true); expect( events.some( (event) => event.type === "cleared" && event.threadId === "thread-1" && event.terminalId === "default", ), ).toBe(true); manager.dispose(); }); it("restarts terminal with empty transcript and respawns pty", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); await manager.open(openInput()); const firstProcess = ptyAdapter.processes[0]; expect(firstProcess).toBeDefined(); if (!firstProcess) return; firstProcess.emitData("before restart\n"); await waitFor(() => fs.existsSync(historyLogPath(logsDir))); const snapshot = await manager.restart(openInput()); expect(snapshot.history).toBe(""); expect(snapshot.status).toBe("running"); expect(ptyAdapter.spawnInputs).toHaveLength(2); await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); manager.dispose(); }); it("emits exited event and reopens with clean transcript after exit", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); const events: TerminalEvent[] = []; manager.on("event", (event) => { events.push(event); }); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("old data\n"); await waitFor(() => fs.existsSync(historyLogPath(logsDir))); process.emitExit({ exitCode: 0, signal: 0 }); await waitFor(() => events.some((event) => event.type === "exited")); const reopened = await manager.open(openInput()); expect(reopened.history).toBe(""); expect(ptyAdapter.spawnInputs).toHaveLength(2); expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); manager.dispose(); }); it("emits subprocess activity events when child-process state changes", async () => { let hasRunningSubprocess = false; const { manager } = makeManager(5, { subprocessChecker: async () => hasRunningSubprocess, subprocessPollIntervalMs: 20, }); const events: TerminalEvent[] = []; manager.on("event", (event) => { events.push(event); }); await manager.open(openInput()); await waitFor(() => events.some((event) => event.type === "started")); expect(events.some((event) => event.type === "activity")).toBe(false); hasRunningSubprocess = true; await waitFor( () => events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), 1_200, ); hasRunningSubprocess = false; await waitFor( () => events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), 1_200, ); manager.dispose(); }); it("caps persisted history to configured line limit", async () => { const { manager, ptyAdapter } = makeManager(3); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("line1\nline2\nline3\nline4\n"); await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); manager.dispose(); }); it("deletes history file when close(deleteHistory=true)", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("bye\n"); await waitFor(() => fs.existsSync(historyLogPath(logsDir))); await manager.close({ threadId: "thread-1", deleteHistory: true }); expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); manager.dispose(); }); it("closes all terminals for a thread when close omits terminalId", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); await manager.open(openInput({ terminalId: "default" })); await 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"); await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); await manager.close({ threadId: "thread-1", deleteHistory: true }); expect(defaultProcess.killed).toBe(true); expect(sidecarProcess.killed).toBe(true); expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false); expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false); manager.dispose(); }); it("escalates terminal shutdown to SIGKILL when process does not exit in time", async () => { const { manager, ptyAdapter } = makeManager(5, { processKillGraceMs: 10 }); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; await manager.close({ threadId: "thread-1" }); await waitFor(() => process.killSignals.includes("SIGKILL")); expect(process.killSignals[0]).toBe("SIGTERM"); expect(process.killSignals).toContain("SIGKILL"); manager.dispose(); }); it("evicts oldest inactive terminal sessions when retention limit is exceeded", async () => { const { manager, ptyAdapter } = makeManager(5, { maxRetainedInactiveSessions: 1 }); await manager.open(openInput({ threadId: "thread-1" })); await 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.emitExit({ exitCode: 0, signal: 0 }); await new Promise((resolve) => setTimeout(resolve, 5)); second.emitExit({ exitCode: 0, signal: 0 }); await waitFor(() => { const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions; return sessions.size === 1; }); const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions; const keys = [...sessions.keys()]; expect(keys).toEqual(["thread-2\u0000default"]); manager.dispose(); }); it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { const { manager, logsDir } = makeManager(); const legacyPath = path.join(logsDir, "thread-1.log"); const nextPath = historyLogPath(logsDir); fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); const snapshot = await manager.open(openInput()); expect(snapshot.history).toBe("legacy-line\n");Lines 1–500 of 619. Load more
import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput, } from "@t3tools/contracts"; import { afterEach, describe, expect, it } from "vitest"; import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; import { TerminalManagerRuntime } from "./Manager"; import { Effect, Encoding } from "effect"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array<string | undefined> = []; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; constructor(readonly pid: number) {} 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 nextPid = 9000; spawn(input: PtySpawnInput): Effect.Effect<PtyProcess> { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { throw failure; } const process = new FakePtyProcess(this.nextPid++); this.processes.push(process); return Effect.succeed(process); } } function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> { const started = Date.now(); return new Promise((resolve, reject) => { const poll = () => { if (predicate()) { resolve(); return; } if (Date.now() - started > timeoutMs) { reject(new Error("Timed out waiting for condition")); return; } setTimeout(poll, 15); }; poll(); }); } function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { return { threadId: "thread-1", cwd: process.cwd(), cols: 100, rows: 24, ...overrides, }; } function historyLogName(threadId: string): string { return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; } function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; if (terminalId === DEFAULT_TERMINAL_ID) { return `${threadPart}.log`; } return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; } function historyLogPath(logsDir: string, threadId = "thread-1"): string { return path.join(logsDir, historyLogName(threadId)); } function multiTerminalHistoryLogPath( logsDir: string, threadId = "thread-1", terminalId = "default", ): string { return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); } describe("TerminalManager", () => { const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); } }); function makeManager( historyLineLimit = 5, options: { shellResolver?: () => string; subprocessChecker?: (terminalPid: number) => Promise<boolean>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; } = {}, ) { const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); tempDirs.push(logsDir); const ptyAdapter = new FakePtyAdapter(); const manager = new TerminalManagerRuntime({ logsDir, ptyAdapter, historyLineLimit, shellResolver: options.shellResolver ?? (() => "/bin/bash"), ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), ...(options.subprocessPollIntervalMs ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), ...(options.processKillGraceMs ? { processKillGraceMs: options.processKillGraceMs } : {}), ...(options.maxRetainedInactiveSessions ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), }); return { logsDir, ptyAdapter, manager }; } it("spawns lazily and reuses running terminal per thread", async () => { const { manager, ptyAdapter } = makeManager(); const [first, second] = await Promise.all([ manager.open(openInput()), manager.open(openInput()), ]); const third = await manager.open(openInput()); expect(first.threadId).toBe("thread-1"); expect(first.terminalId).toBe("default"); expect(second.threadId).toBe("thread-1"); expect(third.threadId).toBe("thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); manager.dispose(); }); it("forwards write and resize to active pty process", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; await manager.write({ threadId: "thread-1", data: "ls\n" }); await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 }); expect(process.writes).toEqual(["ls\n"]); expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); manager.dispose(); }); it("resizes running terminal on open when a different size is requested", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ cols: 100, rows: 24 })); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; await manager.open(openInput({ cols: 140, rows: 40 })); expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); manager.dispose(); }); it("preserves existing terminal size on open when size is omitted", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ cols: 100, rows: 24 })); const ptyProcess = ptyAdapter.processes[0]; expect(ptyProcess).toBeDefined(); if (!ptyProcess) return; await manager.open({ threadId: "thread-1", cwd: globalThis.process.cwd(), }); expect(ptyProcess.resizeCalls).toEqual([]); ptyProcess.emitExit({ exitCode: 0, signal: 0 }); await manager.open({ threadId: "thread-1", cwd: globalThis.process.cwd(), }); const resumedSpawn = ptyAdapter.spawnInputs[1]; expect(resumedSpawn).toBeDefined(); if (!resumedSpawn) return; expect(resumedSpawn.cols).toBe(100); expect(resumedSpawn.rows).toBe(24); manager.dispose(); }); it("uses default dimensions when opening a new terminal without size hints", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open({ threadId: "thread-1", cwd: process.cwd(), }); const spawned = ptyAdapter.spawnInputs[0]; expect(spawned).toBeDefined(); if (!spawned) return; expect(spawned.cols).toBe(120); expect(spawned.rows).toBe(30); manager.dispose(); }); it("supports multiple terminals per thread with isolated sessions", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ terminalId: "default" })); await 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; await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); await 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); manager.dispose(); }); it("clears transcript and emits cleared event", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); const events: TerminalEvent[] = []; manager.on("event", (event) => { events.push(event); }); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("hello\n"); await waitFor(() => fs.existsSync(historyLogPath(logsDir))); await manager.clear({ threadId: "thread-1" }); await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); expect(events.some((event) => event.type === "cleared")).toBe(true); expect( events.some( (event) => event.type === "cleared" && event.threadId === "thread-1" && event.terminalId === "default", ), ).toBe(true); manager.dispose(); }); it("restarts terminal with empty transcript and respawns pty", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); await manager.open(openInput()); const firstProcess = ptyAdapter.processes[0]; expect(firstProcess).toBeDefined(); if (!firstProcess) return; firstProcess.emitData("before restart\n"); await waitFor(() => fs.existsSync(historyLogPath(logsDir))); const snapshot = await manager.restart(openInput()); expect(snapshot.history).toBe(""); expect(snapshot.status).toBe("running"); expect(ptyAdapter.spawnInputs).toHaveLength(2); await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); manager.dispose(); }); it("emits exited event and reopens with clean transcript after exit", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); const events: TerminalEvent[] = []; manager.on("event", (event) => { events.push(event); }); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("old data\n"); await waitFor(() => fs.existsSync(historyLogPath(logsDir))); process.emitExit({ exitCode: 0, signal: 0 }); await waitFor(() => events.some((event) => event.type === "exited")); const reopened = await manager.open(openInput()); expect(reopened.history).toBe(""); expect(ptyAdapter.spawnInputs).toHaveLength(2); expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); manager.dispose(); }); it("emits subprocess activity events when child-process state changes", async () => { let hasRunningSubprocess = false; const { manager } = makeManager(5, { subprocessChecker: async () => hasRunningSubprocess, subprocessPollIntervalMs: 20, }); const events: TerminalEvent[] = []; manager.on("event", (event) => { events.push(event); }); await manager.open(openInput()); await waitFor(() => events.some((event) => event.type === "started")); expect(events.some((event) => event.type === "activity")).toBe(false); hasRunningSubprocess = true; await waitFor( () => events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), 1_200, ); hasRunningSubprocess = false; await waitFor( () => events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), 1_200, ); manager.dispose(); }); it("caps persisted history to configured line limit", async () => { const { manager, ptyAdapter } = makeManager(3); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("line1\nline2\nline3\nline4\n"); await manager.close({ threadId: "thread-1" }); const reopened = await manager.open(openInput()); const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); manager.dispose(); }); it("deletes history file when close(deleteHistory=true)", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("bye\n"); await waitFor(() => fs.existsSync(historyLogPath(logsDir))); await manager.close({ threadId: "thread-1", deleteHistory: true }); expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); manager.dispose(); }); it("closes all terminals for a thread when close omits terminalId", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); await manager.open(openInput({ terminalId: "default" })); await 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"); await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); await manager.close({ threadId: "thread-1", deleteHistory: true }); expect(defaultProcess.killed).toBe(true); expect(sidecarProcess.killed).toBe(true); expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false); expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false); manager.dispose(); }); it("escalates terminal shutdown to SIGKILL when process does not exit in time", async () => { const { manager, ptyAdapter } = makeManager(5, { processKillGraceMs: 10 }); await manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; await manager.close({ threadId: "thread-1" }); await waitFor(() => process.killSignals.includes("SIGKILL")); expect(process.killSignals[0]).toBe("SIGTERM"); expect(process.killSignals).toContain("SIGKILL"); manager.dispose(); }); it("evicts oldest inactive terminal sessions when retention limit is exceeded", async () => { const { manager, ptyAdapter } = makeManager(5, { maxRetainedInactiveSessions: 1 }); await manager.open(openInput({ threadId: "thread-1" })); await 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.emitExit({ exitCode: 0, signal: 0 }); await new Promise((resolve) => setTimeout(resolve, 5)); second.emitExit({ exitCode: 0, signal: 0 }); await waitFor(() => { const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions; return sessions.size === 1; }); const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions; const keys = [...sessions.keys()]; expect(keys).toEqual(["thread-2\u0000default"]); manager.dispose(); }); it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { const { manager, logsDir } = makeManager(); const legacyPath = path.join(logsDir, "thread-1.log"); const nextPath = historyLogPath(logsDir); fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); const snapshot = await manager.open(openInput()); expect(snapshot.history).toBe("legacy-line\n");