authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 21:59:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 21:59:32-07:00
log4e8ee13d8f54e9aa84f1b15f9d802c30650217ea
treefd2a23c9ce24170fc4d2c53b517b37b6f3017066
parentb17b6d50c55e3eff4317163d31c4032423d3d2c1
signature Signed by PGP key B5690EEEBB952194

[codex] Align terminal Effect service modules (#3193)

Co-authored-by: codex <codex@users.noreply.github.com>

19 files changed, 4586 insertions(+), 4642 deletions(-)

apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts+2-2
...@@ -6,7 +6,7 @@ import * as Layer from "effect/Layer";...@@ -6,7 +6,7 @@ import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";6import * as Stream from "effect/Stream";
77
8import { ProviderService } from "../../provider/Services/ProviderService.ts";8import { ProviderService } from "../../provider/Services/ProviderService.ts";
9import { TerminalManager } from "../../terminal/Services/Manager.ts";9import * as TerminalManager from "../../terminal/Manager.ts";
10import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";10import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
11import {11import {
12 ThreadDeletionReactor,12 ThreadDeletionReactor,
...@@ -39,7 +39,7 @@ export const logCleanupCauseUnlessInterrupted = <R, E>({...@@ -39,7 +39,7 @@ export const logCleanupCauseUnlessInterrupted = <R, E>({
39const make = Effect.gen(function* () {39const make = Effect.gen(function* () {
40 const orchestrationEngine = yield* OrchestrationEngineService;40 const orchestrationEngine = yield* OrchestrationEngineService;
41 const providerService = yield* ProviderService;41 const providerService = yield* ProviderService;
42 const terminalManager = yield* TerminalManager;42 const terminalManager = yield* TerminalManager.TerminalManager;
4343
44 const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) =>44 const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) =>
45 logCleanupCauseUnlessInterrupted({45 logCleanupCauseUnlessInterrupted({
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+3-3
...@@ -5,7 +5,7 @@ import * as Option from "effect/Option";...@@ -5,7 +5,7 @@ import * as Option from "effect/Option";
5import { describe, expect, it, vi } from "vite-plus/test";5import { describe, expect, it, vi } from "vite-plus/test";
66
7import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";7import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
8import { TerminalManager } from "../../terminal/Services/Manager.ts";8import * as TerminalManager from "../../terminal/Manager.ts";
9import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";9import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";
10import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";10import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";
1111
...@@ -52,7 +52,7 @@ describe("ProjectSetupScriptRunner", () => {...@@ -52,7 +52,7 @@ describe("ProjectSetupScriptRunner", () => {
52 ProjectSetupScriptRunnerLive.pipe(52 ProjectSetupScriptRunnerLive.pipe(
53 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),53 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
54 Layer.provideMerge(54 Layer.provideMerge(
55 Layer.succeed(TerminalManager, {55 Layer.succeed(TerminalManager.TerminalManager, {
56 open,56 open,
57 attachStream: () => Effect.die(new Error("unused")),57 attachStream: () => Effect.die(new Error("unused")),
58 write,58 write,
...@@ -114,7 +114,7 @@ describe("ProjectSetupScriptRunner", () => {...@@ -114,7 +114,7 @@ describe("ProjectSetupScriptRunner", () => {
114 ProjectSetupScriptRunnerLive.pipe(114 ProjectSetupScriptRunnerLive.pipe(
115 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),115 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
116 Layer.provideMerge(116 Layer.provideMerge(
117 Layer.succeed(TerminalManager, {117 Layer.succeed(TerminalManager.TerminalManager, {
118 open,118 open,
119 attachStream: () => Effect.die(new Error("unused")),119 attachStream: () => Effect.die(new Error("unused")),
120 write,120 write,
apps/server/src/project/Layers/ProjectSetupScriptRunner.ts+2-2
...@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";...@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
66
7import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";7import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
8import { TerminalManager } from "../../terminal/Services/Manager.ts";8import * as TerminalManager from "../../terminal/Manager.ts";
9import {9import {
10 type ProjectSetupScriptRunnerShape,10 type ProjectSetupScriptRunnerShape,
11 ProjectSetupScriptRunner,11 ProjectSetupScriptRunner,
...@@ -14,7 +14,7 @@ import {...@@ -14,7 +14,7 @@ import {
1414
15const makeProjectSetupScriptRunner = Effect.gen(function* () {15const makeProjectSetupScriptRunner = Effect.gen(function* () {
16 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;16 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
17 const terminalManager = yield* TerminalManager;17 const terminalManager = yield* TerminalManager.TerminalManager;
1818
19 const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) =>19 const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) =>
20 Effect.gen(function* () {20 Effect.gen(function* () {
apps/server/src/server.test.ts+1-1
...@@ -85,7 +85,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid...@@ -85,7 +85,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid
85import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";85import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
86import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";86import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
87import * as ServerSettings from "./serverSettings.ts";87import * as ServerSettings from "./serverSettings.ts";
88import * as TerminalManager from "./terminal/Services/Manager.ts";88import * as TerminalManager from "./terminal/Manager.ts";
89import * as PreviewManager from "./preview/Manager.ts";89import * as PreviewManager from "./preview/Manager.ts";
90import * as PortScanner from "./preview/PortScanner.ts";90import * as PortScanner from "./preview/PortScanner.ts";
91import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";91import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
apps/server/src/server.ts+6-6
...@@ -33,7 +33,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts";...@@ -33,7 +33,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts";
33import * as GitLabCli from "./sourceControl/GitLabCli.ts";33import * as GitLabCli from "./sourceControl/GitLabCli.ts";
34import * as TextGeneration from "./textGeneration/TextGeneration.ts";34import * as TextGeneration from "./textGeneration/TextGeneration.ts";
35import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";35import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
36import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";36import * as TerminalManager from "./terminal/Manager.ts";
37import * as McpHttpServer from "./mcp/McpHttpServer.ts";37import * as McpHttpServer from "./mcp/McpHttpServer.ts";
38import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";38import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
39import * as PreviewManager from "./preview/Manager.ts";39import * as PreviewManager from "./preview/Manager.ts";
...@@ -101,11 +101,11 @@ const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0;...@@ -101,11 +101,11 @@ const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0;
101const PtyAdapterLive = Layer.unwrap(101const PtyAdapterLive = Layer.unwrap(
102 Effect.gen(function* () {102 Effect.gen(function* () {
103 if (typeof Bun !== "undefined") {103 if (typeof Bun !== "undefined") {
104 const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts"));104 const BunPtyAdapter = yield* Effect.promise(() => import("./terminal/BunPtyAdapter.ts"));
105 return BunPTY.layer;105 return BunPtyAdapter.layer;
106 } else {106 } else {
107 const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts"));107 const NodePtyAdapter = yield* Effect.promise(() => import("./terminal/NodePtyAdapter.ts"));
108 return NodePTY.layer;108 return NodePtyAdapter.layer;
109 }109 }
110 }),110 }),
111);111);
...@@ -238,7 +238,7 @@ const CheckpointingLayerLive = Layer.empty.pipe(...@@ -238,7 +238,7 @@ const CheckpointingLayerLive = Layer.empty.pipe(
238238
239const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer));239const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer));
240240
241const TerminalLayerLive = TerminalManagerLive.pipe(241const TerminalLayerLive = TerminalManager.layer.pipe(
242 Layer.provide(PtyAdapterLive),242 Layer.provide(PtyAdapterLive),
243 Layer.provide(PortScannerLayerLive),243 Layer.provide(PortScannerLayerLive),
244);244);
apps/server/src/terminal/BunPtyAdapter.ts created+133
...@@ -0,0 +1,133 @@
1/// <reference types="bun" />
2
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6
7import * as PtyAdapter from "./PtyAdapter.ts";
8
9class BunPtyProcess implements PtyAdapter.PtyProcess {
10 private readonly dataListeners = new Set<(data: string) => void>();
11 private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>();
12 private readonly decoder = new TextDecoder();
13 private readonly process: Bun.Subprocess;
14 private didExit = false;
15
16 constructor(process: Bun.Subprocess) {
17 this.process = process;
18 void this.process.exited
19 .then((exitCode) => {
20 this.emitExit({
21 exitCode: Number.isInteger(exitCode) ? exitCode : 0,
22 signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null,
23 });
24 })
25 .catch(() => {
26 this.emitExit({ exitCode: 1, signal: null });
27 });
28 }
29
30 get pid(): number {
31 return this.process.pid;
32 }
33
34 write(data: string): void {
35 if (!this.process.terminal) {
36 throw new Error("Bun PTY terminal handle is unavailable");
37 }
38 this.process.terminal.write(data);
39 }
40
41 resize(cols: number, rows: number): void {
42 if (!this.process.terminal?.resize) {
43 throw new Error("Bun PTY resize is unavailable");
44 }
45 this.process.terminal.resize(cols, rows);
46 }
47
48 kill(signal?: string): void {
49 if (!signal) {
50 this.process.kill();
51 return;
52 }
53 this.process.kill(signal as NodeJS.Signals);
54 }
55
56 onData(callback: (data: string) => void): () => void {
57 this.dataListeners.add(callback);
58 return () => {
59 this.dataListeners.delete(callback);
60 };
61 }
62
63 onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void {
64 this.exitListeners.add(callback);
65 return () => {
66 this.exitListeners.delete(callback);
67 };
68 }
69
70 emitData(data: Uint8Array): void {
71 if (this.didExit) return;
72 const text = this.decoder.decode(data, { stream: true });
73 if (text.length === 0) return;
74 for (const listener of this.dataListeners) {
75 listener(text);
76 }
77 }
78
79 private emitExit(event: PtyAdapter.PtyExitEvent): void {
80 if (this.didExit) return;
81 this.didExit = true;
82
83 const remainder = this.decoder.decode();
84 if (remainder.length > 0) {
85 for (const listener of this.dataListeners) {
86 listener(remainder);
87 }
88 }
89
90 for (const listener of this.exitListeners) {
91 listener(event);
92 }
93 }
94}
95
96export const make = Effect.fn("BunPtyAdapter.make")(function* () {
97 const platform = yield* HostProcessPlatform;
98 if (platform === "win32") {
99 return yield* Effect.die(
100 "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.",
101 );
102 }
103 return PtyAdapter.PtyAdapter.of({
104 spawn: (input) =>
105 Effect.try({
106 try: () => {
107 let processHandle: BunPtyProcess | null = null;
108 const command = [input.shell, ...(input.args ?? [])];
109 const subprocess = Bun.spawn(command, {
110 cwd: input.cwd,
111 env: input.env,
112 terminal: {
113 cols: input.cols,
114 rows: input.rows,
115 data: (_terminal, data) => {
116 processHandle?.emitData(data);
117 },
118 },
119 });
120 processHandle = new BunPtyProcess(subprocess);
121 return processHandle;
122 },
123 catch: (cause) =>
124 new PtyAdapter.PtySpawnError({
125 adapter: "bun",
126 shell: input.shell,
127 cause,
128 }),
129 }),
130 });
131});
132
133export const layer = Layer.effect(PtyAdapter.PtyAdapter, make());
apps/server/src/terminal/Layers/BunPTY.ts deleted-126
...@@ -1,126 +0,0 @@
1/// <reference types="bun" />
2
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6import { PtyAdapter } from "../Services/PTY.ts";
7import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts";
8
9class BunPtyProcess implements PtyProcess {
10 private readonly dataListeners = new Set<(data: string) => void>();
11 private readonly exitListeners = new Set<(event: PtyExitEvent) => void>();
12 private readonly decoder = new TextDecoder();
13 private readonly process: Bun.Subprocess;
14 private didExit = false;
15
16 constructor(process: Bun.Subprocess) {
17 this.process = process;
18 void this.process.exited
19 .then((exitCode) => {
20 this.emitExit({
21 exitCode: Number.isInteger(exitCode) ? exitCode : 0,
22 signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null,
23 });
24 })
25 .catch(() => {
26 this.emitExit({ exitCode: 1, signal: null });
27 });
28 }
29
30 get pid(): number {
31 return this.process.pid;
32 }
33
34 write(data: string): void {
35 if (!this.process.terminal) {
36 throw new Error("Bun PTY terminal handle is unavailable");
37 }
38 this.process.terminal.write(data);
39 }
40
41 resize(cols: number, rows: number): void {
42 if (!this.process.terminal?.resize) {
43 throw new Error("Bun PTY resize is unavailable");
44 }
45 this.process.terminal.resize(cols, rows);
46 }
47
48 kill(signal?: string): void {
49 if (!signal) {
50 this.process.kill();
51 return;
52 }
53 this.process.kill(signal as NodeJS.Signals);
54 }
55
56 onData(callback: (data: string) => void): () => void {
57 this.dataListeners.add(callback);
58 return () => {
59 this.dataListeners.delete(callback);
60 };
61 }
62
63 onExit(callback: (event: PtyExitEvent) => void): () => void {
64 this.exitListeners.add(callback);
65 return () => {
66 this.exitListeners.delete(callback);
67 };
68 }
69
70 emitData(data: Uint8Array): void {
71 if (this.didExit) return;
72 const text = this.decoder.decode(data, { stream: true });
73 if (text.length === 0) return;
74 for (const listener of this.dataListeners) {
75 listener(text);
76 }
77 }
78
79 private emitExit(event: PtyExitEvent): void {
80 if (this.didExit) return;
81 this.didExit = true;
82
83 const remainder = this.decoder.decode();
84 if (remainder.length > 0) {
85 for (const listener of this.dataListeners) {
86 listener(remainder);
87 }
88 }
89
90 for (const listener of this.exitListeners) {
91 listener(event);
92 }
93 }
94}
95
96export const layer = Layer.effect(
97 PtyAdapter,
98 Effect.gen(function* () {
99 const platform = yield* HostProcessPlatform;
100 if (platform === "win32") {
101 return yield* Effect.die(
102 "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.",
103 );
104 }
105 return {
106 spawn: (input) =>
107 Effect.sync(() => {
108 let processHandle: BunPtyProcess | null = null;
109 const command = [input.shell, ...(input.args ?? [])];
110 const subprocess = Bun.spawn(command, {
111 cwd: input.cwd,
112 env: input.env,
113 terminal: {
114 cols: input.cols,
115 rows: input.rows,
116 data: (_terminal, data) => {
117 processHandle?.emitData(data);
118 },
119 },
120 });
121 processHandle = new BunPtyProcess(subprocess);
122 return processHandle;
123 }),
124 } satisfies PtyAdapterShape;
125 }),
126);
apps/server/src/terminal/Layers/Manager.test.ts deleted-1555
...@@ -1,1555 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 DEFAULT_TERMINAL_ID,
5 type TerminalAttachStreamEvent,
6 type TerminalEvent,
7 type TerminalMetadataStreamEvent,
8 type TerminalOpenInput,
9 type TerminalRestartInput,
10} from "@t3tools/contracts";
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import * as Data from "effect/Data";
13import * as Duration from "effect/Duration";
14import * as Effect from "effect/Effect";
15import * as Encoding from "effect/Encoding";
16import * as Exit from "effect/Exit";
17import * as Fiber from "effect/Fiber";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as PlatformError from "effect/PlatformError";
22import * as Path from "effect/Path";
23import * as Ref from "effect/Ref";
24import * as Schedule from "effect/Schedule";
25import * as Scope from "effect/Scope";
26import { TestClock } from "effect/testing";
27import { expect } from "vite-plus/test";
28
29import * as ProcessRunner from "../../processRunner.ts";
30import type { TerminalManagerShape } from "../Services/Manager.ts";
31import {
32 type PtyAdapterShape,
33 type PtyExitEvent,
34 type PtyProcess,
35 type PtySpawnInput,
36 PtySpawnError,
37} from "../Services/PTY.ts";
38import { makeTerminalManagerWithOptions } from "./Manager.ts";
39
40class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{
41 readonly message: string;
42}> {}
43
44class FakePtyProcess implements PtyProcess {
45 readonly writes: string[] = [];
46 readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
47 readonly killSignals: Array<string | undefined> = [];
48 readonly pid: number;
49 private readonly dataListeners = new Set<(data: string) => void>();
50 private readonly exitListeners = new Set<(event: PtyExitEvent) => void>();
51 killed = false;
52
53 constructor(pid: number) {
54 this.pid = pid;
55 }
56
57 write(data: string): void {
58 this.writes.push(data);
59 }
60
61 resize(cols: number, rows: number): void {
62 this.resizeCalls.push({ cols, rows });
63 }
64
65 kill(signal?: string): void {
66 this.killed = true;
67 this.killSignals.push(signal);
68 }
69
70 onData(callback: (data: string) => void): () => void {
71 this.dataListeners.add(callback);
72 return () => {
73 this.dataListeners.delete(callback);
74 };
75 }
76
77 onExit(callback: (event: PtyExitEvent) => void): () => void {
78 this.exitListeners.add(callback);
79 return () => {
80 this.exitListeners.delete(callback);
81 };
82 }
83
84 emitData(data: string): void {
85 for (const listener of this.dataListeners) {
86 listener(data);
87 }
88 }
89
90 emitExit(event: PtyExitEvent): void {
91 for (const listener of this.exitListeners) {
92 listener(event);
93 }
94 }
95}
96
97class FakePtyAdapter implements PtyAdapterShape {
98 readonly spawnInputs: PtySpawnInput[] = [];
99 readonly processes: FakePtyProcess[] = [];
100 readonly spawnFailures: Error[] = [];
101 private readonly mode: "sync" | "async";
102 private nextPid = 9000;
103
104 constructor(mode: "sync" | "async" = "sync") {
105 this.mode = mode;
106 }
107
108 spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError> {
109 this.spawnInputs.push(input);
110 const failure = this.spawnFailures.shift();
111 if (failure) {
112 return Effect.fail(
113 new PtySpawnError({
114 adapter: "fake",
115 message: "Failed to spawn PTY process",
116 cause: failure,
117 }),
118 );
119 }
120 const process = new FakePtyProcess(this.nextPid++);
121 this.processes.push(process);
122 if (this.mode === "async") {
123 return Effect.tryPromise({
124 try: async () => process,
125 catch: (cause) =>
126 new PtySpawnError({
127 adapter: "fake",
128 message: "Failed to spawn PTY process",
129 cause,
130 }),
131 });
132 }
133 return Effect.succeed(process);
134 }
135}
136
137const waitFor = <E, R>(
138 predicate: Effect.Effect<boolean, E, R>,
139 timeout: Duration.Input = 800,
140): Effect.Effect<void, WaitForConditionError | E, R> =>
141 predicate.pipe(
142 Effect.filterOrFail(
143 (done) => done,
144 () => new WaitForConditionError({ message: "Condition not met" }),
145 ),
146 Effect.retry(Schedule.spaced("15 millis")),
147 Effect.timeoutOption(timeout),
148 Effect.flatMap((result) =>
149 Option.match(result, {
150 onNone: () =>
151 Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })),
152 onSome: () => Effect.void,
153 }),
154 ),
155 );
156
157function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {
158 return {
159 threadId: "thread-1",
160 terminalId: DEFAULT_TERMINAL_ID,
161 cwd: process.cwd(),
162 cols: 100,
163 rows: 24,
164 ...overrides,
165 };
166}
167
168function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput {
169 return {
170 threadId: "thread-1",
171 terminalId: DEFAULT_TERMINAL_ID,
172 cwd: process.cwd(),
173 cols: 100,
174 rows: 24,
175 ...overrides,
176 };
177}
178
179const historyLogPath = (logsDir: string, threadId = "thread-1") =>
180 Effect.service(Path.Path).pipe(
181 Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)),
182 );
183
184const multiTerminalHistoryLogPath = (
185 logsDir: string,
186 threadId = "thread-1",
187 terminalId = DEFAULT_TERMINAL_ID,
188) =>
189 Effect.service(Path.Path).pipe(
190 Effect.map(({ join }) => {
191 const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`;
192 return join(
193 logsDir,
194 terminalId === DEFAULT_TERMINAL_ID
195 ? `${threadPart}.log`
196 : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`,
197 );
198 }),
199 );
200
201interface CreateManagerOptions {
202 shellResolver?: () => string;
203 env?: NodeJS.ProcessEnv;
204 subprocessInspector?: (terminalPid: number) => Effect.Effect<{
205 readonly hasRunningSubprocess: boolean;
206 readonly childCommand: string | null;
207 readonly processIds: ReadonlyArray<number>;
208 }>;
209 subprocessPollIntervalMs?: number;
210 processKillGraceMs?: number;
211 maxRetainedInactiveSessions?: number;
212 ptyAdapter?: FakePtyAdapter;
213}
214
215interface ManagerFixture {
216 readonly baseDir: string;
217 readonly logsDir: string;
218 readonly ptyAdapter: FakePtyAdapter;
219 readonly manager: TerminalManagerShape;
220 readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>;
221}
222
223const createManager = (
224 historyLineLimit = 5,
225 options: CreateManagerOptions = {},
226): Effect.Effect<
227 ManagerFixture,
228 PlatformError.PlatformError,
229 FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner
230> =>
231 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
232 Effect.gen(function* () {
233 const { join } = yield* Path.Path;
234 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" });
235 const logsDir = join(baseDir, "userdata", "logs", "terminals");
236 const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter();
237
238 const manager = yield* makeTerminalManagerWithOptions({
239 logsDir,
240 historyLineLimit,
241 ptyAdapter,
242 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
243 ...(options.env !== undefined ? { env: options.env } : {}),
244 ...(options.subprocessInspector !== undefined
245 ? { subprocessInspector: options.subprocessInspector }
246 : {}),
247 ...(options.subprocessPollIntervalMs !== undefined
248 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
249 : {}),
250 processKillGraceMs: options.processKillGraceMs ?? 1,
251 ...(options.maxRetainedInactiveSessions !== undefined
252 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
253 : {}),
254 });
255 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
256 const unsubscribe = yield* manager.subscribe((event) =>
257 Ref.update(eventsRef, (events) => [...events, event]),
258 );
259 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
260
261 return {
262 baseDir,
263 logsDir,
264 join,
265 ptyAdapter,
266 manager,
267 getEvents: Ref.get(eventsRef),
268 };
269 }),
270 );
271
272const withHostPlatform = (platform: NodeJS.Platform) =>
273 Layer.succeed(HostProcessPlatform, platform);
274
275it.layer(
276 Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))),
277 { excludeTestServices: true },
278)("TerminalManager", (it) => {
279 it.effect("spawns lazily and reuses running terminal per thread", () =>
280 Effect.gen(function* () {
281 const { manager, ptyAdapter } = yield* createManager();
282 const [first, second] = yield* Effect.all(
283 [manager.open(openInput()), manager.open(openInput())],
284 { concurrency: "unbounded" },
285 );
286 const third = yield* manager.open(openInput());
287
288 assert.equal(first.threadId, "thread-1");
289 assert.equal(first.terminalId, DEFAULT_TERMINAL_ID);
290 assert.equal(second.threadId, "thread-1");
291 assert.equal(third.threadId, "thread-1");
292 expect(ptyAdapter.spawnInputs).toHaveLength(1);
293 }),
294 );
295
296 it.effect("attaches to running sessions without restarting them", () =>
297 Effect.gen(function* () {
298 const { manager, ptyAdapter } = yield* createManager();
299
300 yield* manager.open(openInput());
301 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
302 const unsubscribe = yield* manager.attachStream(
303 {
304 threadId: "thread-1",
305 terminalId: DEFAULT_TERMINAL_ID,
306 cols: 100,
307 rows: 40,
308 },
309 (event) => Ref.update(attachEvents, (events) => [...events, event]),
310 );
311 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
312
313 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
314 expect(snapshot).toBeDefined();
315 if (!snapshot || snapshot.type !== "snapshot") return;
316 assert.equal(snapshot.snapshot.threadId, "thread-1");
317 assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID);
318 expect(ptyAdapter.spawnInputs).toHaveLength(1);
319 }),
320 );
321
322 it.effect("keeps attach streams live when a terminal id is closed and reopened", () =>
323 Effect.gen(function* () {
324 const { manager, ptyAdapter } = yield* createManager();
325 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
326 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
327 Ref.update(attachEvents, (events) => [...events, event]),
328 );
329 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
330
331 yield* manager.close({
332 threadId: "thread-1",
333 terminalId: DEFAULT_TERMINAL_ID,
334 deleteHistory: true,
335 });
336 yield* manager.open(openInput());
337
338 const events = yield* Ref.get(attachEvents);
339 expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]);
340 expect(
341 events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status),
342 ).toEqual(["running", "running"]);
343 expect(ptyAdapter.spawnInputs).toHaveLength(2);
344 }),
345 );
346
347 it.effect("attaches to exited sessions without restarting them", () =>
348 Effect.gen(function* () {
349 const { manager, ptyAdapter, getEvents } = yield* createManager();
350
351 yield* manager.open(openInput());
352 const process = ptyAdapter.processes[0];
353 expect(process).toBeDefined();
354 if (!process) return;
355
356 process.emitExit({ exitCode: 0, signal: 0 });
357
358 yield* waitFor(
359 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
360 "1200 millis",
361 );
362
363 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
364 const unsubscribe = yield* manager.attachStream(
365 openInput({
366 env: {
367 T3CODE_WORKTREE_PATH: "/tmp/should-not-restart",
368 },
369 worktreePath: "/tmp/should-not-restart",
370 }),
371 (event) => Ref.update(attachEvents, (events) => [...events, event]),
372 );
373 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
374
375 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
376 expect(snapshot).toBeDefined();
377 if (!snapshot || snapshot.type !== "snapshot") return;
378 assert.equal(snapshot.snapshot.status, "exited");
379 assert.equal(snapshot.snapshot.worktreePath, null);
380 expect(ptyAdapter.spawnInputs).toHaveLength(1);
381 }),
382 );
383
384 it.effect("restarts inactive sessions from attach only when requested", () =>
385 Effect.gen(function* () {
386 const { manager, ptyAdapter, getEvents } = yield* createManager();
387
388 yield* manager.open(openInput());
389 const process = ptyAdapter.processes[0];
390 expect(process).toBeDefined();
391 if (!process) return;
392
393 process.emitExit({ exitCode: 0, signal: 0 });
394
395 yield* waitFor(
396 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
397 "1200 millis",
398 );
399
400 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
401 const unsubscribe = yield* manager.attachStream(
402 {
403 ...openInput({
404 env: {
405 T3CODE_WORKTREE_PATH: "/tmp/restart-requested",
406 },
407 worktreePath: "/tmp/restart-requested",
408 }),
409 restartIfNotRunning: true,
410 },
411 (event) => Ref.update(attachEvents, (events) => [...events, event]),
412 );
413 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
414
415 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
416 expect(snapshot).toBeDefined();
417 if (!snapshot || snapshot.type !== "snapshot") return;
418 assert.equal(snapshot.snapshot.status, "running");
419 assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested");
420 expect(ptyAdapter.spawnInputs).toHaveLength(2);
421 }),
422 );
423
424 const makeDirectory = (filePath: string) =>
425 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
426 fs.makeDirectory(filePath, { recursive: true }),
427 );
428
429 const chmod = (filePath: string, mode: number) =>
430 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode));
431
432 const pathExists = (filePath: string) =>
433 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath));
434
435 const readFileString = (filePath: string) =>
436 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath));
437
438 const writeFileString = (filePath: string, contents: string) =>
439 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
440 fs.writeFileString(filePath, contents),
441 );
442
443 it.effect("preserves non-notFound cwd stat failures", () =>
444 Effect.gen(function* () {
445 if ((yield* HostProcessPlatform) === "win32") return;
446
447 const path = yield* Path.Path;
448
449 const { manager, baseDir } = yield* createManager();
450 const blockedRoot = path.join(baseDir, "blocked-root");
451 const blockedCwd = path.join(blockedRoot, "cwd");
452 yield* makeDirectory(blockedCwd);
453 yield* chmod(blockedRoot, 0o000);
454
455 const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe(
456 Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)),
457 );
458
459 expect(error).toMatchObject({
460 _tag: "TerminalCwdError",
461 cwd: blockedCwd,
462 reason: "statFailed",
463 });
464 }),
465 );
466
467 it.effect("supports asynchronous PTY spawn effects", () =>
468 Effect.gen(function* () {
469 const { manager, ptyAdapter } = yield* createManager(5, {
470 ptyAdapter: new FakePtyAdapter("async"),
471 });
472
473 const snapshot = yield* manager.open(openInput());
474
475 assert.equal(snapshot.status, "running");
476 expect(ptyAdapter.spawnInputs).toHaveLength(1);
477 expect(ptyAdapter.processes).toHaveLength(1);
478 }),
479 );
480
481 it.effect("forwards write and resize to active pty process", () =>
482 Effect.gen(function* () {
483 const { manager, ptyAdapter } = yield* createManager();
484 yield* manager.open(openInput());
485 const process = ptyAdapter.processes[0];
486 expect(process).toBeDefined();
487 if (!process) return;
488
489 yield* manager.write({
490 threadId: "thread-1",
491 terminalId: DEFAULT_TERMINAL_ID,
492 data: "ls\n",
493 });
494 yield* manager.resize({
495 threadId: "thread-1",
496 terminalId: DEFAULT_TERMINAL_ID,
497 cols: 120,
498 rows: 30,
499 });
500
501 expect(process.writes).toEqual(["ls\n"]);
502 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
503 }),
504 );
505
506 it.effect("ignores delayed resize requests after a terminal closes", () =>
507 Effect.gen(function* () {
508 const { manager, ptyAdapter } = yield* createManager();
509 yield* manager.open(openInput());
510 const process = ptyAdapter.processes[0];
511 expect(process).toBeDefined();
512 if (!process) return;
513
514 yield* manager.close({
515 threadId: "thread-1",
516 terminalId: DEFAULT_TERMINAL_ID,
517 deleteHistory: true,
518 });
519 yield* manager.resize({
520 threadId: "thread-1",
521 terminalId: DEFAULT_TERMINAL_ID,
522 cols: 120,
523 rows: 30,
524 });
525
526 expect(process.resizeCalls).toEqual([]);
527 }),
528 );
529
530 it.effect("resizes running terminal on open when a different size is requested", () =>
531 Effect.gen(function* () {
532 const { manager, ptyAdapter } = yield* createManager();
533 yield* manager.open(openInput({ cols: 100, rows: 24 }));
534 const process = ptyAdapter.processes[0];
535 expect(process).toBeDefined();
536 if (!process) return;
537
538 const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 }));
539
540 assert.equal(reopened.status, "running");
541 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
542 }),
543 );
544
545 it.effect("supports multiple terminals per thread independently", () =>
546 Effect.gen(function* () {
547 const { manager, ptyAdapter } = yield* createManager();
548 yield* manager.open(openInput({ terminalId: "default" }));
549 yield* manager.open(openInput({ terminalId: "term-2" }));
550
551 const first = ptyAdapter.processes[0];
552 const second = ptyAdapter.processes[1];
553 expect(first).toBeDefined();
554 expect(second).toBeDefined();
555 if (!first || !second) return;
556
557 yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" });
558 yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" });
559
560 expect(first.writes).toEqual(["pwd\n"]);
561 expect(second.writes).toEqual(["ls\n"]);
562 expect(ptyAdapter.spawnInputs).toHaveLength(2);
563 }),
564 );
565
566 it.effect("clears transcript and emits cleared event", () =>
567 Effect.gen(function* () {
568 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
569 const path = yield* Path.Path;
570 yield* manager.open(openInput());
571 const process = ptyAdapter.processes[0];
572 expect(process).toBeDefined();
573 if (!process) return;
574
575 process.emitData("hello\n");
576 yield* waitFor(
577 historyLogPath(logsDir).pipe(
578 Effect.provideService(Path.Path, path),
579 Effect.flatMap(pathExists),
580 ),
581 );
582 yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID });
583 yield* waitFor(
584 historyLogPath(logsDir).pipe(
585 Effect.provideService(Path.Path, path),
586 Effect.flatMap(readFileString),
587 Effect.map((text) => text === ""),
588 ),
589 );
590
591 const events = yield* getEvents;
592 expect(events.some((event) => event.type === "cleared")).toBe(true);
593 expect(
594 events.some(
595 (event) =>
596 event.type === "cleared" &&
597 event.threadId === "thread-1" &&
598 event.terminalId === DEFAULT_TERMINAL_ID,
599 ),
600 ).toBe(true);
601 }),
602 );
603
604 it.effect("restarts terminal with empty transcript and respawns pty", () =>
605 Effect.gen(function* () {
606 const { manager, ptyAdapter, logsDir } = yield* createManager();
607 yield* manager.open(openInput());
608 const firstProcess = ptyAdapter.processes[0];
609 expect(firstProcess).toBeDefined();
610 if (!firstProcess) return;
611 firstProcess.emitData("before restart\n");
612 const path = yield* Path.Path;
613 yield* waitFor(
614 historyLogPath(logsDir).pipe(
615 Effect.provideService(Path.Path, path),
616 Effect.flatMap(pathExists),
617 ),
618 );
619
620 const snapshot = yield* manager.restart(restartInput());
621 assert.equal(snapshot.history, "");
622 assert.equal(snapshot.status, "running");
623 expect(ptyAdapter.spawnInputs).toHaveLength(2);
624 yield* waitFor(
625 historyLogPath(logsDir).pipe(
626 Effect.provideService(Path.Path, path),
627 Effect.flatMap(readFileString),
628 Effect.map((text) => text === ""),
629 ),
630 );
631 }),
632 );
633
634 it.effect("restarts a running session when open is called with a different cwd", () =>
635 Effect.gen(function* () {
636 const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager();
637 const path = yield* Path.Path;
638 const originalCwd = path.join(baseDir, "original");
639 const differentCwd = path.join(baseDir, "different");
640 yield* makeDirectory(originalCwd);
641 yield* makeDirectory(differentCwd);
642
643 yield* manager.open(openInput({ cwd: originalCwd }));
644 const firstProcess = ptyAdapter.processes[0];
645 expect(firstProcess).toBeDefined();
646 if (!firstProcess) return;
647
648 firstProcess.emitData("before reopen\n");
649 const logPath = yield* historyLogPath(logsDir);
650 yield* waitFor(pathExists(logPath));
651
652 const reopened = yield* manager.open(openInput({ cwd: differentCwd }));
653
654 expect(ptyAdapter.spawnInputs).toHaveLength(2);
655 assert.equal(firstProcess.killed, true);
656 assert.equal(reopened.cwd, differentCwd);
657 assert.equal(reopened.history, "");
658 yield* waitFor(Effect.map(readFileString(logPath), (text) => text === ""));
659 }),
660 );
661
662 it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () =>
663 Effect.gen(function* () {
664 const { manager, getEvents, baseDir } = yield* createManager();
665 const path = yield* Path.Path;
666 const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a");
667 const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b");
668 yield* makeDirectory(firstWorktreePath);
669 yield* makeDirectory(secondWorktreePath);
670 const startedSnapshot = yield* manager.open(
671 openInput({
672 cwd: firstWorktreePath,
673 worktreePath: firstWorktreePath,
674 }),
675 );
676 const restartedSnapshot = yield* manager.restart(
677 restartInput({
678 cwd: secondWorktreePath,
679 worktreePath: secondWorktreePath,
680 }),
681 );
682
683 assert.equal(startedSnapshot.worktreePath, firstWorktreePath);
684 assert.equal(restartedSnapshot.worktreePath, secondWorktreePath);
685
686 const events = yield* getEvents;
687 const startedEvent = events.find(
688 (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started",
689 );
690 const restartedEvent = events.find(
691 (event): event is Extract<TerminalEvent, { type: "restarted" }> =>
692 event.type === "restarted",
693 );
694
695 assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath);
696 assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath);
697 }),
698 );
699
700 it.effect("preserves worktree metadata when reopening an exited session", () =>
701 Effect.gen(function* () {
702 const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager();
703 const path = yield* Path.Path;
704 const worktreePath = path.join(baseDir, "worktrees", "feature-a");
705 yield* makeDirectory(worktreePath);
706
707 yield* manager.open(
708 openInput({
709 cwd: worktreePath,
710 worktreePath,
711 }),
712 );
713
714 const process = ptyAdapter.processes[0];
715 expect(process).toBeDefined();
716 if (!process) return;
717 process.emitExit({ exitCode: 0, signal: 0 });
718
719 yield* waitFor(
720 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
721 );
722
723 const reopenedSnapshot = yield* manager.open(
724 openInput({
725 cwd: worktreePath,
726 worktreePath,
727 }),
728 );
729
730 assert.equal(reopenedSnapshot.worktreePath, worktreePath);
731
732 const events = yield* getEvents;
733 const reopenedEvent = events
734 .toReversed()
735 .find(
736 (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started",
737 );
738
739 assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath);
740 }),
741 );
742
743 it.effect("emits exited event and reopens with clean transcript after exit", () =>
744 Effect.gen(function* () {
745 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
746 const path = yield* Path.Path;
747 yield* manager.open(openInput());
748 const process = ptyAdapter.processes[0];
749 expect(process).toBeDefined();
750 if (!process) return;
751 process.emitData("old data\n");
752 yield* waitFor(
753 historyLogPath(logsDir).pipe(
754 Effect.provideService(Path.Path, path),
755 Effect.flatMap(pathExists),
756 ),
757 );
758 process.emitExit({ exitCode: 0, signal: 0 });
759
760 yield* waitFor(
761 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
762 );
763 const reopened = yield* manager.open(openInput());
764
765 assert.equal(reopened.history, "");
766 expect(ptyAdapter.spawnInputs).toHaveLength(2);
767 expect(
768 yield* historyLogPath(logsDir).pipe(
769 Effect.provideService(Path.Path, path),
770 Effect.flatMap(readFileString),
771 ),
772 ).toBe("");
773 }),
774 );
775
776 it.effect("ignores trailing writes after terminal exit", () =>
777 Effect.gen(function* () {
778 const { manager, ptyAdapter } = yield* createManager();
779 yield* manager.open(openInput());
780 const process = ptyAdapter.processes[0];
781 expect(process).toBeDefined();
782 if (!process) return;
783
784 process.emitExit({ exitCode: 0, signal: 0 });
785
786 yield* manager.write({
787 threadId: "thread-1",
788 terminalId: DEFAULT_TERMINAL_ID,
789 data: "\r",
790 });
791 expect(process.writes).toEqual([]);
792 }),
793 );
794
795 it.effect("emits subprocess activity events when child-process state changes", () =>
796 Effect.gen(function* () {
797 let inspect: {
798 readonly hasRunningSubprocess: boolean;
799 readonly childCommand: string | null;
800 readonly processIds: ReadonlyArray<number>;
801 } = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
802 const { manager, getEvents } = yield* createManager(5, {
803 subprocessInspector: () => Effect.succeed(inspect),
804 subprocessPollIntervalMs: 20,
805 });
806
807 yield* manager.open(openInput());
808 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);
809
810 inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] };
811 yield* waitFor(
812 Effect.map(getEvents, (events) =>
813 events.some(
814 (event) =>
815 event.type === "activity" &&
816 event.hasRunningSubprocess === true &&
817 event.label === "vim",
818 ),
819 ),
820 "1200 millis",
821 );
822
823 inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
824 yield* waitFor(
825 Effect.map(getEvents, (events) =>
826 events.some(
827 (event) =>
828 event.type === "activity" &&
829 event.hasRunningSubprocess === false &&
830 event.label === "Terminal 1",
831 ),
832 ),
833 "1200 millis",
834 );
835 }),
836 );
837
838 it.effect("does not invoke subprocess polling until a terminal session is running", () =>
839 Effect.gen(function* () {
840 let checks = 0;
841 const { manager } = yield* createManager(5, {
842 subprocessInspector: () => {
843 checks += 1;
844 return Effect.succeed({
845 hasRunningSubprocess: false,
846 childCommand: null,
847 processIds: [],
848 });
849 },
850 subprocessPollIntervalMs: 20,
851 });
852
853 yield* Effect.sleep("80 millis");
854 assert.equal(checks, 0);
855
856 yield* manager.open(openInput());
857 yield* waitFor(
858 Effect.sync(() => checks > 0),
859 "1200 millis",
860 );
861 }),
862 );
863
864 it.effect("caps persisted history to configured line limit", () =>
865 Effect.gen(function* () {
866 const { manager, ptyAdapter } = yield* createManager(3);
867 yield* manager.open(openInput());
868 const process = ptyAdapter.processes[0];
869 expect(process).toBeDefined();
870 if (!process) return;
871
872 process.emitData("line1\nline2\nline3\nline4\n");
873 yield* manager.close({ threadId: "thread-1" });
874
875 const reopened = yield* manager.open(openInput());
876 const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);
877 expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);
878 }),
879 );
880
881 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
882 Effect.gen(function* () {
883 const { manager, ptyAdapter } = yield* createManager();
884 yield* manager.open(openInput());
885 const process = ptyAdapter.processes[0];
886 expect(process).toBeDefined();
887 if (!process) return;
888
889 process.emitData("prompt ");
890 process.emitData("\u001b[32mok\u001b[0m ");
891 process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007");
892 process.emitData("\u001b[1;1R");
893 process.emitData("done\n");
894
895 yield* manager.close({ threadId: "thread-1" });
896
897 const reopened = yield* manager.open(openInput());
898 assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n");
899 }),
900 );
901
902 it.effect(
903 "preserves clear and style control sequences while dropping chunk-split query traffic",
904 () =>
905 Effect.gen(function* () {
906 const { manager, ptyAdapter } = yield* createManager();
907 yield* manager.open(openInput());
908 const process = ptyAdapter.processes[0];
909 expect(process).toBeDefined();
910 if (!process) return;
911
912 process.emitData("before clear\n");
913 process.emitData("\u001b[H\u001b[2J");
914 process.emitData("prompt ");
915 process.emitData("\u001b]11;");
916 process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1");
917 process.emitData("R\u001b[36mdone\u001b[0m\n");
918
919 yield* manager.close({ threadId: "thread-1" });
920
921 const reopened = yield* manager.open(openInput());
922 assert.equal(
923 reopened.history,
924 "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n",
925 );
926 }),
927 );
928
929 it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () =>
930 Effect.gen(function* () {
931 const { manager, ptyAdapter } = yield* createManager();
932 yield* manager.open(openInput());
933 const process = ptyAdapter.processes[0];
934 expect(process).toBeDefined();
935 if (!process) return;
936
937 process.emitData("before ");
938 process.emitData("\u001b(B");
939 process.emitData("after\n");
940
941 yield* manager.close({ threadId: "thread-1" });
942
943 const reopened = yield* manager.open(openInput());
944 assert.equal(reopened.history, "before \u001b(Bafter\n");
945 }),
946 );
947
948 it.effect(
949 "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes",
950 () =>
951 Effect.gen(function* () {
952 const { manager, ptyAdapter } = yield* createManager();
953 yield* manager.open(openInput());
954 const process = ptyAdapter.processes[0];
955 expect(process).toBeDefined();
956 if (!process) return;
957
958 process.emitData("before ");
959 process.emitData("\u001b(");
960 process.emitData("Bafter\n");
961
962 yield* manager.close({ threadId: "thread-1" });
963
964 const reopened = yield* manager.open(openInput());
965 assert.equal(reopened.history, "before \u001b(Bafter\n");
966 }),
967 );
968
969 it.effect("deletes history file when close(deleteHistory=true)", () =>
970 Effect.gen(function* () {
971 const { manager, ptyAdapter, logsDir } = yield* createManager();
972 yield* manager.open(openInput());
973 const process = ptyAdapter.processes[0];
974 expect(process).toBeDefined();
975 if (!process) return;
976 process.emitData("bye\n");
977 const path = yield* Path.Path;
978 yield* waitFor(
979 historyLogPath(logsDir).pipe(
980 Effect.provideService(Path.Path, path),
981 Effect.flatMap(pathExists),
982 ),
983 );
984
985 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
986 expect(
987 yield* historyLogPath(logsDir).pipe(
988 Effect.provideService(Path.Path, path),
989 Effect.flatMap(pathExists),
990 ),
991 ).toBe(false);
992 }),
993 );
994
995 it.effect("closes all terminals for a thread when close omits terminalId", () =>
996 Effect.gen(function* () {
997 const { manager, ptyAdapter, logsDir } = yield* createManager();
998 yield* manager.open(openInput({ terminalId: "default" }));
999 yield* manager.open(openInput({ terminalId: "sidecar" }));
1000 const defaultProcess = ptyAdapter.processes[0];
1001 const sidecarProcess = ptyAdapter.processes[1];
1002 expect(defaultProcess).toBeDefined();
1003 expect(sidecarProcess).toBeDefined();
1004 if (!defaultProcess || !sidecarProcess) return;
1005
1006 defaultProcess.emitData("default\n");
1007 sidecarProcess.emitData("sidecar\n");
1008 const path = yield* Path.Path;
1009 yield* waitFor(
1010 multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe(
1011 Effect.provideService(Path.Path, path),
1012 Effect.flatMap(pathExists),
1013 ),
1014 );
1015 yield* waitFor(
1016 multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe(
1017 Effect.provideService(Path.Path, path),
1018 Effect.flatMap(pathExists),
1019 ),
1020 );
1021
1022 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
1023
1024 assert.equal(defaultProcess.killed, true);
1025 assert.equal(sidecarProcess.killed, true);
1026 expect(
1027 yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe(
1028 Effect.provideService(Path.Path, path),
1029 Effect.flatMap(pathExists),
1030 ),
1031 ).toBe(false);
1032 expect(
1033 yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe(
1034 Effect.provideService(Path.Path, path),
1035 Effect.flatMap(pathExists),
1036 ),
1037 ).toBe(false);
1038 }),
1039 );
1040
1041 it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () =>
1042 Effect.gen(function* () {
1043 const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 });
1044 yield* manager.open(openInput());
1045 const process = ptyAdapter.processes[0];
1046 expect(process).toBeDefined();
1047 if (!process) return;
1048
1049 const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped);
1050 yield* Effect.yieldNow;
1051 yield* TestClock.adjust("10 millis");
1052 yield* Fiber.join(closeFiber);
1053
1054 assert.equal(process.killSignals[0], "SIGTERM");
1055 expect(process.killSignals).toContain("SIGKILL");
1056 }).pipe(Effect.provide(TestClock.layer())),
1057 );
1058
1059 it.effect("publishes closed events when terminals are explicitly closed", () =>
1060 Effect.gen(function* () {
1061 const { manager, getEvents } = yield* createManager();
1062 yield* manager.open(openInput({ terminalId: "default" }));
1063 yield* manager.open(openInput({ terminalId: "sidecar" }));
1064
1065 yield* manager.close({ threadId: "thread-1" });
1066
1067 const closedEvents = (yield* getEvents).filter(
1068 (event): event is Extract<TerminalEvent, { type: "closed" }> => event.type === "closed",
1069 );
1070 expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]);
1071 }),
1072 );
1073
1074 it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () =>
1075 Effect.gen(function* () {
1076 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, {
1077 maxRetainedInactiveSessions: 1,
1078 });
1079
1080 yield* manager.open(openInput({ threadId: "thread-1" }));
1081 yield* manager.open(openInput({ threadId: "thread-2" }));
1082
1083 const first = ptyAdapter.processes[0];
1084 const second = ptyAdapter.processes[1];
1085 expect(first).toBeDefined();
1086 expect(second).toBeDefined();
1087 if (!first || !second) return;
1088
1089 first.emitData("first-history\n");
1090 second.emitData("second-history\n");
1091 const path = yield* Path.Path;
1092 yield* waitFor(
1093 historyLogPath(logsDir, "thread-1").pipe(
1094 Effect.provideService(Path.Path, path),
1095 Effect.flatMap(pathExists),
1096 ),
1097 );
1098 first.emitExit({ exitCode: 0, signal: 0 });
1099 yield* Effect.sleep(Duration.millis(5));
1100 second.emitExit({ exitCode: 0, signal: 0 });
1101
1102 yield* waitFor(
1103 Effect.map(
1104 getEvents,
1105 (events) => events.filter((event) => event.type === "exited").length === 2,
1106 ),
1107 );
1108
1109 const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" }));
1110 const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" }));
1111
1112 assert.equal(reopenedFirst.history, "first-history\n");
1113 assert.equal(reopenedSecond.history, "");
1114 }),
1115 );
1116
1117 it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () =>
1118 Effect.gen(function* () {
1119 const { manager, logsDir } = yield* createManager();
1120 const path = yield* Path.Path;
1121 const legacyPath = path.join(logsDir, "thread-1.log");
1122 const nextPath = yield* historyLogPath(logsDir);
1123 yield* writeFileString(legacyPath, "legacy-line\n");
1124
1125 const snapshot = yield* manager.open(openInput());
1126
1127 assert.equal(snapshot.history, "legacy-line\n");
1128 expect(yield* pathExists(nextPath)).toBe(true);
1129 expect(yield* readFileString(nextPath)).toBe("legacy-line\n");
1130 expect(yield* pathExists(legacyPath)).toBe(false);
1131 }),
1132 );
1133
1134 it.effect("retries with fallback shells when preferred shell spawn fails", () =>
1135 Effect.gen(function* () {
1136 const platform = yield* HostProcessPlatform;
1137 const missingShell =
1138 platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l";
1139 const { manager, ptyAdapter } = yield* createManager(5, {
1140 shellResolver: () => missingShell,
1141 });
1142 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
1143
1144 const snapshot = yield* manager.open(openInput());
1145
1146 assert.equal(snapshot.status, "running");
1147 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
1148 expect(ptyAdapter.spawnInputs[0]?.shell).toBe(
1149 platform === "win32" ? missingShell : "/definitely/missing-shell",
1150 );
1151
1152 if (platform === "win32") {
1153 expect(
1154 ptyAdapter.spawnInputs.some(
1155 (input) =>
1156 input.shell === "pwsh.exe" ||
1157 input.shell === "powershell.exe" ||
1158 input.shell === "cmd.exe",
1159 ),
1160 ).toBe(true);
1161 } else {
1162 expect(
1163 ptyAdapter.spawnInputs
1164 .slice(1)
1165 .some((input) => input.shell !== "/definitely/missing-shell"),
1166 ).toBe(true);
1167 }
1168 }),
1169 );
1170
1171 it.effect("prefers PowerShell over ComSpec for Windows terminals", () =>
1172 Effect.gen(function* () {
1173 const { manager, ptyAdapter } = yield* createManager(5, {
1174 env: {
1175 ComSpec: "C:\\Windows\\System32\\cmd.exe",
1176 PATH: "C:\\Windows\\System32",
1177 SystemRoot: "C:\\Windows",
1178 },
1179 }).pipe(Effect.provide(withHostPlatform("win32")));
1180
1181 yield* manager.open(openInput());
1182
1183 expect(ptyAdapter.spawnInputs[0]).toEqual(
1184 expect.objectContaining({
1185 shell: "pwsh.exe",
1186 args: ["-NoLogo"],
1187 }),
1188 );
1189 }),
1190 );
1191
1192 it.effect("falls back to built-in PowerShell by absolute path on Windows", () =>
1193 Effect.gen(function* () {
1194 const ptyAdapter = new FakePtyAdapter();
1195 const { manager } = yield* createManager(5, {
1196 ptyAdapter,
1197 shellResolver: () => "C:\\missing\\custom-shell.exe",
1198 env: {
1199 ComSpec: "C:\\Windows\\System32\\cmd.exe",
1200 PATH: "C:\\Windows\\System32",
1201 SystemRoot: "C:\\Windows",
1202 },
1203 }).pipe(Effect.provide(withHostPlatform("win32")));
1204 ptyAdapter.spawnFailures.push(
1205 new Error("spawn custom-shell.exe ENOENT"),
1206 new Error("spawn pwsh.exe ENOENT"),
1207 );
1208
1209 yield* manager.open(openInput());
1210
1211 expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([
1212 "C:\\missing\\custom-shell.exe",
1213 "pwsh.exe",
1214 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
1215 ]);
1216 expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]);
1217 expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]);
1218 }),
1219 );
1220
1221 it.effect("filters app runtime env variables from terminal sessions", () =>
1222 Effect.gen(function* () {
1223 const { manager, ptyAdapter } = yield* createManager(5, {
1224 env: {
1225 PORT: "5173",
1226 T3CODE_PORT: "3773",
1227 VITE_DEV_SERVER_URL: "http://localhost:5173",
1228 TEST_TERMINAL_KEEP: "keep-me",
1229 },
1230 });
1231 yield* manager.open(openInput());
1232 const spawnInput = ptyAdapter.spawnInputs[0];
1233 expect(spawnInput).toBeDefined();
1234 if (!spawnInput) return;
1235
1236 expect(spawnInput.env.PORT).toBeUndefined();
1237 expect(spawnInput.env.T3CODE_PORT).toBeUndefined();
1238 expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined();
1239 // Arbitrary host env vars must pass through — terminals inherit the
1240 // user's environment apart from the explicit blocklist.
1241 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");
1242 }),
1243 );
1244
1245 it.effect("injects runtime env overrides into spawned terminals", () =>
1246 Effect.gen(function* () {
1247 const { manager, ptyAdapter } = yield* createManager();
1248 yield* manager.open(
1249 openInput({
1250 env: {
1251 T3CODE_PROJECT_ROOT: "/repo",
1252 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
1253 CUSTOM_FLAG: "1",
1254 },
1255 }),
1256 );
1257 const spawnInput = ptyAdapter.spawnInputs[0];
1258 expect(spawnInput).toBeDefined();
1259 if (!spawnInput) return;
1260
1261 assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo");
1262 assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a");
1263 assert.equal(spawnInput.env.CUSTOM_FLAG, "1");
1264 }),
1265 );
1266
1267 it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>
1268 Effect.gen(function* () {
1269 if ((yield* HostProcessPlatform) === "win32") return;
1270 const { manager, ptyAdapter } = yield* createManager(5, {
1271 shellResolver: () => "/bin/zsh",
1272 });
1273 yield* manager.open(openInput());
1274 const spawnInput = ptyAdapter.spawnInputs[0];
1275 expect(spawnInput).toBeDefined();
1276 if (!spawnInput) return;
1277
1278 expect(spawnInput.args).toEqual(["-o", "nopromptsp"]);
1279 }),
1280 );
1281
1282 it.effect("bridges PTY callbacks back into Effect-managed event streaming", () =>
1283 Effect.gen(function* () {
1284 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
1285 ptyAdapter: new FakePtyAdapter("async"),
1286 });
1287
1288 yield* manager.open(openInput());
1289 const process = ptyAdapter.processes[0];
1290 expect(process).toBeDefined();
1291 if (!process) return;
1292
1293 process.emitData("hello from callback\n");
1294
1295 yield* waitFor(
1296 Effect.map(getEvents, (events) =>
1297 events.some((event) => event.type === "output" && event.data === "hello from callback\n"),
1298 ),
1299 "1200 millis",
1300 );
1301 }),
1302 );
1303
1304 it.effect("pushes PTY callbacks to direct event subscribers", () =>
1305 Effect.gen(function* () {
1306 const { manager, ptyAdapter } = yield* createManager(5, {
1307 ptyAdapter: new FakePtyAdapter("async"),
1308 });
1309 const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
1310 const unsubscribe = yield* manager.subscribe((event) =>
1311 Ref.update(subscriberEvents, (events) => [...events, event]),
1312 );
1313 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1314
1315 yield* manager.open(openInput());
1316 const process = ptyAdapter.processes[0];
1317 expect(process).toBeDefined();
1318 if (!process) return;
1319
1320 process.emitData("hello from subscriber\n");
1321
1322 yield* waitFor(
1323 Effect.map(Ref.get(subscriberEvents), (events) =>
1324 events.some(
1325 (event) => event.type === "output" && event.data === "hello from subscriber\n",
1326 ),
1327 ),
1328 "1200 millis",
1329 );
1330 }),
1331 );
1332
1333 it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () =>
1334 Effect.gen(function* () {
1335 const { manager } = yield* createManager();
1336 yield* manager.open(openInput({ threadId: "existing-thread" }));
1337
1338 const metadataEvents = yield* Ref.make<ReadonlyArray<TerminalMetadataStreamEvent>>([]);
1339 const unsubscribe = yield* manager.subscribeMetadata((event) =>
1340 Ref.update(metadataEvents, (events) => [...events, event]),
1341 );
1342 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1343
1344 const initialEvents = yield* Ref.get(metadataEvents);
1345 expect(initialEvents[0]).toMatchObject({
1346 type: "snapshot",
1347 terminals: [
1348 {
1349 threadId: "existing-thread",
1350 terminalId: DEFAULT_TERMINAL_ID,
1351 },
1352 ],
1353 });
1354
1355 yield* manager.open(openInput({ threadId: "new-thread" }));
1356
1357 yield* waitFor(
1358 Effect.map(Ref.get(metadataEvents), (events) =>
1359 events.some(
1360 (event) =>
1361 event.type === "upsert" &&
1362 event.terminal.threadId === "new-thread" &&
1363 event.terminal.terminalId === DEFAULT_TERMINAL_ID,
1364 ),
1365 ),
1366 "1200 millis",
1367 );
1368
1369 yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID });
1370
1371 yield* waitFor(
1372 Effect.map(Ref.get(metadataEvents), (events) =>
1373 events.some(
1374 (event) =>
1375 event.type === "remove" &&
1376 event.threadId === "new-thread" &&
1377 event.terminalId === DEFAULT_TERMINAL_ID,
1378 ),
1379 ),
1380 "1200 millis",
1381 );
1382 }),
1383 );
1384
1385 it.effect("removes terminal metadata subscriptions when initial delivery fails", () =>
1386 Effect.gen(function* () {
1387 const { manager } = yield* createManager();
1388 yield* manager.open(openInput({ threadId: "existing-thread" }));
1389
1390 const leakedLiveEvents = yield* Ref.make(0);
1391 const exit = yield* Effect.exit(
1392 manager.subscribeMetadata((event) =>
1393 event.type === "snapshot"
1394 ? Effect.die("snapshot listener failed")
1395 : Ref.update(leakedLiveEvents, (count) => count + 1),
1396 ),
1397 );
1398
1399 expect(Exit.isFailure(exit)).toBe(true);
1400
1401 yield* manager.open(openInput({ threadId: "new-thread" }));
1402 expect(yield* Ref.get(leakedLiveEvents)).toBe(0);
1403 }),
1404 );
1405
1406 it.effect(
1407 "streams attach snapshots followed by live events without duplicate start snapshots",
1408 () =>
1409 Effect.gen(function* () {
1410 const { manager, ptyAdapter } = yield* createManager(5, {
1411 ptyAdapter: new FakePtyAdapter("async"),
1412 });
1413 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1414 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
1415 Ref.update(attachEvents, (events) => [...events, event]),
1416 );
1417 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1418
1419 const process = ptyAdapter.processes[0];
1420 expect(process).toBeDefined();
1421 if (!process) return;
1422
1423 expect(yield* Ref.get(attachEvents)).toMatchObject([
1424 {
1425 type: "snapshot",
1426 snapshot: {
1427 threadId: "thread-1",
1428 terminalId: DEFAULT_TERMINAL_ID,
1429 },
1430 },
1431 ]);
1432
1433 process.emitData("hello from attach\n");
1434
1435 yield* waitFor(
1436 Effect.map(Ref.get(attachEvents), (events) =>
1437 events.some((event) => event.type === "output" && event.data === "hello from attach\n"),
1438 ),
1439 "1200 millis",
1440 );
1441
1442 const events = yield* Ref.get(attachEvents);
1443 expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1);
1444 }),
1445 );
1446
1447 it.effect("buffers attach output delivered during the initial snapshot callback", () =>
1448 Effect.gen(function* () {
1449 const { manager, ptyAdapter } = yield* createManager(5, {
1450 ptyAdapter: new FakePtyAdapter("async"),
1451 });
1452 yield* manager.open(openInput());
1453
1454 const process = ptyAdapter.processes[0];
1455 expect(process).toBeDefined();
1456 if (!process) return;
1457
1458 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1459 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
1460 Effect.gen(function* () {
1461 yield* Ref.update(attachEvents, (events) => [...events, event]);
1462 if (event.type === "snapshot") {
1463 yield* Effect.sync(() => process.emitData("during snapshot\n"));
1464 yield* Effect.yieldNow;
1465 }
1466 }),
1467 );
1468 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1469
1470 yield* waitFor(
1471 Effect.map(Ref.get(attachEvents), (events) =>
1472 events.some((event) => event.type === "output" && event.data === "during snapshot\n"),
1473 ),
1474 "1200 millis",
1475 );
1476
1477 expect(yield* Ref.get(attachEvents)).toMatchObject([
1478 { type: "snapshot" },
1479 { type: "output", data: "during snapshot\n" },
1480 ]);
1481 }),
1482 );
1483
1484 it.effect("preserves queued PTY output ordering through exit callbacks", () =>
1485 Effect.gen(function* () {
1486 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
1487 ptyAdapter: new FakePtyAdapter("async"),
1488 });
1489
1490 yield* manager.open(openInput());
1491 const process = ptyAdapter.processes[0];
1492 expect(process).toBeDefined();
1493 if (!process) return;
1494
1495 process.emitData("first\n");
1496 process.emitData("second\n");
1497 process.emitExit({ exitCode: 0, signal: 0 });
1498
1499 yield* waitFor(
1500 Effect.map(getEvents, (events) => {
1501 const relevant = events.filter(
1502 (event) => event.type === "output" || event.type === "exited",
1503 );
1504 return relevant.length >= 3;
1505 }),
1506 "1200 millis",
1507 );
1508
1509 const relevant = (yield* getEvents).filter(
1510 (event) => event.type === "output" || event.type === "exited",
1511 );
1512 expect(relevant).toEqual([
1513 expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }),
1514 expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }),
1515 expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }),
1516 ]);
1517
1518 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1519 const unsubscribe = yield* manager.attachStream(
1520 {
1521 threadId: "thread-1",
1522 terminalId: DEFAULT_TERMINAL_ID,
1523 },
1524 (event) => Ref.update(attachEvents, (events) => [...events, event]),
1525 );
1526 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1527
1528 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
1529 expect(snapshot).toBeDefined();
1530 if (!snapshot || snapshot.type !== "snapshot") return;
1531 expect(snapshot.snapshot.sequence).toBe(4);
1532 }),
1533 );
1534
1535 it.effect("scoped runtime shutdown stops active terminals cleanly", () =>
1536 Effect.gen(function* () {
1537 const scope = yield* Scope.make("sequential");
1538 const { manager, ptyAdapter } = yield* createManager(5, {
1539 processKillGraceMs: 10,
1540 }).pipe(Effect.provideService(Scope.Scope, scope));
1541 yield* manager.open(openInput());
1542 const process = ptyAdapter.processes[0];
1543 expect(process).toBeDefined();
1544 if (!process) return;
1545
1546 const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped);
1547 yield* Effect.yieldNow;
1548 yield* TestClock.adjust("10 millis");
1549 yield* Fiber.join(closeScope);
1550
1551 assert.equal(process.killSignals[0], "SIGTERM");
1552 expect(process.killSignals).toContain("SIGKILL");
1553 }).pipe(Effect.provide(TestClock.layer())),
1554 );
1555});
apps/server/src/terminal/Layers/Manager.ts deleted-2488
...@@ -1,2488 +0,0 @@
1import {
2 DEFAULT_TERMINAL_ID,
3 type TerminalAttachInput,
4 type TerminalAttachStreamEvent,
5 type TerminalEvent,
6 type TerminalMetadataStreamEvent,
7 type TerminalOpenInput,
8 type TerminalResizeInput,
9 type TerminalSessionSnapshot,
10 type TerminalSessionStatus,
11 type TerminalSummary,
12} from "@t3tools/contracts";
13import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
15import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as Encoding from "effect/Encoding";
19import * as Equal from "effect/Equal";
20import * as Exit from "effect/Exit";
21import * as Fiber from "effect/Fiber";
22import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import * as Schema from "effect/Schema";
27import * as Scope from "effect/Scope";
28import * as Semaphore from "effect/Semaphore";
29import * as SynchronizedRef from "effect/SynchronizedRef";
30
31import { ServerConfig } from "../../config.ts";
32import {
33 increment,
34 terminalRestartsTotal,
35 terminalSessionsTotal,
36} from "../../observability/Metrics.ts";
37import * as ProcessRunner from "../../processRunner.ts";
38import * as PortScanner from "../../preview/PortScanner.ts";
39import {
40 TerminalCwdError,
41 TerminalHistoryError,
42 TerminalManager,
43 TerminalNotRunningError,
44 TerminalSessionLookupError,
45 type TerminalManagerShape,
46} from "../Services/Manager.ts";
47import {
48 PtyAdapter,
49 PtySpawnError,
50 type PtyAdapterShape,
51 type PtyExitEvent,
52 type PtyProcess,
53} from "../Services/PTY.ts";
54
55const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
56const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
57const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
58const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;
59const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;
60const DEFAULT_OPEN_COLS = 120;
61const DEFAULT_OPEN_ROWS = 30;
62const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
63const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
64const MAX_TERMINAL_LABEL_LENGTH = 128;
65
66class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
67 "TerminalSubprocessCheckError",
68 {
69 message: Schema.String,
70 cause: Schema.optional(Schema.Defect()),
71 terminalPid: Schema.Number,
72 command: Schema.Literals(["powershell", "pgrep", "ps"]),
73 },
74) {}
75
76class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcessSignalError>()(
77 "TerminalProcessSignalError",
78 {
79 message: Schema.String,
80 cause: Schema.optional(Schema.Defect()),
81 signal: Schema.Literals(["SIGTERM", "SIGKILL"]),
82 },
83) {}
84
85interface TerminalSubprocessInspectResult {
86 readonly hasRunningSubprocess: boolean;
87 readonly childCommand: string | null;
88 readonly processIds: ReadonlyArray<number>;
89}
90
91interface TerminalSubprocessInspector {
92 (
93 terminalPid: number,
94 ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;
95}
96
97interface ShellCandidate {
98 shell: string;
99 args?: string[];
100}
101
102interface TerminalStartInput {
103 threadId: string;
104 terminalId: string;
105 cwd: string;
106 worktreePath?: string | null;
107 cols: number;
108 rows: number;
109 env?: Record<string, string>;
110}
111
112interface TerminalSessionState {
113 threadId: string;
114 terminalId: string;
115 cwd: string;
116 worktreePath: string | null;
117 status: TerminalSessionStatus;
118 pid: number | null;
119 history: string;
120 pendingHistoryControlSequence: string;
121 pendingProcessEvents: Array<PendingProcessEvent>;
122 pendingProcessEventIndex: number;
123 processEventDrainRunning: boolean;
124 exitCode: number | null;
125 exitSignal: number | null;
126 updatedAt: string;
127 eventSequence: number;
128 cols: number;
129 rows: number;
130 process: PtyProcess | null;
131 unsubscribeData: (() => void) | null;
132 unsubscribeExit: (() => void) | null;
133 hasRunningSubprocess: boolean;
134 /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */
135 childCommandLabel: string | null;
136 runtimeEnv: Record<string, string> | null;
137}
138
139interface PersistHistoryRequest {
140 history: string;
141 immediate: boolean;
142}
143
144type PendingProcessEvent = { type: "output"; data: string } | { type: "exit"; event: PtyExitEvent };
145
146type DrainProcessEventAction =
147 | { type: "idle" }
148 | {
149 type: "output";
150 threadId: string;
151 terminalId: string;
152 sequence: number;
153 history: string | null;
154 data: string;
155 }
156 | {
157 type: "exit";
158 process: PtyProcess | null;
159 threadId: string;
160 terminalId: string;
161 sequence: number;
162 exitCode: number | null;
163 exitSignal: number | null;
164 };
165
166interface TerminalManagerState {
167 sessions: Map<string, TerminalSessionState>;
168 killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;
169}
170
171function truncateTerminalWireLabel(value: string): string {
172 if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value;
173 return value.slice(0, MAX_TERMINAL_LABEL_LENGTH);
174}
175
176function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null {
177 let trimmed = raw.trim();
178 if (trimmed.length === 0) return null;
179 if (
180 (trimmed.startsWith("[") && trimmed.endsWith("]")) ||
181 (trimmed.startsWith("(") && trimmed.endsWith(")"))
182 ) {
183 trimmed = trimmed.slice(1, -1).trim();
184 }
185 const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim();
186 if (firstToken.length === 0) return null;
187 const separators = platform === "win32" ? /[\\/]/ : /\//;
188 const base = firstToken.split(separators).at(-1) ?? firstToken;
189 const withoutExe =
190 platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base;
191 return withoutExe.length > 0 ? withoutExe : null;
192}
193
194function terminalWireLabel(session: TerminalSessionState): string {
195 if (session.hasRunningSubprocess && session.childCommandLabel) {
196 const trimmed = session.childCommandLabel.trim();
197 if (trimmed.length > 0) {
198 return truncateTerminalWireLabel(trimmed);
199 }
200 }
201 return truncateTerminalWireLabel(getTerminalLabel(session.terminalId));
202}
203
204function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
205 return {
206 threadId: session.threadId,
207 terminalId: session.terminalId,
208 cwd: session.cwd,
209 worktreePath: session.worktreePath,
210 status: session.status,
211 pid: session.pid,
212 history: session.history,
213 exitCode: session.exitCode,
214 exitSignal: session.exitSignal,
215 label: terminalWireLabel(session),
216 updatedAt: session.updatedAt,
217 sequence: session.eventSequence,
218 };
219}
220
221function summary(session: TerminalSessionState): TerminalSummary {
222 return {
223 threadId: session.threadId,
224 terminalId: session.terminalId,
225 cwd: session.cwd,
226 worktreePath: session.worktreePath,
227 status: session.status,
228 pid: session.pid,
229 exitCode: session.exitCode,
230 exitSignal: session.exitSignal,
231 hasRunningSubprocess: session.hasRunningSubprocess,
232 label: terminalWireLabel(session),
233 updatedAt: session.updatedAt,
234 };
235}
236
237function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean {
238 switch (event.type) {
239 case "started":
240 case "restarted":
241 case "exited":
242 case "closed":
243 case "error":
244 case "activity":
245 return true;
246 case "output":
247 case "cleared":
248 return false;
249 }
250}
251
252function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null {
253 switch (event.type) {
254 case "started":
255 return {
256 type: "snapshot",
257 snapshot: event.snapshot,
258 };
259 case "output":
260 case "exited":
261 case "closed":
262 case "error":
263 case "cleared":
264 case "restarted":
265 case "activity":
266 return event;
267 }
268}
269
270function isDuplicateAttachSnapshotEvent(
271 event: TerminalEvent,
272 initialSnapshot: TerminalSessionSnapshot,
273) {
274 return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number"
275 ? event.sequence <= initialSnapshot.sequence
276 : event.type === "started" &&
277 event.snapshot.threadId === initialSnapshot.threadId &&
278 event.snapshot.terminalId === initialSnapshot.terminalId &&
279 event.snapshot.updatedAt <= initialSnapshot.updatedAt;
280}
281
282function advanceEventSequence(session: TerminalSessionState): {
283 readonly updatedAt: string;
284 readonly sequence: number;
285} {
286 const updatedAt = DateTime.formatIso(DateTime.nowUnsafe());
287 session.eventSequence += 1;
288 session.updatedAt = updatedAt;
289 return { updatedAt, sequence: session.eventSequence };
290}
291
292function cleanupProcessHandles(session: TerminalSessionState): void {
293 session.unsubscribeData?.();
294 session.unsubscribeData = null;
295 session.unsubscribeExit?.();
296 session.unsubscribeExit = null;
297}
298
299function enqueueProcessEvent(
300 session: TerminalSessionState,
301 expectedPid: number,
302 event: PendingProcessEvent,
303): boolean {
304 if (!session.process || session.status !== "running" || session.pid !== expectedPid) {
305 return false;
306 }
307
308 session.pendingProcessEvents.push(event);
309 if (session.processEventDrainRunning) {
310 return false;
311 }
312
313 session.processEventDrainRunning = true;
314 return true;
315}
316
317function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
318 if (platform === "win32") {
319 return "pwsh.exe";
320 }
321 return env.SHELL ?? "bash";
322}
323
324function normalizeShellCommand(
325 value: string | undefined,
326 platform: NodeJS.Platform,
327): string | null {
328 if (!value) return null;
329 const trimmed = value.trim();
330 if (trimmed.length === 0) return null;
331
332 if (platform === "win32") {
333 return trimmed;
334 }
335
336 const firstToken = trimmed.split(/\s+/g)[0]?.trim();
337 if (!firstToken) return null;
338 return firstToken.replace(/^['"]|['"]$/g, "");
339}
340
341function basenameForPlatform(command: string, platform: NodeJS.Platform): string {
342 const normalized =
343 platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/");
344 const parts = normalized
345 .split(platform === "win32" ? /\\+/ : /\/+/)
346 .filter((part) => part.length > 0);
347 return parts.at(-1) ?? normalized;
348}
349
350function joinWindowsPath(...parts: ReadonlyArray<string>): string {
351 return parts
352 .map((part, index) => {
353 if (index === 0) return part.replace(/[\\/]+$/g, "");
354 return part.replace(/^[\\/]+|[\\/]+$/g, "");
355 })
356 .filter((part) => part.length > 0)
357 .join("\\");
358}
359
360function shellCandidateFromCommand(
361 command: string | null,
362 platform: NodeJS.Platform,
363): ShellCandidate | null {
364 if (!command || command.length === 0) return null;
365 const shellName = basenameForPlatform(command, platform).toLowerCase();
366 if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) {
367 return { shell: command, args: ["-NoLogo"] };
368 }
369 if (platform !== "win32" && shellName === "zsh") {
370 return { shell: command, args: ["-o", "nopromptsp"] };
371 }
372 return { shell: command };
373}
374
375function windowsSystemRoot(env: NodeJS.ProcessEnv): string {
376 return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows";
377}
378
379function windowsPowerShellPath(env: NodeJS.ProcessEnv): string {
380 return joinWindowsPath(
381 windowsSystemRoot(env),
382 "System32",
383 "WindowsPowerShell",
384 "v1.0",
385 "powershell.exe",
386 );
387}
388
389function windowsCmdPath(env: NodeJS.ProcessEnv): string {
390 return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe");
391}
392
393function formatShellCandidate(candidate: ShellCandidate): string {
394 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
395 return `${candidate.shell} ${candidate.args.join(" ")}`;
396}
397
398function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] {
399 const seen = new Set<string>();
400 const ordered: ShellCandidate[] = [];
401 for (const candidate of candidates) {
402 if (!candidate) continue;
403 const key = formatShellCandidate(candidate);
404 if (seen.has(key)) continue;
405 seen.add(key);
406 ordered.push(candidate);
407 }
408 return ordered;
409}
410
411function resolveShellCandidates(
412 shellResolver: () => string,
413 platform: NodeJS.Platform,
414 env: NodeJS.ProcessEnv,
415): ShellCandidate[] {
416 const requested = shellCandidateFromCommand(
417 normalizeShellCommand(shellResolver(), platform),
418 platform,
419 );
420
421 if (platform === "win32") {
422 return uniqueShellCandidates([
423 requested,
424 shellCandidateFromCommand("pwsh.exe", platform),
425 shellCandidateFromCommand(windowsPowerShellPath(env), platform),
426 shellCandidateFromCommand("powershell.exe", platform),
427 shellCandidateFromCommand(env.ComSpec ?? null, platform),
428 shellCandidateFromCommand(windowsCmdPath(env), platform),
429 shellCandidateFromCommand("cmd.exe", platform),
430 ]);
431 }
432
433 return uniqueShellCandidates([
434 requested,
435 shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform),
436 shellCandidateFromCommand("/bin/zsh", platform),
437 shellCandidateFromCommand("/bin/bash", platform),
438 shellCandidateFromCommand("/bin/sh", platform),
439 shellCandidateFromCommand("zsh", platform),
440 shellCandidateFromCommand("bash", platform),
441 shellCandidateFromCommand("sh", platform),
442 ]);
443}
444
445function isRetryableShellSpawnError(error: PtySpawnError): boolean {
446 const queue: unknown[] = [error];
447 const seen = new Set<unknown>();
448 const messages: string[] = [];
449
450 while (queue.length > 0) {
451 const current = queue.shift();
452 if (!current || seen.has(current)) {
453 continue;
454 }
455 seen.add(current);
456
457 if (typeof current === "string") {
458 messages.push(current);
459 continue;
460 }
461
462 if (current instanceof Error) {
463 messages.push(current.message);
464 if (current.cause) {
465 queue.push(current.cause);
466 }
467 continue;
468 }
469
470 if (typeof current === "object") {
471 const value = current as { message?: unknown; cause?: unknown };
472 if (typeof value.message === "string") {
473 messages.push(value.message);
474 }
475 if (value.cause) {
476 queue.push(value.cause);
477 }
478 }
479 }
480
481 const message = messages.join(" ").toLowerCase();
482 return (
483 message.includes("posix_spawnp failed") ||
484 message.includes("enoent") ||
485 message.includes("not found") ||
486 message.includes("file not found") ||
487 message.includes("no such file")
488 );
489}
490
491function parseFirstChildPidFromPgrep(stdout: string): number | null {
492 for (const line of stdout.split(/\r?\n/g)) {
493 const n = Number.parseInt(line.trim(), 10);
494 if (Number.isInteger(n) && n > 0) {
495 return n;
496 }
497 }
498 return null;
499}
500
501function windowsInspectSubprocess(
502 terminalPid: number,
503 platform: NodeJS.Platform,
504): Effect.Effect<
505 TerminalSubprocessInspectResult,
506 TerminalSubprocessCheckError,
507 ProcessRunner.ProcessRunner
508> {
509 const command =
510 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
511 return Effect.gen(function* () {
512 const processRunner = yield* ProcessRunner.ProcessRunner;
513 return yield* processRunner.run({
514 // powershell.exe is a real executable — never spawn it through cmd.exe
515 // shell mode, which would re-tokenize the `-Command` payload (pipes,
516 // semicolons) before PowerShell ever sees it.
517 command: "powershell.exe",
518 args: ["-NoProfile", "-NonInteractive", "-Command", command],
519 timeout: "1500 millis",
520 maxOutputBytes: 32_768,
521 outputMode: "truncate",
522 timeoutBehavior: "timedOutResult",
523 });
524 }).pipe(
525 Effect.map((result) => {
526 if (result.code !== 0) {
527 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
528 }
529 const processNameById = new Map<number, string>();
530 const childrenByParent = new Map<number, number[]>();
531 for (const line of result.stdout.split(/\r?\n/g)) {
532 const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3);
533 const pid = Number(pidRaw);
534 const parentPid = Number(parentPidRaw);
535 if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
536 processNameById.set(pid, nameRaw?.trim() ?? "");
537 const children = childrenByParent.get(parentPid) ?? [];
538 children.push(pid);
539 childrenByParent.set(parentPid, children);
540 }
541 const directChildren = childrenByParent.get(terminalPid) ?? [];
542 const childPid = directChildren[0];
543 if (childPid === undefined) {
544 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
545 }
546 const processIds = new Set<number>([terminalPid]);
547 const pending = [terminalPid];
548 while (pending.length > 0) {
549 const parentPid = pending.pop();
550 if (parentPid === undefined) continue;
551 for (const pid of childrenByParent.get(parentPid) ?? []) {
552 if (processIds.has(pid)) continue;
553 processIds.add(pid);
554 pending.push(pid);
555 }
556 }
557 const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform);
558 return {
559 hasRunningSubprocess: true,
560 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
561 processIds: [...processIds],
562 } as const;
563 }),
564 Effect.mapError(
565 (cause) =>
566 new TerminalSubprocessCheckError({
567 message: "Failed to inspect Windows terminal subprocesses.",
568 cause,
569 terminalPid,
570 command: "powershell",
571 }),
572 ),
573 );
574}
575
576const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* (
577 terminalPid: number,
578 platform: NodeJS.Platform,
579): Effect.fn.Return<
580 TerminalSubprocessInspectResult,
581 TerminalSubprocessCheckError,
582 ProcessRunner.ProcessRunner
583> {
584 const processRunner = yield* ProcessRunner.ProcessRunner;
585 const runPgrep = processRunner
586 .run({
587 command: "pgrep",
588 args: ["-P", String(terminalPid)],
589 timeout: "1 second",
590 maxOutputBytes: 32_768,
591 outputMode: "truncate",
592 timeoutBehavior: "timedOutResult",
593 })
594 .pipe(
595 Effect.mapError(
596 (cause) =>
597 new TerminalSubprocessCheckError({
598 message: "Failed to inspect terminal subprocesses with pgrep.",
599 cause,
600 terminalPid,
601 command: "pgrep",
602 }),
603 ),
604 );
605
606 const runPs = processRunner
607 .run({
608 command: "ps",
609 args: ["-eo", "pid=,ppid="],
610 timeout: "1 second",
611 maxOutputBytes: 262_144,
612 outputMode: "truncate",
613 timeoutBehavior: "timedOutResult",
614 })
615 .pipe(
616 Effect.mapError(
617 (cause) =>
618 new TerminalSubprocessCheckError({
619 message: "Failed to inspect terminal subprocesses with ps.",
620 cause,
621 terminalPid,
622 command: "ps",
623 }),
624 ),
625 );
626
627 let childPid: number | null = null;
628
629 const pgrepResult = yield* Effect.exit(runPgrep);
630 if (pgrepResult._tag === "Success") {
631 if (pgrepResult.value.code === 0) {
632 childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
633 } else if (pgrepResult.value.code === 1) {
634 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
635 }
636 }
637
638 if (childPid === null) {
639 const psResult = yield* Effect.exit(runPs);
640 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
641 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
642 }
643 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
644 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
645 const pid = Number(pidRaw);
646 const ppid = Number(ppidRaw);
647 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
648 if (ppid === terminalPid) {
649 childPid = pid;
650 break;
651 }
652 }
653 }
654
655 if (childPid === null) {
656 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
657 }
658
659 const runComm = processRunner.run({
660 command: "ps",
661 args: ["-p", String(childPid), "-o", "comm="],
662 timeout: "1 second",
663 maxOutputBytes: 8_192,
664 outputMode: "truncate",
665 timeoutBehavior: "timedOutResult",
666 });
667
668 const commResult = yield* Effect.exit(runComm);
669 let rawComm: string | null = null;
670 if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) {
671 rawComm = commResult.value.stdout.trim();
672 }
673
674 if (!rawComm || rawComm.length === 0) {
675 const runArgs = processRunner.run({
676 command: "ps",
677 args: ["-p", String(childPid), "-o", "args="],
678 timeout: "1 second",
679 maxOutputBytes: 16_384,
680 outputMode: "truncate",
681 timeoutBehavior: "timedOutResult",
682 });
683 const argsResult = yield* Effect.exit(runArgs);
684 if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) {
685 const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? "";
686 rawComm = first.length > 0 ? first : null;
687 }
688 }
689
690 const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;
691 const processIds = new Set<number>([terminalPid]);
692 const psResult = yield* Effect.exit(runPs);
693 if (psResult._tag === "Success" && psResult.value.code === 0) {
694 const childrenByParent = new Map<number, number[]>();
695 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
696 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
697 const pid = Number(pidRaw);
698 const ppid = Number(ppidRaw);
699 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
700 const children = childrenByParent.get(ppid) ?? [];
701 children.push(pid);
702 childrenByParent.set(ppid, children);
703 }
704 const pending = [terminalPid];
705 while (pending.length > 0) {
706 const parentPid = pending.pop();
707 if (parentPid === undefined) continue;
708 for (const child of childrenByParent.get(parentPid) ?? []) {
709 if (processIds.has(child)) continue;
710 processIds.add(child);
711 pending.push(child);
712 }
713 }
714 } else {
715 processIds.add(childPid);
716 }
717 return {
718 hasRunningSubprocess: true,
719 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
720 processIds: [...processIds],
721 };
722});
723
724function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) {
725 return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {
726 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
727 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
728 }
729 if (platform === "win32") {
730 return yield* windowsInspectSubprocess(terminalPid, platform);
731 }
732 return yield* posixInspectSubprocess(terminalPid, platform);
733 });
734}
735
736function capHistory(history: string, maxLines: number): string {
737 if (history.length === 0) return history;
738 const hasTrailingNewline = history.endsWith("\n");
739 const lines = history.split("\n");
740 if (hasTrailingNewline) {
741 lines.pop();
742 }
743 if (lines.length <= maxLines) return history;
744 const capped = lines.slice(lines.length - maxLines).join("\n");
745 return hasTrailingNewline ? `${capped}\n` : capped;
746}
747
748function isCsiFinalByte(codePoint: number): boolean {
749 return codePoint >= 0x40 && codePoint <= 0x7e;
750}
751
752function shouldStripCsiSequence(body: string, finalByte: string): boolean {
753 if (finalByte === "n") {
754 return true;
755 }
756 if (finalByte === "R" && /^[0-9;?]*$/.test(body)) {
757 return true;
758 }
759 if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {
760 return true;
761 }
762 return false;
763}
764
765function shouldStripOscSequence(content: string): boolean {
766 return /^(10|11|12);(?:\?|rgb:)/.test(content);
767}
768
769function stripStringTerminator(value: string): string {
770 if (value.endsWith("\u001b\\")) {
771 return value.slice(0, -2);
772 }
773 const lastCharacter = value.at(-1);
774 if (lastCharacter === "\u0007" || lastCharacter === "\u009c") {
775 return value.slice(0, -1);
776 }
777 return value;
778}
779
780function findStringTerminatorIndex(input: string, start: number): number | null {
781 for (let index = start; index < input.length; index += 1) {
782 const codePoint = input.charCodeAt(index);
783 if (codePoint === 0x07 || codePoint === 0x9c) {
784 return index + 1;
785 }
786 if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) {
787 return index + 2;
788 }
789 }
790 return null;
791}
792
793function isEscapeIntermediateByte(codePoint: number): boolean {
794 return codePoint >= 0x20 && codePoint <= 0x2f;
795}
796
797function isEscapeFinalByte(codePoint: number): boolean {
798 return codePoint >= 0x30 && codePoint <= 0x7e;
799}
800
801function findEscapeSequenceEndIndex(input: string, start: number): number | null {
802 let cursor = start;
803 while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) {
804 cursor += 1;
805 }
806 if (cursor >= input.length) {
807 return null;
808 }
809 return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1;
810}
811
812function sanitizeTerminalHistoryChunk(
813 pendingControlSequence: string,
814 data: string,
815): { visibleText: string; pendingControlSequence: string } {
816 const input = `${pendingControlSequence}${data}`;
817 let visibleText = "";
818 let index = 0;
819
820 const append = (value: string) => {
821 visibleText += value;
822 };
823
824 while (index < input.length) {
825 const codePoint = input.charCodeAt(index);
826
827 if (codePoint === 0x1b) {
828 const nextCodePoint = input.charCodeAt(index + 1);
829 if (Number.isNaN(nextCodePoint)) {
830 return { visibleText, pendingControlSequence: input.slice(index) };
831 }
832
833 if (nextCodePoint === 0x5b) {
834 let cursor = index + 2;
835 while (cursor < input.length) {
836 if (isCsiFinalByte(input.charCodeAt(cursor))) {
837 const sequence = input.slice(index, cursor + 1);
838 const body = input.slice(index + 2, cursor);
839 if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
840 append(sequence);
841 }
842 index = cursor + 1;
843 break;
844 }
845 cursor += 1;
846 }
847 if (cursor >= input.length) {
848 return { visibleText, pendingControlSequence: input.slice(index) };
849 }
850 continue;
851 }
852
853 if (
854 nextCodePoint === 0x5d ||
855 nextCodePoint === 0x50 ||
856 nextCodePoint === 0x5e ||
857 nextCodePoint === 0x5f
858 ) {
859 const terminatorIndex = findStringTerminatorIndex(input, index + 2);
860 if (terminatorIndex === null) {
861 return { visibleText, pendingControlSequence: input.slice(index) };
862 }
863 const sequence = input.slice(index, terminatorIndex);
864 const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));
865 if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) {
866 append(sequence);
867 }
868 index = terminatorIndex;
869 continue;
870 }
871
872 const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1);
873 if (escapeSequenceEndIndex === null) {
874 return { visibleText, pendingControlSequence: input.slice(index) };
875 }
876 append(input.slice(index, escapeSequenceEndIndex));
877 index = escapeSequenceEndIndex;
878 continue;
879 }
880
881 if (codePoint === 0x9b) {
882 let cursor = index + 1;
883 while (cursor < input.length) {
884 if (isCsiFinalByte(input.charCodeAt(cursor))) {
885 const sequence = input.slice(index, cursor + 1);
886 const body = input.slice(index + 1, cursor);
887 if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
888 append(sequence);
889 }
890 index = cursor + 1;
891 break;
892 }
893 cursor += 1;
894 }
895 if (cursor >= input.length) {
896 return { visibleText, pendingControlSequence: input.slice(index) };
897 }
898 continue;
899 }
900
901 if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) {
902 const terminatorIndex = findStringTerminatorIndex(input, index + 1);
903 if (terminatorIndex === null) {
904 return { visibleText, pendingControlSequence: input.slice(index) };
905 }
906 const sequence = input.slice(index, terminatorIndex);
907 const content = stripStringTerminator(input.slice(index + 1, terminatorIndex));
908 if (codePoint !== 0x9d || !shouldStripOscSequence(content)) {
909 append(sequence);
910 }
911 index = terminatorIndex;
912 continue;
913 }
914
915 append(input[index] ?? "");
916 index += 1;
917 }
918
919 return { visibleText, pendingControlSequence: "" };
920}
921
922function legacySafeThreadId(threadId: string): string {
923 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
924}
925
926function toSafeThreadId(threadId: string): string {
927 return `terminal_${Encoding.encodeBase64Url(threadId)}`;
928}
929
930function toSafeTerminalId(terminalId: string): string {
931 return Encoding.encodeBase64Url(terminalId);
932}
933
934function toSessionKey(threadId: string, terminalId: string): string {
935 return `${threadId}\u0000${terminalId}`;
936}
937
938function shouldExcludeTerminalEnvKey(key: string): boolean {
939 const normalizedKey = key.toUpperCase();
940 if (normalizedKey.startsWith("T3CODE_")) {
941 return true;
942 }
943 if (normalizedKey.startsWith("VITE_")) {
944 return true;
945 }
946 return TERMINAL_ENV_BLOCKLIST.has(normalizedKey);
947}
948
949function createTerminalSpawnEnv(
950 baseEnv: NodeJS.ProcessEnv,
951 runtimeEnv?: Record<string, string> | null,
952): NodeJS.ProcessEnv {
953 const spawnEnv: NodeJS.ProcessEnv = {};
954 for (const [key, value] of Object.entries(baseEnv)) {
955 if (value === undefined) continue;
956 if (shouldExcludeTerminalEnvKey(key)) continue;
957 spawnEnv[key] = value;
958 }
959 if (runtimeEnv) {
960 for (const [key, value] of Object.entries(runtimeEnv)) {
961 spawnEnv[key] = value;
962 }
963 }
964 return spawnEnv;
965}
966
967function normalizedRuntimeEnv(
968 env: Record<string, string> | undefined,
969): Record<string, string> | null {
970 if (!env) return null;
971 const entries = Object.entries(env);
972 if (entries.length === 0) return null;
973 return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right)));
974}
975
976interface TerminalManagerOptions {
977 logsDir: string;
978 historyLineLimit?: number;
979 ptyAdapter: PtyAdapterShape;
980 shellResolver?: () => string;
981 env?: NodeJS.ProcessEnv;
982 subprocessInspector?: TerminalSubprocessInspector;
983 subprocessPollIntervalMs?: number;
984 processKillGraceMs?: number;
985 maxRetainedInactiveSessions?: number;
986 registerTerminalProcesses?: (input: {
987 readonly threadId: string;
988 readonly terminalId: string;
989 readonly processIds: ReadonlyArray<number>;
990 }) => Effect.Effect<void>;
991 unregisterTerminal?: (input: {
992 readonly threadId: string;
993 readonly terminalId: string;
994 }) => Effect.Effect<void>;
995}
996
997const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () {
998 const { terminalLogsDir } = yield* ServerConfig;
999 const ptyAdapter = yield* PtyAdapter;
1000 const portDiscovery = yield* PortScanner.PortDiscovery;
1001 return yield* makeTerminalManagerWithOptions({
1002 logsDir: terminalLogsDir,
1003 ptyAdapter,
1004 registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
1005 unregisterTerminal: portDiscovery.unregisterTerminal,
1006 });
1007});
1008
1009export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")(
1010 function* (options: TerminalManagerOptions) {
1011 const fileSystem = yield* FileSystem.FileSystem;
1012 const path = yield* Path.Path;
1013 const context = yield* Effect.context<never>();
1014 const runFork = Effect.runForkWith(context);
1015
1016 const logsDir = options.logsDir;
1017 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
1018 const platform = yield* HostProcessPlatform;
1019 // Terminals must inherit the user's full environment (minus the blocklist
1020 // applied in createTerminalSpawnEnv) — an allowlist here silently strips
1021 // things like PSModulePath, DISPLAY, proxies, and toolchain variables.
1022 // `options.env` is the test seam.
1023 const baseEnv = options.env ?? process.env;
1024 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
1025 const processRunner = yield* ProcessRunner.ProcessRunner;
1026 const subprocessInspector =
1027 options.subprocessInspector ??
1028 ((terminalPid) =>
1029 defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe(
1030 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
1031 ));
1032 const subprocessPollIntervalMs =
1033 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
1034 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
1035 const maxRetainedInactiveSessions =
1036 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;
1037 const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void);
1038 const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void);
1039
1040 yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie);
1041
1042 const managerStateRef = yield* SynchronizedRef.make<TerminalManagerState>({
1043 sessions: new Map(),
1044 killFibers: new Map(),
1045 });
1046 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
1047 const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
1048 const workerScope = yield* Scope.make("sequential");
1049 yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void));
1050
1051 const publishEvent = (event: TerminalEvent) =>
1052 Effect.gen(function* () {
1053 for (const listener of terminalEventListeners) {
1054 yield* listener(event).pipe(Effect.ignoreCause({ log: true }));
1055 }
1056 });
1057
1058 const historyPath = (threadId: string, terminalId: string) => {
1059 const threadPart = toSafeThreadId(threadId);
1060 if (terminalId === DEFAULT_TERMINAL_ID) {
1061 return path.join(logsDir, `${threadPart}.log`);
1062 }
1063 return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`);
1064 };
1065
1066 const legacyHistoryPath = (threadId: string) =>
1067 path.join(logsDir, `${legacySafeThreadId(threadId)}.log`);
1068
1069 const toTerminalHistoryError =
1070 (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) =>
1071 (cause: unknown) =>
1072 new TerminalHistoryError({
1073 operation,
1074 threadId,
1075 terminalId,
1076 cause,
1077 });
1078
1079 const readManagerState = SynchronizedRef.get(managerStateRef);
1080
1081 const modifyManagerState = <A>(
1082 f: (state: TerminalManagerState) => readonly [A, TerminalManagerState],
1083 ) => SynchronizedRef.modify(managerStateRef, f);
1084
1085 const getThreadSemaphore = (threadId: string) =>
1086 SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
1087 const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr(
1088 current.get(threadId),
1089 );
1090 return Option.match(existing, {
1091 onNone: () =>
1092 Semaphore.make(1).pipe(
1093 Effect.map((semaphore) => {
1094 const next = new Map(current);
1095 next.set(threadId, semaphore);
1096 return [semaphore, next] as const;
1097 }),
1098 ),
1099 onSome: (semaphore) => Effect.succeed([semaphore, current] as const),
1100 });
1101 });
1102
1103 const withThreadLock = <A, E, R>(
1104 threadId: string,
1105 effect: Effect.Effect<A, E, R>,
1106 ): Effect.Effect<A, E, R> =>
1107 Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
1108
1109 const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* (
1110 process: PtyProcess | null,
1111 ) {
1112 if (!process) return;
1113 const fiber: Option.Option<Fiber.Fiber<void, never>> = yield* modifyManagerState<
1114 Option.Option<Fiber.Fiber<void, never>>
1115 >((state) => {
1116 const existing: Option.Option<Fiber.Fiber<void, never>> = Option.fromNullishOr(
1117 state.killFibers.get(process),
1118 );
1119 if (Option.isNone(existing)) {
1120 return [Option.none<Fiber.Fiber<void, never>>(), state] as const;
1121 }
1122 const killFibers = new Map(state.killFibers);
1123 killFibers.delete(process);
1124 return [existing, { ...state, killFibers }] as const;
1125 });
1126 if (Option.isSome(fiber)) {
1127 yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore);
1128 }
1129 });
1130
1131 const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* (
1132 process: PtyProcess,
1133 fiber: Fiber.Fiber<void, never>,
1134 ) {
1135 yield* modifyManagerState((state) => {
1136 const killFibers = new Map(state.killFibers);
1137 killFibers.set(process, fiber);
1138 return [undefined, { ...state, killFibers }] as const;
1139 });
1140 });
1141
1142 const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* (
1143 process: PtyProcess,
1144 threadId: string,
1145 terminalId: string,
1146 ) {
1147 const terminated = yield* Effect.try({
1148 try: () => process.kill("SIGTERM"),
1149 catch: (cause) =>
1150 new TerminalProcessSignalError({
1151 message: "Failed to send SIGTERM to terminal process.",
1152 cause,
1153 signal: "SIGTERM",
1154 }),
1155 }).pipe(
1156 Effect.as(true),
1157 Effect.catch((error) =>
1158 Effect.logWarning("failed to kill terminal process", {
1159 threadId,
1160 terminalId,
1161 signal: "SIGTERM",
1162 error: error.message,
1163 }).pipe(Effect.as(false)),
1164 ),
1165 );
1166 if (!terminated) {
1167 return;
1168 }
1169
1170 yield* Effect.sleep(processKillGraceMs);
1171
1172 yield* Effect.try({
1173 try: () => process.kill("SIGKILL"),
1174 catch: (cause) =>
1175 new TerminalProcessSignalError({
1176 message: "Failed to send SIGKILL to terminal process.",
1177 cause,
1178 signal: "SIGKILL",
1179 }),
1180 }).pipe(
1181 Effect.catch((error) =>
1182 Effect.logWarning("failed to force-kill terminal process", {
1183 threadId,
1184 terminalId,
1185 signal: "SIGKILL",
1186 error: error.message,
1187 }),
1188 ),
1189 );
1190 });
1191
1192 const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* (
1193 process: PtyProcess,
1194 threadId: string,
1195 terminalId: string,
1196 ) {
1197 const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe(
1198 Effect.ensuring(
1199 modifyManagerState((state) => {
1200 if (!state.killFibers.has(process)) {
1201 return [undefined, state] as const;
1202 }
1203 const killFibers = new Map(state.killFibers);
1204 killFibers.delete(process);
1205 return [undefined, { ...state, killFibers }] as const;
1206 }),
1207 ),
1208 Effect.forkIn(workerScope),
1209 );
1210
1211 yield* registerKillFiber(process, fiber);
1212 });
1213
1214 const persistWorker = yield* makeKeyedCoalescingWorker<
1215 string,
1216 PersistHistoryRequest,
1217 never,
1218 never
1219 >({
1220 merge: (current, next) => ({
1221 history: next.history,
1222 immediate: current.immediate || next.immediate,
1223 }),
1224 process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) {
1225 if (!request.immediate) {
1226 yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS);
1227 }
1228
1229 const [threadId, terminalId] = sessionKey.split("\u0000");
1230 if (!threadId || !terminalId) {
1231 return;
1232 }
1233
1234 yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe(
1235 Effect.catch((error) =>
1236 Effect.logWarning("failed to persist terminal history", {
1237 threadId,
1238 terminalId,
1239 error,
1240 }),
1241 ),
1242 );
1243 }),
1244 });
1245
1246 const queuePersist = Effect.fn("terminal.queuePersist")(function* (
1247 threadId: string,
1248 terminalId: string,
1249 history: string,
1250 ) {
1251 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
1252 history,
1253 immediate: false,
1254 });
1255 });
1256
1257 const flushPersist = Effect.fn("terminal.flushPersist")(function* (
1258 threadId: string,
1259 terminalId: string,
1260 ) {
1261 yield* persistWorker.drainKey(toSessionKey(threadId, terminalId));
1262 });
1263
1264 const persistHistory = Effect.fn("terminal.persistHistory")(function* (
1265 threadId: string,
1266 terminalId: string,
1267 history: string,
1268 ) {
1269 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
1270 history,
1271 immediate: true,
1272 });
1273 yield* flushPersist(threadId, terminalId);
1274 });
1275
1276 const readHistory = Effect.fn("terminal.readHistory")(function* (
1277 threadId: string,
1278 terminalId: string,
1279 ) {
1280 const nextPath = historyPath(threadId, terminalId);
1281 if (
1282 yield* fileSystem
1283 .exists(nextPath)
1284 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)))
1285 ) {
1286 const raw = yield* fileSystem
1287 .readFileString(nextPath)
1288 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)));
1289 const capped = capHistory(raw, historyLineLimit);
1290 if (capped !== raw) {
1291 yield* fileSystem
1292 .writeFileString(nextPath, capped)
1293 .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId)));
1294 }
1295 return capped;
1296 }
1297
1298 if (terminalId !== DEFAULT_TERMINAL_ID) {
1299 return "";
1300 }
1301
1302 const legacyPath = legacyHistoryPath(threadId);
1303 if (
1304 !(yield* fileSystem
1305 .exists(legacyPath)
1306 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))))
1307 ) {
1308 return "";
1309 }
1310
1311 const raw = yield* fileSystem
1312 .readFileString(legacyPath)
1313 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
1314 const capped = capHistory(raw, historyLineLimit);
1315 yield* fileSystem
1316 .writeFileString(nextPath, capped)
1317 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
1318 yield* fileSystem.remove(legacyPath, { force: true }).pipe(
1319 Effect.catch((cleanupError) =>
1320 Effect.logWarning("failed to remove legacy terminal history", {
1321 threadId,
1322 error: cleanupError,
1323 }),
1324 ),
1325 );
1326 return capped;
1327 });
1328
1329 const deleteHistory = Effect.fn("terminal.deleteHistory")(function* (
1330 threadId: string,
1331 terminalId: string,
1332 ) {
1333 yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe(
1334 Effect.catch((error) =>
1335 Effect.logWarning("failed to delete terminal history", {
1336 threadId,
1337 terminalId,
1338 error,
1339 }),
1340 ),
1341 );
1342 if (terminalId === DEFAULT_TERMINAL_ID) {
1343 yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe(
1344 Effect.catch((error) =>
1345 Effect.logWarning("failed to delete terminal history", {
1346 threadId,
1347 terminalId,
1348 error,
1349 }),
1350 ),
1351 );
1352 }
1353 });
1354
1355 const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* (
1356 threadId: string,
1357 ) {
1358 const threadPrefix = `${toSafeThreadId(threadId)}_`;
1359 const entries = yield* fileSystem
1360 .readDirectory(logsDir, { recursive: false })
1361 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
1362 yield* Effect.forEach(
1363 entries.filter(
1364 (name) =>
1365 name === `${toSafeThreadId(threadId)}.log` ||
1366 name === `${legacySafeThreadId(threadId)}.log` ||
1367 name.startsWith(threadPrefix),
1368 ),
1369 (name) =>
1370 fileSystem.remove(path.join(logsDir, name), { force: true }).pipe(
1371 Effect.catch((error) =>
1372 Effect.logWarning("failed to delete terminal histories for thread", {
1373 threadId,
1374 error,
1375 }),
1376 ),
1377 ),
1378 { discard: true },
1379 );
1380 });
1381
1382 const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) {
1383 const stats = yield* fileSystem.stat(cwd).pipe(
1384 Effect.mapError(
1385 (cause) =>
1386 new TerminalCwdError({
1387 cwd,
1388 reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed",
1389 cause,
1390 }),
1391 ),
1392 );
1393 if (stats.type !== "Directory") {
1394 return yield* new TerminalCwdError({
1395 cwd,
1396 reason: "notDirectory",
1397 });
1398 }
1399 });
1400
1401 const getSession = Effect.fn("terminal.getSession")(function* (
1402 threadId: string,
1403 terminalId: string,
1404 ): Effect.fn.Return<Option.Option<TerminalSessionState>> {
1405 return yield* Effect.map(readManagerState, (state) =>
1406 Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))),
1407 );
1408 });
1409
1410 const requireSession = Effect.fn("terminal.requireSession")(function* (
1411 threadId: string,
1412 terminalId: string,
1413 ): Effect.fn.Return<TerminalSessionState, TerminalSessionLookupError> {
1414 return yield* Effect.flatMap(getSession(threadId, terminalId), (session) =>
1415 Option.match(session, {
1416 onNone: () =>
1417 Effect.fail(
1418 new TerminalSessionLookupError({
1419 threadId,
1420 terminalId,
1421 }),
1422 ),
1423 onSome: Effect.succeed,
1424 }),
1425 );
1426 });
1427
1428 const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) {
1429 return yield* readManagerState.pipe(
1430 Effect.map((state) =>
1431 [...state.sessions.values()].filter((session) => session.threadId === threadId),
1432 ),
1433 );
1434 });
1435
1436 const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")(
1437 function* () {
1438 yield* modifyManagerState((state) => {
1439 const inactiveSessions = [...state.sessions.values()].filter(
1440 (session) => session.status !== "running",
1441 );
1442 if (inactiveSessions.length <= maxRetainedInactiveSessions) {
1443 return [undefined, state] as const;
1444 }
1445
1446 inactiveSessions.sort(
1447 (left, right) =>
1448 left.updatedAt.localeCompare(right.updatedAt) ||
1449 left.threadId.localeCompare(right.threadId) ||
1450 left.terminalId.localeCompare(right.terminalId),
1451 );
1452
1453 const sessions = new Map(state.sessions);
1454
1455 const toEvict = inactiveSessions.length - maxRetainedInactiveSessions;
1456 for (const session of inactiveSessions.slice(0, toEvict)) {
1457 const key = toSessionKey(session.threadId, session.terminalId);
1458 sessions.delete(key);
1459 }
1460
1461 return [undefined, { ...state, sessions }] as const;
1462 });
1463 },
1464 );
1465
1466 const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* (
1467 session: TerminalSessionState,
1468 expectedPid: number,
1469 ) {
1470 while (true) {
1471 const action: DrainProcessEventAction = yield* Effect.sync(() => {
1472 if (session.pid !== expectedPid || !session.process || session.status !== "running") {
1473 session.pendingProcessEvents = [];
1474 session.pendingProcessEventIndex = 0;
1475 session.processEventDrainRunning = false;
1476 return { type: "idle" } as const;
1477 }
1478
1479 const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex];
1480 if (!nextEvent) {
1481 session.pendingProcessEvents = [];
1482 session.pendingProcessEventIndex = 0;
1483 session.processEventDrainRunning = false;
1484 return { type: "idle" } as const;
1485 }
1486
1487 session.pendingProcessEventIndex += 1;
1488 if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) {
1489 session.pendingProcessEvents = [];
1490 session.pendingProcessEventIndex = 0;
1491 }
1492
1493 if (nextEvent.type === "output") {
1494 const sanitized = sanitizeTerminalHistoryChunk(
1495 session.pendingHistoryControlSequence,
1496 nextEvent.data,
1497 );
1498 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
1499 if (sanitized.visibleText.length > 0) {
1500 session.history = capHistory(
1501 `${session.history}${sanitized.visibleText}`,
1502 historyLineLimit,
1503 );
1504 }
1505 const eventStamp = advanceEventSequence(session);
1506
1507 return {
1508 type: "output",
1509 threadId: session.threadId,
1510 terminalId: session.terminalId,
1511 sequence: eventStamp.sequence,
1512 history: sanitized.visibleText.length > 0 ? session.history : null,
1513 data: nextEvent.data,
1514 } as const;
1515 }
1516
1517 const process = session.process;
1518 cleanupProcessHandles(session);
1519 session.process = null;
1520 session.pid = null;
1521 session.hasRunningSubprocess = false;
1522 session.childCommandLabel = null;
1523 session.status = "exited";
1524 session.pendingHistoryControlSequence = "";
1525 session.pendingProcessEvents = [];
1526 session.pendingProcessEventIndex = 0;
1527 session.processEventDrainRunning = false;
1528 session.exitCode = Number.isInteger(nextEvent.event.exitCode)
1529 ? nextEvent.event.exitCode
1530 : null;
1531 session.exitSignal = Number.isInteger(nextEvent.event.signal)
1532 ? nextEvent.event.signal
1533 : null;
1534 const eventStamp = advanceEventSequence(session);
1535
1536 return {
1537 type: "exit",
1538 process,
1539 threadId: session.threadId,
1540 terminalId: session.terminalId,
1541 sequence: eventStamp.sequence,
1542 exitCode: session.exitCode,
1543 exitSignal: session.exitSignal,
1544 } as const;
1545 });
1546
1547 if (action.type === "idle") {
1548 return;
1549 }
1550
1551 if (action.type === "output") {
1552 if (action.history !== null) {
1553 yield* queuePersist(action.threadId, action.terminalId, action.history);
1554 }
1555
1556 yield* publishEvent({
1557 type: "output",
1558 threadId: action.threadId,
1559 terminalId: action.terminalId,
1560 sequence: action.sequence,
1561 data: action.data,
1562 });
1563 continue;
1564 }
1565
1566 yield* clearKillFiber(action.process);
1567 yield* unregisterTerminal({
1568 threadId: action.threadId,
1569 terminalId: action.terminalId,
1570 });
1571 yield* publishEvent({
1572 type: "exited",
1573 threadId: action.threadId,
1574 terminalId: action.terminalId,
1575 sequence: action.sequence,
1576 exitCode: action.exitCode,
1577 exitSignal: action.exitSignal,
1578 });
1579 yield* evictInactiveSessionsIfNeeded();
1580 return;
1581 }
1582 });
1583
1584 const stopProcess = Effect.fn("terminal.stopProcess")(function* (
1585 session: TerminalSessionState,
1586 ) {
1587 const process = session.process;
1588 if (!process) return;
1589
1590 const updatedAt = yield* nowIso;
1591 yield* modifyManagerState((state) => {
1592 cleanupProcessHandles(session);
1593 session.process = null;
1594 session.pid = null;
1595 session.hasRunningSubprocess = false;
1596 session.childCommandLabel = null;
1597 session.status = "exited";
1598 session.pendingHistoryControlSequence = "";
1599 session.pendingProcessEvents = [];
1600 session.pendingProcessEventIndex = 0;
1601 session.processEventDrainRunning = false;
1602 session.updatedAt = updatedAt;
1603 return [undefined, state] as const;
1604 });
1605
1606 yield* clearKillFiber(process);
1607 yield* unregisterTerminal({
1608 threadId: session.threadId,
1609 terminalId: session.terminalId,
1610 });
1611 yield* startKillEscalation(process, session.threadId, session.terminalId);
1612 yield* evictInactiveSessionsIfNeeded();
1613 });
1614
1615 const trySpawn = Effect.fn("terminal.trySpawn")(function* (
1616 shellCandidates: ReadonlyArray<ShellCandidate>,
1617 spawnEnv: NodeJS.ProcessEnv,
1618 session: TerminalSessionState,
1619 index = 0,
1620 lastError: PtySpawnError | null = null,
1621 ): Effect.fn.Return<{ process: PtyProcess; shellLabel: string }, PtySpawnError> {
1622 if (index >= shellCandidates.length) {
1623 const detail = lastError?.message ?? "Failed to spawn PTY process";
1624 const tried =
1625 shellCandidates.length > 0
1626 ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.`
1627 : "";
1628 return yield* new PtySpawnError({
1629 adapter: "terminal-manager",
1630 message: `${detail}.${tried}`.trim(),
1631 ...(lastError ? { cause: lastError } : {}),
1632 });
1633 }
1634
1635 const candidate = shellCandidates[index];
1636 if (!candidate) {
1637 return yield* (
1638 lastError ??
1639 new PtySpawnError({
1640 adapter: "terminal-manager",
1641 message: "No shell candidate available for PTY spawn.",
1642 })
1643 );
1644 }
1645
1646 const attempt = yield* Effect.result(
1647 options.ptyAdapter.spawn({
1648 shell: candidate.shell,
1649 ...(candidate.args ? { args: candidate.args } : {}),
1650 cwd: session.cwd,
1651 cols: session.cols,
1652 rows: session.rows,
1653 env: spawnEnv,
1654 }),
1655 );
1656
1657 if (attempt._tag === "Success") {
1658 return {
1659 process: attempt.success,
1660 shellLabel: formatShellCandidate(candidate),
1661 };
1662 }
1663
1664 const spawnError = attempt.failure;
1665 if (!isRetryableShellSpawnError(spawnError)) {
1666 return yield* spawnError;
1667 }
1668
1669 return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError);
1670 });
1671
1672 const startSession = Effect.fn("terminal.startSession")(function* (
1673 session: TerminalSessionState,
1674 input: TerminalStartInput,
1675 eventType: "started" | "restarted",
1676 ) {
1677 yield* stopProcess(session);
1678 yield* Effect.annotateCurrentSpan({
1679 "terminal.thread_id": session.threadId,
1680 "terminal.id": session.terminalId,
1681 "terminal.event_type": eventType,
1682 "terminal.cwd": input.cwd,
1683 });
1684
1685 const startingAt = yield* nowIso;
1686 yield* modifyManagerState((state) => {
1687 session.status = "starting";
1688 session.cwd = input.cwd;
1689 session.worktreePath = input.worktreePath ?? null;
1690 session.cols = input.cols;
1691 session.rows = input.rows;
1692 session.exitCode = null;
1693 session.exitSignal = null;
1694 session.hasRunningSubprocess = false;
1695 session.childCommandLabel = null;
1696 session.pendingProcessEvents = [];
1697 session.pendingProcessEventIndex = 0;
1698 session.processEventDrainRunning = false;
1699 session.updatedAt = startingAt;
1700 return [undefined, state] as const;
1701 });
1702
1703 let ptyProcess: PtyProcess | null = null;
1704 let startedShell: string | null = null;
1705
1706 const startResult = yield* Effect.result(
1707 increment(terminalSessionsTotal, { lifecycle: eventType }).pipe(
1708 Effect.andThen(
1709 Effect.gen(function* () {
1710 const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv);
1711 const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv);
1712 const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session);
1713 ptyProcess = spawnResult.process;
1714 startedShell = spawnResult.shellLabel;
1715
1716 const processPid = ptyProcess.pid;
1717 const unsubscribeData = ptyProcess.onData((data) => {
1718 if (!enqueueProcessEvent(session, processPid, { type: "output", data })) {
1719 return;
1720 }
1721 runFork(drainProcessEvents(session, processPid));
1722 });
1723 const unsubscribeExit = ptyProcess.onExit((event) => {
1724 if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) {
1725 return;
1726 }
1727 runFork(drainProcessEvents(session, processPid));
1728 });
1729
1730 let eventStamp: ReturnType<typeof advanceEventSequence> = {
1731 updatedAt: session.updatedAt,
1732 sequence: session.eventSequence,
1733 };
1734 yield* modifyManagerState((state) => {
1735 session.process = ptyProcess;
1736 session.pid = processPid;
1737 session.status = "running";
1738 session.unsubscribeData = unsubscribeData;
1739 session.unsubscribeExit = unsubscribeExit;
1740 eventStamp = advanceEventSequence(session);
1741 return [undefined, state] as const;
1742 });
1743
1744 yield* publishEvent({
1745 type: eventType,
1746 threadId: session.threadId,
1747 terminalId: session.terminalId,
1748 sequence: eventStamp.sequence,
1749 snapshot: snapshot(session),
1750 });
1751 }),
1752 ),
1753 ),
1754 );
1755
1756 if (startResult._tag === "Success") {
1757 return;
1758 }
1759
1760 {
1761 const error = startResult.failure;
1762 if (ptyProcess) {
1763 yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId);
1764 }
1765
1766 yield* modifyManagerState((state) => {
1767 session.status = "error";
1768 session.pid = null;
1769 session.process = null;
1770 session.unsubscribeData = null;
1771 session.unsubscribeExit = null;
1772 session.hasRunningSubprocess = false;
1773 session.childCommandLabel = null;
1774 session.pendingProcessEvents = [];
1775 session.pendingProcessEventIndex = 0;
1776 session.processEventDrainRunning = false;
1777 advanceEventSequence(session);
1778 return [undefined, state] as const;
1779 });
1780 yield* unregisterTerminal({
1781 threadId: session.threadId,
1782 terminalId: session.terminalId,
1783 });
1784
1785 yield* evictInactiveSessionsIfNeeded();
1786
1787 const message = error.message;
1788 yield* publishEvent({
1789 type: "error",
1790 threadId: session.threadId,
1791 terminalId: session.terminalId,
1792 sequence: session.eventSequence,
1793 message,
1794 });
1795 yield* Effect.logError("failed to start terminal", {
1796 threadId: session.threadId,
1797 terminalId: session.terminalId,
1798 error: message,
1799 ...(startedShell ? { shell: startedShell } : {}),
1800 });
1801 }
1802 });
1803
1804 const closeSession = Effect.fn("terminal.closeSession")(function* (
1805 threadId: string,
1806 terminalId: string,
1807 deleteHistoryOnClose: boolean,
1808 ) {
1809 const key = toSessionKey(threadId, terminalId);
1810 const session = yield* getSession(threadId, terminalId);
1811 const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0;
1812
1813 if (Option.isSome(session)) {
1814 yield* stopProcess(session.value);
1815 yield* unregisterTerminal({ threadId, terminalId });
1816 yield* persistHistory(threadId, terminalId, session.value.history);
1817 }
1818
1819 yield* flushPersist(threadId, terminalId);
1820
1821 const removed = yield* modifyManagerState((state) => {
1822 if (!state.sessions.has(key)) {
1823 return [false, state] as const;
1824 }
1825 const sessions = new Map(state.sessions);
1826 sessions.delete(key);
1827 return [true, { ...state, sessions }] as const;
1828 });
1829
1830 if (removed) {
1831 yield* publishEvent({
1832 type: "closed",
1833 threadId,
1834 terminalId,
1835 sequence: closedEventSequence,
1836 });
1837 }
1838
1839 if (deleteHistoryOnClose) {
1840 yield* deleteHistory(threadId, terminalId);
1841 }
1842 });
1843
1844 const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () {
1845 const state = yield* readManagerState;
1846 const runningSessions = [...state.sessions.values()].filter(
1847 (session): session is TerminalSessionState & { pid: number } =>
1848 session.status === "running" && Number.isInteger(session.pid),
1849 );
1850
1851 if (runningSessions.length === 0) {
1852 return;
1853 }
1854
1855 const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* (
1856 session: TerminalSessionState & { pid: number },
1857 ) {
1858 const terminalPid = session.pid;
1859 const inspectResult = yield* subprocessInspector(terminalPid).pipe(
1860 Effect.map(Option.some),
1861 Effect.catch((reason) =>
1862 Effect.logWarning("failed to check terminal subprocess activity", {
1863 threadId: session.threadId,
1864 terminalId: session.terminalId,
1865 terminalPid,
1866 reason,
1867 }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())),
1868 ),
1869 );
1870
1871 if (Option.isNone(inspectResult)) {
1872 return;
1873 }
1874
1875 const next = inspectResult.value;
1876 yield* registerTerminalProcesses({
1877 threadId: session.threadId,
1878 terminalId: session.terminalId,
1879 processIds: next.processIds,
1880 });
1881 const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;
1882 const event = yield* modifyManagerState((state) => {
1883 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
1884 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),
1885 );
1886 if (
1887 Option.isNone(liveSession) ||
1888 liveSession.value.status !== "running" ||
1889 liveSession.value.pid !== terminalPid ||
1890 (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess &&
1891 liveSession.value.childCommandLabel === nextChildLabel)
1892 ) {
1893 return [Option.none(), state] as const;
1894 }
1895
1896 liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess;
1897 liveSession.value.childCommandLabel = nextChildLabel;
1898 const eventStamp = advanceEventSequence(liveSession.value);
1899
1900 return [
1901 Option.some({
1902 type: "activity" as const,
1903 threadId: liveSession.value.threadId,
1904 terminalId: liveSession.value.terminalId,
1905 sequence: eventStamp.sequence,
1906 hasRunningSubprocess: next.hasRunningSubprocess,
1907 label: terminalWireLabel(liveSession.value),
1908 }),
1909 state,
1910 ] as const;
1911 });
1912
1913 if (Option.isSome(event)) {
1914 yield* publishEvent(event.value);
1915 }
1916 });
1917
1918 yield* Effect.forEach(runningSessions, checkSubprocessActivity, {
1919 concurrency: "unbounded",
1920 discard: true,
1921 });
1922 });
1923
1924 const hasRunningSessions = readManagerState.pipe(
1925 Effect.map((state) =>
1926 [...state.sessions.values()].some((session) => session.status === "running"),
1927 ),
1928 );
1929
1930 yield* Effect.forever(
1931 hasRunningSessions.pipe(
1932 Effect.flatMap((active) =>
1933 active
1934 ? pollSubprocessActivity().pipe(
1935 Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)),
1936 )
1937 : Effect.sleep(subprocessPollIntervalMs),
1938 ),
1939 ),
1940 ).pipe(Effect.forkIn(workerScope));
1941
1942 yield* Effect.addFinalizer(() =>
1943 Effect.gen(function* () {
1944 const sessions = yield* modifyManagerState(
1945 (state) =>
1946 [
1947 [...state.sessions.values()],
1948 {
1949 ...state,
1950 sessions: new Map(),
1951 },
1952 ] as const,
1953 );
1954
1955 const cleanupSession = Effect.fn("terminal.cleanupSession")(function* (
1956 session: TerminalSessionState,
1957 ) {
1958 cleanupProcessHandles(session);
1959 if (!session.process) return;
1960 yield* clearKillFiber(session.process);
1961 yield* runKillEscalation(session.process, session.threadId, session.terminalId);
1962 });
1963
1964 yield* Effect.forEach(sessions, cleanupSession, {
1965 concurrency: "unbounded",
1966 discard: true,
1967 });
1968 }).pipe(Effect.ignoreCause({ log: true })),
1969 );
1970
1971 const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) {
1972 const terminalId = input.terminalId;
1973 yield* assertValidCwd(input.cwd);
1974
1975 const sessionKey = toSessionKey(input.threadId, terminalId);
1976 const existing = yield* getSession(input.threadId, terminalId);
1977 if (Option.isNone(existing)) {
1978 yield* flushPersist(input.threadId, terminalId);
1979 const history = yield* readHistory(input.threadId, terminalId);
1980 const cols = input.cols ?? DEFAULT_OPEN_COLS;
1981 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
1982 const session: TerminalSessionState = {
1983 threadId: input.threadId,
1984 terminalId,
1985 cwd: input.cwd,
1986 worktreePath: input.worktreePath ?? null,
1987 status: "starting",
1988 pid: null,
1989 history,
1990 pendingHistoryControlSequence: "",
1991 pendingProcessEvents: [],
1992 pendingProcessEventIndex: 0,
1993 processEventDrainRunning: false,
1994 exitCode: null,
1995 exitSignal: null,
1996 updatedAt: yield* nowIso,
1997 eventSequence: 0,
1998 cols,
1999 rows,
2000 process: null,
2001 unsubscribeData: null,
2002 unsubscribeExit: null,
2003 hasRunningSubprocess: false,
2004 childCommandLabel: null,
2005 runtimeEnv: normalizedRuntimeEnv(input.env),
2006 };
2007
2008 const createdSession = session;
2009 yield* modifyManagerState((state) => {
2010 const sessions = new Map(state.sessions);
2011 sessions.set(sessionKey, createdSession);
2012 return [undefined, { ...state, sessions }] as const;
2013 });
2014
2015 yield* evictInactiveSessionsIfNeeded();
2016 yield* startSession(
2017 session,
2018 {
2019 threadId: input.threadId,
2020 terminalId,
2021 cwd: input.cwd,
2022 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
2023 cols,
2024 rows,
2025 ...(input.env ? { env: input.env } : {}),
2026 },
2027 "started",
2028 );
2029 return snapshot(session);
2030 }
2031
2032 const liveSession = existing.value;
2033 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
2034 const currentRuntimeEnv = liveSession.runtimeEnv;
2035 const targetCols = input.cols ?? liveSession.cols;
2036 const targetRows = input.rows ?? liveSession.rows;
2037 const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv);
2038 const nextWorktreePath =
2039 input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath;
2040 const launchContextChanged =
2041 liveSession.cwd !== input.cwd ||
2042 runtimeEnvChanged ||
2043 liveSession.worktreePath !== nextWorktreePath;
2044
2045 if (launchContextChanged) {
2046 yield* stopProcess(liveSession);
2047 liveSession.cwd = input.cwd;
2048 liveSession.worktreePath = nextWorktreePath;
2049 liveSession.runtimeEnv = nextRuntimeEnv;
2050 liveSession.history = "";
2051 liveSession.pendingHistoryControlSequence = "";
2052 liveSession.pendingProcessEvents = [];
2053 liveSession.pendingProcessEventIndex = 0;
2054 liveSession.processEventDrainRunning = false;
2055 yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history);
2056 } else if (liveSession.status === "exited" || liveSession.status === "error") {
2057 liveSession.runtimeEnv = nextRuntimeEnv;
2058 liveSession.worktreePath = nextWorktreePath;
2059 liveSession.history = "";
2060 liveSession.pendingHistoryControlSequence = "";
2061 liveSession.pendingProcessEvents = [];
2062 liveSession.pendingProcessEventIndex = 0;
2063 liveSession.processEventDrainRunning = false;
2064 yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history);
2065 }
2066
2067 if (!liveSession.process) {
2068 yield* startSession(
2069 liveSession,
2070 {
2071 threadId: input.threadId,
2072 terminalId,
2073 cwd: input.cwd,
2074 worktreePath: liveSession.worktreePath,
2075 cols: targetCols,
2076 rows: targetRows,
2077 ...(input.env ? { env: input.env } : {}),
2078 },
2079 "started",
2080 );
2081 return snapshot(liveSession);
2082 }
2083
2084 if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) {
2085 liveSession.cols = targetCols;
2086 liveSession.rows = targetRows;
2087 liveSession.updatedAt = yield* nowIso;
2088 liveSession.process.resize(targetCols, targetRows);
2089 }
2090
2091 return snapshot(liveSession);
2092 });
2093
2094 const open: TerminalManagerShape["open"] = (input) =>
2095 withThreadLock(input.threadId, openLocked(input));
2096
2097 const openOrAttachForStream = (input: TerminalAttachInput) =>
2098 withThreadLock(
2099 input.threadId,
2100 Effect.gen(function* () {
2101 const terminalId = input.terminalId;
2102 const existing = yield* getSession(input.threadId, terminalId);
2103
2104 if (Option.isNone(existing)) {
2105 if (!input.cwd) {
2106 return yield* new TerminalSessionLookupError({
2107 threadId: input.threadId,
2108 terminalId,
2109 });
2110 }
2111
2112 return yield* openLocked({
2113 ...input,
2114 terminalId,
2115 cwd: input.cwd,
2116 });
2117 }
2118
2119 const session = existing.value;
2120 const targetCols = input.cols ?? session.cols;
2121 const targetRows = input.rows ?? session.rows;
2122
2123 if (!session.process && input.cwd && input.restartIfNotRunning === true) {
2124 return yield* openLocked({
2125 ...input,
2126 terminalId,
2127 cwd: input.cwd,
2128 });
2129 }
2130
2131 if (
2132 session.process &&
2133 session.status === "running" &&
2134 (session.cols !== targetCols || session.rows !== targetRows)
2135 ) {
2136 session.cols = targetCols;
2137 session.rows = targetRows;
2138 session.updatedAt = yield* nowIso;
2139 yield* Effect.sync(() => session.process?.resize(targetCols, targetRows));
2140 }
2141
2142 return snapshot(session);
2143 }),
2144 );
2145
2146 const readAllTerminalMetadata = () =>
2147 readManagerState.pipe(
2148 Effect.map((state) =>
2149 [...state.sessions.values()]
2150 .map(summary)
2151 .sort(
2152 (left, right) =>
2153 right.updatedAt.localeCompare(left.updatedAt) ||
2154 left.threadId.localeCompare(right.threadId) ||
2155 left.terminalId.localeCompare(right.terminalId),
2156 ),
2157 ),
2158 );
2159
2160 const readTerminalMetadata = (input: {
2161 readonly threadId: string;
2162 readonly terminalId: string;
2163 }) =>
2164 getSession(input.threadId, input.terminalId).pipe(
2165 Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)),
2166 );
2167
2168 const subscribe: TerminalManagerShape["subscribe"] = (listener) =>
2169 Effect.sync(() => {
2170 terminalEventListeners.add(listener);
2171 return () => {
2172 terminalEventListeners.delete(listener);
2173 };
2174 });
2175
2176 const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => {
2177 let unsubscribe: (() => void) | null = null;
2178
2179 return Effect.gen(function* () {
2180 const bufferedEvents: TerminalEvent[] = [];
2181 let deliverLive = false;
2182
2183 unsubscribe = yield* subscribe((event) => {
2184 if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
2185 return Effect.void;
2186 }
2187
2188 if (!deliverLive) {
2189 bufferedEvents.push(event);
2190 return Effect.void;
2191 }
2192
2193 const attachEvent = terminalEventToAttachEvent(event);
2194 return attachEvent ? listener(attachEvent) : Effect.void;
2195 });
2196
2197 const initialSnapshot = yield* openOrAttachForStream(input);
2198
2199 yield* listener({
2200 type: "snapshot",
2201 snapshot: initialSnapshot,
2202 });
2203
2204 for (const event of bufferedEvents) {
2205 if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) {
2206 continue;
2207 }
2208
2209 const attachEvent = terminalEventToAttachEvent(event);
2210 if (attachEvent) {
2211 yield* listener(attachEvent);
2212 }
2213 }
2214
2215 deliverLive = true;
2216 return () => {
2217 unsubscribe?.();
2218 unsubscribe = null;
2219 };
2220 }).pipe(
2221 Effect.catchCause((cause) =>
2222 Effect.flatMap(
2223 Effect.sync(() => {
2224 unsubscribe?.();
2225 unsubscribe = null;
2226 }),
2227 () => Effect.failCause(cause),
2228 ),
2229 ),
2230 );
2231 };
2232
2233 const metadataEventFromTerminalEvent = (
2234 event: TerminalEvent,
2235 ): Effect.Effect<TerminalMetadataStreamEvent | null> => {
2236 if (!shouldPublishTerminalMetadataEvent(event)) {
2237 return Effect.succeed(null);
2238 }
2239
2240 if (event.type === "closed") {
2241 return Effect.succeed({
2242 type: "remove" as const,
2243 threadId: event.threadId,
2244 terminalId: event.terminalId,
2245 });
2246 }
2247
2248 return readTerminalMetadata({
2249 threadId: event.threadId,
2250 terminalId: event.terminalId,
2251 }).pipe(
2252 Effect.map((terminal) =>
2253 terminal
2254 ? {
2255 type: "upsert" as const,
2256 terminal,
2257 }
2258 : null,
2259 ),
2260 );
2261 };
2262
2263 const offerMetadataEvent = (
2264 listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,
2265 event: TerminalEvent,
2266 ) =>
2267 metadataEventFromTerminalEvent(event).pipe(
2268 Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)),
2269 );
2270
2271 const subscribeMetadata: TerminalManagerShape["subscribeMetadata"] = (listener) => {
2272 let unsubscribe: (() => void) | null = null;
2273
2274 return Effect.gen(function* () {
2275 const bufferedEvents: TerminalEvent[] = [];
2276 let deliverLive = false;
2277
2278 unsubscribe = yield* subscribe((event) => {
2279 if (!deliverLive) {
2280 bufferedEvents.push(event);
2281 return Effect.void;
2282 }
2283
2284 return offerMetadataEvent(listener, event);
2285 });
2286
2287 const terminals = yield* readAllTerminalMetadata();
2288 yield* listener({
2289 type: "snapshot",
2290 terminals,
2291 });
2292
2293 for (const event of bufferedEvents) {
2294 yield* offerMetadataEvent(listener, event);
2295 }
2296
2297 deliverLive = true;
2298 return () => {
2299 unsubscribe?.();
2300 unsubscribe = null;
2301 };
2302 }).pipe(
2303 Effect.catchCause((cause) =>
2304 Effect.flatMap(
2305 Effect.sync(() => {
2306 unsubscribe?.();
2307 unsubscribe = null;
2308 }),
2309 () => Effect.failCause(cause),
2310 ),
2311 ),
2312 );
2313 };
2314
2315 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {
2316 const terminalId = input.terminalId;
2317 const session = yield* requireSession(input.threadId, terminalId);
2318 const process = session.process;
2319 if (!process || session.status !== "running") {
2320 if (session.status === "exited") return;
2321 return yield* new TerminalNotRunningError({
2322 threadId: input.threadId,
2323 terminalId,
2324 });
2325 }
2326 yield* Effect.sync(() => process.write(input.data));
2327 });
2328
2329 const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) {
2330 const session = yield* getSession(input.threadId, input.terminalId);
2331 // ResizeObserver traffic can already be in flight when the UI closes the session.
2332 if (Option.isNone(session)) {
2333 return;
2334 }
2335 const process = session.value.process;
2336 if (!process || session.value.status !== "running") {
2337 return;
2338 }
2339 session.value.cols = input.cols;
2340 session.value.rows = input.rows;
2341 session.value.updatedAt = yield* nowIso;
2342 yield* Effect.sync(() => process.resize(input.cols, input.rows));
2343 });
2344
2345 const resize: TerminalManagerShape["resize"] = (input) =>
2346 withThreadLock(input.threadId, resizeLocked(input));
2347
2348 const clear: TerminalManagerShape["clear"] = (input) =>
2349 withThreadLock(
2350 input.threadId,
2351 Effect.gen(function* () {
2352 const terminalId = input.terminalId;
2353 const session = yield* requireSession(input.threadId, terminalId);
2354 session.history = "";
2355 session.pendingHistoryControlSequence = "";
2356 session.pendingProcessEvents = [];
2357 session.pendingProcessEventIndex = 0;
2358 session.processEventDrainRunning = false;
2359 const eventStamp = advanceEventSequence(session);
2360 yield* persistHistory(input.threadId, terminalId, session.history);
2361 yield* publishEvent({
2362 type: "cleared",
2363 threadId: input.threadId,
2364 terminalId,
2365 sequence: eventStamp.sequence,
2366 });
2367 }),
2368 );
2369
2370 const restart: TerminalManagerShape["restart"] = (input) =>
2371 withThreadLock(
2372 input.threadId,
2373 Effect.gen(function* () {
2374 yield* increment(terminalRestartsTotal, { scope: "thread" });
2375 const terminalId = input.terminalId;
2376 yield* assertValidCwd(input.cwd);
2377
2378 const sessionKey = toSessionKey(input.threadId, terminalId);
2379 const existingSession = yield* getSession(input.threadId, terminalId);
2380 let session: TerminalSessionState;
2381 if (Option.isNone(existingSession)) {
2382 const cols = input.cols ?? DEFAULT_OPEN_COLS;
2383 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
2384 session = {
2385 threadId: input.threadId,
2386 terminalId,
2387 cwd: input.cwd,
2388 worktreePath: input.worktreePath ?? null,
2389 status: "starting",
2390 pid: null,
2391 history: "",
2392 pendingHistoryControlSequence: "",
2393 pendingProcessEvents: [],
2394 pendingProcessEventIndex: 0,
2395 processEventDrainRunning: false,
2396 exitCode: null,
2397 exitSignal: null,
2398 updatedAt: yield* nowIso,
2399 eventSequence: 0,
2400 cols,
2401 rows,
2402 process: null,
2403 unsubscribeData: null,
2404 unsubscribeExit: null,
2405 hasRunningSubprocess: false,
2406 childCommandLabel: null,
2407 runtimeEnv: normalizedRuntimeEnv(input.env),
2408 };
2409 const createdSession = session;
2410 yield* modifyManagerState((state) => {
2411 const sessions = new Map(state.sessions);
2412 sessions.set(sessionKey, createdSession);
2413 return [undefined, { ...state, sessions }] as const;
2414 });
2415 yield* evictInactiveSessionsIfNeeded();
2416 } else {
2417 session = existingSession.value;
2418 yield* stopProcess(session);
2419 session.cwd = input.cwd;
2420 session.worktreePath = input.worktreePath ?? null;
2421 session.runtimeEnv = normalizedRuntimeEnv(input.env);
2422 }
2423
2424 const cols = input.cols ?? session.cols;
2425 const rows = input.rows ?? session.rows;
2426
2427 session.history = "";
2428 session.pendingHistoryControlSequence = "";
2429 session.pendingProcessEvents = [];
2430 session.pendingProcessEventIndex = 0;
2431 session.processEventDrainRunning = false;
2432 yield* persistHistory(input.threadId, terminalId, session.history);
2433 yield* startSession(
2434 session,
2435 {
2436 threadId: input.threadId,
2437 terminalId,
2438 cwd: input.cwd,
2439 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
2440 cols,
2441 rows,
2442 ...(input.env ? { env: input.env } : {}),
2443 },
2444 "restarted",
2445 );
2446 return snapshot(session);
2447 }),
2448 );
2449
2450 const close: TerminalManagerShape["close"] = (input) =>
2451 withThreadLock(
2452 input.threadId,
2453 Effect.gen(function* () {
2454 if (input.terminalId) {
2455 yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true);
2456 return;
2457 }
2458
2459 const threadSessions = yield* sessionsForThread(input.threadId);
2460 yield* Effect.forEach(
2461 threadSessions,
2462 (session) => closeSession(input.threadId, session.terminalId, false),
2463 { discard: true },
2464 );
2465
2466 if (input.deleteHistory) {
2467 yield* deleteAllHistoryForThread(input.threadId);
2468 }
2469 }),
2470 );
2471
2472 return {
2473 open,
2474 attachStream,
2475 write,
2476 resize,
2477 clear,
2478 restart,
2479 close,
2480 subscribe,
2481 subscribeMetadata,
2482 } satisfies TerminalManagerShape;
2483 },
2484);
2485
2486export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe(
2487 Layer.provide(ProcessRunner.layer),
2488);
apps/server/src/terminal/Layers/NodePTY.test.ts deleted-58
...@@ -1,58 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { vi } from "vite-plus/test";
7
8import { PtyAdapter } from "../Services/PTY.ts";
9import { layer } from "./NodePTY.ts";
10
11const spawn = vi.fn(() => ({
12 pid: 42,
13 write: vi.fn(),
14 resize: vi.fn(),
15 kill: vi.fn(),
16 onData: vi.fn(() => ({ dispose: vi.fn() })),
17 onExit: vi.fn(() => ({ dispose: vi.fn() })),
18}));
19
20vi.mock("node-pty", () => ({ spawn }));
21
22const testLayer = layer.pipe(
23 Layer.provide(
24 Layer.mergeAll(
25 NodeServices.layer,
26 Layer.succeed(HostProcessPlatform, "win32"),
27 Layer.succeed(HostProcessArchitecture, "x64"),
28 ),
29 ),
30);
31
32it.effect("spawns through the public adapter with the provided host references", () =>
33 Effect.gen(function* () {
34 const adapter = yield* PtyAdapter;
35 const process = yield* adapter.spawn({
36 shell: "powershell.exe",
37 args: ["-NoLogo"],
38 cwd: "C:\\workspace",
39 cols: 120,
40 rows: 40,
41 env: {},
42 });
43
44 assert.equal(process.pid, 42);
45 assert.equal(spawn.mock.calls.length, 1);
46 assert.deepEqual(spawn.mock.calls[0], [
47 "powershell.exe",
48 ["-NoLogo"],
49 {
50 cwd: "C:\\workspace",
51 cols: 120,
52 rows: 40,
53 env: {},
54 name: "xterm-color",
55 },
56 ]);
57 }).pipe(Effect.provide(testLayer)),
58);
apps/server/src/terminal/Layers/NodePTY.ts deleted-145
...@@ -1,145 +0,0 @@
1import { createRequire } from "node:module";
2
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import { PtyAdapter } from "../Services/PTY.ts";
9import {
10 PtySpawnError,
11 type PtyAdapterShape,
12 type PtyExitEvent,
13 type PtyProcess,
14} from "../Services/PTY.ts";
15
16let didEnsureSpawnHelperExecutable = false;
17
18const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
19 const requireForNodePty = createRequire(import.meta.url);
20 const path = yield* Path.Path;
21 const fs = yield* FileSystem.FileSystem;
22 const platform = yield* HostProcessPlatform;
23 const architecture = yield* HostProcessArchitecture;
24
25 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
26 const packageDir = path.dirname(packageJsonPath);
27 const candidates = [
28 path.join(packageDir, "build", "Release", "spawn-helper"),
29 path.join(packageDir, "build", "Debug", "spawn-helper"),
30 path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"),
31 ];
32
33 for (const candidate of candidates) {
34 if (yield* fs.exists(candidate)) {
35 return candidate;
36 }
37 }
38 return null;
39}).pipe(Effect.orElseSucceed(() => null));
40
41const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () {
42 const fs = yield* FileSystem.FileSystem;
43 const platform = yield* HostProcessPlatform;
44 if (platform === "win32") return;
45 if (didEnsureSpawnHelperExecutable) return;
46
47 const helperPath = yield* resolveNodePtySpawnHelperPath;
48 if (!helperPath) return;
49 didEnsureSpawnHelperExecutable = true;
50
51 if (!(yield* fs.exists(helperPath))) {
52 return;
53 }
54
55 // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing.
56 yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined));
57});
58
59class NodePtyProcess implements PtyProcess {
60 private readonly process: import("node-pty").IPty;
61
62 constructor(process: import("node-pty").IPty) {
63 this.process = process;
64 }
65
66 get pid(): number {
67 return this.process.pid;
68 }
69
70 write(data: string): void {
71 this.process.write(data);
72 }
73
74 resize(cols: number, rows: number): void {
75 this.process.resize(cols, rows);
76 }
77
78 kill(signal?: string): void {
79 this.process.kill(signal);
80 }
81
82 onData(callback: (data: string) => void): () => void {
83 const disposable = this.process.onData(callback);
84 return () => {
85 disposable.dispose();
86 };
87 }
88
89 onExit(callback: (event: PtyExitEvent) => void): () => void {
90 const disposable = this.process.onExit((event) => {
91 callback({
92 exitCode: event.exitCode,
93 signal: event.signal ?? null,
94 });
95 });
96 return () => {
97 disposable.dispose();
98 };
99 }
100}
101
102export const layer = Layer.effect(
103 PtyAdapter,
104 Effect.gen(function* () {
105 const fs = yield* FileSystem.FileSystem;
106 const path = yield* Path.Path;
107 const platform = yield* HostProcessPlatform;
108 const architecture = yield* HostProcessArchitecture;
109
110 const nodePty = yield* Effect.promise(() => import("node-pty"));
111
112 const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached(
113 ensureNodePtySpawnHelperExecutable().pipe(
114 Effect.provideService(FileSystem.FileSystem, fs),
115 Effect.provideService(Path.Path, path),
116 Effect.provideService(HostProcessPlatform, platform),
117 Effect.provideService(HostProcessArchitecture, architecture),
118 Effect.orElseSucceed(() => undefined),
119 ),
120 );
121
122 return {
123 spawn: Effect.fn(function* (input) {
124 yield* ensureNodePtySpawnHelperExecutableCached;
125 const ptyProcess = yield* Effect.try({
126 try: () =>
127 nodePty.spawn(input.shell, input.args ?? [], {
128 cwd: input.cwd,
129 cols: input.cols,
130 rows: input.rows,
131 env: input.env,
132 name: platform === "win32" ? "xterm-color" : "xterm-256color",
133 }),
134 catch: (cause) =>
135 new PtySpawnError({
136 adapter: "node-pty",
137 message: cause instanceof Error ? cause.message : "Failed to spawn PTY process",
138 cause,
139 }),
140 });
141 return new NodePtyProcess(ptyProcess);
142 }),
143 } satisfies PtyAdapterShape;
144 }),
145);
apps/server/src/terminal/Manager.test.ts created+1550
...@@ -0,0 +1,1550 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 DEFAULT_TERMINAL_ID,
5 type TerminalAttachStreamEvent,
6 type TerminalEvent,
7 type TerminalMetadataStreamEvent,
8 type TerminalOpenInput,
9 type TerminalRestartInput,
10} from "@t3tools/contracts";
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import * as Data from "effect/Data";
13import * as Duration from "effect/Duration";
14import * as Effect from "effect/Effect";
15import * as Encoding from "effect/Encoding";
16import * as Exit from "effect/Exit";
17import * as Fiber from "effect/Fiber";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as PlatformError from "effect/PlatformError";
22import * as Path from "effect/Path";
23import * as Ref from "effect/Ref";
24import * as Schedule from "effect/Schedule";
25import * as Scope from "effect/Scope";
26import * as TestClock from "effect/testing/TestClock";
27import { expect } from "vite-plus/test";
28
29import * as ProcessRunner from "../processRunner.ts";
30import * as TerminalManager from "./Manager.ts";
31import * as PtyAdapter from "./PtyAdapter.ts";
32
33class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{
34 readonly message: string;
35}> {}
36
37class FakePtyProcess implements PtyAdapter.PtyProcess {
38 readonly writes: string[] = [];
39 readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
40 readonly killSignals: Array<string | undefined> = [];
41 readonly pid: number;
42 private readonly dataListeners = new Set<(data: string) => void>();
43 private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>();
44 killed = false;
45
46 constructor(pid: number) {
47 this.pid = pid;
48 }
49
50 write(data: string): void {
51 this.writes.push(data);
52 }
53
54 resize(cols: number, rows: number): void {
55 this.resizeCalls.push({ cols, rows });
56 }
57
58 kill(signal?: string): void {
59 this.killed = true;
60 this.killSignals.push(signal);
61 }
62
63 onData(callback: (data: string) => void): () => void {
64 this.dataListeners.add(callback);
65 return () => {
66 this.dataListeners.delete(callback);
67 };
68 }
69
70 onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void {
71 this.exitListeners.add(callback);
72 return () => {
73 this.exitListeners.delete(callback);
74 };
75 }
76
77 emitData(data: string): void {
78 for (const listener of this.dataListeners) {
79 listener(data);
80 }
81 }
82
83 emitExit(event: PtyAdapter.PtyExitEvent): void {
84 for (const listener of this.exitListeners) {
85 listener(event);
86 }
87 }
88}
89
90class FakePtyAdapter {
91 readonly spawnInputs: PtyAdapter.PtySpawnInput[] = [];
92 readonly processes: FakePtyProcess[] = [];
93 readonly spawnFailures: Error[] = [];
94 private readonly mode: "sync" | "async";
95 private nextPid = 9000;
96
97 constructor(mode: "sync" | "async" = "sync") {
98 this.mode = mode;
99 }
100
101 spawn(
102 input: PtyAdapter.PtySpawnInput,
103 ): Effect.Effect<PtyAdapter.PtyProcess, PtyAdapter.PtySpawnError> {
104 this.spawnInputs.push(input);
105 const failure = this.spawnFailures.shift();
106 if (failure) {
107 return Effect.fail(
108 new PtyAdapter.PtySpawnError({
109 adapter: "fake",
110 shell: input.shell,
111 cause: failure,
112 }),
113 );
114 }
115 const process = new FakePtyProcess(this.nextPid++);
116 this.processes.push(process);
117 if (this.mode === "async") {
118 return Effect.tryPromise({
119 try: async () => process,
120 catch: (cause) =>
121 new PtyAdapter.PtySpawnError({
122 adapter: "fake",
123 shell: input.shell,
124 cause,
125 }),
126 });
127 }
128 return Effect.succeed(process);
129 }
130}
131
132const waitFor = <E, R>(
133 predicate: Effect.Effect<boolean, E, R>,
134 timeout: Duration.Input = 800,
135): Effect.Effect<void, WaitForConditionError | E, R> =>
136 predicate.pipe(
137 Effect.filterOrFail(
138 (done) => done,
139 () => new WaitForConditionError({ message: "Condition not met" }),
140 ),
141 Effect.retry(Schedule.spaced("15 millis")),
142 Effect.timeoutOption(timeout),
143 Effect.flatMap((result) =>
144 Option.match(result, {
145 onNone: () =>
146 Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })),
147 onSome: () => Effect.void,
148 }),
149 ),
150 );
151
152function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {
153 return {
154 threadId: "thread-1",
155 terminalId: DEFAULT_TERMINAL_ID,
156 cwd: process.cwd(),
157 cols: 100,
158 rows: 24,
159 ...overrides,
160 };
161}
162
163function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput {
164 return {
165 threadId: "thread-1",
166 terminalId: DEFAULT_TERMINAL_ID,
167 cwd: process.cwd(),
168 cols: 100,
169 rows: 24,
170 ...overrides,
171 };
172}
173
174const historyLogPath = (logsDir: string, threadId = "thread-1") =>
175 Effect.service(Path.Path).pipe(
176 Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)),
177 );
178
179const multiTerminalHistoryLogPath = (
180 logsDir: string,
181 threadId = "thread-1",
182 terminalId = DEFAULT_TERMINAL_ID,
183) =>
184 Effect.service(Path.Path).pipe(
185 Effect.map(({ join }) => {
186 const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`;
187 return join(
188 logsDir,
189 terminalId === DEFAULT_TERMINAL_ID
190 ? `${threadPart}.log`
191 : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`,
192 );
193 }),
194 );
195
196interface CreateManagerOptions {
197 shellResolver?: () => string;
198 env?: NodeJS.ProcessEnv;
199 subprocessInspector?: (terminalPid: number) => Effect.Effect<{
200 readonly hasRunningSubprocess: boolean;
201 readonly childCommand: string | null;
202 readonly processIds: ReadonlyArray<number>;
203 }>;
204 subprocessPollIntervalMs?: number;
205 processKillGraceMs?: number;
206 maxRetainedInactiveSessions?: number;
207 ptyAdapter?: FakePtyAdapter;
208}
209
210interface ManagerFixture {
211 readonly baseDir: string;
212 readonly logsDir: string;
213 readonly ptyAdapter: FakePtyAdapter;
214 readonly manager: TerminalManager.TerminalManager["Service"];
215 readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>;
216}
217
218const createManager = (
219 historyLineLimit = 5,
220 options: CreateManagerOptions = {},
221): Effect.Effect<
222 ManagerFixture,
223 PlatformError.PlatformError,
224 FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner
225> =>
226 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
227 Effect.gen(function* () {
228 const { join } = yield* Path.Path;
229 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" });
230 const logsDir = join(baseDir, "userdata", "logs", "terminals");
231 const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter();
232
233 const manager = yield* TerminalManager.makeWithOptions({
234 logsDir,
235 historyLineLimit,
236 ptyAdapter,
237 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
238 ...(options.env !== undefined ? { env: options.env } : {}),
239 ...(options.subprocessInspector !== undefined
240 ? { subprocessInspector: options.subprocessInspector }
241 : {}),
242 ...(options.subprocessPollIntervalMs !== undefined
243 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
244 : {}),
245 processKillGraceMs: options.processKillGraceMs ?? 1,
246 ...(options.maxRetainedInactiveSessions !== undefined
247 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
248 : {}),
249 });
250 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
251 const unsubscribe = yield* manager.subscribe((event) =>
252 Ref.update(eventsRef, (events) => [...events, event]),
253 );
254 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
255
256 return {
257 baseDir,
258 logsDir,
259 join,
260 ptyAdapter,
261 manager,
262 getEvents: Ref.get(eventsRef),
263 };
264 }),
265 );
266
267const withHostPlatform = (platform: NodeJS.Platform) =>
268 Layer.succeed(HostProcessPlatform, platform);
269
270it.layer(
271 Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))),
272 { excludeTestServices: true },
273)("TerminalManager", (it) => {
274 it.effect("spawns lazily and reuses running terminal per thread", () =>
275 Effect.gen(function* () {
276 const { manager, ptyAdapter } = yield* createManager();
277 const [first, second] = yield* Effect.all(
278 [manager.open(openInput()), manager.open(openInput())],
279 { concurrency: "unbounded" },
280 );
281 const third = yield* manager.open(openInput());
282
283 assert.equal(first.threadId, "thread-1");
284 assert.equal(first.terminalId, DEFAULT_TERMINAL_ID);
285 assert.equal(second.threadId, "thread-1");
286 assert.equal(third.threadId, "thread-1");
287 expect(ptyAdapter.spawnInputs).toHaveLength(1);
288 }),
289 );
290
291 it.effect("attaches to running sessions without restarting them", () =>
292 Effect.gen(function* () {
293 const { manager, ptyAdapter } = yield* createManager();
294
295 yield* manager.open(openInput());
296 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
297 const unsubscribe = yield* manager.attachStream(
298 {
299 threadId: "thread-1",
300 terminalId: DEFAULT_TERMINAL_ID,
301 cols: 100,
302 rows: 40,
303 },
304 (event) => Ref.update(attachEvents, (events) => [...events, event]),
305 );
306 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
307
308 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
309 expect(snapshot).toBeDefined();
310 if (!snapshot || snapshot.type !== "snapshot") return;
311 assert.equal(snapshot.snapshot.threadId, "thread-1");
312 assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID);
313 expect(ptyAdapter.spawnInputs).toHaveLength(1);
314 }),
315 );
316
317 it.effect("keeps attach streams live when a terminal id is closed and reopened", () =>
318 Effect.gen(function* () {
319 const { manager, ptyAdapter } = yield* createManager();
320 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
321 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
322 Ref.update(attachEvents, (events) => [...events, event]),
323 );
324 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
325
326 yield* manager.close({
327 threadId: "thread-1",
328 terminalId: DEFAULT_TERMINAL_ID,
329 deleteHistory: true,
330 });
331 yield* manager.open(openInput());
332
333 const events = yield* Ref.get(attachEvents);
334 expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]);
335 expect(
336 events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status),
337 ).toEqual(["running", "running"]);
338 expect(ptyAdapter.spawnInputs).toHaveLength(2);
339 }),
340 );
341
342 it.effect("attaches to exited sessions without restarting them", () =>
343 Effect.gen(function* () {
344 const { manager, ptyAdapter, getEvents } = yield* createManager();
345
346 yield* manager.open(openInput());
347 const process = ptyAdapter.processes[0];
348 expect(process).toBeDefined();
349 if (!process) return;
350
351 process.emitExit({ exitCode: 0, signal: 0 });
352
353 yield* waitFor(
354 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
355 "1200 millis",
356 );
357
358 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
359 const unsubscribe = yield* manager.attachStream(
360 openInput({
361 env: {
362 T3CODE_WORKTREE_PATH: "/tmp/should-not-restart",
363 },
364 worktreePath: "/tmp/should-not-restart",
365 }),
366 (event) => Ref.update(attachEvents, (events) => [...events, event]),
367 );
368 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
369
370 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
371 expect(snapshot).toBeDefined();
372 if (!snapshot || snapshot.type !== "snapshot") return;
373 assert.equal(snapshot.snapshot.status, "exited");
374 assert.equal(snapshot.snapshot.worktreePath, null);
375 expect(ptyAdapter.spawnInputs).toHaveLength(1);
376 }),
377 );
378
379 it.effect("restarts inactive sessions from attach only when requested", () =>
380 Effect.gen(function* () {
381 const { manager, ptyAdapter, getEvents } = yield* createManager();
382
383 yield* manager.open(openInput());
384 const process = ptyAdapter.processes[0];
385 expect(process).toBeDefined();
386 if (!process) return;
387
388 process.emitExit({ exitCode: 0, signal: 0 });
389
390 yield* waitFor(
391 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
392 "1200 millis",
393 );
394
395 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
396 const unsubscribe = yield* manager.attachStream(
397 {
398 ...openInput({
399 env: {
400 T3CODE_WORKTREE_PATH: "/tmp/restart-requested",
401 },
402 worktreePath: "/tmp/restart-requested",
403 }),
404 restartIfNotRunning: true,
405 },
406 (event) => Ref.update(attachEvents, (events) => [...events, event]),
407 );
408 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
409
410 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
411 expect(snapshot).toBeDefined();
412 if (!snapshot || snapshot.type !== "snapshot") return;
413 assert.equal(snapshot.snapshot.status, "running");
414 assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested");
415 expect(ptyAdapter.spawnInputs).toHaveLength(2);
416 }),
417 );
418
419 const makeDirectory = (filePath: string) =>
420 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
421 fs.makeDirectory(filePath, { recursive: true }),
422 );
423
424 const chmod = (filePath: string, mode: number) =>
425 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode));
426
427 const pathExists = (filePath: string) =>
428 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath));
429
430 const readFileString = (filePath: string) =>
431 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath));
432
433 const writeFileString = (filePath: string, contents: string) =>
434 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
435 fs.writeFileString(filePath, contents),
436 );
437
438 it.effect("preserves non-notFound cwd stat failures", () =>
439 Effect.gen(function* () {
440 if ((yield* HostProcessPlatform) === "win32") return;
441
442 const path = yield* Path.Path;
443
444 const { manager, baseDir } = yield* createManager();
445 const blockedRoot = path.join(baseDir, "blocked-root");
446 const blockedCwd = path.join(blockedRoot, "cwd");
447 yield* makeDirectory(blockedCwd);
448 yield* chmod(blockedRoot, 0o000);
449
450 const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe(
451 Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)),
452 );
453
454 expect(error).toMatchObject({
455 _tag: "TerminalCwdError",
456 cwd: blockedCwd,
457 reason: "statFailed",
458 });
459 }),
460 );
461
462 it.effect("supports asynchronous PTY spawn effects", () =>
463 Effect.gen(function* () {
464 const { manager, ptyAdapter } = yield* createManager(5, {
465 ptyAdapter: new FakePtyAdapter("async"),
466 });
467
468 const snapshot = yield* manager.open(openInput());
469
470 assert.equal(snapshot.status, "running");
471 expect(ptyAdapter.spawnInputs).toHaveLength(1);
472 expect(ptyAdapter.processes).toHaveLength(1);
473 }),
474 );
475
476 it.effect("forwards write and resize to active pty process", () =>
477 Effect.gen(function* () {
478 const { manager, ptyAdapter } = yield* createManager();
479 yield* manager.open(openInput());
480 const process = ptyAdapter.processes[0];
481 expect(process).toBeDefined();
482 if (!process) return;
483
484 yield* manager.write({
485 threadId: "thread-1",
486 terminalId: DEFAULT_TERMINAL_ID,
487 data: "ls\n",
488 });
489 yield* manager.resize({
490 threadId: "thread-1",
491 terminalId: DEFAULT_TERMINAL_ID,
492 cols: 120,
493 rows: 30,
494 });
495
496 expect(process.writes).toEqual(["ls\n"]);
497 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
498 }),
499 );
500
501 it.effect("ignores delayed resize requests after a terminal closes", () =>
502 Effect.gen(function* () {
503 const { manager, ptyAdapter } = yield* createManager();
504 yield* manager.open(openInput());
505 const process = ptyAdapter.processes[0];
506 expect(process).toBeDefined();
507 if (!process) return;
508
509 yield* manager.close({
510 threadId: "thread-1",
511 terminalId: DEFAULT_TERMINAL_ID,
512 deleteHistory: true,
513 });
514 yield* manager.resize({
515 threadId: "thread-1",
516 terminalId: DEFAULT_TERMINAL_ID,
517 cols: 120,
518 rows: 30,
519 });
520
521 expect(process.resizeCalls).toEqual([]);
522 }),
523 );
524
525 it.effect("resizes running terminal on open when a different size is requested", () =>
526 Effect.gen(function* () {
527 const { manager, ptyAdapter } = yield* createManager();
528 yield* manager.open(openInput({ cols: 100, rows: 24 }));
529 const process = ptyAdapter.processes[0];
530 expect(process).toBeDefined();
531 if (!process) return;
532
533 const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 }));
534
535 assert.equal(reopened.status, "running");
536 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
537 }),
538 );
539
540 it.effect("supports multiple terminals per thread independently", () =>
541 Effect.gen(function* () {
542 const { manager, ptyAdapter } = yield* createManager();
543 yield* manager.open(openInput({ terminalId: "default" }));
544 yield* manager.open(openInput({ terminalId: "term-2" }));
545
546 const first = ptyAdapter.processes[0];
547 const second = ptyAdapter.processes[1];
548 expect(first).toBeDefined();
549 expect(second).toBeDefined();
550 if (!first || !second) return;
551
552 yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" });
553 yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" });
554
555 expect(first.writes).toEqual(["pwd\n"]);
556 expect(second.writes).toEqual(["ls\n"]);
557 expect(ptyAdapter.spawnInputs).toHaveLength(2);
558 }),
559 );
560
561 it.effect("clears transcript and emits cleared event", () =>
562 Effect.gen(function* () {
563 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
564 const path = yield* Path.Path;
565 yield* manager.open(openInput());
566 const process = ptyAdapter.processes[0];
567 expect(process).toBeDefined();
568 if (!process) return;
569
570 process.emitData("hello\n");
571 yield* waitFor(
572 historyLogPath(logsDir).pipe(
573 Effect.provideService(Path.Path, path),
574 Effect.flatMap(pathExists),
575 ),
576 );
577 yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID });
578 yield* waitFor(
579 historyLogPath(logsDir).pipe(
580 Effect.provideService(Path.Path, path),
581 Effect.flatMap(readFileString),
582 Effect.map((text) => text === ""),
583 ),
584 );
585
586 const events = yield* getEvents;
587 expect(events.some((event) => event.type === "cleared")).toBe(true);
588 expect(
589 events.some(
590 (event) =>
591 event.type === "cleared" &&
592 event.threadId === "thread-1" &&
593 event.terminalId === DEFAULT_TERMINAL_ID,
594 ),
595 ).toBe(true);
596 }),
597 );
598
599 it.effect("restarts terminal with empty transcript and respawns pty", () =>
600 Effect.gen(function* () {
601 const { manager, ptyAdapter, logsDir } = yield* createManager();
602 yield* manager.open(openInput());
603 const firstProcess = ptyAdapter.processes[0];
604 expect(firstProcess).toBeDefined();
605 if (!firstProcess) return;
606 firstProcess.emitData("before restart\n");
607 const path = yield* Path.Path;
608 yield* waitFor(
609 historyLogPath(logsDir).pipe(
610 Effect.provideService(Path.Path, path),
611 Effect.flatMap(pathExists),
612 ),
613 );
614
615 const snapshot = yield* manager.restart(restartInput());
616 assert.equal(snapshot.history, "");
617 assert.equal(snapshot.status, "running");
618 expect(ptyAdapter.spawnInputs).toHaveLength(2);
619 yield* waitFor(
620 historyLogPath(logsDir).pipe(
621 Effect.provideService(Path.Path, path),
622 Effect.flatMap(readFileString),
623 Effect.map((text) => text === ""),
624 ),
625 );
626 }),
627 );
628
629 it.effect("restarts a running session when open is called with a different cwd", () =>
630 Effect.gen(function* () {
631 const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager();
632 const path = yield* Path.Path;
633 const originalCwd = path.join(baseDir, "original");
634 const differentCwd = path.join(baseDir, "different");
635 yield* makeDirectory(originalCwd);
636 yield* makeDirectory(differentCwd);
637
638 yield* manager.open(openInput({ cwd: originalCwd }));
639 const firstProcess = ptyAdapter.processes[0];
640 expect(firstProcess).toBeDefined();
641 if (!firstProcess) return;
642
643 firstProcess.emitData("before reopen\n");
644 const logPath = yield* historyLogPath(logsDir);
645 yield* waitFor(pathExists(logPath));
646
647 const reopened = yield* manager.open(openInput({ cwd: differentCwd }));
648
649 expect(ptyAdapter.spawnInputs).toHaveLength(2);
650 assert.equal(firstProcess.killed, true);
651 assert.equal(reopened.cwd, differentCwd);
652 assert.equal(reopened.history, "");
653 yield* waitFor(Effect.map(readFileString(logPath), (text) => text === ""));
654 }),
655 );
656
657 it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () =>
658 Effect.gen(function* () {
659 const { manager, getEvents, baseDir } = yield* createManager();
660 const path = yield* Path.Path;
661 const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a");
662 const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b");
663 yield* makeDirectory(firstWorktreePath);
664 yield* makeDirectory(secondWorktreePath);
665 const startedSnapshot = yield* manager.open(
666 openInput({
667 cwd: firstWorktreePath,
668 worktreePath: firstWorktreePath,
669 }),
670 );
671 const restartedSnapshot = yield* manager.restart(
672 restartInput({
673 cwd: secondWorktreePath,
674 worktreePath: secondWorktreePath,
675 }),
676 );
677
678 assert.equal(startedSnapshot.worktreePath, firstWorktreePath);
679 assert.equal(restartedSnapshot.worktreePath, secondWorktreePath);
680
681 const events = yield* getEvents;
682 const startedEvent = events.find(
683 (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started",
684 );
685 const restartedEvent = events.find(
686 (event): event is Extract<TerminalEvent, { type: "restarted" }> =>
687 event.type === "restarted",
688 );
689
690 assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath);
691 assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath);
692 }),
693 );
694
695 it.effect("preserves worktree metadata when reopening an exited session", () =>
696 Effect.gen(function* () {
697 const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager();
698 const path = yield* Path.Path;
699 const worktreePath = path.join(baseDir, "worktrees", "feature-a");
700 yield* makeDirectory(worktreePath);
701
702 yield* manager.open(
703 openInput({
704 cwd: worktreePath,
705 worktreePath,
706 }),
707 );
708
709 const process = ptyAdapter.processes[0];
710 expect(process).toBeDefined();
711 if (!process) return;
712 process.emitExit({ exitCode: 0, signal: 0 });
713
714 yield* waitFor(
715 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
716 );
717
718 const reopenedSnapshot = yield* manager.open(
719 openInput({
720 cwd: worktreePath,
721 worktreePath,
722 }),
723 );
724
725 assert.equal(reopenedSnapshot.worktreePath, worktreePath);
726
727 const events = yield* getEvents;
728 const reopenedEvent = events
729 .toReversed()
730 .find(
731 (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started",
732 );
733
734 assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath);
735 }),
736 );
737
738 it.effect("emits exited event and reopens with clean transcript after exit", () =>
739 Effect.gen(function* () {
740 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
741 const path = yield* Path.Path;
742 yield* manager.open(openInput());
743 const process = ptyAdapter.processes[0];
744 expect(process).toBeDefined();
745 if (!process) return;
746 process.emitData("old data\n");
747 yield* waitFor(
748 historyLogPath(logsDir).pipe(
749 Effect.provideService(Path.Path, path),
750 Effect.flatMap(pathExists),
751 ),
752 );
753 process.emitExit({ exitCode: 0, signal: 0 });
754
755 yield* waitFor(
756 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
757 );
758 const reopened = yield* manager.open(openInput());
759
760 assert.equal(reopened.history, "");
761 expect(ptyAdapter.spawnInputs).toHaveLength(2);
762 expect(
763 yield* historyLogPath(logsDir).pipe(
764 Effect.provideService(Path.Path, path),
765 Effect.flatMap(readFileString),
766 ),
767 ).toBe("");
768 }),
769 );
770
771 it.effect("ignores trailing writes after terminal exit", () =>
772 Effect.gen(function* () {
773 const { manager, ptyAdapter } = yield* createManager();
774 yield* manager.open(openInput());
775 const process = ptyAdapter.processes[0];
776 expect(process).toBeDefined();
777 if (!process) return;
778
779 process.emitExit({ exitCode: 0, signal: 0 });
780
781 yield* manager.write({
782 threadId: "thread-1",
783 terminalId: DEFAULT_TERMINAL_ID,
784 data: "\r",
785 });
786 expect(process.writes).toEqual([]);
787 }),
788 );
789
790 it.effect("emits subprocess activity events when child-process state changes", () =>
791 Effect.gen(function* () {
792 let inspect: {
793 readonly hasRunningSubprocess: boolean;
794 readonly childCommand: string | null;
795 readonly processIds: ReadonlyArray<number>;
796 } = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
797 const { manager, getEvents } = yield* createManager(5, {
798 subprocessInspector: () => Effect.succeed(inspect),
799 subprocessPollIntervalMs: 20,
800 });
801
802 yield* manager.open(openInput());
803 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);
804
805 inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] };
806 yield* waitFor(
807 Effect.map(getEvents, (events) =>
808 events.some(
809 (event) =>
810 event.type === "activity" &&
811 event.hasRunningSubprocess === true &&
812 event.label === "vim",
813 ),
814 ),
815 "1200 millis",
816 );
817
818 inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
819 yield* waitFor(
820 Effect.map(getEvents, (events) =>
821 events.some(
822 (event) =>
823 event.type === "activity" &&
824 event.hasRunningSubprocess === false &&
825 event.label === "Terminal 1",
826 ),
827 ),
828 "1200 millis",
829 );
830 }),
831 );
832
833 it.effect("does not invoke subprocess polling until a terminal session is running", () =>
834 Effect.gen(function* () {
835 let checks = 0;
836 const { manager } = yield* createManager(5, {
837 subprocessInspector: () => {
838 checks += 1;
839 return Effect.succeed({
840 hasRunningSubprocess: false,
841 childCommand: null,
842 processIds: [],
843 });
844 },
845 subprocessPollIntervalMs: 20,
846 });
847
848 yield* Effect.sleep("80 millis");
849 assert.equal(checks, 0);
850
851 yield* manager.open(openInput());
852 yield* waitFor(
853 Effect.sync(() => checks > 0),
854 "1200 millis",
855 );
856 }),
857 );
858
859 it.effect("caps persisted history to configured line limit", () =>
860 Effect.gen(function* () {
861 const { manager, ptyAdapter } = yield* createManager(3);
862 yield* manager.open(openInput());
863 const process = ptyAdapter.processes[0];
864 expect(process).toBeDefined();
865 if (!process) return;
866
867 process.emitData("line1\nline2\nline3\nline4\n");
868 yield* manager.close({ threadId: "thread-1" });
869
870 const reopened = yield* manager.open(openInput());
871 const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);
872 expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);
873 }),
874 );
875
876 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
877 Effect.gen(function* () {
878 const { manager, ptyAdapter } = yield* createManager();
879 yield* manager.open(openInput());
880 const process = ptyAdapter.processes[0];
881 expect(process).toBeDefined();
882 if (!process) return;
883
884 process.emitData("prompt ");
885 process.emitData("\u001b[32mok\u001b[0m ");
886 process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007");
887 process.emitData("\u001b[1;1R");
888 process.emitData("done\n");
889
890 yield* manager.close({ threadId: "thread-1" });
891
892 const reopened = yield* manager.open(openInput());
893 assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n");
894 }),
895 );
896
897 it.effect(
898 "preserves clear and style control sequences while dropping chunk-split query traffic",
899 () =>
900 Effect.gen(function* () {
901 const { manager, ptyAdapter } = yield* createManager();
902 yield* manager.open(openInput());
903 const process = ptyAdapter.processes[0];
904 expect(process).toBeDefined();
905 if (!process) return;
906
907 process.emitData("before clear\n");
908 process.emitData("\u001b[H\u001b[2J");
909 process.emitData("prompt ");
910 process.emitData("\u001b]11;");
911 process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1");
912 process.emitData("R\u001b[36mdone\u001b[0m\n");
913
914 yield* manager.close({ threadId: "thread-1" });
915
916 const reopened = yield* manager.open(openInput());
917 assert.equal(
918 reopened.history,
919 "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n",
920 );
921 }),
922 );
923
924 it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () =>
925 Effect.gen(function* () {
926 const { manager, ptyAdapter } = yield* createManager();
927 yield* manager.open(openInput());
928 const process = ptyAdapter.processes[0];
929 expect(process).toBeDefined();
930 if (!process) return;
931
932 process.emitData("before ");
933 process.emitData("\u001b(B");
934 process.emitData("after\n");
935
936 yield* manager.close({ threadId: "thread-1" });
937
938 const reopened = yield* manager.open(openInput());
939 assert.equal(reopened.history, "before \u001b(Bafter\n");
940 }),
941 );
942
943 it.effect(
944 "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes",
945 () =>
946 Effect.gen(function* () {
947 const { manager, ptyAdapter } = yield* createManager();
948 yield* manager.open(openInput());
949 const process = ptyAdapter.processes[0];
950 expect(process).toBeDefined();
951 if (!process) return;
952
953 process.emitData("before ");
954 process.emitData("\u001b(");
955 process.emitData("Bafter\n");
956
957 yield* manager.close({ threadId: "thread-1" });
958
959 const reopened = yield* manager.open(openInput());
960 assert.equal(reopened.history, "before \u001b(Bafter\n");
961 }),
962 );
963
964 it.effect("deletes history file when close(deleteHistory=true)", () =>
965 Effect.gen(function* () {
966 const { manager, ptyAdapter, logsDir } = yield* createManager();
967 yield* manager.open(openInput());
968 const process = ptyAdapter.processes[0];
969 expect(process).toBeDefined();
970 if (!process) return;
971 process.emitData("bye\n");
972 const path = yield* Path.Path;
973 yield* waitFor(
974 historyLogPath(logsDir).pipe(
975 Effect.provideService(Path.Path, path),
976 Effect.flatMap(pathExists),
977 ),
978 );
979
980 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
981 expect(
982 yield* historyLogPath(logsDir).pipe(
983 Effect.provideService(Path.Path, path),
984 Effect.flatMap(pathExists),
985 ),
986 ).toBe(false);
987 }),
988 );
989
990 it.effect("closes all terminals for a thread when close omits terminalId", () =>
991 Effect.gen(function* () {
992 const { manager, ptyAdapter, logsDir } = yield* createManager();
993 yield* manager.open(openInput({ terminalId: "default" }));
994 yield* manager.open(openInput({ terminalId: "sidecar" }));
995 const defaultProcess = ptyAdapter.processes[0];
996 const sidecarProcess = ptyAdapter.processes[1];
997 expect(defaultProcess).toBeDefined();
998 expect(sidecarProcess).toBeDefined();
999 if (!defaultProcess || !sidecarProcess) return;
1000
1001 defaultProcess.emitData("default\n");
1002 sidecarProcess.emitData("sidecar\n");
1003 const path = yield* Path.Path;
1004 yield* waitFor(
1005 multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe(
1006 Effect.provideService(Path.Path, path),
1007 Effect.flatMap(pathExists),
1008 ),
1009 );
1010 yield* waitFor(
1011 multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe(
1012 Effect.provideService(Path.Path, path),
1013 Effect.flatMap(pathExists),
1014 ),
1015 );
1016
1017 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
1018
1019 assert.equal(defaultProcess.killed, true);
1020 assert.equal(sidecarProcess.killed, true);
1021 expect(
1022 yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe(
1023 Effect.provideService(Path.Path, path),
1024 Effect.flatMap(pathExists),
1025 ),
1026 ).toBe(false);
1027 expect(
1028 yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe(
1029 Effect.provideService(Path.Path, path),
1030 Effect.flatMap(pathExists),
1031 ),
1032 ).toBe(false);
1033 }),
1034 );
1035
1036 it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () =>
1037 Effect.gen(function* () {
1038 const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 });
1039 yield* manager.open(openInput());
1040 const process = ptyAdapter.processes[0];
1041 expect(process).toBeDefined();
1042 if (!process) return;
1043
1044 const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped);
1045 yield* Effect.yieldNow;
1046 yield* TestClock.adjust("10 millis");
1047 yield* Fiber.join(closeFiber);
1048
1049 assert.equal(process.killSignals[0], "SIGTERM");
1050 expect(process.killSignals).toContain("SIGKILL");
1051 }).pipe(Effect.provide(TestClock.layer())),
1052 );
1053
1054 it.effect("publishes closed events when terminals are explicitly closed", () =>
1055 Effect.gen(function* () {
1056 const { manager, getEvents } = yield* createManager();
1057 yield* manager.open(openInput({ terminalId: "default" }));
1058 yield* manager.open(openInput({ terminalId: "sidecar" }));
1059
1060 yield* manager.close({ threadId: "thread-1" });
1061
1062 const closedEvents = (yield* getEvents).filter(
1063 (event): event is Extract<TerminalEvent, { type: "closed" }> => event.type === "closed",
1064 );
1065 expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]);
1066 }),
1067 );
1068
1069 it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () =>
1070 Effect.gen(function* () {
1071 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, {
1072 maxRetainedInactiveSessions: 1,
1073 });
1074
1075 yield* manager.open(openInput({ threadId: "thread-1" }));
1076 yield* manager.open(openInput({ threadId: "thread-2" }));
1077
1078 const first = ptyAdapter.processes[0];
1079 const second = ptyAdapter.processes[1];
1080 expect(first).toBeDefined();
1081 expect(second).toBeDefined();
1082 if (!first || !second) return;
1083
1084 first.emitData("first-history\n");
1085 second.emitData("second-history\n");
1086 const path = yield* Path.Path;
1087 yield* waitFor(
1088 historyLogPath(logsDir, "thread-1").pipe(
1089 Effect.provideService(Path.Path, path),
1090 Effect.flatMap(pathExists),
1091 ),
1092 );
1093 first.emitExit({ exitCode: 0, signal: 0 });
1094 yield* Effect.sleep(Duration.millis(5));
1095 second.emitExit({ exitCode: 0, signal: 0 });
1096
1097 yield* waitFor(
1098 Effect.map(
1099 getEvents,
1100 (events) => events.filter((event) => event.type === "exited").length === 2,
1101 ),
1102 );
1103
1104 const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" }));
1105 const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" }));
1106
1107 assert.equal(reopenedFirst.history, "first-history\n");
1108 assert.equal(reopenedSecond.history, "");
1109 }),
1110 );
1111
1112 it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () =>
1113 Effect.gen(function* () {
1114 const { manager, logsDir } = yield* createManager();
1115 const path = yield* Path.Path;
1116 const legacyPath = path.join(logsDir, "thread-1.log");
1117 const nextPath = yield* historyLogPath(logsDir);
1118 yield* writeFileString(legacyPath, "legacy-line\n");
1119
1120 const snapshot = yield* manager.open(openInput());
1121
1122 assert.equal(snapshot.history, "legacy-line\n");
1123 expect(yield* pathExists(nextPath)).toBe(true);
1124 expect(yield* readFileString(nextPath)).toBe("legacy-line\n");
1125 expect(yield* pathExists(legacyPath)).toBe(false);
1126 }),
1127 );
1128
1129 it.effect("retries with fallback shells when preferred shell spawn fails", () =>
1130 Effect.gen(function* () {
1131 const platform = yield* HostProcessPlatform;
1132 const missingShell =
1133 platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l";
1134 const { manager, ptyAdapter } = yield* createManager(5, {
1135 shellResolver: () => missingShell,
1136 });
1137 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
1138
1139 const snapshot = yield* manager.open(openInput());
1140
1141 assert.equal(snapshot.status, "running");
1142 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
1143 expect(ptyAdapter.spawnInputs[0]?.shell).toBe(
1144 platform === "win32" ? missingShell : "/definitely/missing-shell",
1145 );
1146
1147 if (platform === "win32") {
1148 expect(
1149 ptyAdapter.spawnInputs.some(
1150 (input) =>
1151 input.shell === "pwsh.exe" ||
1152 input.shell === "powershell.exe" ||
1153 input.shell === "cmd.exe",
1154 ),
1155 ).toBe(true);
1156 } else {
1157 expect(
1158 ptyAdapter.spawnInputs
1159 .slice(1)
1160 .some((input) => input.shell !== "/definitely/missing-shell"),
1161 ).toBe(true);
1162 }
1163 }),
1164 );
1165
1166 it.effect("prefers PowerShell over ComSpec for Windows terminals", () =>
1167 Effect.gen(function* () {
1168 const { manager, ptyAdapter } = yield* createManager(5, {
1169 env: {
1170 ComSpec: "C:\\Windows\\System32\\cmd.exe",
1171 PATH: "C:\\Windows\\System32",
1172 SystemRoot: "C:\\Windows",
1173 },
1174 }).pipe(Effect.provide(withHostPlatform("win32")));
1175
1176 yield* manager.open(openInput());
1177
1178 expect(ptyAdapter.spawnInputs[0]).toEqual(
1179 expect.objectContaining({
1180 shell: "pwsh.exe",
1181 args: ["-NoLogo"],
1182 }),
1183 );
1184 }),
1185 );
1186
1187 it.effect("falls back to built-in PowerShell by absolute path on Windows", () =>
1188 Effect.gen(function* () {
1189 const ptyAdapter = new FakePtyAdapter();
1190 const { manager } = yield* createManager(5, {
1191 ptyAdapter,
1192 shellResolver: () => "C:\\missing\\custom-shell.exe",
1193 env: {
1194 ComSpec: "C:\\Windows\\System32\\cmd.exe",
1195 PATH: "C:\\Windows\\System32",
1196 SystemRoot: "C:\\Windows",
1197 },
1198 }).pipe(Effect.provide(withHostPlatform("win32")));
1199 ptyAdapter.spawnFailures.push(
1200 new Error("spawn custom-shell.exe ENOENT"),
1201 new Error("spawn pwsh.exe ENOENT"),
1202 );
1203
1204 yield* manager.open(openInput());
1205
1206 expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([
1207 "C:\\missing\\custom-shell.exe",
1208 "pwsh.exe",
1209 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
1210 ]);
1211 expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]);
1212 expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]);
1213 }),
1214 );
1215
1216 it.effect("filters app runtime env variables from terminal sessions", () =>
1217 Effect.gen(function* () {
1218 const { manager, ptyAdapter } = yield* createManager(5, {
1219 env: {
1220 PORT: "5173",
1221 T3CODE_PORT: "3773",
1222 VITE_DEV_SERVER_URL: "http://localhost:5173",
1223 TEST_TERMINAL_KEEP: "keep-me",
1224 },
1225 });
1226 yield* manager.open(openInput());
1227 const spawnInput = ptyAdapter.spawnInputs[0];
1228 expect(spawnInput).toBeDefined();
1229 if (!spawnInput) return;
1230
1231 expect(spawnInput.env.PORT).toBeUndefined();
1232 expect(spawnInput.env.T3CODE_PORT).toBeUndefined();
1233 expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined();
1234 // Arbitrary host env vars must pass through — terminals inherit the
1235 // user's environment apart from the explicit blocklist.
1236 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");
1237 }),
1238 );
1239
1240 it.effect("injects runtime env overrides into spawned terminals", () =>
1241 Effect.gen(function* () {
1242 const { manager, ptyAdapter } = yield* createManager();
1243 yield* manager.open(
1244 openInput({
1245 env: {
1246 T3CODE_PROJECT_ROOT: "/repo",
1247 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
1248 CUSTOM_FLAG: "1",
1249 },
1250 }),
1251 );
1252 const spawnInput = ptyAdapter.spawnInputs[0];
1253 expect(spawnInput).toBeDefined();
1254 if (!spawnInput) return;
1255
1256 assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo");
1257 assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a");
1258 assert.equal(spawnInput.env.CUSTOM_FLAG, "1");
1259 }),
1260 );
1261
1262 it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>
1263 Effect.gen(function* () {
1264 if ((yield* HostProcessPlatform) === "win32") return;
1265 const { manager, ptyAdapter } = yield* createManager(5, {
1266 shellResolver: () => "/bin/zsh",
1267 });
1268 yield* manager.open(openInput());
1269 const spawnInput = ptyAdapter.spawnInputs[0];
1270 expect(spawnInput).toBeDefined();
1271 if (!spawnInput) return;
1272
1273 expect(spawnInput.args).toEqual(["-o", "nopromptsp"]);
1274 }),
1275 );
1276
1277 it.effect("bridges PTY callbacks back into Effect-managed event streaming", () =>
1278 Effect.gen(function* () {
1279 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
1280 ptyAdapter: new FakePtyAdapter("async"),
1281 });
1282
1283 yield* manager.open(openInput());
1284 const process = ptyAdapter.processes[0];
1285 expect(process).toBeDefined();
1286 if (!process) return;
1287
1288 process.emitData("hello from callback\n");
1289
1290 yield* waitFor(
1291 Effect.map(getEvents, (events) =>
1292 events.some((event) => event.type === "output" && event.data === "hello from callback\n"),
1293 ),
1294 "1200 millis",
1295 );
1296 }),
1297 );
1298
1299 it.effect("pushes PTY callbacks to direct event subscribers", () =>
1300 Effect.gen(function* () {
1301 const { manager, ptyAdapter } = yield* createManager(5, {
1302 ptyAdapter: new FakePtyAdapter("async"),
1303 });
1304 const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
1305 const unsubscribe = yield* manager.subscribe((event) =>
1306 Ref.update(subscriberEvents, (events) => [...events, event]),
1307 );
1308 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1309
1310 yield* manager.open(openInput());
1311 const process = ptyAdapter.processes[0];
1312 expect(process).toBeDefined();
1313 if (!process) return;
1314
1315 process.emitData("hello from subscriber\n");
1316
1317 yield* waitFor(
1318 Effect.map(Ref.get(subscriberEvents), (events) =>
1319 events.some(
1320 (event) => event.type === "output" && event.data === "hello from subscriber\n",
1321 ),
1322 ),
1323 "1200 millis",
1324 );
1325 }),
1326 );
1327
1328 it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () =>
1329 Effect.gen(function* () {
1330 const { manager } = yield* createManager();
1331 yield* manager.open(openInput({ threadId: "existing-thread" }));
1332
1333 const metadataEvents = yield* Ref.make<ReadonlyArray<TerminalMetadataStreamEvent>>([]);
1334 const unsubscribe = yield* manager.subscribeMetadata((event) =>
1335 Ref.update(metadataEvents, (events) => [...events, event]),
1336 );
1337 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1338
1339 const initialEvents = yield* Ref.get(metadataEvents);
1340 expect(initialEvents[0]).toMatchObject({
1341 type: "snapshot",
1342 terminals: [
1343 {
1344 threadId: "existing-thread",
1345 terminalId: DEFAULT_TERMINAL_ID,
1346 },
1347 ],
1348 });
1349
1350 yield* manager.open(openInput({ threadId: "new-thread" }));
1351
1352 yield* waitFor(
1353 Effect.map(Ref.get(metadataEvents), (events) =>
1354 events.some(
1355 (event) =>
1356 event.type === "upsert" &&
1357 event.terminal.threadId === "new-thread" &&
1358 event.terminal.terminalId === DEFAULT_TERMINAL_ID,
1359 ),
1360 ),
1361 "1200 millis",
1362 );
1363
1364 yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID });
1365
1366 yield* waitFor(
1367 Effect.map(Ref.get(metadataEvents), (events) =>
1368 events.some(
1369 (event) =>
1370 event.type === "remove" &&
1371 event.threadId === "new-thread" &&
1372 event.terminalId === DEFAULT_TERMINAL_ID,
1373 ),
1374 ),
1375 "1200 millis",
1376 );
1377 }),
1378 );
1379
1380 it.effect("removes terminal metadata subscriptions when initial delivery fails", () =>
1381 Effect.gen(function* () {
1382 const { manager } = yield* createManager();
1383 yield* manager.open(openInput({ threadId: "existing-thread" }));
1384
1385 const leakedLiveEvents = yield* Ref.make(0);
1386 const exit = yield* Effect.exit(
1387 manager.subscribeMetadata((event) =>
1388 event.type === "snapshot"
1389 ? Effect.die("snapshot listener failed")
1390 : Ref.update(leakedLiveEvents, (count) => count + 1),
1391 ),
1392 );
1393
1394 expect(Exit.isFailure(exit)).toBe(true);
1395
1396 yield* manager.open(openInput({ threadId: "new-thread" }));
1397 expect(yield* Ref.get(leakedLiveEvents)).toBe(0);
1398 }),
1399 );
1400
1401 it.effect(
1402 "streams attach snapshots followed by live events without duplicate start snapshots",
1403 () =>
1404 Effect.gen(function* () {
1405 const { manager, ptyAdapter } = yield* createManager(5, {
1406 ptyAdapter: new FakePtyAdapter("async"),
1407 });
1408 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1409 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
1410 Ref.update(attachEvents, (events) => [...events, event]),
1411 );
1412 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1413
1414 const process = ptyAdapter.processes[0];
1415 expect(process).toBeDefined();
1416 if (!process) return;
1417
1418 expect(yield* Ref.get(attachEvents)).toMatchObject([
1419 {
1420 type: "snapshot",
1421 snapshot: {
1422 threadId: "thread-1",
1423 terminalId: DEFAULT_TERMINAL_ID,
1424 },
1425 },
1426 ]);
1427
1428 process.emitData("hello from attach\n");
1429
1430 yield* waitFor(
1431 Effect.map(Ref.get(attachEvents), (events) =>
1432 events.some((event) => event.type === "output" && event.data === "hello from attach\n"),
1433 ),
1434 "1200 millis",
1435 );
1436
1437 const events = yield* Ref.get(attachEvents);
1438 expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1);
1439 }),
1440 );
1441
1442 it.effect("buffers attach output delivered during the initial snapshot callback", () =>
1443 Effect.gen(function* () {
1444 const { manager, ptyAdapter } = yield* createManager(5, {
1445 ptyAdapter: new FakePtyAdapter("async"),
1446 });
1447 yield* manager.open(openInput());
1448
1449 const process = ptyAdapter.processes[0];
1450 expect(process).toBeDefined();
1451 if (!process) return;
1452
1453 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1454 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
1455 Effect.gen(function* () {
1456 yield* Ref.update(attachEvents, (events) => [...events, event]);
1457 if (event.type === "snapshot") {
1458 yield* Effect.sync(() => process.emitData("during snapshot\n"));
1459 yield* Effect.yieldNow;
1460 }
1461 }),
1462 );
1463 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1464
1465 yield* waitFor(
1466 Effect.map(Ref.get(attachEvents), (events) =>
1467 events.some((event) => event.type === "output" && event.data === "during snapshot\n"),
1468 ),
1469 "1200 millis",
1470 );
1471
1472 expect(yield* Ref.get(attachEvents)).toMatchObject([
1473 { type: "snapshot" },
1474 { type: "output", data: "during snapshot\n" },
1475 ]);
1476 }),
1477 );
1478
1479 it.effect("preserves queued PTY output ordering through exit callbacks", () =>
1480 Effect.gen(function* () {
1481 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
1482 ptyAdapter: new FakePtyAdapter("async"),
1483 });
1484
1485 yield* manager.open(openInput());
1486 const process = ptyAdapter.processes[0];
1487 expect(process).toBeDefined();
1488 if (!process) return;
1489
1490 process.emitData("first\n");
1491 process.emitData("second\n");
1492 process.emitExit({ exitCode: 0, signal: 0 });
1493
1494 yield* waitFor(
1495 Effect.map(getEvents, (events) => {
1496 const relevant = events.filter(
1497 (event) => event.type === "output" || event.type === "exited",
1498 );
1499 return relevant.length >= 3;
1500 }),
1501 "1200 millis",
1502 );
1503
1504 const relevant = (yield* getEvents).filter(
1505 (event) => event.type === "output" || event.type === "exited",
1506 );
1507 expect(relevant).toEqual([
1508 expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }),
1509 expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }),
1510 expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }),
1511 ]);
1512
1513 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1514 const unsubscribe = yield* manager.attachStream(
1515 {
1516 threadId: "thread-1",
1517 terminalId: DEFAULT_TERMINAL_ID,
1518 },
1519 (event) => Ref.update(attachEvents, (events) => [...events, event]),
1520 );
1521 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1522
1523 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
1524 expect(snapshot).toBeDefined();
1525 if (!snapshot || snapshot.type !== "snapshot") return;
1526 expect(snapshot.snapshot.sequence).toBe(4);
1527 }),
1528 );
1529
1530 it.effect("scoped runtime shutdown stops active terminals cleanly", () =>
1531 Effect.gen(function* () {
1532 const scope = yield* Scope.make("sequential");
1533 const { manager, ptyAdapter } = yield* createManager(5, {
1534 processKillGraceMs: 10,
1535 }).pipe(Effect.provideService(Scope.Scope, scope));
1536 yield* manager.open(openInput());
1537 const process = ptyAdapter.processes[0];
1538 expect(process).toBeDefined();
1539 if (!process) return;
1540
1541 const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped);
1542 yield* Effect.yieldNow;
1543 yield* TestClock.adjust("10 millis");
1544 yield* Fiber.join(closeScope);
1545
1546 assert.equal(process.killSignals[0], "SIGTERM");
1547 expect(process.killSignals).toContain("SIGKILL");
1548 }).pipe(Effect.provide(TestClock.layer())),
1549 );
1550});
apps/server/src/terminal/Manager.ts created+2571
...@@ -0,0 +1,2571 @@
1/**
2 * TerminalManager - Terminal session orchestration service interface.
3 *
4 * Owns terminal lifecycle operations, output fanout, and session state
5 * transitions for thread-scoped terminals.
6 *
7 * @module TerminalManager
8 */
9import {
10 DEFAULT_TERMINAL_ID,
11 TerminalCwdError,
12 TerminalError,
13 TerminalHistoryError,
14 TerminalNotRunningError,
15 TerminalSessionLookupError,
16 type TerminalAttachInput,
17 type TerminalAttachStreamEvent,
18 type TerminalClearInput,
19 type TerminalCloseInput,
20 type TerminalEvent,
21 type TerminalMetadataStreamEvent,
22 type TerminalOpenInput,
23 type TerminalResizeInput,
24 type TerminalRestartInput,
25 type TerminalSessionSnapshot,
26 type TerminalSessionStatus,
27 type TerminalSummary,
28 type TerminalWriteInput,
29} from "@t3tools/contracts";
30import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
31import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
32import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
33import * as DateTime from "effect/DateTime";
34import * as Context from "effect/Context";
35import * as Effect from "effect/Effect";
36import * as Encoding from "effect/Encoding";
37import * as Equal from "effect/Equal";
38import * as Exit from "effect/Exit";
39import * as Fiber from "effect/Fiber";
40import * as FileSystem from "effect/FileSystem";
41import * as Layer from "effect/Layer";
42import * as Option from "effect/Option";
43import * as Path from "effect/Path";
44import * as Schema from "effect/Schema";
45import * as Scope from "effect/Scope";
46import * as Semaphore from "effect/Semaphore";
47import * as SynchronizedRef from "effect/SynchronizedRef";
48
49import * as ServerConfig from "../config.ts";
50import {
51 increment,
52 terminalRestartsTotal,
53 terminalSessionsTotal,
54} from "../observability/Metrics.ts";
55import * as ProcessRunner from "../processRunner.ts";
56import * as PortScanner from "../preview/PortScanner.ts";
57import * as PtyAdapter from "./PtyAdapter.ts";
58
59export {
60 TerminalCwdError,
61 TerminalError,
62 TerminalHistoryError,
63 TerminalNotRunningError,
64 TerminalSessionLookupError,
65};
66
67const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
68const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
69const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
70const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;
71const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;
72const DEFAULT_OPEN_COLS = 120;
73const DEFAULT_OPEN_ROWS = 30;
74const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
75const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
76const MAX_TERMINAL_LABEL_LENGTH = 128;
77
78class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
79 "TerminalSubprocessCheckError",
80 {
81 cause: Schema.optional(Schema.Defect()),
82 terminalPid: Schema.Number,
83 command: Schema.Literals(["powershell", "pgrep", "ps"]),
84 },
85) {
86 override get message(): string {
87 return `Failed to inspect terminal subprocesses for PID ${this.terminalPid} with ${this.command}`;
88 }
89}
90
91class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcessSignalError>()(
92 "TerminalProcessSignalError",
93 {
94 cause: Schema.optional(Schema.Defect()),
95 signal: Schema.Literals(["SIGTERM", "SIGKILL"]),
96 terminalPid: Schema.Number,
97 },
98) {
99 override get message(): string {
100 return `Failed to send ${this.signal} to terminal process ${this.terminalPid}`;
101 }
102}
103
104/**
105 * TerminalManager - Service tag for terminal session orchestration.
106 */
107export class TerminalManager extends Context.Service<
108 TerminalManager,
109 {
110 /**
111 * Open or attach to a terminal session.
112 *
113 * Reuses an existing session for the same thread/terminal id and restores
114 * persisted history on first open.
115 */
116 readonly open: (
117 input: TerminalOpenInput,
118 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
119
120 /**
121 * Attach to a terminal and stream its initial snapshot followed by live events.
122 *
123 * Returns an unsubscribe function.
124 */
125 readonly attachStream: (
126 input: TerminalAttachInput,
127 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
128 ) => Effect.Effect<() => void, TerminalError>;
129
130 /**
131 * Write input bytes to a terminal session.
132 */
133 readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>;
134
135 /**
136 * Resize the PTY backing a terminal session.
137 */
138 readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>;
139
140 /**
141 * Clear terminal output history.
142 */
143 readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>;
144
145 /**
146 * Restart a terminal session in place.
147 *
148 * Always resets history before spawning the new process.
149 */
150 readonly restart: (
151 input: TerminalRestartInput,
152 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
153
154 /**
155 * Close an active terminal session.
156 *
157 * When `terminalId` is omitted, closes all sessions for the thread.
158 */
159 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
160
161 /**
162 * Subscribe to terminal runtime events with a direct callback.
163 *
164 * Returns an unsubscribe function.
165 */
166 readonly subscribe: (
167 listener: (event: TerminalEvent) => Effect.Effect<void>,
168 ) => Effect.Effect<() => void>;
169
170 /**
171 * Subscribe to lightweight terminal metadata with an initial full snapshot.
172 *
173 * Returns an unsubscribe function.
174 */
175 readonly subscribeMetadata: (
176 listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,
177 ) => Effect.Effect<() => void>;
178 }
179>()("t3/terminal/Manager/TerminalManager") {}
180
181interface TerminalSubprocessInspectResult {
182 readonly hasRunningSubprocess: boolean;
183 readonly childCommand: string | null;
184 readonly processIds: ReadonlyArray<number>;
185}
186
187interface TerminalSubprocessInspector {
188 (
189 terminalPid: number,
190 ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;
191}
192
193export interface ShellCandidate {
194 shell: string;
195 args?: string[];
196}
197
198export interface TerminalStartInput extends TerminalOpenInput {
199 cols: number;
200 rows: number;
201}
202
203export interface TerminalSessionState {
204 threadId: string;
205 terminalId: string;
206 cwd: string;
207 worktreePath: string | null;
208 status: TerminalSessionStatus;
209 pid: number | null;
210 history: string;
211 pendingHistoryControlSequence: string;
212 pendingProcessEvents: Array<PendingProcessEvent>;
213 pendingProcessEventIndex: number;
214 processEventDrainRunning: boolean;
215 exitCode: number | null;
216 exitSignal: number | null;
217 updatedAt: string;
218 eventSequence: number;
219 cols: number;
220 rows: number;
221 process: PtyAdapter.PtyProcess | null;
222 unsubscribeData: (() => void) | null;
223 unsubscribeExit: (() => void) | null;
224 hasRunningSubprocess: boolean;
225 /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */
226 childCommandLabel: string | null;
227 runtimeEnv: Record<string, string> | null;
228}
229
230interface PersistHistoryRequest {
231 history: string;
232 immediate: boolean;
233}
234
235type PendingProcessEvent =
236 | { type: "output"; data: string }
237 | { type: "exit"; event: PtyAdapter.PtyExitEvent };
238
239type DrainProcessEventAction =
240 | { type: "idle" }
241 | {
242 type: "output";
243 threadId: string;
244 terminalId: string;
245 sequence: number;
246 history: string | null;
247 data: string;
248 }
249 | {
250 type: "exit";
251 process: PtyAdapter.PtyProcess | null;
252 threadId: string;
253 terminalId: string;
254 sequence: number;
255 exitCode: number | null;
256 exitSignal: number | null;
257 };
258
259interface TerminalManagerState {
260 sessions: Map<string, TerminalSessionState>;
261 killFibers: Map<PtyAdapter.PtyProcess, Fiber.Fiber<void, never>>;
262}
263
264function truncateTerminalWireLabel(value: string): string {
265 if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value;
266 return value.slice(0, MAX_TERMINAL_LABEL_LENGTH);
267}
268
269function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null {
270 let trimmed = raw.trim();
271 if (trimmed.length === 0) return null;
272 if (
273 (trimmed.startsWith("[") && trimmed.endsWith("]")) ||
274 (trimmed.startsWith("(") && trimmed.endsWith(")"))
275 ) {
276 trimmed = trimmed.slice(1, -1).trim();
277 }
278 const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim();
279 if (firstToken.length === 0) return null;
280 const separators = platform === "win32" ? /[\\/]/ : /\//;
281 const base = firstToken.split(separators).at(-1) ?? firstToken;
282 const withoutExe =
283 platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base;
284 return withoutExe.length > 0 ? withoutExe : null;
285}
286
287function terminalWireLabel(session: TerminalSessionState): string {
288 if (session.hasRunningSubprocess && session.childCommandLabel) {
289 const trimmed = session.childCommandLabel.trim();
290 if (trimmed.length > 0) {
291 return truncateTerminalWireLabel(trimmed);
292 }
293 }
294 return truncateTerminalWireLabel(getTerminalLabel(session.terminalId));
295}
296
297function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
298 return {
299 threadId: session.threadId,
300 terminalId: session.terminalId,
301 cwd: session.cwd,
302 worktreePath: session.worktreePath,
303 status: session.status,
304 pid: session.pid,
305 history: session.history,
306 exitCode: session.exitCode,
307 exitSignal: session.exitSignal,
308 label: terminalWireLabel(session),
309 updatedAt: session.updatedAt,
310 sequence: session.eventSequence,
311 };
312}
313
314function summary(session: TerminalSessionState): TerminalSummary {
315 return {
316 threadId: session.threadId,
317 terminalId: session.terminalId,
318 cwd: session.cwd,
319 worktreePath: session.worktreePath,
320 status: session.status,
321 pid: session.pid,
322 exitCode: session.exitCode,
323 exitSignal: session.exitSignal,
324 hasRunningSubprocess: session.hasRunningSubprocess,
325 label: terminalWireLabel(session),
326 updatedAt: session.updatedAt,
327 };
328}
329
330function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean {
331 switch (event.type) {
332 case "started":
333 case "restarted":
334 case "exited":
335 case "closed":
336 case "error":
337 case "activity":
338 return true;
339 case "output":
340 case "cleared":
341 return false;
342 }
343}
344
345function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null {
346 switch (event.type) {
347 case "started":
348 return {
349 type: "snapshot",
350 snapshot: event.snapshot,
351 };
352 case "output":
353 case "exited":
354 case "closed":
355 case "error":
356 case "cleared":
357 case "restarted":
358 case "activity":
359 return event;
360 }
361}
362
363function isDuplicateAttachSnapshotEvent(
364 event: TerminalEvent,
365 initialSnapshot: TerminalSessionSnapshot,
366) {
367 return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number"
368 ? event.sequence <= initialSnapshot.sequence
369 : event.type === "started" &&
370 event.snapshot.threadId === initialSnapshot.threadId &&
371 event.snapshot.terminalId === initialSnapshot.terminalId &&
372 event.snapshot.updatedAt <= initialSnapshot.updatedAt;
373}
374
375function advanceEventSequence(session: TerminalSessionState): {
376 readonly updatedAt: string;
377 readonly sequence: number;
378} {
379 const updatedAt = DateTime.formatIso(DateTime.nowUnsafe());
380 session.eventSequence += 1;
381 session.updatedAt = updatedAt;
382 return { updatedAt, sequence: session.eventSequence };
383}
384
385function cleanupProcessHandles(session: TerminalSessionState): void {
386 session.unsubscribeData?.();
387 session.unsubscribeData = null;
388 session.unsubscribeExit?.();
389 session.unsubscribeExit = null;
390}
391
392function enqueueProcessEvent(
393 session: TerminalSessionState,
394 expectedPid: number,
395 event: PendingProcessEvent,
396): boolean {
397 if (!session.process || session.status !== "running" || session.pid !== expectedPid) {
398 return false;
399 }
400
401 session.pendingProcessEvents.push(event);
402 if (session.processEventDrainRunning) {
403 return false;
404 }
405
406 session.processEventDrainRunning = true;
407 return true;
408}
409
410function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
411 if (platform === "win32") {
412 return "pwsh.exe";
413 }
414 return env.SHELL ?? "bash";
415}
416
417function normalizeShellCommand(
418 value: string | undefined,
419 platform: NodeJS.Platform,
420): string | null {
421 if (!value) return null;
422 const trimmed = value.trim();
423 if (trimmed.length === 0) return null;
424
425 if (platform === "win32") {
426 return trimmed;
427 }
428
429 const firstToken = trimmed.split(/\s+/g)[0]?.trim();
430 if (!firstToken) return null;
431 return firstToken.replace(/^['"]|['"]$/g, "");
432}
433
434function basenameForPlatform(command: string, platform: NodeJS.Platform): string {
435 const normalized =
436 platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/");
437 const parts = normalized
438 .split(platform === "win32" ? /\\+/ : /\/+/)
439 .filter((part) => part.length > 0);
440 return parts.at(-1) ?? normalized;
441}
442
443function joinWindowsPath(...parts: ReadonlyArray<string>): string {
444 return parts
445 .map((part, index) => {
446 if (index === 0) return part.replace(/[\\/]+$/g, "");
447 return part.replace(/^[\\/]+|[\\/]+$/g, "");
448 })
449 .filter((part) => part.length > 0)
450 .join("\\");
451}
452
453function shellCandidateFromCommand(
454 command: string | null,
455 platform: NodeJS.Platform,
456): ShellCandidate | null {
457 if (!command || command.length === 0) return null;
458 const shellName = basenameForPlatform(command, platform).toLowerCase();
459 if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) {
460 return { shell: command, args: ["-NoLogo"] };
461 }
462 if (platform !== "win32" && shellName === "zsh") {
463 return { shell: command, args: ["-o", "nopromptsp"] };
464 }
465 return { shell: command };
466}
467
468function windowsSystemRoot(env: NodeJS.ProcessEnv): string {
469 return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows";
470}
471
472function windowsPowerShellPath(env: NodeJS.ProcessEnv): string {
473 return joinWindowsPath(
474 windowsSystemRoot(env),
475 "System32",
476 "WindowsPowerShell",
477 "v1.0",
478 "powershell.exe",
479 );
480}
481
482function windowsCmdPath(env: NodeJS.ProcessEnv): string {
483 return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe");
484}
485
486function formatShellCandidate(candidate: ShellCandidate): string {
487 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
488 return `${candidate.shell} ${candidate.args.join(" ")}`;
489}
490
491function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] {
492 const seen = new Set<string>();
493 const ordered: ShellCandidate[] = [];
494 for (const candidate of candidates) {
495 if (!candidate) continue;
496 const key = formatShellCandidate(candidate);
497 if (seen.has(key)) continue;
498 seen.add(key);
499 ordered.push(candidate);
500 }
501 return ordered;
502}
503
504function resolveShellCandidates(
505 shellResolver: () => string,
506 platform: NodeJS.Platform,
507 env: NodeJS.ProcessEnv,
508): ShellCandidate[] {
509 const requested = shellCandidateFromCommand(
510 normalizeShellCommand(shellResolver(), platform),
511 platform,
512 );
513
514 if (platform === "win32") {
515 return uniqueShellCandidates([
516 requested,
517 shellCandidateFromCommand("pwsh.exe", platform),
518 shellCandidateFromCommand(windowsPowerShellPath(env), platform),
519 shellCandidateFromCommand("powershell.exe", platform),
520 shellCandidateFromCommand(env.ComSpec ?? null, platform),
521 shellCandidateFromCommand(windowsCmdPath(env), platform),
522 shellCandidateFromCommand("cmd.exe", platform),
523 ]);
524 }
525
526 return uniqueShellCandidates([
527 requested,
528 shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform),
529 shellCandidateFromCommand("/bin/zsh", platform),
530 shellCandidateFromCommand("/bin/bash", platform),
531 shellCandidateFromCommand("/bin/sh", platform),
532 shellCandidateFromCommand("zsh", platform),
533 shellCandidateFromCommand("bash", platform),
534 shellCandidateFromCommand("sh", platform),
535 ]);
536}
537
538function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean {
539 const queue: unknown[] = [error];
540 const seen = new Set<unknown>();
541 const messages: string[] = [];
542
543 while (queue.length > 0) {
544 const current = queue.shift();
545 if (!current || seen.has(current)) {
546 continue;
547 }
548 seen.add(current);
549
550 if (typeof current === "string") {
551 messages.push(current);
552 continue;
553 }
554
555 if (current instanceof Error) {
556 messages.push(current.message);
557 if (current.cause) {
558 queue.push(current.cause);
559 }
560 continue;
561 }
562
563 if (typeof current === "object") {
564 const value = current as { message?: unknown; cause?: unknown };
565 if (typeof value.message === "string") {
566 messages.push(value.message);
567 }
568 if (value.cause) {
569 queue.push(value.cause);
570 }
571 }
572 }
573
574 const message = messages.join(" ").toLowerCase();
575 return (
576 message.includes("posix_spawnp failed") ||
577 message.includes("enoent") ||
578 message.includes("not found") ||
579 message.includes("file not found") ||
580 message.includes("no such file")
581 );
582}
583
584function parseFirstChildPidFromPgrep(stdout: string): number | null {
585 for (const line of stdout.split(/\r?\n/g)) {
586 const n = Number.parseInt(line.trim(), 10);
587 if (Number.isInteger(n) && n > 0) {
588 return n;
589 }
590 }
591 return null;
592}
593
594function windowsInspectSubprocess(
595 terminalPid: number,
596 platform: NodeJS.Platform,
597): Effect.Effect<
598 TerminalSubprocessInspectResult,
599 TerminalSubprocessCheckError,
600 ProcessRunner.ProcessRunner
601> {
602 const command =
603 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
604 return Effect.gen(function* () {
605 const processRunner = yield* ProcessRunner.ProcessRunner;
606 return yield* processRunner.run({
607 // powershell.exe is a real executable — never spawn it through cmd.exe
608 // shell mode, which would re-tokenize the `-Command` payload (pipes,
609 // semicolons) before PowerShell ever sees it.
610 command: "powershell.exe",
611 args: ["-NoProfile", "-NonInteractive", "-Command", command],
612 timeout: "1500 millis",
613 maxOutputBytes: 32_768,
614 outputMode: "truncate",
615 timeoutBehavior: "timedOutResult",
616 });
617 }).pipe(
618 Effect.map((result) => {
619 if (result.code !== 0) {
620 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
621 }
622 const processNameById = new Map<number, string>();
623 const childrenByParent = new Map<number, number[]>();
624 for (const line of result.stdout.split(/\r?\n/g)) {
625 const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3);
626 const pid = Number(pidRaw);
627 const parentPid = Number(parentPidRaw);
628 if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
629 processNameById.set(pid, nameRaw?.trim() ?? "");
630 const children = childrenByParent.get(parentPid) ?? [];
631 children.push(pid);
632 childrenByParent.set(parentPid, children);
633 }
634 const directChildren = childrenByParent.get(terminalPid) ?? [];
635 const childPid = directChildren[0];
636 if (childPid === undefined) {
637 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
638 }
639 const processIds = new Set<number>([terminalPid]);
640 const pending = [terminalPid];
641 while (pending.length > 0) {
642 const parentPid = pending.pop();
643 if (parentPid === undefined) continue;
644 for (const pid of childrenByParent.get(parentPid) ?? []) {
645 if (processIds.has(pid)) continue;
646 processIds.add(pid);
647 pending.push(pid);
648 }
649 }
650 const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform);
651 return {
652 hasRunningSubprocess: true,
653 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
654 processIds: [...processIds],
655 } as const;
656 }),
657 Effect.mapError(
658 (cause) =>
659 new TerminalSubprocessCheckError({
660 cause,
661 terminalPid,
662 command: "powershell",
663 }),
664 ),
665 );
666}
667
668const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* (
669 terminalPid: number,
670 platform: NodeJS.Platform,
671): Effect.fn.Return<
672 TerminalSubprocessInspectResult,
673 TerminalSubprocessCheckError,
674 ProcessRunner.ProcessRunner
675> {
676 const processRunner = yield* ProcessRunner.ProcessRunner;
677 const runPgrep = processRunner
678 .run({
679 command: "pgrep",
680 args: ["-P", String(terminalPid)],
681 timeout: "1 second",
682 maxOutputBytes: 32_768,
683 outputMode: "truncate",
684 timeoutBehavior: "timedOutResult",
685 })
686 .pipe(
687 Effect.mapError(
688 (cause) =>
689 new TerminalSubprocessCheckError({
690 cause,
691 terminalPid,
692 command: "pgrep",
693 }),
694 ),
695 );
696
697 const runPs = processRunner
698 .run({
699 command: "ps",
700 args: ["-eo", "pid=,ppid="],
701 timeout: "1 second",
702 maxOutputBytes: 262_144,
703 outputMode: "truncate",
704 timeoutBehavior: "timedOutResult",
705 })
706 .pipe(
707 Effect.mapError(
708 (cause) =>
709 new TerminalSubprocessCheckError({
710 cause,
711 terminalPid,
712 command: "ps",
713 }),
714 ),
715 );
716
717 let childPid: number | null = null;
718
719 const pgrepResult = yield* Effect.exit(runPgrep);
720 if (pgrepResult._tag === "Success") {
721 if (pgrepResult.value.code === 0) {
722 childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
723 } else if (pgrepResult.value.code === 1) {
724 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
725 }
726 }
727
728 if (childPid === null) {
729 const psResult = yield* Effect.exit(runPs);
730 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
731 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
732 }
733 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
734 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
735 const pid = Number(pidRaw);
736 const ppid = Number(ppidRaw);
737 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
738 if (ppid === terminalPid) {
739 childPid = pid;
740 break;
741 }
742 }
743 }
744
745 if (childPid === null) {
746 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
747 }
748
749 const runComm = processRunner.run({
750 command: "ps",
751 args: ["-p", String(childPid), "-o", "comm="],
752 timeout: "1 second",
753 maxOutputBytes: 8_192,
754 outputMode: "truncate",
755 timeoutBehavior: "timedOutResult",
756 });
757
758 const commResult = yield* Effect.exit(runComm);
759 let rawComm: string | null = null;
760 if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) {
761 rawComm = commResult.value.stdout.trim();
762 }
763
764 if (!rawComm || rawComm.length === 0) {
765 const runArgs = processRunner.run({
766 command: "ps",
767 args: ["-p", String(childPid), "-o", "args="],
768 timeout: "1 second",
769 maxOutputBytes: 16_384,
770 outputMode: "truncate",
771 timeoutBehavior: "timedOutResult",
772 });
773 const argsResult = yield* Effect.exit(runArgs);
774 if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) {
775 const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? "";
776 rawComm = first.length > 0 ? first : null;
777 }
778 }
779
780 const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;
781 const processIds = new Set<number>([terminalPid]);
782 const psResult = yield* Effect.exit(runPs);
783 if (psResult._tag === "Success" && psResult.value.code === 0) {
784 const childrenByParent = new Map<number, number[]>();
785 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
786 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
787 const pid = Number(pidRaw);
788 const ppid = Number(ppidRaw);
789 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
790 const children = childrenByParent.get(ppid) ?? [];
791 children.push(pid);
792 childrenByParent.set(ppid, children);
793 }
794 const pending = [terminalPid];
795 while (pending.length > 0) {
796 const parentPid = pending.pop();
797 if (parentPid === undefined) continue;
798 for (const child of childrenByParent.get(parentPid) ?? []) {
799 if (processIds.has(child)) continue;
800 processIds.add(child);
801 pending.push(child);
802 }
803 }
804 } else {
805 processIds.add(childPid);
806 }
807 return {
808 hasRunningSubprocess: true,
809 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
810 processIds: [...processIds],
811 };
812});
813
814function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) {
815 return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {
816 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
817 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
818 }
819 if (platform === "win32") {
820 return yield* windowsInspectSubprocess(terminalPid, platform);
821 }
822 return yield* posixInspectSubprocess(terminalPid, platform);
823 });
824}
825
826function capHistory(history: string, maxLines: number): string {
827 if (history.length === 0) return history;
828 const hasTrailingNewline = history.endsWith("\n");
829 const lines = history.split("\n");
830 if (hasTrailingNewline) {
831 lines.pop();
832 }
833 if (lines.length <= maxLines) return history;
834 const capped = lines.slice(lines.length - maxLines).join("\n");
835 return hasTrailingNewline ? `${capped}\n` : capped;
836}
837
838function isCsiFinalByte(codePoint: number): boolean {
839 return codePoint >= 0x40 && codePoint <= 0x7e;
840}
841
842function shouldStripCsiSequence(body: string, finalByte: string): boolean {
843 if (finalByte === "n") {
844 return true;
845 }
846 if (finalByte === "R" && /^[0-9;?]*$/.test(body)) {
847 return true;
848 }
849 if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {
850 return true;
851 }
852 return false;
853}
854
855function shouldStripOscSequence(content: string): boolean {
856 return /^(10|11|12);(?:\?|rgb:)/.test(content);
857}
858
859function stripStringTerminator(value: string): string {
860 if (value.endsWith("\u001b\\")) {
861 return value.slice(0, -2);
862 }
863 const lastCharacter = value.at(-1);
864 if (lastCharacter === "\u0007" || lastCharacter === "\u009c") {
865 return value.slice(0, -1);
866 }
867 return value;
868}
869
870function findStringTerminatorIndex(input: string, start: number): number | null {
871 for (let index = start; index < input.length; index += 1) {
872 const codePoint = input.charCodeAt(index);
873 if (codePoint === 0x07 || codePoint === 0x9c) {
874 return index + 1;
875 }
876 if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) {
877 return index + 2;
878 }
879 }
880 return null;
881}
882
883function isEscapeIntermediateByte(codePoint: number): boolean {
884 return codePoint >= 0x20 && codePoint <= 0x2f;
885}
886
887function isEscapeFinalByte(codePoint: number): boolean {
888 return codePoint >= 0x30 && codePoint <= 0x7e;
889}
890
891function findEscapeSequenceEndIndex(input: string, start: number): number | null {
892 let cursor = start;
893 while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) {
894 cursor += 1;
895 }
896 if (cursor >= input.length) {
897 return null;
898 }
899 return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1;
900}
901
902function sanitizeTerminalHistoryChunk(
903 pendingControlSequence: string,
904 data: string,
905): { visibleText: string; pendingControlSequence: string } {
906 const input = `${pendingControlSequence}${data}`;
907 let visibleText = "";
908 let index = 0;
909
910 const append = (value: string) => {
911 visibleText += value;
912 };
913
914 while (index < input.length) {
915 const codePoint = input.charCodeAt(index);
916
917 if (codePoint === 0x1b) {
918 const nextCodePoint = input.charCodeAt(index + 1);
919 if (Number.isNaN(nextCodePoint)) {
920 return { visibleText, pendingControlSequence: input.slice(index) };
921 }
922
923 if (nextCodePoint === 0x5b) {
924 let cursor = index + 2;
925 while (cursor < input.length) {
926 if (isCsiFinalByte(input.charCodeAt(cursor))) {
927 const sequence = input.slice(index, cursor + 1);
928 const body = input.slice(index + 2, cursor);
929 if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
930 append(sequence);
931 }
932 index = cursor + 1;
933 break;
934 }
935 cursor += 1;
936 }
937 if (cursor >= input.length) {
938 return { visibleText, pendingControlSequence: input.slice(index) };
939 }
940 continue;
941 }
942
943 if (
944 nextCodePoint === 0x5d ||
945 nextCodePoint === 0x50 ||
946 nextCodePoint === 0x5e ||
947 nextCodePoint === 0x5f
948 ) {
949 const terminatorIndex = findStringTerminatorIndex(input, index + 2);
950 if (terminatorIndex === null) {
951 return { visibleText, pendingControlSequence: input.slice(index) };
952 }
953 const sequence = input.slice(index, terminatorIndex);
954 const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));
955 if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) {
956 append(sequence);
957 }
958 index = terminatorIndex;
959 continue;
960 }
961
962 const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1);
963 if (escapeSequenceEndIndex === null) {
964 return { visibleText, pendingControlSequence: input.slice(index) };
965 }
966 append(input.slice(index, escapeSequenceEndIndex));
967 index = escapeSequenceEndIndex;
968 continue;
969 }
970
971 if (codePoint === 0x9b) {
972 let cursor = index + 1;
973 while (cursor < input.length) {
974 if (isCsiFinalByte(input.charCodeAt(cursor))) {
975 const sequence = input.slice(index, cursor + 1);
976 const body = input.slice(index + 1, cursor);
977 if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
978 append(sequence);
979 }
980 index = cursor + 1;
981 break;
982 }
983 cursor += 1;
984 }
985 if (cursor >= input.length) {
986 return { visibleText, pendingControlSequence: input.slice(index) };
987 }
988 continue;
989 }
990
991 if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) {
992 const terminatorIndex = findStringTerminatorIndex(input, index + 1);
993 if (terminatorIndex === null) {
994 return { visibleText, pendingControlSequence: input.slice(index) };
995 }
996 const sequence = input.slice(index, terminatorIndex);
997 const content = stripStringTerminator(input.slice(index + 1, terminatorIndex));
998 if (codePoint !== 0x9d || !shouldStripOscSequence(content)) {
999 append(sequence);
1000 }
1001 index = terminatorIndex;
1002 continue;
1003 }
1004
1005 append(input[index] ?? "");
1006 index += 1;
1007 }
1008
1009 return { visibleText, pendingControlSequence: "" };
1010}
1011
1012function legacySafeThreadId(threadId: string): string {
1013 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
1014}
1015
1016function toSafeThreadId(threadId: string): string {
1017 return `terminal_${Encoding.encodeBase64Url(threadId)}`;
1018}
1019
1020function toSafeTerminalId(terminalId: string): string {
1021 return Encoding.encodeBase64Url(terminalId);
1022}
1023
1024function toSessionKey(threadId: string, terminalId: string): string {
1025 return `${threadId}\u0000${terminalId}`;
1026}
1027
1028function shouldExcludeTerminalEnvKey(key: string): boolean {
1029 const normalizedKey = key.toUpperCase();
1030 if (normalizedKey.startsWith("T3CODE_")) {
1031 return true;
1032 }
1033 if (normalizedKey.startsWith("VITE_")) {
1034 return true;
1035 }
1036 return TERMINAL_ENV_BLOCKLIST.has(normalizedKey);
1037}
1038
1039function createTerminalSpawnEnv(
1040 baseEnv: NodeJS.ProcessEnv,
1041 runtimeEnv?: Record<string, string> | null,
1042): NodeJS.ProcessEnv {
1043 const spawnEnv: NodeJS.ProcessEnv = {};
1044 for (const [key, value] of Object.entries(baseEnv)) {
1045 if (value === undefined) continue;
1046 if (shouldExcludeTerminalEnvKey(key)) continue;
1047 spawnEnv[key] = value;
1048 }
1049 if (runtimeEnv) {
1050 for (const [key, value] of Object.entries(runtimeEnv)) {
1051 spawnEnv[key] = value;
1052 }
1053 }
1054 return spawnEnv;
1055}
1056
1057function normalizedRuntimeEnv(
1058 env: Record<string, string> | undefined,
1059): Record<string, string> | null {
1060 if (!env) return null;
1061 const entries = Object.entries(env);
1062 if (entries.length === 0) return null;
1063 return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right)));
1064}
1065
1066interface TerminalManagerOptions {
1067 logsDir: string;
1068 historyLineLimit?: number;
1069 ptyAdapter: PtyAdapter.PtyAdapter["Service"];
1070 shellResolver?: () => string;
1071 env?: NodeJS.ProcessEnv;
1072 subprocessInspector?: TerminalSubprocessInspector;
1073 subprocessPollIntervalMs?: number;
1074 processKillGraceMs?: number;
1075 maxRetainedInactiveSessions?: number;
1076 registerTerminalProcesses?: (input: {
1077 readonly threadId: string;
1078 readonly terminalId: string;
1079 readonly processIds: ReadonlyArray<number>;
1080 }) => Effect.Effect<void>;
1081 unregisterTerminal?: (input: {
1082 readonly threadId: string;
1083 readonly terminalId: string;
1084 }) => Effect.Effect<void>;
1085}
1086
1087export const make = Effect.fn("TerminalManager.make")(function* () {
1088 const { terminalLogsDir } = yield* ServerConfig.ServerConfig;
1089 const ptyAdapter = yield* PtyAdapter.PtyAdapter;
1090 const portDiscovery = yield* PortScanner.PortDiscovery;
1091 return yield* makeWithOptions({
1092 logsDir: terminalLogsDir,
1093 ptyAdapter,
1094 registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
1095 unregisterTerminal: portDiscovery.unregisterTerminal,
1096 });
1097});
1098
1099export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(function* (
1100 options: TerminalManagerOptions,
1101) {
1102 const fileSystem = yield* FileSystem.FileSystem;
1103 const path = yield* Path.Path;
1104 const context = yield* Effect.context<never>();
1105 const runFork = Effect.runForkWith(context);
1106
1107 const logsDir = options.logsDir;
1108 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
1109 const platform = yield* HostProcessPlatform;
1110 // Terminals must inherit the user's full environment (minus the blocklist
1111 // applied in createTerminalSpawnEnv) — an allowlist here silently strips
1112 // things like PSModulePath, DISPLAY, proxies, and toolchain variables.
1113 // `options.env` is the test seam.
1114 const baseEnv = options.env ?? process.env;
1115 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
1116 const processRunner = yield* ProcessRunner.ProcessRunner;
1117 const subprocessInspector =
1118 options.subprocessInspector ??
1119 ((terminalPid) =>
1120 defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe(
1121 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
1122 ));
1123 const subprocessPollIntervalMs =
1124 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
1125 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
1126 const maxRetainedInactiveSessions =
1127 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;
1128 const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void);
1129 const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void);
1130
1131 yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie);
1132
1133 const managerStateRef = yield* SynchronizedRef.make<TerminalManagerState>({
1134 sessions: new Map(),
1135 killFibers: new Map(),
1136 });
1137 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
1138 const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
1139 const workerScope = yield* Scope.make("sequential");
1140 yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void));
1141
1142 const publishEvent = (event: TerminalEvent) =>
1143 Effect.gen(function* () {
1144 for (const listener of terminalEventListeners) {
1145 yield* listener(event).pipe(Effect.ignoreCause({ log: true }));
1146 }
1147 });
1148
1149 const historyPath = (threadId: string, terminalId: string) => {
1150 const threadPart = toSafeThreadId(threadId);
1151 if (terminalId === DEFAULT_TERMINAL_ID) {
1152 return path.join(logsDir, `${threadPart}.log`);
1153 }
1154 return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`);
1155 };
1156
1157 const legacyHistoryPath = (threadId: string) =>
1158 path.join(logsDir, `${legacySafeThreadId(threadId)}.log`);
1159
1160 const toTerminalHistoryError =
1161 (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) =>
1162 (cause: unknown) =>
1163 new TerminalHistoryError({
1164 operation,
1165 threadId,
1166 terminalId,
1167 cause,
1168 });
1169
1170 const readManagerState = SynchronizedRef.get(managerStateRef);
1171
1172 const modifyManagerState = <A>(
1173 f: (state: TerminalManagerState) => readonly [A, TerminalManagerState],
1174 ) => SynchronizedRef.modify(managerStateRef, f);
1175
1176 const getThreadSemaphore = (threadId: string) =>
1177 SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
1178 const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr(
1179 current.get(threadId),
1180 );
1181 return Option.match(existing, {
1182 onNone: () =>
1183 Semaphore.make(1).pipe(
1184 Effect.map((semaphore) => {
1185 const next = new Map(current);
1186 next.set(threadId, semaphore);
1187 return [semaphore, next] as const;
1188 }),
1189 ),
1190 onSome: (semaphore) => Effect.succeed([semaphore, current] as const),
1191 });
1192 });
1193
1194 const withThreadLock = <A, E, R>(
1195 threadId: string,
1196 effect: Effect.Effect<A, E, R>,
1197 ): Effect.Effect<A, E, R> =>
1198 Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
1199
1200 const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* (
1201 process: PtyAdapter.PtyProcess | null,
1202 ) {
1203 if (!process) return;
1204 const fiber: Option.Option<Fiber.Fiber<void, never>> = yield* modifyManagerState<
1205 Option.Option<Fiber.Fiber<void, never>>
1206 >((state) => {
1207 const existing: Option.Option<Fiber.Fiber<void, never>> = Option.fromNullishOr(
1208 state.killFibers.get(process),
1209 );
1210 if (Option.isNone(existing)) {
1211 return [Option.none<Fiber.Fiber<void, never>>(), state] as const;
1212 }
1213 const killFibers = new Map(state.killFibers);
1214 killFibers.delete(process);
1215 return [existing, { ...state, killFibers }] as const;
1216 });
1217 if (Option.isSome(fiber)) {
1218 yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore);
1219 }
1220 });
1221
1222 const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* (
1223 process: PtyAdapter.PtyProcess,
1224 fiber: Fiber.Fiber<void, never>,
1225 ) {
1226 yield* modifyManagerState((state) => {
1227 const killFibers = new Map(state.killFibers);
1228 killFibers.set(process, fiber);
1229 return [undefined, { ...state, killFibers }] as const;
1230 });
1231 });
1232
1233 const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* (
1234 process: PtyAdapter.PtyProcess,
1235 threadId: string,
1236 terminalId: string,
1237 ) {
1238 const terminated = yield* Effect.try({
1239 try: () => process.kill("SIGTERM"),
1240 catch: (cause) =>
1241 new TerminalProcessSignalError({
1242 cause,
1243 signal: "SIGTERM",
1244 terminalPid: process.pid,
1245 }),
1246 }).pipe(
1247 Effect.as(true),
1248 Effect.catch((error) =>
1249 Effect.logWarning("failed to kill terminal process", {
1250 threadId,
1251 terminalId,
1252 signal: "SIGTERM",
1253 error: error.message,
1254 }).pipe(Effect.as(false)),
1255 ),
1256 );
1257 if (!terminated) {
1258 return;
1259 }
1260
1261 yield* Effect.sleep(processKillGraceMs);
1262
1263 yield* Effect.try({
1264 try: () => process.kill("SIGKILL"),
1265 catch: (cause) =>
1266 new TerminalProcessSignalError({
1267 cause,
1268 signal: "SIGKILL",
1269 terminalPid: process.pid,
1270 }),
1271 }).pipe(
1272 Effect.catch((error) =>
1273 Effect.logWarning("failed to force-kill terminal process", {
1274 threadId,
1275 terminalId,
1276 signal: "SIGKILL",
1277 error: error.message,
1278 }),
1279 ),
1280 );
1281 });
1282
1283 const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* (
1284 process: PtyAdapter.PtyProcess,
1285 threadId: string,
1286 terminalId: string,
1287 ) {
1288 const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe(
1289 Effect.ensuring(
1290 modifyManagerState((state) => {
1291 if (!state.killFibers.has(process)) {
1292 return [undefined, state] as const;
1293 }
1294 const killFibers = new Map(state.killFibers);
1295 killFibers.delete(process);
1296 return [undefined, { ...state, killFibers }] as const;
1297 }),
1298 ),
1299 Effect.forkIn(workerScope),
1300 );
1301
1302 yield* registerKillFiber(process, fiber);
1303 });
1304
1305 const persistWorker = yield* makeKeyedCoalescingWorker<
1306 string,
1307 PersistHistoryRequest,
1308 never,
1309 never
1310 >({
1311 merge: (current, next) => ({
1312 history: next.history,
1313 immediate: current.immediate || next.immediate,
1314 }),
1315 process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) {
1316 if (!request.immediate) {
1317 yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS);
1318 }
1319
1320 const [threadId, terminalId] = sessionKey.split("\u0000");
1321 if (!threadId || !terminalId) {
1322 return;
1323 }
1324
1325 yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe(
1326 Effect.catch((error) =>
1327 Effect.logWarning("failed to persist terminal history", {
1328 threadId,
1329 terminalId,
1330 error,
1331 }),
1332 ),
1333 );
1334 }),
1335 });
1336
1337 const queuePersist = Effect.fn("terminal.queuePersist")(function* (
1338 threadId: string,
1339 terminalId: string,
1340 history: string,
1341 ) {
1342 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
1343 history,
1344 immediate: false,
1345 });
1346 });
1347
1348 const flushPersist = Effect.fn("terminal.flushPersist")(function* (
1349 threadId: string,
1350 terminalId: string,
1351 ) {
1352 yield* persistWorker.drainKey(toSessionKey(threadId, terminalId));
1353 });
1354
1355 const persistHistory = Effect.fn("terminal.persistHistory")(function* (
1356 threadId: string,
1357 terminalId: string,
1358 history: string,
1359 ) {
1360 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
1361 history,
1362 immediate: true,
1363 });
1364 yield* flushPersist(threadId, terminalId);
1365 });
1366
1367 const readHistory = Effect.fn("terminal.readHistory")(function* (
1368 threadId: string,
1369 terminalId: string,
1370 ) {
1371 const nextPath = historyPath(threadId, terminalId);
1372 if (
1373 yield* fileSystem
1374 .exists(nextPath)
1375 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)))
1376 ) {
1377 const raw = yield* fileSystem
1378 .readFileString(nextPath)
1379 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)));
1380 const capped = capHistory(raw, historyLineLimit);
1381 if (capped !== raw) {
1382 yield* fileSystem
1383 .writeFileString(nextPath, capped)
1384 .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId)));
1385 }
1386 return capped;
1387 }
1388
1389 if (terminalId !== DEFAULT_TERMINAL_ID) {
1390 return "";
1391 }
1392
1393 const legacyPath = legacyHistoryPath(threadId);
1394 if (
1395 !(yield* fileSystem
1396 .exists(legacyPath)
1397 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))))
1398 ) {
1399 return "";
1400 }
1401
1402 const raw = yield* fileSystem
1403 .readFileString(legacyPath)
1404 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
1405 const capped = capHistory(raw, historyLineLimit);
1406 yield* fileSystem
1407 .writeFileString(nextPath, capped)
1408 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
1409 yield* fileSystem.remove(legacyPath, { force: true }).pipe(
1410 Effect.catch((cleanupError) =>
1411 Effect.logWarning("failed to remove legacy terminal history", {
1412 threadId,
1413 error: cleanupError,
1414 }),
1415 ),
1416 );
1417 return capped;
1418 });
1419
1420 const deleteHistory = Effect.fn("terminal.deleteHistory")(function* (
1421 threadId: string,
1422 terminalId: string,
1423 ) {
1424 yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe(
1425 Effect.catch((error) =>
1426 Effect.logWarning("failed to delete terminal history", {
1427 threadId,
1428 terminalId,
1429 error,
1430 }),
1431 ),
1432 );
1433 if (terminalId === DEFAULT_TERMINAL_ID) {
1434 yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe(
1435 Effect.catch((error) =>
1436 Effect.logWarning("failed to delete terminal history", {
1437 threadId,
1438 terminalId,
1439 error,
1440 }),
1441 ),
1442 );
1443 }
1444 });
1445
1446 const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* (
1447 threadId: string,
1448 ) {
1449 const threadPrefix = `${toSafeThreadId(threadId)}_`;
1450 const entries = yield* fileSystem
1451 .readDirectory(logsDir, { recursive: false })
1452 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
1453 yield* Effect.forEach(
1454 entries.filter(
1455 (name) =>
1456 name === `${toSafeThreadId(threadId)}.log` ||
1457 name === `${legacySafeThreadId(threadId)}.log` ||
1458 name.startsWith(threadPrefix),
1459 ),
1460 (name) =>
1461 fileSystem.remove(path.join(logsDir, name), { force: true }).pipe(
1462 Effect.catch((error) =>
1463 Effect.logWarning("failed to delete terminal histories for thread", {
1464 threadId,
1465 error,
1466 }),
1467 ),
1468 ),
1469 { discard: true },
1470 );
1471 });
1472
1473 const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) {
1474 const stats = yield* fileSystem.stat(cwd).pipe(
1475 Effect.mapError(
1476 (cause) =>
1477 new TerminalCwdError({
1478 cwd,
1479 reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed",
1480 cause,
1481 }),
1482 ),
1483 );
1484 if (stats.type !== "Directory") {
1485 return yield* new TerminalCwdError({
1486 cwd,
1487 reason: "notDirectory",
1488 });
1489 }
1490 });
1491
1492 const getSession = Effect.fn("terminal.getSession")(function* (
1493 threadId: string,
1494 terminalId: string,
1495 ): Effect.fn.Return<Option.Option<TerminalSessionState>> {
1496 return yield* Effect.map(readManagerState, (state) =>
1497 Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))),
1498 );
1499 });
1500
1501 const requireSession = Effect.fn("terminal.requireSession")(function* (
1502 threadId: string,
1503 terminalId: string,
1504 ): Effect.fn.Return<TerminalSessionState, TerminalSessionLookupError> {
1505 return yield* Effect.flatMap(getSession(threadId, terminalId), (session) =>
1506 Option.match(session, {
1507 onNone: () =>
1508 Effect.fail(
1509 new TerminalSessionLookupError({
1510 threadId,
1511 terminalId,
1512 }),
1513 ),
1514 onSome: Effect.succeed,
1515 }),
1516 );
1517 });
1518
1519 const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) {
1520 return yield* readManagerState.pipe(
1521 Effect.map((state) =>
1522 [...state.sessions.values()].filter((session) => session.threadId === threadId),
1523 ),
1524 );
1525 });
1526
1527 const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")(
1528 function* () {
1529 yield* modifyManagerState((state) => {
1530 const inactiveSessions = [...state.sessions.values()].filter(
1531 (session) => session.status !== "running",
1532 );
1533 if (inactiveSessions.length <= maxRetainedInactiveSessions) {
1534 return [undefined, state] as const;
1535 }
1536
1537 inactiveSessions.sort(
1538 (left, right) =>
1539 left.updatedAt.localeCompare(right.updatedAt) ||
1540 left.threadId.localeCompare(right.threadId) ||
1541 left.terminalId.localeCompare(right.terminalId),
1542 );
1543
1544 const sessions = new Map(state.sessions);
1545
1546 const toEvict = inactiveSessions.length - maxRetainedInactiveSessions;
1547 for (const session of inactiveSessions.slice(0, toEvict)) {
1548 const key = toSessionKey(session.threadId, session.terminalId);
1549 sessions.delete(key);
1550 }
1551
1552 return [undefined, { ...state, sessions }] as const;
1553 });
1554 },
1555 );
1556
1557 const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* (
1558 session: TerminalSessionState,
1559 expectedPid: number,
1560 ) {
1561 while (true) {
1562 const action: DrainProcessEventAction = yield* Effect.sync(() => {
1563 if (session.pid !== expectedPid || !session.process || session.status !== "running") {
1564 session.pendingProcessEvents = [];
1565 session.pendingProcessEventIndex = 0;
1566 session.processEventDrainRunning = false;
1567 return { type: "idle" } as const;
1568 }
1569
1570 const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex];
1571 if (!nextEvent) {
1572 session.pendingProcessEvents = [];
1573 session.pendingProcessEventIndex = 0;
1574 session.processEventDrainRunning = false;
1575 return { type: "idle" } as const;
1576 }
1577
1578 session.pendingProcessEventIndex += 1;
1579 if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) {
1580 session.pendingProcessEvents = [];
1581 session.pendingProcessEventIndex = 0;
1582 }
1583
1584 if (nextEvent.type === "output") {
1585 const sanitized = sanitizeTerminalHistoryChunk(
1586 session.pendingHistoryControlSequence,
1587 nextEvent.data,
1588 );
1589 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
1590 if (sanitized.visibleText.length > 0) {
1591 session.history = capHistory(
1592 `${session.history}${sanitized.visibleText}`,
1593 historyLineLimit,
1594 );
1595 }
1596 const eventStamp = advanceEventSequence(session);
1597
1598 return {
1599 type: "output",
1600 threadId: session.threadId,
1601 terminalId: session.terminalId,
1602 sequence: eventStamp.sequence,
1603 history: sanitized.visibleText.length > 0 ? session.history : null,
1604 data: nextEvent.data,
1605 } as const;
1606 }
1607
1608 const process = session.process;
1609 cleanupProcessHandles(session);
1610 session.process = null;
1611 session.pid = null;
1612 session.hasRunningSubprocess = false;
1613 session.childCommandLabel = null;
1614 session.status = "exited";
1615 session.pendingHistoryControlSequence = "";
1616 session.pendingProcessEvents = [];
1617 session.pendingProcessEventIndex = 0;
1618 session.processEventDrainRunning = false;
1619 session.exitCode = Number.isInteger(nextEvent.event.exitCode)
1620 ? nextEvent.event.exitCode
1621 : null;
1622 session.exitSignal = Number.isInteger(nextEvent.event.signal)
1623 ? nextEvent.event.signal
1624 : null;
1625 const eventStamp = advanceEventSequence(session);
1626
1627 return {
1628 type: "exit",
1629 process,
1630 threadId: session.threadId,
1631 terminalId: session.terminalId,
1632 sequence: eventStamp.sequence,
1633 exitCode: session.exitCode,
1634 exitSignal: session.exitSignal,
1635 } as const;
1636 });
1637
1638 if (action.type === "idle") {
1639 return;
1640 }
1641
1642 if (action.type === "output") {
1643 if (action.history !== null) {
1644 yield* queuePersist(action.threadId, action.terminalId, action.history);
1645 }
1646
1647 yield* publishEvent({
1648 type: "output",
1649 threadId: action.threadId,
1650 terminalId: action.terminalId,
1651 sequence: action.sequence,
1652 data: action.data,
1653 });
1654 continue;
1655 }
1656
1657 yield* clearKillFiber(action.process);
1658 yield* unregisterTerminal({
1659 threadId: action.threadId,
1660 terminalId: action.terminalId,
1661 });
1662 yield* publishEvent({
1663 type: "exited",
1664 threadId: action.threadId,
1665 terminalId: action.terminalId,
1666 sequence: action.sequence,
1667 exitCode: action.exitCode,
1668 exitSignal: action.exitSignal,
1669 });
1670 yield* evictInactiveSessionsIfNeeded();
1671 return;
1672 }
1673 });
1674
1675 const stopProcess = Effect.fn("terminal.stopProcess")(function* (session: TerminalSessionState) {
1676 const process = session.process;
1677 if (!process) return;
1678
1679 const updatedAt = yield* nowIso;
1680 yield* modifyManagerState((state) => {
1681 cleanupProcessHandles(session);
1682 session.process = null;
1683 session.pid = null;
1684 session.hasRunningSubprocess = false;
1685 session.childCommandLabel = null;
1686 session.status = "exited";
1687 session.pendingHistoryControlSequence = "";
1688 session.pendingProcessEvents = [];
1689 session.pendingProcessEventIndex = 0;
1690 session.processEventDrainRunning = false;
1691 session.updatedAt = updatedAt;
1692 return [undefined, state] as const;
1693 });
1694
1695 yield* clearKillFiber(process);
1696 yield* unregisterTerminal({
1697 threadId: session.threadId,
1698 terminalId: session.terminalId,
1699 });
1700 yield* startKillEscalation(process, session.threadId, session.terminalId);
1701 yield* evictInactiveSessionsIfNeeded();
1702 });
1703
1704 const trySpawn = Effect.fn("terminal.trySpawn")(function* (
1705 shellCandidates: ReadonlyArray<ShellCandidate>,
1706 spawnEnv: NodeJS.ProcessEnv,
1707 session: TerminalSessionState,
1708 index = 0,
1709 lastError: PtyAdapter.PtySpawnError | null = null,
1710 ): Effect.fn.Return<
1711 { process: PtyAdapter.PtyProcess; shellLabel: string },
1712 PtyAdapter.PtySpawnError
1713 > {
1714 if (index >= shellCandidates.length) {
1715 return yield* new PtyAdapter.PtySpawnError({
1716 adapter: "terminal-manager",
1717 attemptedShells: shellCandidates.map((candidate) => formatShellCandidate(candidate)),
1718 ...(lastError ? { cause: lastError } : {}),
1719 });
1720 }
1721
1722 const candidate = shellCandidates[index];
1723 if (!candidate) {
1724 return yield* (
1725 lastError ??
1726 new PtyAdapter.PtySpawnError({
1727 adapter: "terminal-manager",
1728 attemptedShells: [],
1729 })
1730 );
1731 }
1732
1733 const attempt = yield* Effect.result(
1734 options.ptyAdapter.spawn({
1735 shell: candidate.shell,
1736 ...(candidate.args ? { args: candidate.args } : {}),
1737 cwd: session.cwd,
1738 cols: session.cols,
1739 rows: session.rows,
1740 env: spawnEnv,
1741 }),
1742 );
1743
1744 if (attempt._tag === "Success") {
1745 return {
1746 process: attempt.success,
1747 shellLabel: formatShellCandidate(candidate),
1748 };
1749 }
1750
1751 const spawnError = attempt.failure;
1752 if (!isRetryableShellSpawnError(spawnError)) {
1753 return yield* spawnError;
1754 }
1755
1756 return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError);
1757 });
1758
1759 const startSession = Effect.fn("terminal.startSession")(function* (
1760 session: TerminalSessionState,
1761 input: TerminalStartInput,
1762 eventType: "started" | "restarted",
1763 ) {
1764 yield* stopProcess(session);
1765 yield* Effect.annotateCurrentSpan({
1766 "terminal.thread_id": session.threadId,
1767 "terminal.id": session.terminalId,
1768 "terminal.event_type": eventType,
1769 "terminal.cwd": input.cwd,
1770 });
1771
1772 const startingAt = yield* nowIso;
1773 yield* modifyManagerState((state) => {
1774 session.status = "starting";
1775 session.cwd = input.cwd;
1776 session.worktreePath = input.worktreePath ?? null;
1777 session.cols = input.cols;
1778 session.rows = input.rows;
1779 session.exitCode = null;
1780 session.exitSignal = null;
1781 session.hasRunningSubprocess = false;
1782 session.childCommandLabel = null;
1783 session.pendingProcessEvents = [];
1784 session.pendingProcessEventIndex = 0;
1785 session.processEventDrainRunning = false;
1786 session.updatedAt = startingAt;
1787 return [undefined, state] as const;
1788 });
1789
1790 let ptyProcess: PtyAdapter.PtyProcess | null = null;
1791 let startedShell: string | null = null;
1792
1793 const startResult = yield* Effect.result(
1794 increment(terminalSessionsTotal, { lifecycle: eventType }).pipe(
1795 Effect.andThen(
1796 Effect.gen(function* () {
1797 const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv);
1798 const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv);
1799 const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session);
1800 ptyProcess = spawnResult.process;
1801 startedShell = spawnResult.shellLabel;
1802
1803 const processPid = ptyProcess.pid;
1804 const unsubscribeData = ptyProcess.onData((data) => {
1805 if (!enqueueProcessEvent(session, processPid, { type: "output", data })) {
1806 return;
1807 }
1808 runFork(drainProcessEvents(session, processPid));
1809 });
1810 const unsubscribeExit = ptyProcess.onExit((event) => {
1811 if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) {
1812 return;
1813 }
1814 runFork(drainProcessEvents(session, processPid));
1815 });
1816
1817 let eventStamp: ReturnType<typeof advanceEventSequence> = {
1818 updatedAt: session.updatedAt,
1819 sequence: session.eventSequence,
1820 };
1821 yield* modifyManagerState((state) => {
1822 session.process = ptyProcess;
1823 session.pid = processPid;
1824 session.status = "running";
1825 session.unsubscribeData = unsubscribeData;
1826 session.unsubscribeExit = unsubscribeExit;
1827 eventStamp = advanceEventSequence(session);
1828 return [undefined, state] as const;
1829 });
1830
1831 yield* publishEvent({
1832 type: eventType,
1833 threadId: session.threadId,
1834 terminalId: session.terminalId,
1835 sequence: eventStamp.sequence,
1836 snapshot: snapshot(session),
1837 });
1838 }),
1839 ),
1840 ),
1841 );
1842
1843 if (startResult._tag === "Success") {
1844 return;
1845 }
1846
1847 {
1848 const error = startResult.failure;
1849 if (ptyProcess) {
1850 yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId);
1851 }
1852
1853 yield* modifyManagerState((state) => {
1854 cleanupProcessHandles(session);
1855 session.status = "error";
1856 session.pid = null;
1857 session.process = null;
1858 session.hasRunningSubprocess = false;
1859 session.childCommandLabel = null;
1860 session.pendingProcessEvents = [];
1861 session.pendingProcessEventIndex = 0;
1862 session.processEventDrainRunning = false;
1863 advanceEventSequence(session);
1864 return [undefined, state] as const;
1865 });
1866 yield* unregisterTerminal({
1867 threadId: session.threadId,
1868 terminalId: session.terminalId,
1869 });
1870
1871 yield* evictInactiveSessionsIfNeeded();
1872
1873 const message = error.message;
1874 yield* publishEvent({
1875 type: "error",
1876 threadId: session.threadId,
1877 terminalId: session.terminalId,
1878 sequence: session.eventSequence,
1879 message,
1880 });
1881 yield* Effect.logError("failed to start terminal", {
1882 threadId: session.threadId,
1883 terminalId: session.terminalId,
1884 error: message,
1885 ...(startedShell ? { shell: startedShell } : {}),
1886 });
1887 }
1888 });
1889
1890 const closeSession = Effect.fn("terminal.closeSession")(function* (
1891 threadId: string,
1892 terminalId: string,
1893 deleteHistoryOnClose: boolean,
1894 ) {
1895 const key = toSessionKey(threadId, terminalId);
1896 const session = yield* getSession(threadId, terminalId);
1897 const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0;
1898
1899 if (Option.isSome(session)) {
1900 yield* stopProcess(session.value);
1901 yield* unregisterTerminal({ threadId, terminalId });
1902 yield* persistHistory(threadId, terminalId, session.value.history);
1903 }
1904
1905 yield* flushPersist(threadId, terminalId);
1906
1907 const removed = yield* modifyManagerState((state) => {
1908 if (!state.sessions.has(key)) {
1909 return [false, state] as const;
1910 }
1911 const sessions = new Map(state.sessions);
1912 sessions.delete(key);
1913 return [true, { ...state, sessions }] as const;
1914 });
1915
1916 if (removed) {
1917 yield* publishEvent({
1918 type: "closed",
1919 threadId,
1920 terminalId,
1921 sequence: closedEventSequence,
1922 });
1923 }
1924
1925 if (deleteHistoryOnClose) {
1926 yield* deleteHistory(threadId, terminalId);
1927 }
1928 });
1929
1930 const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () {
1931 const state = yield* readManagerState;
1932 const runningSessions = [...state.sessions.values()].filter(
1933 (session): session is TerminalSessionState & { pid: number } =>
1934 session.status === "running" && Number.isInteger(session.pid),
1935 );
1936
1937 if (runningSessions.length === 0) {
1938 return;
1939 }
1940
1941 const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* (
1942 session: TerminalSessionState & { pid: number },
1943 ) {
1944 const terminalPid = session.pid;
1945 const inspectResult = yield* subprocessInspector(terminalPid).pipe(
1946 Effect.map(Option.some),
1947 Effect.catch((reason) =>
1948 Effect.logWarning("failed to check terminal subprocess activity", {
1949 threadId: session.threadId,
1950 terminalId: session.terminalId,
1951 terminalPid,
1952 reason,
1953 }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())),
1954 ),
1955 );
1956
1957 if (Option.isNone(inspectResult)) {
1958 return;
1959 }
1960
1961 const next = inspectResult.value;
1962 yield* registerTerminalProcesses({
1963 threadId: session.threadId,
1964 terminalId: session.terminalId,
1965 processIds: next.processIds,
1966 });
1967 const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;
1968 const event = yield* modifyManagerState((state) => {
1969 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
1970 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),
1971 );
1972 if (
1973 Option.isNone(liveSession) ||
1974 liveSession.value.status !== "running" ||
1975 liveSession.value.pid !== terminalPid ||
1976 (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess &&
1977 liveSession.value.childCommandLabel === nextChildLabel)
1978 ) {
1979 return [Option.none(), state] as const;
1980 }
1981
1982 liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess;
1983 liveSession.value.childCommandLabel = nextChildLabel;
1984 const eventStamp = advanceEventSequence(liveSession.value);
1985
1986 return [
1987 Option.some({
1988 type: "activity" as const,
1989 threadId: liveSession.value.threadId,
1990 terminalId: liveSession.value.terminalId,
1991 sequence: eventStamp.sequence,
1992 hasRunningSubprocess: next.hasRunningSubprocess,
1993 label: terminalWireLabel(liveSession.value),
1994 }),
1995 state,
1996 ] as const;
1997 });
1998
1999 if (Option.isSome(event)) {
2000 yield* publishEvent(event.value);
2001 }
2002 });
2003
2004 yield* Effect.forEach(runningSessions, checkSubprocessActivity, {
2005 concurrency: "unbounded",
2006 discard: true,
2007 });
2008 });
2009
2010 const hasRunningSessions = readManagerState.pipe(
2011 Effect.map((state) =>
2012 [...state.sessions.values()].some((session) => session.status === "running"),
2013 ),
2014 );
2015
2016 yield* Effect.forever(
2017 hasRunningSessions.pipe(
2018 Effect.flatMap((active) =>
2019 active
2020 ? pollSubprocessActivity().pipe(
2021 Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)),
2022 )
2023 : Effect.sleep(subprocessPollIntervalMs),
2024 ),
2025 ),
2026 ).pipe(Effect.forkIn(workerScope));
2027
2028 yield* Effect.addFinalizer(() =>
2029 Effect.gen(function* () {
2030 const sessions = yield* modifyManagerState(
2031 (state) =>
2032 [
2033 [...state.sessions.values()],
2034 {
2035 ...state,
2036 sessions: new Map(),
2037 },
2038 ] as const,
2039 );
2040
2041 const cleanupSession = Effect.fn("terminal.cleanupSession")(function* (
2042 session: TerminalSessionState,
2043 ) {
2044 cleanupProcessHandles(session);
2045 if (!session.process) return;
2046 yield* clearKillFiber(session.process);
2047 yield* runKillEscalation(session.process, session.threadId, session.terminalId);
2048 });
2049
2050 yield* Effect.forEach(sessions, cleanupSession, {
2051 concurrency: "unbounded",
2052 discard: true,
2053 });
2054 }).pipe(Effect.ignoreCause({ log: true })),
2055 );
2056
2057 const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) {
2058 const terminalId = input.terminalId;
2059 yield* assertValidCwd(input.cwd);
2060
2061 const sessionKey = toSessionKey(input.threadId, terminalId);
2062 const existing = yield* getSession(input.threadId, terminalId);
2063 if (Option.isNone(existing)) {
2064 yield* flushPersist(input.threadId, terminalId);
2065 const history = yield* readHistory(input.threadId, terminalId);
2066 const cols = input.cols ?? DEFAULT_OPEN_COLS;
2067 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
2068 const session: TerminalSessionState = {
2069 threadId: input.threadId,
2070 terminalId,
2071 cwd: input.cwd,
2072 worktreePath: input.worktreePath ?? null,
2073 status: "starting",
2074 pid: null,
2075 history,
2076 pendingHistoryControlSequence: "",
2077 pendingProcessEvents: [],
2078 pendingProcessEventIndex: 0,
2079 processEventDrainRunning: false,
2080 exitCode: null,
2081 exitSignal: null,
2082 updatedAt: yield* nowIso,
2083 eventSequence: 0,
2084 cols,
2085 rows,
2086 process: null,
2087 unsubscribeData: null,
2088 unsubscribeExit: null,
2089 hasRunningSubprocess: false,
2090 childCommandLabel: null,
2091 runtimeEnv: normalizedRuntimeEnv(input.env),
2092 };
2093
2094 const createdSession = session;
2095 yield* modifyManagerState((state) => {
2096 const sessions = new Map(state.sessions);
2097 sessions.set(sessionKey, createdSession);
2098 return [undefined, { ...state, sessions }] as const;
2099 });
2100
2101 yield* evictInactiveSessionsIfNeeded();
2102 yield* startSession(
2103 session,
2104 {
2105 threadId: input.threadId,
2106 terminalId,
2107 cwd: input.cwd,
2108 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
2109 cols,
2110 rows,
2111 ...(input.env ? { env: input.env } : {}),
2112 },
2113 "started",
2114 );
2115 return snapshot(session);
2116 }
2117
2118 const liveSession = existing.value;
2119 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
2120 const currentRuntimeEnv = liveSession.runtimeEnv;
2121 const targetCols = input.cols ?? liveSession.cols;
2122 const targetRows = input.rows ?? liveSession.rows;
2123 const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv);
2124 const nextWorktreePath =
2125 input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath;
2126 const launchContextChanged =
2127 liveSession.cwd !== input.cwd ||
2128 runtimeEnvChanged ||
2129 liveSession.worktreePath !== nextWorktreePath;
2130
2131 if (launchContextChanged) {
2132 yield* stopProcess(liveSession);
2133 liveSession.cwd = input.cwd;
2134 liveSession.worktreePath = nextWorktreePath;
2135 liveSession.runtimeEnv = nextRuntimeEnv;
2136 liveSession.history = "";
2137 liveSession.pendingHistoryControlSequence = "";
2138 liveSession.pendingProcessEvents = [];
2139 liveSession.pendingProcessEventIndex = 0;
2140 liveSession.processEventDrainRunning = false;
2141 yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history);
2142 } else if (liveSession.status === "exited" || liveSession.status === "error") {
2143 liveSession.runtimeEnv = nextRuntimeEnv;
2144 liveSession.worktreePath = nextWorktreePath;
2145 liveSession.history = "";
2146 liveSession.pendingHistoryControlSequence = "";
2147 liveSession.pendingProcessEvents = [];
2148 liveSession.pendingProcessEventIndex = 0;
2149 liveSession.processEventDrainRunning = false;
2150 yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history);
2151 }
2152
2153 if (!liveSession.process) {
2154 yield* startSession(
2155 liveSession,
2156 {
2157 threadId: input.threadId,
2158 terminalId,
2159 cwd: input.cwd,
2160 worktreePath: liveSession.worktreePath,
2161 cols: targetCols,
2162 rows: targetRows,
2163 ...(input.env ? { env: input.env } : {}),
2164 },
2165 "started",
2166 );
2167 return snapshot(liveSession);
2168 }
2169
2170 if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) {
2171 liveSession.cols = targetCols;
2172 liveSession.rows = targetRows;
2173 liveSession.updatedAt = yield* nowIso;
2174 liveSession.process.resize(targetCols, targetRows);
2175 }
2176
2177 return snapshot(liveSession);
2178 });
2179
2180 const open: TerminalManager["Service"]["open"] = (input) =>
2181 withThreadLock(input.threadId, openLocked(input));
2182
2183 const openOrAttachForStream = (input: TerminalAttachInput) =>
2184 withThreadLock(
2185 input.threadId,
2186 Effect.gen(function* () {
2187 const terminalId = input.terminalId;
2188 const existing = yield* getSession(input.threadId, terminalId);
2189
2190 if (Option.isNone(existing)) {
2191 if (!input.cwd) {
2192 return yield* new TerminalSessionLookupError({
2193 threadId: input.threadId,
2194 terminalId,
2195 });
2196 }
2197
2198 return yield* openLocked({
2199 ...input,
2200 terminalId,
2201 cwd: input.cwd,
2202 });
2203 }
2204
2205 const session = existing.value;
2206 const targetCols = input.cols ?? session.cols;
2207 const targetRows = input.rows ?? session.rows;
2208
2209 if (!session.process && input.cwd && input.restartIfNotRunning === true) {
2210 return yield* openLocked({
2211 ...input,
2212 terminalId,
2213 cwd: input.cwd,
2214 });
2215 }
2216
2217 if (
2218 session.process &&
2219 session.status === "running" &&
2220 (session.cols !== targetCols || session.rows !== targetRows)
2221 ) {
2222 session.cols = targetCols;
2223 session.rows = targetRows;
2224 session.updatedAt = yield* nowIso;
2225 yield* Effect.sync(() => session.process?.resize(targetCols, targetRows));
2226 }
2227
2228 return snapshot(session);
2229 }),
2230 );
2231
2232 const readAllTerminalMetadata = () =>
2233 readManagerState.pipe(
2234 Effect.map((state) =>
2235 [...state.sessions.values()]
2236 .map(summary)
2237 .sort(
2238 (left, right) =>
2239 right.updatedAt.localeCompare(left.updatedAt) ||
2240 left.threadId.localeCompare(right.threadId) ||
2241 left.terminalId.localeCompare(right.terminalId),
2242 ),
2243 ),
2244 );
2245
2246 const readTerminalMetadata = (input: {
2247 readonly threadId: string;
2248 readonly terminalId: string;
2249 }) =>
2250 getSession(input.threadId, input.terminalId).pipe(
2251 Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)),
2252 );
2253
2254 const subscribe: TerminalManager["Service"]["subscribe"] = (listener) =>
2255 Effect.sync(() => {
2256 terminalEventListeners.add(listener);
2257 return () => {
2258 terminalEventListeners.delete(listener);
2259 };
2260 });
2261
2262 const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => {
2263 let unsubscribe: (() => void) | null = null;
2264
2265 return Effect.gen(function* () {
2266 const bufferedEvents: TerminalEvent[] = [];
2267 let deliverLive = false;
2268
2269 unsubscribe = yield* subscribe((event) => {
2270 if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
2271 return Effect.void;
2272 }
2273
2274 if (!deliverLive) {
2275 bufferedEvents.push(event);
2276 return Effect.void;
2277 }
2278
2279 const attachEvent = terminalEventToAttachEvent(event);
2280 return attachEvent ? listener(attachEvent) : Effect.void;
2281 });
2282
2283 const initialSnapshot = yield* openOrAttachForStream(input);
2284
2285 yield* listener({
2286 type: "snapshot",
2287 snapshot: initialSnapshot,
2288 });
2289
2290 for (const event of bufferedEvents) {
2291 if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) {
2292 continue;
2293 }
2294
2295 const attachEvent = terminalEventToAttachEvent(event);
2296 if (attachEvent) {
2297 yield* listener(attachEvent);
2298 }
2299 }
2300
2301 deliverLive = true;
2302 return () => {
2303 unsubscribe?.();
2304 unsubscribe = null;
2305 };
2306 }).pipe(
2307 Effect.catchCause((cause) =>
2308 Effect.flatMap(
2309 Effect.sync(() => {
2310 unsubscribe?.();
2311 unsubscribe = null;
2312 }),
2313 () => Effect.failCause(cause),
2314 ),
2315 ),
2316 );
2317 };
2318
2319 const metadataEventFromTerminalEvent = (
2320 event: TerminalEvent,
2321 ): Effect.Effect<TerminalMetadataStreamEvent | null> => {
2322 if (!shouldPublishTerminalMetadataEvent(event)) {
2323 return Effect.succeed(null);
2324 }
2325
2326 if (event.type === "closed") {
2327 return Effect.succeed({
2328 type: "remove" as const,
2329 threadId: event.threadId,
2330 terminalId: event.terminalId,
2331 });
2332 }
2333
2334 return readTerminalMetadata({
2335 threadId: event.threadId,
2336 terminalId: event.terminalId,
2337 }).pipe(
2338 Effect.map((terminal) =>
2339 terminal
2340 ? {
2341 type: "upsert" as const,
2342 terminal,
2343 }
2344 : null,
2345 ),
2346 );
2347 };
2348
2349 const offerMetadataEvent = (
2350 listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,
2351 event: TerminalEvent,
2352 ) =>
2353 metadataEventFromTerminalEvent(event).pipe(
2354 Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)),
2355 );
2356
2357 const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => {
2358 let unsubscribe: (() => void) | null = null;
2359
2360 return Effect.gen(function* () {
2361 const bufferedEvents: TerminalEvent[] = [];
2362 let deliverLive = false;
2363
2364 unsubscribe = yield* subscribe((event) => {
2365 if (!deliverLive) {
2366 bufferedEvents.push(event);
2367 return Effect.void;
2368 }
2369
2370 return offerMetadataEvent(listener, event);
2371 });
2372
2373 const terminals = yield* readAllTerminalMetadata();
2374 yield* listener({
2375 type: "snapshot",
2376 terminals,
2377 });
2378
2379 for (const event of bufferedEvents) {
2380 yield* offerMetadataEvent(listener, event);
2381 }
2382
2383 deliverLive = true;
2384 return () => {
2385 unsubscribe?.();
2386 unsubscribe = null;
2387 };
2388 }).pipe(
2389 Effect.catchCause((cause) =>
2390 Effect.flatMap(
2391 Effect.sync(() => {
2392 unsubscribe?.();
2393 unsubscribe = null;
2394 }),
2395 () => Effect.failCause(cause),
2396 ),
2397 ),
2398 );
2399 };
2400
2401 const write: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(function* (input) {
2402 const terminalId = input.terminalId;
2403 const session = yield* requireSession(input.threadId, terminalId);
2404 const process = session.process;
2405 if (!process || session.status !== "running") {
2406 if (session.status === "exited") return;
2407 return yield* new TerminalNotRunningError({
2408 threadId: input.threadId,
2409 terminalId,
2410 });
2411 }
2412 yield* Effect.sync(() => process.write(input.data));
2413 });
2414
2415 const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) {
2416 const session = yield* getSession(input.threadId, input.terminalId);
2417 // ResizeObserver traffic can already be in flight when the UI closes the session.
2418 if (Option.isNone(session)) {
2419 return;
2420 }
2421 const process = session.value.process;
2422 if (!process || session.value.status !== "running") {
2423 return;
2424 }
2425 session.value.cols = input.cols;
2426 session.value.rows = input.rows;
2427 session.value.updatedAt = yield* nowIso;
2428 yield* Effect.sync(() => process.resize(input.cols, input.rows));
2429 });
2430
2431 const resize: TerminalManager["Service"]["resize"] = (input) =>
2432 withThreadLock(input.threadId, resizeLocked(input));
2433
2434 const clear: TerminalManager["Service"]["clear"] = (input) =>
2435 withThreadLock(
2436 input.threadId,
2437 Effect.gen(function* () {
2438 const terminalId = input.terminalId;
2439 const session = yield* requireSession(input.threadId, terminalId);
2440 session.history = "";
2441 session.pendingHistoryControlSequence = "";
2442 session.pendingProcessEvents = [];
2443 session.pendingProcessEventIndex = 0;
2444 session.processEventDrainRunning = false;
2445 const eventStamp = advanceEventSequence(session);
2446 yield* persistHistory(input.threadId, terminalId, session.history);
2447 yield* publishEvent({
2448 type: "cleared",
2449 threadId: input.threadId,
2450 terminalId,
2451 sequence: eventStamp.sequence,
2452 });
2453 }),
2454 );
2455
2456 const restart: TerminalManager["Service"]["restart"] = (input) =>
2457 withThreadLock(
2458 input.threadId,
2459 Effect.gen(function* () {
2460 yield* increment(terminalRestartsTotal, { scope: "thread" });
2461 const terminalId = input.terminalId;
2462 yield* assertValidCwd(input.cwd);
2463
2464 const sessionKey = toSessionKey(input.threadId, terminalId);
2465 const existingSession = yield* getSession(input.threadId, terminalId);
2466 let session: TerminalSessionState;
2467 if (Option.isNone(existingSession)) {
2468 const cols = input.cols ?? DEFAULT_OPEN_COLS;
2469 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
2470 session = {
2471 threadId: input.threadId,
2472 terminalId,
2473 cwd: input.cwd,
2474 worktreePath: input.worktreePath ?? null,
2475 status: "starting",
2476 pid: null,
2477 history: "",
2478 pendingHistoryControlSequence: "",
2479 pendingProcessEvents: [],
2480 pendingProcessEventIndex: 0,
2481 processEventDrainRunning: false,
2482 exitCode: null,
2483 exitSignal: null,
2484 updatedAt: yield* nowIso,
2485 eventSequence: 0,
2486 cols,
2487 rows,
2488 process: null,
2489 unsubscribeData: null,
2490 unsubscribeExit: null,
2491 hasRunningSubprocess: false,
2492 childCommandLabel: null,
2493 runtimeEnv: normalizedRuntimeEnv(input.env),
2494 };
2495 const createdSession = session;
2496 yield* modifyManagerState((state) => {
2497 const sessions = new Map(state.sessions);
2498 sessions.set(sessionKey, createdSession);
2499 return [undefined, { ...state, sessions }] as const;
2500 });
2501 yield* evictInactiveSessionsIfNeeded();
2502 } else {
2503 session = existingSession.value;
2504 yield* stopProcess(session);
2505 session.cwd = input.cwd;
2506 session.worktreePath = input.worktreePath ?? null;
2507 session.runtimeEnv = normalizedRuntimeEnv(input.env);
2508 }
2509
2510 const cols = input.cols ?? session.cols;
2511 const rows = input.rows ?? session.rows;
2512
2513 session.history = "";
2514 session.pendingHistoryControlSequence = "";
2515 session.pendingProcessEvents = [];
2516 session.pendingProcessEventIndex = 0;
2517 session.processEventDrainRunning = false;
2518 yield* persistHistory(input.threadId, terminalId, session.history);
2519 yield* startSession(
2520 session,
2521 {
2522 threadId: input.threadId,
2523 terminalId,
2524 cwd: input.cwd,
2525 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
2526 cols,
2527 rows,
2528 ...(input.env ? { env: input.env } : {}),
2529 },
2530 "restarted",
2531 );
2532 return snapshot(session);
2533 }),
2534 );
2535
2536 const close: TerminalManager["Service"]["close"] = (input) =>
2537 withThreadLock(
2538 input.threadId,
2539 Effect.gen(function* () {
2540 if (input.terminalId) {
2541 yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true);
2542 return;
2543 }
2544
2545 const threadSessions = yield* sessionsForThread(input.threadId);
2546 yield* Effect.forEach(
2547 threadSessions,
2548 (session) => closeSession(input.threadId, session.terminalId, false),
2549 { discard: true },
2550 );
2551
2552 if (input.deleteHistory) {
2553 yield* deleteAllHistoryForThread(input.threadId);
2554 }
2555 }),
2556 );
2557
2558 return TerminalManager.of({
2559 open,
2560 attachStream,
2561 write,
2562 resize,
2563 clear,
2564 restart,
2565 close,
2566 subscribe,
2567 subscribeMetadata,
2568 });
2569});
2570
2571export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer));
apps/server/src/terminal/NodePtyAdapter.test.ts created+58
...@@ -0,0 +1,58 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { vi } from "vite-plus/test";
7
8import * as NodePtyAdapter from "./NodePtyAdapter.ts";
9import * as PtyAdapter from "./PtyAdapter.ts";
10
11const spawn = vi.fn(() => ({
12 pid: 42,
13 write: vi.fn(),
14 resize: vi.fn(),
15 kill: vi.fn(),
16 onData: vi.fn(() => ({ dispose: vi.fn() })),
17 onExit: vi.fn(() => ({ dispose: vi.fn() })),
18}));
19
20vi.mock("node-pty", () => ({ spawn }));
21
22const testLayer = NodePtyAdapter.layer.pipe(
23 Layer.provide(
24 Layer.mergeAll(
25 NodeServices.layer,
26 Layer.succeed(HostProcessPlatform, "win32"),
27 Layer.succeed(HostProcessArchitecture, "x64"),
28 ),
29 ),
30);
31
32it.effect("spawns through the public adapter with the provided host references", () =>
33 Effect.gen(function* () {
34 const adapter = yield* PtyAdapter.PtyAdapter;
35 const process = yield* adapter.spawn({
36 shell: "powershell.exe",
37 args: ["-NoLogo"],
38 cwd: "C:\\workspace",
39 cols: 120,
40 rows: 40,
41 env: {},
42 });
43
44 assert.equal(process.pid, 42);
45 assert.equal(spawn.mock.calls.length, 1);
46 assert.deepEqual(spawn.mock.calls[0], [
47 "powershell.exe",
48 ["-NoLogo"],
49 {
50 cwd: "C:\\workspace",
51 cols: 120,
52 rows: 40,
53 env: {},
54 name: "xterm-color",
55 },
56 ]);
57 }).pipe(Effect.provide(testLayer)),
58);
apps/server/src/terminal/NodePtyAdapter.ts created+139
...@@ -0,0 +1,139 @@
1import { createRequire } from "node:module";
2
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
8
9import * as PtyAdapter from "./PtyAdapter.ts";
10
11let didEnsureSpawnHelperExecutable = false;
12
13const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
14 const requireForNodePty = createRequire(import.meta.url);
15 const path = yield* Path.Path;
16 const fs = yield* FileSystem.FileSystem;
17 const platform = yield* HostProcessPlatform;
18 const architecture = yield* HostProcessArchitecture;
19
20 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
21 const packageDir = path.dirname(packageJsonPath);
22 const candidates = [
23 path.join(packageDir, "build", "Release", "spawn-helper"),
24 path.join(packageDir, "build", "Debug", "spawn-helper"),
25 path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"),
26 ];
27
28 for (const candidate of candidates) {
29 if (yield* fs.exists(candidate)) {
30 return candidate;
31 }
32 }
33 return null;
34}).pipe(Effect.orElseSucceed(() => null));
35
36const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () {
37 const fs = yield* FileSystem.FileSystem;
38 const platform = yield* HostProcessPlatform;
39 if (platform === "win32") return;
40 if (didEnsureSpawnHelperExecutable) return;
41
42 const helperPath = yield* resolveNodePtySpawnHelperPath;
43 if (!helperPath) return;
44 didEnsureSpawnHelperExecutable = true;
45
46 if (!(yield* fs.exists(helperPath))) {
47 return;
48 }
49
50 // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing.
51 yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined));
52});
53
54class NodePtyProcess implements PtyAdapter.PtyProcess {
55 private readonly process: import("node-pty").IPty;
56
57 constructor(process: import("node-pty").IPty) {
58 this.process = process;
59 }
60
61 get pid(): number {
62 return this.process.pid;
63 }
64
65 write(data: string): void {
66 this.process.write(data);
67 }
68
69 resize(cols: number, rows: number): void {
70 this.process.resize(cols, rows);
71 }
72
73 kill(signal?: string): void {
74 this.process.kill(signal);
75 }
76
77 onData(callback: (data: string) => void): () => void {
78 const disposable = this.process.onData(callback);
79 return () => {
80 disposable.dispose();
81 };
82 }
83
84 onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void {
85 const disposable = this.process.onExit((event) => {
86 callback({
87 exitCode: event.exitCode,
88 signal: event.signal ?? null,
89 });
90 });
91 return () => {
92 disposable.dispose();
93 };
94 }
95}
96
97export const make = Effect.fn("NodePtyAdapter.make")(function* () {
98 const fs = yield* FileSystem.FileSystem;
99 const path = yield* Path.Path;
100 const platform = yield* HostProcessPlatform;
101 const architecture = yield* HostProcessArchitecture;
102
103 const nodePty = yield* Effect.promise(() => import("node-pty"));
104
105 const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached(
106 ensureNodePtySpawnHelperExecutable().pipe(
107 Effect.provideService(FileSystem.FileSystem, fs),
108 Effect.provideService(Path.Path, path),
109 Effect.provideService(HostProcessPlatform, platform),
110 Effect.provideService(HostProcessArchitecture, architecture),
111 Effect.orElseSucceed(() => undefined),
112 ),
113 );
114
115 return PtyAdapter.PtyAdapter.of({
116 spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) {
117 yield* ensureNodePtySpawnHelperExecutableCached;
118 const ptyProcess = yield* Effect.try({
119 try: () =>
120 nodePty.spawn(input.shell, input.args ?? [], {
121 cwd: input.cwd,
122 cols: input.cols,
123 rows: input.rows,
124 env: input.env,
125 name: platform === "win32" ? "xterm-color" : "xterm-256color",
126 }),
127 catch: (cause) =>
128 new PtyAdapter.PtySpawnError({
129 adapter: "node-pty",
130 shell: input.shell,
131 cause,
132 }),
133 });
134 return new NodePtyProcess(ptyProcess);
135 }),
136 });
137});
138
139export const layer = Layer.effect(PtyAdapter.PtyAdapter, make());
apps/server/src/terminal/PtyAdapter.ts created+68
...@@ -0,0 +1,68 @@
1/**
2 * PtyAdapter - Terminal PTY adapter service contract.
3 *
4 * Defines the process primitives required by terminal session management
5 * without binding to a specific PTY implementation.
6 *
7 * @module PtyAdapter
8 */
9import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12
13/**
14 * PtySpawnError - Error type for PTY spawn failures.
15 */
16export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", {
17 adapter: Schema.String,
18 shell: Schema.optional(Schema.String),
19 attemptedShells: Schema.optional(Schema.Array(Schema.String)),
20 cause: Schema.optional(Schema.Defect()),
21}) {
22 override get message(): string {
23 const shell = this.shell === undefined ? "" : ` '${this.shell}'`;
24 const attemptedShells =
25 this.attemptedShells === undefined || this.attemptedShells.length === 0
26 ? ""
27 : ` Tried shells: ${this.attemptedShells.join(", ")}.`;
28 const causeMessage =
29 this.cause instanceof Error && this.cause.message.length > 0 ? ` ${this.cause.message}` : "";
30 return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}${causeMessage}`;
31 }
32}
33
34export interface PtyExitEvent {
35 exitCode: number;
36 signal: number | null;
37}
38
39export interface PtyProcess {
40 readonly pid: number;
41 write(data: string): void;
42 resize(cols: number, rows: number): void;
43 kill(signal?: string): void;
44 onData(callback: (data: string) => void): () => void;
45 onExit(callback: (event: PtyExitEvent) => void): () => void;
46}
47
48export interface PtySpawnInput {
49 shell: string;
50 args?: string[];
51 cwd: string;
52 cols: number;
53 rows: number;
54 env: NodeJS.ProcessEnv;
55}
56
57/**
58 * PtyAdapter - Service tag for PTY process integration.
59 */
60export class PtyAdapter extends Context.Service<
61 PtyAdapter,
62 {
63 /**
64 * Spawn a PTY process for a terminal session.
65 */
66 readonly spawn: (input: PtySpawnInput) => Effect.Effect<PtyProcess, PtySpawnError>;
67 }
68>()("t3/terminal/PtyAdapter") {}
apps/server/src/terminal/Services/Manager.ts deleted-150
...@@ -1,150 +0,0 @@
1/**
2 * TerminalManager - Terminal session orchestration service interface.
3 *
4 * Owns terminal lifecycle operations, output fanout, and session state
5 * transitions for thread-scoped terminals.
6 *
7 * @module TerminalManager
8 */
9import {
10 TerminalAttachInput,
11 TerminalAttachStreamEvent,
12 TerminalClearInput,
13 TerminalCloseInput,
14 TerminalEvent,
15 TerminalCwdError,
16 TerminalError,
17 TerminalHistoryError,
18 TerminalMetadataStreamEvent,
19 TerminalNotRunningError,
20 TerminalOpenInput,
21 TerminalResizeInput,
22 TerminalRestartInput,
23 TerminalSessionSnapshot,
24 TerminalSessionLookupError,
25 TerminalSessionStatus,
26 TerminalWriteInput,
27} from "@t3tools/contracts";
28import type { PtyProcess } from "./PTY.ts";
29import * as Effect from "effect/Effect";
30import * as Context from "effect/Context";
31
32export {
33 TerminalCwdError,
34 TerminalError,
35 TerminalHistoryError,
36 TerminalNotRunningError,
37 TerminalSessionLookupError,
38};
39
40export interface TerminalSessionState {
41 threadId: string;
42 terminalId: string;
43 cwd: string;
44 worktreePath: string | null;
45 status: TerminalSessionStatus;
46 pid: number | null;
47 history: string;
48 pendingHistoryControlSequence: string;
49 exitCode: number | null;
50 exitSignal: number | null;
51 updatedAt: string;
52 cols: number;
53 rows: number;
54 process: PtyProcess | null;
55 unsubscribeData: (() => void) | null;
56 unsubscribeExit: (() => void) | null;
57 hasRunningSubprocess: boolean;
58 runtimeEnv: Record<string, string> | null;
59}
60
61export interface ShellCandidate {
62 shell: string;
63 args?: string[];
64}
65
66export interface TerminalStartInput extends TerminalOpenInput {
67 cols: number;
68 rows: number;
69}
70
71/**
72 * TerminalManagerShape - Service API for terminal session lifecycle operations.
73 */
74export interface TerminalManagerShape {
75 /**
76 * Open or attach to a terminal session.
77 *
78 * Reuses an existing session for the same thread/terminal id and restores
79 * persisted history on first open.
80 */
81 readonly open: (
82 input: TerminalOpenInput,
83 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
84
85 /**
86 * Attach to a terminal and stream its initial snapshot followed by live events.
87 *
88 * Returns an unsubscribe function.
89 */
90 readonly attachStream: (
91 input: TerminalAttachInput,
92 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
93 ) => Effect.Effect<() => void, TerminalError>;
94
95 /**
96 * Write input bytes to a terminal session.
97 */
98 readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>;
99
100 /**
101 * Resize the PTY backing a terminal session.
102 */
103 readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>;
104
105 /**
106 * Clear terminal output history.
107 */
108 readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>;
109
110 /**
111 * Restart a terminal session in place.
112 *
113 * Always resets history before spawning the new process.
114 */
115 readonly restart: (
116 input: TerminalRestartInput,
117 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
118
119 /**
120 * Close an active terminal session.
121 *
122 * When `terminalId` is omitted, closes all sessions for the thread.
123 */
124 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
125
126 /**
127 * Subscribe to terminal runtime events with a direct callback.
128 *
129 * Returns an unsubscribe function.
130 */
131 readonly subscribe: (
132 listener: (event: TerminalEvent) => Effect.Effect<void>,
133 ) => Effect.Effect<() => void>;
134
135 /**
136 * Subscribe to lightweight terminal metadata with an initial full snapshot.
137 *
138 * Returns an unsubscribe function.
139 */
140 readonly subscribeMetadata: (
141 listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>,
142 ) => Effect.Effect<() => void>;
143}
144
145/**
146 * TerminalManager - Service tag for terminal session orchestration.
147 */
148export class TerminalManager extends Context.Service<TerminalManager, TerminalManagerShape>()(
149 "t3/terminal/Services/Manager/TerminalManager",
150) {}
apps/server/src/terminal/Services/PTY.ts deleted-60
...@@ -1,60 +0,0 @@
1/**
2 * PtyAdapter - Terminal PTY adapter service contract.
3 *
4 * Defines the process primitives required by terminal session management
5 * without binding to a specific PTY implementation.
6 *
7 * @module PtyAdapter
8 */
9import * as Effect from "effect/Effect";
10import * as Schema from "effect/Schema";
11import * as Context from "effect/Context";
12
13/**
14 * PtyError - Error type for PTY adapter operations.
15 */
16export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", {
17 adapter: Schema.String,
18 message: Schema.String,
19 cause: Schema.optional(Schema.Defect()),
20}) {}
21
22export interface PtyExitEvent {
23 exitCode: number;
24 signal: number | null;
25}
26
27export interface PtyProcess {
28 readonly pid: number;
29 write(data: string): void;
30 resize(cols: number, rows: number): void;
31 kill(signal?: string): void;
32 onData(callback: (data: string) => void): () => void;
33 onExit(callback: (event: PtyExitEvent) => void): () => void;
34}
35
36export interface PtySpawnInput {
37 shell: string;
38 args?: string[];
39 cwd: string;
40 cols: number;
41 rows: number;
42 env: NodeJS.ProcessEnv;
43}
44
45/**
46 * PtyAdapterShape - Service API for spawning and controlling PTY processes.
47 */
48export interface PtyAdapterShape {
49 /**
50 * Spawn a PTY process for a terminal session.
51 */
52 spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError>;
53}
54
55/**
56 * PtyAdapter - Service tag for PTY process integration.
57 */
58export class PtyAdapter extends Context.Service<PtyAdapter, PtyAdapterShape>()(
59 "t3/terminal/Services/PTY/PtyAdapter",
60) {}
apps/server/src/ws.ts+53-46
...@@ -56,45 +56,44 @@ import { clamp } from "effect/Number";...@@ -56,45 +56,44 @@ import { clamp } from "effect/Number";
56import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";56import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
57import { RpcSerialization, RpcServer } from "effect/unstable/rpc";57import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
5858
59import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts";59import * as CheckpointDiffQuery from "./checkpointing/Services/CheckpointDiffQuery.ts";
60import { ServerConfig } from "./config.ts";60import * as ServerConfig from "./config.ts";
61import { Keybindings } from "./keybindings.ts";61import * as Keybindings from "./keybindings.ts";
62import * as ExternalLauncher from "./process/externalLauncher.ts";62import * as ExternalLauncher from "./process/externalLauncher.ts";
63import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";63import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";
64import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";64import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
65import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";65import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
66import {66import {
67 observeRpcEffect as instrumentRpcEffect,67 observeRpcEffect as instrumentRpcEffect,
68 observeRpcStream as instrumentRpcStream,68 observeRpcStream as instrumentRpcStream,
69 observeRpcStreamEffect as instrumentRpcStreamEffect,69 observeRpcStreamEffect as instrumentRpcStreamEffect,
70} from "./observability/RpcInstrumentation.ts";70} from "./observability/RpcInstrumentation.ts";
71import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";71import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
72import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";72import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
73import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";73import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
74import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";74import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
75import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";75import * as ServerSettings from "./serverSettings.ts";
76import { TerminalManager } from "./terminal/Services/Manager.ts";76import * as TerminalManager from "./terminal/Manager.ts";
77import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";77import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
78import * as PreviewManager from "./preview/Manager.ts";78import * as PreviewManager from "./preview/Manager.ts";
79import { issueAssetUrl } from "./assets/AssetAccess.ts";79import { issueAssetUrl } from "./assets/AssetAccess.ts";
80import * as PortScanner from "./preview/PortScanner.ts";80import * as PortScanner from "./preview/PortScanner.ts";
81import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";81import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
82import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";82import * as WorkspaceFileSystem from "./workspace/Services/WorkspaceFileSystem.ts";
83import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";83import * as WorkspacePaths from "./workspace/Services/WorkspacePaths.ts";
84import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts";84import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
85import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts";85import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
86import { GitWorkflowService } from "./git/GitWorkflowService.ts";86import * as GitWorkflowService from "./git/GitWorkflowService.ts";
87import { ReviewService } from "./review/ReviewService.ts";87import * as ReviewService from "./review/ReviewService.ts";
88import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts";88import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";
89import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts";89import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";
90import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";90import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
91import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";91import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
92import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts";
93import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";92import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
94import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";93import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
95import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";94import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
96import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";95import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
97import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";96import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
98import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";97import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
99import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";98import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
100import * as GitHubCli from "./sourceControl/GitHubCli.ts";99import * as GitHubCli from "./sourceControl/GitHubCli.ts";
...@@ -109,7 +108,7 @@ import * as SessionStore from "./auth/SessionStore.ts";...@@ -109,7 +108,7 @@ import * as SessionStore from "./auth/SessionStore.ts";
109import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";108import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
110import * as RelayClient from "@t3tools/shared/relayClient";109import * as RelayClient from "@t3tools/shared/relayClient";
111const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);110const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
112const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError);111const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError);
113112
114const nowIso = Effect.map(DateTime.now, DateTime.formatIso);113const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
115114
...@@ -248,35 +247,36 @@ function toAuthAccessStreamEvent(...@@ -248,35 +247,36 @@ function toAuthAccessStreamEvent(
248 }247 }
249}248}
250249
251const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>250const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
252 WsRpcGroup.toLayer(251 WsRpcGroup.toLayer(
253 Effect.gen(function* () {252 Effect.gen(function* () {
254 const currentSessionId = currentSession.sessionId;253 const currentSessionId = currentSession.sessionId;
255 const crypto = yield* Crypto.Crypto;254 const crypto = yield* Crypto.Crypto;
256 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;255 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
257 const orchestrationEngine = yield* OrchestrationEngineService;256 const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService;
258 const checkpointDiffQuery = yield* CheckpointDiffQuery;257 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
259 const keybindings = yield* Keybindings;258 const keybindings = yield* Keybindings.Keybindings;
260 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;259 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
261 const gitWorkflow = yield* GitWorkflowService;260 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
262 const review = yield* ReviewService;261 const review = yield* ReviewService.ReviewService;
263 const vcsProvisioning = yield* VcsProvisioningService;262 const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService;
264 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;263 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
265 const terminalManager = yield* TerminalManager;264 const terminalManager = yield* TerminalManager.TerminalManager;
266 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;265 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
267 const previewManager = yield* PreviewManager.PreviewManager;266 const previewManager = yield* PreviewManager.PreviewManager;
268 const portDiscovery = yield* PortScanner.PortDiscovery;267 const portDiscovery = yield* PortScanner.PortDiscovery;
269 const providerRegistry = yield* ProviderRegistry;268 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
270 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;269 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
271 const config = yield* ServerConfig;270 const config = yield* ServerConfig.ServerConfig;
272 const lifecycleEvents = yield* ServerLifecycleEvents;271 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
273 const serverSettings = yield* ServerSettingsService;272 const serverSettings = yield* ServerSettings.ServerSettingsService;
274 const startup = yield* ServerRuntimeStartup;273 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
275 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;274 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
276 const workspaceFileSystem = yield* WorkspaceFileSystem;275 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
277 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;276 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
278 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;277 const repositoryIdentityResolver =
279 const serverEnvironment = yield* ServerEnvironment;278 yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
279 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
280 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;280 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
281 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;281 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;
282 const automaticGitFetchInterval = serverSettings.getSettings.pipe(282 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
...@@ -287,7 +287,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -287,7 +287,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
287 }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)),287 }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)),
288 ),288 ),
289 );289 );
290 const sourceControlRepositories = yield* SourceControlRepositoryService;290 const sourceControlRepositories =
291 yield* SourceControlRepositoryService.SourceControlRepositoryService;
291 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;292 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
292 const sessions = yield* SessionStore.SessionStore;293 const sessions = yield* SessionStore.SessionStore;
293 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;294 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
...@@ -766,7 +767,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -766,7 +767,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
766 const loadServerConfig = Effect.gen(function* () {767 const loadServerConfig = Effect.gen(function* () {
767 const keybindingsConfig = yield* keybindings.loadConfigState;768 const keybindingsConfig = yield* keybindings.loadConfigState;
768 const providers = yield* providerRegistry.getProviders;769 const providers = yield* providerRegistry.getProviders;
769 const settings = redactServerSettingsForClient(yield* serverSettings.getSettings);770 const settings = ServerSettings.redactServerSettingsForClient(
771 yield* serverSettings.getSettings,
772 );
770 const environment = yield* serverEnvironment.getDescriptor;773 const environment = yield* serverEnvironment.getDescriptor;
771 const auth = yield* serverAuth.getDescriptor();774 const auth = yield* serverAuth.getDescriptor();
772775
...@@ -1068,7 +1071,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -1068,7 +1071,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
1068 [WS_METHODS.serverGetSettings]: (_input) =>1071 [WS_METHODS.serverGetSettings]: (_input) =>
1069 observeRpcEffect(1072 observeRpcEffect(
1070 WS_METHODS.serverGetSettings,1073 WS_METHODS.serverGetSettings,
1071 serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)),1074 serverSettings.getSettings.pipe(
1075 Effect.map(ServerSettings.redactServerSettingsForClient),
1076 ),
1072 {1077 {
1073 "rpc.aggregate": "server",1078 "rpc.aggregate": "server",
1074 },1079 },
...@@ -1076,7 +1081,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -1076,7 +1081,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
1076 [WS_METHODS.serverUpdateSettings]: ({ patch }) =>1081 [WS_METHODS.serverUpdateSettings]: ({ patch }) =>
1077 observeRpcEffect(1082 observeRpcEffect(
1078 WS_METHODS.serverUpdateSettings,1083 WS_METHODS.serverUpdateSettings,
1079 serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)),1084 serverSettings
1085 .updateSettings(patch)
1086 .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)),
1080 {1087 {
1081 "rpc.aggregate": "server",1088 "rpc.aggregate": "server",
1082 },1089 },
...@@ -1559,7 +1566,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -1559,7 +1566,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
1559 Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)),1566 Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)),
1560 );1567 );
1561 const settingsUpdates = serverSettings.streamChanges.pipe(1568 const settingsUpdates = serverSettings.streamChanges.pipe(
1562 Stream.map((settings) => redactServerSettingsForClient(settings)),1569 Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)),
1563 Stream.map((settings) => ({1570 Stream.map((settings) => ({
1564 version: 1 as const,1571 version: 1 as const,
1565 type: "settingsUpdated" as const,1572 type: "settingsUpdated" as const,