authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-30 10:19:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-30 10:19:11-07:00
log8622b952cae4d520381bbd22c2c1d410de5f3285
treed6ec6b00f6c8f23785e5087682685905dd4d316f
parentf47c1f10465762d108082aa687681c8461c5e017
signature Signed by PGP key B5690EEEBB952194

Refactor terminal manager onto Effect runtime (#1525)


10 files changed, 2426 insertions(+), 1528 deletions(-)

apps/server/src/terminal/Layers/Manager.test.ts+780-584
......@@ -1,24 +1,38 @@
1import fs from "node:fs";
2import os from "node:os";
31import path from "node:path";
42
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, it } from "@effect/vitest";
55import {
66 DEFAULT_TERMINAL_ID,
77 type TerminalEvent,
88 type TerminalOpenInput,
99 type TerminalRestartInput,
1010} from "@t3tools/contracts";
11import { afterEach, describe, expect, it } from "vitest";
12
1311import {
14 PtySpawnError,
12 Duration,
13 Effect,
14 Encoding,
15 Exit,
16 Fiber,
17 FileSystem,
18 Option,
19 PlatformError,
20 Ref,
21 Schedule,
22 Scope,
23} from "effect";
24import { TestClock } from "effect/testing";
25import { expect } from "vitest";
26
27import type { TerminalManagerShape } from "../Services/Manager";
28import {
1529 type PtyAdapterShape,
1630 type PtyExitEvent,
1731 type PtyProcess,
1832 type PtySpawnInput,
33 PtySpawnError,
1934} from "../Services/PTY";
20import { TerminalManagerRuntime } from "./Manager";
21import { Effect, Encoding } from "effect";
35import { makeTerminalManagerWithOptions } from "./Manager";
2236
2337class FakePtyProcess implements PtyProcess {
2438 readonly writes: string[] = [];
......@@ -107,27 +121,29 @@ class FakePtyAdapter implements PtyAdapterShape {
107121 }
108122}
109123
110function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> {
111 const started = Date.now();
112 return new Promise((resolve, reject) => {
113 const poll = () => {
114 if (predicate()) {
115 resolve();
116 return;
117 }
118 if (Date.now() - started > timeoutMs) {
119 reject(new Error("Timed out waiting for condition"));
120 return;
121 }
122 setTimeout(poll, 15);
123 };
124 poll();
125 });
126}
124const waitFor = <E, R>(
125 predicate: Effect.Effect<boolean, E, R>,
126 timeout: Duration.Input = 800,
127): Effect.Effect<void, Error | E, R> =>
128 predicate.pipe(
129 Effect.filterOrFail(
130 (done) => done,
131 () => new Error("Condition not met"),
132 ),
133 Effect.retry(Schedule.spaced("15 millis")),
134 Effect.timeoutOption(timeout),
135 Effect.flatMap((result) =>
136 Option.match(result, {
137 onNone: () => Effect.fail(new Error("Timed out waiting for condition")),
138 onSome: () => Effect.void,
139 }),
140 ),
141 );
127142
128143function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {
129144 return {
130145 threadId: "thread-1",
146 terminalId: DEFAULT_TERMINAL_ID,
131147 cwd: process.cwd(),
132148 cols: 100,
133149 rows: 24,
......@@ -138,6 +154,7 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu
138154function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput {
139155 return {
140156 threadId: "thread-1",
157 terminalId: DEFAULT_TERMINAL_ID,
141158 cwd: process.cwd(),
142159 cols: 100,
143160 rows: 24,
......@@ -169,598 +186,777 @@ function multiTerminalHistoryLogPath(
169186 return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));
170187}
171188
172describe("TerminalManager", () => {
173 const tempDirs: string[] = [];
174
175 afterEach(() => {
176 for (const dir of tempDirs.splice(0, tempDirs.length)) {
177 fs.rmSync(dir, { recursive: true, force: true });
178 }
179 });
180
181 function makeManager(
182 historyLineLimit = 5,
183 options: {
184 shellResolver?: () => string;
185 subprocessChecker?: (terminalPid: number) => Promise<boolean>;
186 subprocessPollIntervalMs?: number;
187 processKillGraceMs?: number;
188 maxRetainedInactiveSessions?: number;
189 ptyAdapter?: FakePtyAdapter;
190 } = {},
191 ) {
192 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
193 tempDirs.push(logsDir);
194 const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter();
195 const manager = new TerminalManagerRuntime({
196 logsDir,
197 ptyAdapter,
198 historyLineLimit,
199 shellResolver: options.shellResolver ?? (() => "/bin/bash"),
200 ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}),
201 ...(options.subprocessPollIntervalMs
202 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
203 : {}),
204 ...(options.processKillGraceMs ? { processKillGraceMs: options.processKillGraceMs } : {}),
205 ...(options.maxRetainedInactiveSessions
206 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
207 : {}),
208 });
209 return { logsDir, ptyAdapter, manager };
210 }
189interface CreateManagerOptions {
190 shellResolver?: () => string;
191 subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>;
192 subprocessPollIntervalMs?: number;
193 processKillGraceMs?: number;
194 maxRetainedInactiveSessions?: number;
195 ptyAdapter?: FakePtyAdapter;
196}
211197
212 it("spawns lazily and reuses running terminal per thread", async () => {
213 const { manager, ptyAdapter } = makeManager();
214 const [first, second] = await Promise.all([
215 manager.open(openInput()),
216 manager.open(openInput()),
217 ]);
218 const third = await manager.open(openInput());
219
220 expect(first.threadId).toBe("thread-1");
221 expect(first.terminalId).toBe("default");
222 expect(second.threadId).toBe("thread-1");
223 expect(third.threadId).toBe("thread-1");
224 expect(ptyAdapter.spawnInputs).toHaveLength(1);
225
226 manager.dispose();
227 });
228
229 it("supports asynchronous PTY spawn effects", async () => {
230 const { manager, ptyAdapter } = makeManager(5, { ptyAdapter: new FakePtyAdapter("async") });
231
232 const snapshot = await manager.open(openInput());
233
234 expect(snapshot.status).toBe("running");
235 expect(ptyAdapter.spawnInputs).toHaveLength(1);
236 expect(ptyAdapter.processes).toHaveLength(1);
237
238 manager.dispose();
239 });
240
241 it("forwards write and resize to active pty process", async () => {
242 const { manager, ptyAdapter } = makeManager();
243 await manager.open(openInput());
244 const process = ptyAdapter.processes[0];
245 expect(process).toBeDefined();
246 if (!process) return;
247
248 await manager.write({ threadId: "thread-1", data: "ls\n" });
249 await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 });
250
251 expect(process.writes).toEqual(["ls\n"]);
252 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
253
254 manager.dispose();
255 });
256
257 it("resizes running terminal on open when a different size is requested", async () => {
258 const { manager, ptyAdapter } = makeManager();
259 await manager.open(openInput({ cols: 100, rows: 24 }));
260 const process = ptyAdapter.processes[0];
261 expect(process).toBeDefined();
262 if (!process) return;
263
264 await manager.open(openInput({ cols: 140, rows: 40 }));
265
266 expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]);
267
268 manager.dispose();
269 });
270
271 it("preserves existing terminal size on open when size is omitted", async () => {
272 const { manager, ptyAdapter } = makeManager();
273 await manager.open(openInput({ cols: 100, rows: 24 }));
274 const ptyProcess = ptyAdapter.processes[0];
275 expect(ptyProcess).toBeDefined();
276 if (!ptyProcess) return;
277
278 await manager.open({
279 threadId: "thread-1",
280 cwd: globalThis.process.cwd(),
281 });
282
283 expect(ptyProcess.resizeCalls).toEqual([]);
284
285 ptyProcess.emitExit({ exitCode: 0, signal: 0 });
286 await manager.open({
287 threadId: "thread-1",
288 cwd: globalThis.process.cwd(),
289 });
290
291 const resumedSpawn = ptyAdapter.spawnInputs[1];
292 expect(resumedSpawn).toBeDefined();
293 if (!resumedSpawn) return;
294 expect(resumedSpawn.cols).toBe(100);
295 expect(resumedSpawn.rows).toBe(24);
296
297 manager.dispose();
298 });
299
300 it("uses default dimensions when opening a new terminal without size hints", async () => {
301 const { manager, ptyAdapter } = makeManager();
302 await manager.open({
303 threadId: "thread-1",
304 cwd: process.cwd(),
305 });
306
307 const spawned = ptyAdapter.spawnInputs[0];
308 expect(spawned).toBeDefined();
309 if (!spawned) return;
310 expect(spawned.cols).toBe(120);
311 expect(spawned.rows).toBe(30);
312
313 manager.dispose();
314 });
315
316 it("supports multiple terminals per thread with isolated sessions", async () => {
317 const { manager, ptyAdapter } = makeManager();
318 await manager.open(openInput({ terminalId: "default" }));
319 await manager.open(openInput({ terminalId: "term-2" }));
320
321 const first = ptyAdapter.processes[0];
322 const second = ptyAdapter.processes[1];
323 expect(first).toBeDefined();
324 expect(second).toBeDefined();
325 if (!first || !second) return;
326
327 await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" });
328 await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" });
329
330 expect(first.writes).toEqual(["pwd\n"]);
331 expect(second.writes).toEqual(["ls\n"]);
332 expect(ptyAdapter.spawnInputs).toHaveLength(2);
333
334 manager.dispose();
335 });
336
337 it("clears transcript and emits cleared event", async () => {
338 const { manager, ptyAdapter, logsDir } = makeManager();
339 const events: TerminalEvent[] = [];
340 manager.on("event", (event) => {
341 events.push(event);
342 });
343 await manager.open(openInput());
344 const process = ptyAdapter.processes[0];
345 expect(process).toBeDefined();
346 if (!process) return;
347
348 process.emitData("hello\n");
349 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
350 await manager.clear({ threadId: "thread-1" });
351 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");
352
353 expect(events.some((event) => event.type === "cleared")).toBe(true);
354 expect(
355 events.some(
356 (event) =>
357 event.type === "cleared" &&
358 event.threadId === "thread-1" &&
359 event.terminalId === "default",
360 ),
361 ).toBe(true);
362
363 manager.dispose();
364 });
365
366 it("restarts terminal with empty transcript and respawns pty", async () => {
367 const { manager, ptyAdapter, logsDir } = makeManager();
368 await manager.open(openInput());
369 const firstProcess = ptyAdapter.processes[0];
370 expect(firstProcess).toBeDefined();
371 if (!firstProcess) return;
372 firstProcess.emitData("before restart\n");
373 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
374
375 const snapshot = await manager.restart(restartInput());
376 expect(snapshot.history).toBe("");
377 expect(snapshot.status).toBe("running");
378 expect(ptyAdapter.spawnInputs).toHaveLength(2);
379 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");
380
381 manager.dispose();
382 });
383
384 it("emits exited event and reopens with clean transcript after exit", async () => {
385 const { manager, ptyAdapter, logsDir } = makeManager();
386 const events: TerminalEvent[] = [];
387 manager.on("event", (event) => {
388 events.push(event);
389 });
390 await manager.open(openInput());
391 const process = ptyAdapter.processes[0];
392 expect(process).toBeDefined();
393 if (!process) return;
394 process.emitData("old data\n");
395 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
396 process.emitExit({ exitCode: 0, signal: 0 });
397
398 await waitFor(() => events.some((event) => event.type === "exited"));
399 const reopened = await manager.open(openInput());
400
401 expect(reopened.history).toBe("");
402 expect(ptyAdapter.spawnInputs).toHaveLength(2);
403 expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe("");
404
405 manager.dispose();
406 });
407
408 it("ignores trailing writes after terminal exit", async () => {
409 const { manager, ptyAdapter } = makeManager();
410 await manager.open(openInput());
411 const process = ptyAdapter.processes[0];
412 expect(process).toBeDefined();
413 if (!process) return;
414
415 process.emitExit({ exitCode: 0, signal: 0 });
416
417 await expect(manager.write({ threadId: "thread-1", data: "\r" })).resolves.toBeUndefined();
418 expect(process.writes).toEqual([]);
419
420 manager.dispose();
421 });
422
423 it("emits subprocess activity events when child-process state changes", async () => {
424 let hasRunningSubprocess = false;
425 const { manager } = makeManager(5, {
426 subprocessChecker: async () => hasRunningSubprocess,
427 subprocessPollIntervalMs: 20,
428 });
429 const events: TerminalEvent[] = [];
430 manager.on("event", (event) => {
431 events.push(event);
432 });
433
434 await manager.open(openInput());
435 await waitFor(() => events.some((event) => event.type === "started"));
436 expect(events.some((event) => event.type === "activity")).toBe(false);
437
438 hasRunningSubprocess = true;
439 await waitFor(
440 () =>
441 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true),
442 1_200,
443 );
198interface ManagerFixture {
199 readonly baseDir: string;
200 readonly logsDir: string;
201 readonly ptyAdapter: FakePtyAdapter;
202 readonly manager: TerminalManagerShape;
203 readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>;
204}
444205
445 hasRunningSubprocess = false;
446 await waitFor(
447 () =>
448 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false),
449 1_200,
206const createManager = (
207 historyLineLimit = 5,
208 options: CreateManagerOptions = {},
209): Effect.Effect<
210 ManagerFixture,
211 PlatformError.PlatformError,
212 FileSystem.FileSystem | Scope.Scope
213> =>
214 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
215 Effect.gen(function* () {
216 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" });
217 const logsDir = path.join(baseDir, "userdata", "logs", "terminals");
218 const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter();
219
220 const manager = yield* makeTerminalManagerWithOptions({
221 logsDir,
222 historyLineLimit,
223 ptyAdapter,
224 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
225 ...(options.subprocessChecker !== undefined
226 ? { subprocessChecker: options.subprocessChecker }
227 : {}),
228 ...(options.subprocessPollIntervalMs !== undefined
229 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
230 : {}),
231 ...(options.processKillGraceMs !== undefined
232 ? { processKillGraceMs: options.processKillGraceMs }
233 : {}),
234 ...(options.maxRetainedInactiveSessions !== undefined
235 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
236 : {}),
237 });
238 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
239 const scope = yield* Effect.scope;
240 const unsubscribe = yield* manager.subscribe((event) =>
241 Ref.update(eventsRef, (events) => [...events, event]),
242 );
243 yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
244
245 return {
246 baseDir,
247 logsDir,
248 ptyAdapter,
249 manager,
250 getEvents: Ref.get(eventsRef),
251 };
252 }),
253 );
254
255it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (it) => {
256 it.effect("spawns lazily and reuses running terminal per thread", () =>
257 Effect.gen(function* () {
258 const { manager, ptyAdapter } = yield* createManager();
259 const [first, second] = yield* Effect.all(
260 [manager.open(openInput()), manager.open(openInput())],
261 { concurrency: "unbounded" },
262 );
263 const third = yield* manager.open(openInput());
264
265 assert.equal(first.threadId, "thread-1");
266 assert.equal(first.terminalId, "default");
267 assert.equal(second.threadId, "thread-1");
268 assert.equal(third.threadId, "thread-1");
269 expect(ptyAdapter.spawnInputs).toHaveLength(1);
270 }),
271 );
272
273 const makeDirectory = (filePath: string) =>
274 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
275 fs.makeDirectory(filePath, { recursive: true }),
450276 );
451277
452 manager.dispose();
453 });
454
455 it("caps persisted history to configured line limit", async () => {
456 const { manager, ptyAdapter } = makeManager(3);
457 await manager.open(openInput());
458 const process = ptyAdapter.processes[0];
459 expect(process).toBeDefined();
460 if (!process) return;
461
462 process.emitData("line1\nline2\nline3\nline4\n");
463 await manager.close({ threadId: "thread-1" });
464
465 const reopened = await manager.open(openInput());
466 const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);
467 expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);
468
469 manager.dispose();
470 });
471
472 it("strips replay-unsafe terminal query and reply sequences from persisted history", async () => {
473 const { manager, ptyAdapter } = makeManager();
474 await manager.open(openInput());
475 const process = ptyAdapter.processes[0];
476 expect(process).toBeDefined();
477 if (!process) return;
478
479 process.emitData("prompt ");
480 process.emitData("\u001b[32mok\u001b[0m ");
481 process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007");
482 process.emitData("\u001b[1;1R");
483 process.emitData("done\n");
484
485 await manager.close({ threadId: "thread-1" });
486
487 const reopened = await manager.open(openInput());
488 expect(reopened.history).toBe("prompt \u001b[32mok\u001b[0m done\n");
489
490 manager.dispose();
491 });
492
493 it("preserves clear and style control sequences while dropping chunk-split query traffic", async () => {
494 const { manager, ptyAdapter } = makeManager();
495 await manager.open(openInput());
496 const process = ptyAdapter.processes[0];
497 expect(process).toBeDefined();
498 if (!process) return;
499
500 process.emitData("before clear\n");
501 process.emitData("\u001b[H\u001b[2J");
502 process.emitData("prompt ");
503 process.emitData("\u001b]11;");
504 process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1");
505 process.emitData("R\u001b[36mdone\u001b[0m\n");
506
507 await manager.close({ threadId: "thread-1" });
508
509 const reopened = await manager.open(openInput());
510 expect(reopened.history).toBe(
511 "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n",
512 );
278 const chmod = (filePath: string, mode: number) =>
279 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode));
513280
514 manager.dispose();
515 });
281 const pathExists = (filePath: string) =>
282 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath));
516283
517 it("does not leak final bytes from ESC sequences with intermediate bytes", async () => {
518 const { manager, ptyAdapter } = makeManager();
519 await manager.open(openInput());
520 const process = ptyAdapter.processes[0];
521 expect(process).toBeDefined();
522 if (!process) return;
284 const readFileString = (filePath: string) =>
285 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath));
523286
524 process.emitData("before ");
525 process.emitData("\u001b(B");
526 process.emitData("after\n");
287 const writeFileString = (filePath: string, contents: string) =>
288 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
289 fs.writeFileString(filePath, contents),
290 );
527291
528 await manager.close({ threadId: "thread-1" });
529
530 const reopened = await manager.open(openInput());
531 expect(reopened.history).toBe("before \u001b(Bafter\n");
532
533 manager.dispose();
534 });
292 it.effect("preserves non-notFound cwd stat failures", () =>
293 Effect.gen(function* () {
294 const { manager, baseDir } = yield* createManager();
295 const blockedRoot = path.join(baseDir, "blocked-root");
296 const blockedCwd = path.join(blockedRoot, "cwd");
297 yield* makeDirectory(blockedCwd);
298 yield* chmod(blockedRoot, 0o000);
535299
536 it("preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", async () => {
537 const { manager, ptyAdapter } = makeManager();
538 await manager.open(openInput());
539 const process = ptyAdapter.processes[0];
540 expect(process).toBeDefined();
541 if (!process) return;
542
543 process.emitData("before ");
544 process.emitData("\u001b(");
545 process.emitData("Bafter\n");
300 const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe(
301 Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)),
302 );
546303
547 await manager.close({ threadId: "thread-1" });
548
549 const reopened = await manager.open(openInput());
550 expect(reopened.history).toBe("before \u001b(Bafter\n");
304 expect(error).toMatchObject({
305 _tag: "TerminalCwdError",
306 cwd: blockedCwd,
307 reason: "statFailed",
308 });
309 }),
310 );
551311
552 manager.dispose();
553 });
312 it.effect("supports asynchronous PTY spawn effects", () =>
313 Effect.gen(function* () {
314 const { manager, ptyAdapter } = yield* createManager(5, {
315 ptyAdapter: new FakePtyAdapter("async"),
316 });
554317
555 it("deletes history file when close(deleteHistory=true)", async () => {
556 const { manager, ptyAdapter, logsDir } = makeManager();
557 await manager.open(openInput());
558 const process = ptyAdapter.processes[0];
559 expect(process).toBeDefined();
560 if (!process) return;
561 process.emitData("bye\n");
562 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
318 const snapshot = yield* manager.open(openInput());
319
320 assert.equal(snapshot.status, "running");
321 expect(ptyAdapter.spawnInputs).toHaveLength(1);
322 expect(ptyAdapter.processes).toHaveLength(1);
323 }),
324 );
325
326 it.effect("forwards write and resize to active pty process", () =>
327 Effect.gen(function* () {
328 const { manager, ptyAdapter } = yield* createManager();
329 yield* manager.open(openInput());
330 const process = ptyAdapter.processes[0];
331 expect(process).toBeDefined();
332 if (!process) return;
333
334 yield* manager.write({
335 threadId: "thread-1",
336 terminalId: DEFAULT_TERMINAL_ID,
337 data: "ls\n",
338 });
339 yield* manager.resize({
340 threadId: "thread-1",
341 terminalId: DEFAULT_TERMINAL_ID,
342 cols: 120,
343 rows: 30,
344 });
563345
564 await manager.close({ threadId: "thread-1", deleteHistory: true });
565 expect(fs.existsSync(historyLogPath(logsDir))).toBe(false);
346 expect(process.writes).toEqual(["ls\n"]);
347 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
348 }),
349 );
350
351 it.effect("resizes running terminal on open when a different size is requested", () =>
352 Effect.gen(function* () {
353 const { manager, ptyAdapter } = yield* createManager();
354 yield* manager.open(openInput({ cols: 100, rows: 24 }));
355 const process = ptyAdapter.processes[0];
356 expect(process).toBeDefined();
357 if (!process) return;
358
359 const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 }));
360
361 assert.equal(reopened.status, "running");
362 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
363 }),
364 );
365
366 it.effect("supports multiple terminals per thread independently", () =>
367 Effect.gen(function* () {
368 const { manager, ptyAdapter } = yield* createManager();
369 yield* manager.open(openInput({ terminalId: "default" }));
370 yield* manager.open(openInput({ terminalId: "term-2" }));
371
372 const first = ptyAdapter.processes[0];
373 const second = ptyAdapter.processes[1];
374 expect(first).toBeDefined();
375 expect(second).toBeDefined();
376 if (!first || !second) return;
377
378 yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" });
379 yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" });
380
381 expect(first.writes).toEqual(["pwd\n"]);
382 expect(second.writes).toEqual(["ls\n"]);
383 expect(ptyAdapter.spawnInputs).toHaveLength(2);
384 }),
385 );
386
387 it.effect("clears transcript and emits cleared event", () =>
388 Effect.gen(function* () {
389 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
390 yield* manager.open(openInput());
391 const process = ptyAdapter.processes[0];
392 expect(process).toBeDefined();
393 if (!process) return;
394
395 process.emitData("hello\n");
396 yield* waitFor(pathExists(historyLogPath(logsDir)));
397 yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID });
398 yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === ""));
399
400 const events = yield* getEvents;
401 expect(events.some((event) => event.type === "cleared")).toBe(true);
402 expect(
403 events.some(
404 (event) =>
405 event.type === "cleared" &&
406 event.threadId === "thread-1" &&
407 event.terminalId === "default",
408 ),
409 ).toBe(true);
410 }),
411 );
412
413 it.effect("restarts terminal with empty transcript and respawns pty", () =>
414 Effect.gen(function* () {
415 const { manager, ptyAdapter, logsDir } = yield* createManager();
416 yield* manager.open(openInput());
417 const firstProcess = ptyAdapter.processes[0];
418 expect(firstProcess).toBeDefined();
419 if (!firstProcess) return;
420 firstProcess.emitData("before restart\n");
421 yield* waitFor(pathExists(historyLogPath(logsDir)));
422
423 const snapshot = yield* manager.restart(restartInput());
424 assert.equal(snapshot.history, "");
425 assert.equal(snapshot.status, "running");
426 expect(ptyAdapter.spawnInputs).toHaveLength(2);
427 yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === ""));
428 }),
429 );
430
431 it.effect("emits exited event and reopens with clean transcript after exit", () =>
432 Effect.gen(function* () {
433 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
434 yield* manager.open(openInput());
435 const process = ptyAdapter.processes[0];
436 expect(process).toBeDefined();
437 if (!process) return;
438 process.emitData("old data\n");
439 yield* waitFor(pathExists(historyLogPath(logsDir)));
440 process.emitExit({ exitCode: 0, signal: 0 });
441
442 yield* waitFor(
443 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
444 );
445 const reopened = yield* manager.open(openInput());
446
447 assert.equal(reopened.history, "");
448 expect(ptyAdapter.spawnInputs).toHaveLength(2);
449 expect(yield* readFileString(historyLogPath(logsDir))).toBe("");
450 }),
451 );
452
453 it.effect("ignores trailing writes after terminal exit", () =>
454 Effect.gen(function* () {
455 const { manager, ptyAdapter } = yield* createManager();
456 yield* manager.open(openInput());
457 const process = ptyAdapter.processes[0];
458 expect(process).toBeDefined();
459 if (!process) return;
460
461 process.emitExit({ exitCode: 0, signal: 0 });
462
463 yield* manager.write({
464 threadId: "thread-1",
465 terminalId: DEFAULT_TERMINAL_ID,
466 data: "\r",
467 });
468 expect(process.writes).toEqual([]);
469 }),
470 );
471
472 it.effect("emits subprocess activity events when child-process state changes", () =>
473 Effect.gen(function* () {
474 let hasRunningSubprocess = false;
475 const { manager, getEvents } = yield* createManager(5, {
476 subprocessChecker: () => Effect.succeed(hasRunningSubprocess),
477 subprocessPollIntervalMs: 20,
478 });
566479
567 manager.dispose();
568 });
480 yield* manager.open(openInput());
481 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);
569482
570 it("closes all terminals for a thread when close omits terminalId", async () => {
571 const { manager, ptyAdapter, logsDir } = makeManager();
572 await manager.open(openInput({ terminalId: "default" }));
573 await manager.open(openInput({ terminalId: "sidecar" }));
574 const defaultProcess = ptyAdapter.processes[0];
575 const sidecarProcess = ptyAdapter.processes[1];
576 expect(defaultProcess).toBeDefined();
577 expect(sidecarProcess).toBeDefined();
578 if (!defaultProcess || !sidecarProcess) return;
483 hasRunningSubprocess = true;
484 yield* waitFor(
485 Effect.map(getEvents, (events) =>
486 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true),
487 ),
488 "1200 millis",
489 );
579490
580 defaultProcess.emitData("default\n");
581 sidecarProcess.emitData("sidecar\n");
582 await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default")));
583 await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar")));
491 hasRunningSubprocess = false;
492 yield* waitFor(
493 Effect.map(getEvents, (events) =>
494 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false),
495 ),
496 "1200 millis",
497 );
498 }),
499 );
500
501 it.effect("does not invoke subprocess polling until a terminal session is running", () =>
502 Effect.gen(function* () {
503 let checks = 0;
504 const { manager } = yield* createManager(5, {
505 subprocessChecker: () => {
506 checks += 1;
507 return Effect.succeed(false);
508 },
509 subprocessPollIntervalMs: 20,
510 });
584511
585 await manager.close({ threadId: "thread-1", deleteHistory: true });
512 yield* Effect.sleep("80 millis");
513 assert.equal(checks, 0);
586514
587 expect(defaultProcess.killed).toBe(true);
588 expect(sidecarProcess.killed).toBe(true);
589 expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false);
590 expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false);
515 yield* manager.open(openInput());
516 yield* waitFor(
517 Effect.sync(() => checks > 0),
518 "1200 millis",
519 );
520 }),
521 );
522
523 it.effect("caps persisted history to configured line limit", () =>
524 Effect.gen(function* () {
525 const { manager, ptyAdapter } = yield* createManager(3);
526 yield* manager.open(openInput());
527 const process = ptyAdapter.processes[0];
528 expect(process).toBeDefined();
529 if (!process) return;
530
531 process.emitData("line1\nline2\nline3\nline4\n");
532 yield* manager.close({ threadId: "thread-1" });
533
534 const reopened = yield* manager.open(openInput());
535 const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);
536 expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);
537 }),
538 );
539
540 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
541 Effect.gen(function* () {
542 const { manager, ptyAdapter } = yield* createManager();
543 yield* manager.open(openInput());
544 const process = ptyAdapter.processes[0];
545 expect(process).toBeDefined();
546 if (!process) return;
547
548 process.emitData("prompt ");
549 process.emitData("\u001b[32mok\u001b[0m ");
550 process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007");
551 process.emitData("\u001b[1;1R");
552 process.emitData("done\n");
553
554 yield* manager.close({ threadId: "thread-1" });
555
556 const reopened = yield* manager.open(openInput());
557 assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n");
558 }),
559 );
560
561 it.effect(
562 "preserves clear and style control sequences while dropping chunk-split query traffic",
563 () =>
564 Effect.gen(function* () {
565 const { manager, ptyAdapter } = yield* createManager();
566 yield* manager.open(openInput());
567 const process = ptyAdapter.processes[0];
568 expect(process).toBeDefined();
569 if (!process) return;
570
571 process.emitData("before clear\n");
572 process.emitData("\u001b[H\u001b[2J");
573 process.emitData("prompt ");
574 process.emitData("\u001b]11;");
575 process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1");
576 process.emitData("R\u001b[36mdone\u001b[0m\n");
577
578 yield* manager.close({ threadId: "thread-1" });
579
580 const reopened = yield* manager.open(openInput());
581 assert.equal(
582 reopened.history,
583 "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n",
584 );
585 }),
586 );
587
588 it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () =>
589 Effect.gen(function* () {
590 const { manager, ptyAdapter } = yield* createManager();
591 yield* manager.open(openInput());
592 const process = ptyAdapter.processes[0];
593 expect(process).toBeDefined();
594 if (!process) return;
595
596 process.emitData("before ");
597 process.emitData("\u001b(B");
598 process.emitData("after\n");
599
600 yield* manager.close({ threadId: "thread-1" });
601
602 const reopened = yield* manager.open(openInput());
603 assert.equal(reopened.history, "before \u001b(Bafter\n");
604 }),
605 );
606
607 it.effect(
608 "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes",
609 () =>
610 Effect.gen(function* () {
611 const { manager, ptyAdapter } = yield* createManager();
612 yield* manager.open(openInput());
613 const process = ptyAdapter.processes[0];
614 expect(process).toBeDefined();
615 if (!process) return;
616
617 process.emitData("before ");
618 process.emitData("\u001b(");
619 process.emitData("Bafter\n");
620
621 yield* manager.close({ threadId: "thread-1" });
622
623 const reopened = yield* manager.open(openInput());
624 assert.equal(reopened.history, "before \u001b(Bafter\n");
625 }),
626 );
627
628 it.effect("deletes history file when close(deleteHistory=true)", () =>
629 Effect.gen(function* () {
630 const { manager, ptyAdapter, logsDir } = yield* createManager();
631 yield* manager.open(openInput());
632 const process = ptyAdapter.processes[0];
633 expect(process).toBeDefined();
634 if (!process) return;
635 process.emitData("bye\n");
636 yield* waitFor(pathExists(historyLogPath(logsDir)));
637
638 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
639 expect(yield* pathExists(historyLogPath(logsDir))).toBe(false);
640 }),
641 );
642
643 it.effect("closes all terminals for a thread when close omits terminalId", () =>
644 Effect.gen(function* () {
645 const { manager, ptyAdapter, logsDir } = yield* createManager();
646 yield* manager.open(openInput({ terminalId: "default" }));
647 yield* manager.open(openInput({ terminalId: "sidecar" }));
648 const defaultProcess = ptyAdapter.processes[0];
649 const sidecarProcess = ptyAdapter.processes[1];
650 expect(defaultProcess).toBeDefined();
651 expect(sidecarProcess).toBeDefined();
652 if (!defaultProcess || !sidecarProcess) return;
653
654 defaultProcess.emitData("default\n");
655 sidecarProcess.emitData("sidecar\n");
656 yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default")));
657 yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar")));
658
659 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
660
661 assert.equal(defaultProcess.killed, true);
662 assert.equal(sidecarProcess.killed, true);
663 expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(
664 false,
665 );
666 expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(
667 false,
668 );
669 }),
670 );
671
672 it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () =>
673 Effect.gen(function* () {
674 const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 });
675 yield* manager.open(openInput());
676 const process = ptyAdapter.processes[0];
677 expect(process).toBeDefined();
678 if (!process) return;
679
680 const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped);
681 yield* Effect.yieldNow;
682 yield* TestClock.adjust("10 millis");
683 yield* Fiber.join(closeFiber);
684
685 assert.equal(process.killSignals[0], "SIGTERM");
686 expect(process.killSignals).toContain("SIGKILL");
687 }).pipe(Effect.provide(TestClock.layer())),
688 );
689
690 it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () =>
691 Effect.gen(function* () {
692 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, {
693 maxRetainedInactiveSessions: 1,
694 });
591695
592 manager.dispose();
593 });
696 yield* manager.open(openInput({ threadId: "thread-1" }));
697 yield* manager.open(openInput({ threadId: "thread-2" }));
698
699 const first = ptyAdapter.processes[0];
700 const second = ptyAdapter.processes[1];
701 expect(first).toBeDefined();
702 expect(second).toBeDefined();
703 if (!first || !second) return;
704
705 first.emitData("first-history\n");
706 second.emitData("second-history\n");
707 yield* waitFor(pathExists(historyLogPath(logsDir, "thread-1")));
708 first.emitExit({ exitCode: 0, signal: 0 });
709 yield* Effect.sleep(Duration.millis(5));
710 second.emitExit({ exitCode: 0, signal: 0 });
711
712 yield* waitFor(
713 Effect.map(
714 getEvents,
715 (events) => events.filter((event) => event.type === "exited").length === 2,
716 ),
717 );
594718
595 it("escalates terminal shutdown to SIGKILL when process does not exit in time", async () => {
596 const { manager, ptyAdapter } = makeManager(5, { processKillGraceMs: 10 });
597 await manager.open(openInput());
598 const process = ptyAdapter.processes[0];
599 expect(process).toBeDefined();
600 if (!process) return;
719 const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" }));
720 const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" }));
721
722 assert.equal(reopenedFirst.history, "first-history\n");
723 assert.equal(reopenedSecond.history, "");
724 }),
725 );
726
727 it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () =>
728 Effect.gen(function* () {
729 const { manager, logsDir } = yield* createManager();
730 const legacyPath = path.join(logsDir, "thread-1.log");
731 const nextPath = historyLogPath(logsDir);
732 yield* writeFileString(legacyPath, "legacy-line\n");
733
734 const snapshot = yield* manager.open(openInput());
735
736 assert.equal(snapshot.history, "legacy-line\n");
737 expect(yield* pathExists(nextPath)).toBe(true);
738 expect(yield* readFileString(nextPath)).toBe("legacy-line\n");
739 expect(yield* pathExists(legacyPath)).toBe(false);
740 }),
741 );
742
743 it.effect("retries with fallback shells when preferred shell spawn fails", () =>
744 Effect.gen(function* () {
745 const { manager, ptyAdapter } = yield* createManager(5, {
746 shellResolver: () => "/definitely/missing-shell -l",
747 });
748 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
749
750 const snapshot = yield* manager.open(openInput());
751
752 assert.equal(snapshot.status, "running");
753 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
754 expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell");
755
756 if (process.platform === "win32") {
757 expect(
758 ptyAdapter.spawnInputs.some(
759 (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe",
760 ),
761 ).toBe(true);
762 } else {
763 expect(
764 ptyAdapter.spawnInputs
765 .slice(1)
766 .some((input) => input.shell !== "/definitely/missing-shell"),
767 ).toBe(true);
768 }
769 }),
770 );
771
772 it.effect("filters app runtime env variables from terminal sessions", () =>
773 Effect.gen(function* () {
774 const originalValues = new Map<string, string | undefined>();
775 const setEnv = (key: string, value: string | undefined) => {
776 if (!originalValues.has(key)) {
777 originalValues.set(key, process.env[key]);
778 }
779 if (value === undefined) {
780 delete process.env[key];
781 return;
782 }
783 process.env[key] = value;
784 };
785 const restoreEnv = () => {
786 for (const [key, value] of originalValues) {
787 if (value === undefined) {
788 delete process.env[key];
789 } else {
790 process.env[key] = value;
791 }
792 }
793 };
794
795 setEnv("PORT", "5173");
796 setEnv("T3CODE_PORT", "3773");
797 setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173");
798 setEnv("TEST_TERMINAL_KEEP", "keep-me");
799
800 try {
801 const { manager, ptyAdapter } = yield* createManager();
802 yield* manager.open(openInput());
803 const spawnInput = ptyAdapter.spawnInputs[0];
804 expect(spawnInput).toBeDefined();
805 if (!spawnInput) return;
806
807 expect(spawnInput.env.PORT).toBeUndefined();
808 expect(spawnInput.env.T3CODE_PORT).toBeUndefined();
809 expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined();
810 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");
811 } finally {
812 restoreEnv();
813 }
814 }),
815 );
816
817 it.effect("injects runtime env overrides into spawned terminals", () =>
818 Effect.gen(function* () {
819 const { manager, ptyAdapter } = yield* createManager();
820 yield* manager.open(
821 openInput({
822 env: {
823 T3CODE_PROJECT_ROOT: "/repo",
824 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
825 CUSTOM_FLAG: "1",
826 },
827 }),
828 );
829 const spawnInput = ptyAdapter.spawnInputs[0];
830 expect(spawnInput).toBeDefined();
831 if (!spawnInput) return;
601832
602 await manager.close({ threadId: "thread-1" });
603 await waitFor(() => process.killSignals.includes("SIGKILL"));
833 assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo");
834 assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a");
835 assert.equal(spawnInput.env.CUSTOM_FLAG, "1");
836 }),
837 );
838
839 it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>
840 Effect.gen(function* () {
841 if (process.platform === "win32") return;
842 const { manager, ptyAdapter } = yield* createManager(5, {
843 shellResolver: () => "/bin/zsh",
844 });
845 yield* manager.open(openInput());
846 const spawnInput = ptyAdapter.spawnInputs[0];
847 expect(spawnInput).toBeDefined();
848 if (!spawnInput) return;
604849
605 expect(process.killSignals[0]).toBe("SIGTERM");
606 expect(process.killSignals).toContain("SIGKILL");
850 expect(spawnInput.args).toEqual(["-o", "nopromptsp"]);
851 }),
852 );
607853
608 manager.dispose();
609 });
854 it.effect("bridges PTY callbacks back into Effect-managed event streaming", () =>
855 Effect.gen(function* () {
856 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
857 ptyAdapter: new FakePtyAdapter("async"),
858 });
610859
611 it("evicts oldest inactive terminal sessions when retention limit is exceeded", async () => {
612 const { manager, ptyAdapter } = makeManager(5, { maxRetainedInactiveSessions: 1 });
860 yield* manager.open(openInput());
861 const process = ptyAdapter.processes[0];
862 expect(process).toBeDefined();
863 if (!process) return;
613864
614 await manager.open(openInput({ threadId: "thread-1" }));
615 await manager.open(openInput({ threadId: "thread-2" }));
616
617 const first = ptyAdapter.processes[0];
618 const second = ptyAdapter.processes[1];
619 expect(first).toBeDefined();
620 expect(second).toBeDefined();
621 if (!first || !second) return;
865 process.emitData("hello from callback\n");
622866
623 first.emitExit({ exitCode: 0, signal: 0 });
624 await new Promise((resolve) => setTimeout(resolve, 5));
625 second.emitExit({ exitCode: 0, signal: 0 });
867 yield* waitFor(
868 Effect.map(getEvents, (events) =>
869 events.some((event) => event.type === "output" && event.data === "hello from callback\n"),
870 ),
871 "1200 millis",
872 );
873 }),
874 );
626875
627 await waitFor(() => {
628 const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions;
629 return sessions.size === 1;
630 });
631
632 const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions;
633 const keys = [...sessions.keys()];
634 expect(keys).toEqual(["thread-2\u0000default"]);
635
636 manager.dispose();
637 });
638
639 it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => {
640 const { manager, logsDir } = makeManager();
641 const legacyPath = path.join(logsDir, "thread-1.log");
642 const nextPath = historyLogPath(logsDir);
643 fs.writeFileSync(legacyPath, "legacy-line\n", "utf8");
644
645 const snapshot = await manager.open(openInput());
876 it.effect("pushes PTY callbacks to direct event subscribers", () =>
877 Effect.gen(function* () {
878 const { manager, ptyAdapter } = yield* createManager(5, {
879 ptyAdapter: new FakePtyAdapter("async"),
880 });
881 const scope = yield* Effect.scope;
882 const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
883 const unsubscribe = yield* manager.subscribe((event) =>
884 Ref.update(subscriberEvents, (events) => [...events, event]),
885 );
886 yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
646887
647 expect(snapshot.history).toBe("legacy-line\n");
648 expect(fs.existsSync(nextPath)).toBe(true);
649 expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n");
650 expect(fs.existsSync(legacyPath)).toBe(false);
651
652 manager.dispose();
653 });
654
655 it("retries with fallback shells when preferred shell spawn fails", async () => {
656 const { manager, ptyAdapter } = makeManager(5, {
657 shellResolver: () => "/definitely/missing-shell -l",
658 });
659 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
660
661 const snapshot = await manager.open(openInput());
888 yield* manager.open(openInput());
889 const process = ptyAdapter.processes[0];
890 expect(process).toBeDefined();
891 if (!process) return;
662892
663 expect(snapshot.status).toBe("running");
664 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
665 expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell");
893 process.emitData("hello from subscriber\n");
666894
667 if (process.platform === "win32") {
668 expect(
669 ptyAdapter.spawnInputs.some(
670 (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe",
895 yield* waitFor(
896 Effect.map(Ref.get(subscriberEvents), (events) =>
897 events.some(
898 (event) => event.type === "output" && event.data === "hello from subscriber\n",
899 ),
671900 ),
672 ).toBe(true);
673 } else {
674 expect(
675 ptyAdapter.spawnInputs
676 .slice(1)
677 .some((input) => input.shell !== "/definitely/missing-shell"),
678 ).toBe(true);
679 }
680
681 manager.dispose();
682 });
683
684 it("filters app runtime env variables from terminal sessions", async () => {
685 const originalValues = new Map<string, string | undefined>();
686 const setEnv = (key: string, value: string | undefined) => {
687 if (!originalValues.has(key)) {
688 originalValues.set(key, process.env[key]);
689 }
690 if (value === undefined) {
691 delete process.env[key];
692 return;
693 }
694 process.env[key] = value;
695 };
696 const restoreEnv = () => {
697 for (const [key, value] of originalValues) {
698 if (value === undefined) {
699 delete process.env[key];
700 } else {
701 process.env[key] = value;
702 }
703 }
704 };
705
706 setEnv("PORT", "5173");
707 setEnv("T3CODE_PORT", "3773");
708 setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173");
709 setEnv("TEST_TERMINAL_KEEP", "keep-me");
901 "1200 millis",
902 );
903 }),
904 );
710905
711 try {
712 const { manager, ptyAdapter } = makeManager();
713 await manager.open(openInput());
714 const spawnInput = ptyAdapter.spawnInputs[0];
715 expect(spawnInput).toBeDefined();
716 if (!spawnInput) return;
906 it.effect("preserves queued PTY output ordering through exit callbacks", () =>
907 Effect.gen(function* () {
908 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
909 ptyAdapter: new FakePtyAdapter("async"),
910 });
717911
718 expect(spawnInput.env.PORT).toBeUndefined();
719 expect(spawnInput.env.T3CODE_PORT).toBeUndefined();
720 expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined();
721 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");
912 yield* manager.open(openInput());
913 const process = ptyAdapter.processes[0];
914 expect(process).toBeDefined();
915 if (!process) return;
916
917 process.emitData("first\n");
918 process.emitData("second\n");
919 process.emitExit({ exitCode: 0, signal: 0 });
920
921 yield* waitFor(
922 Effect.map(getEvents, (events) => {
923 const relevant = events.filter(
924 (event) => event.type === "output" || event.type === "exited",
925 );
926 return relevant.length >= 3;
927 }),
928 "1200 millis",
929 );
722930
723 manager.dispose();
724 } finally {
725 restoreEnv();
726 }
727 });
728
729 it("injects runtime env overrides into spawned terminals", async () => {
730 const { manager, ptyAdapter } = makeManager();
731 await manager.open(
732 openInput({
733 env: {
734 T3CODE_PROJECT_ROOT: "/repo",
735 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
736 CUSTOM_FLAG: "1",
737 },
738 }),
739 );
740 const spawnInput = ptyAdapter.spawnInputs[0];
741 expect(spawnInput).toBeDefined();
742 if (!spawnInput) return;
743
744 expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo");
745 expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a");
746 expect(spawnInput.env.CUSTOM_FLAG).toBe("1");
747
748 manager.dispose();
749 });
750
751 it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => {
752 if (process.platform === "win32") return;
753 const { manager, ptyAdapter } = makeManager(5, {
754 shellResolver: () => "/bin/zsh",
755 });
756 await manager.open(openInput());
757 const spawnInput = ptyAdapter.spawnInputs[0];
758 expect(spawnInput).toBeDefined();
759 if (!spawnInput) return;
760
761 expect(spawnInput.shell).toBe("/bin/zsh");
762 expect(spawnInput.args).toEqual(["-o", "nopromptsp"]);
763
764 manager.dispose();
765 });
931 const relevant = (yield* getEvents).filter(
932 (event) => event.type === "output" || event.type === "exited",
933 );
934 expect(relevant).toEqual([
935 expect.objectContaining({ type: "output", data: "first\n" }),
936 expect.objectContaining({ type: "output", data: "second\n" }),
937 expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0 }),
938 ]);
939 }),
940 );
941
942 it.effect("scoped runtime shutdown stops active terminals cleanly", () =>
943 Effect.gen(function* () {
944 const scope = yield* Scope.make("sequential");
945 const { manager, ptyAdapter } = yield* createManager(5, {
946 processKillGraceMs: 10,
947 }).pipe(Effect.provideService(Scope.Scope, scope));
948 yield* manager.open(openInput());
949 const process = ptyAdapter.processes[0];
950 expect(process).toBeDefined();
951 if (!process) return;
952
953 const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped);
954 yield* Effect.yieldNow;
955 yield* TestClock.adjust("10 millis");
956 yield* Fiber.join(closeScope);
957
958 assert.equal(process.killSignals[0], "SIGTERM");
959 expect(process.killSignals).toContain("SIGKILL");
960 }).pipe(Effect.provide(TestClock.layer())),
961 );
766962});
apps/server/src/terminal/Layers/Manager.ts+1305-883
......@@ -1,32 +1,44 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
31import path from "node:path";
42
53import {
64 DEFAULT_TERMINAL_ID,
7 TerminalClearInput,
8 TerminalCloseInput,
9 TerminalOpenInput,
10 TerminalResizeInput,
11 TerminalRestartInput,
12 TerminalWriteInput,
135 type TerminalEvent,
146 type TerminalSessionSnapshot,
7 type TerminalSessionStatus,
158} from "@t3tools/contracts";
16import { Effect, Encoding, Layer, Schema } from "effect";
9import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
10import {
11 Data,
12 Effect,
13 Encoding,
14 Equal,
15 Exit,
16 Fiber,
17 FileSystem,
18 Layer,
19 Option,
20 Scope,
21 Semaphore,
22 SynchronizedRef,
23} from "effect";
1724
18import { createLogger } from "../../logger";
19import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";
20import { runProcess } from "../../processRunner";
2125import { ServerConfig } from "../../config";
26import { runProcess } from "../../processRunner";
2227import {
23 ShellCandidate,
24 TerminalError,
28 TerminalCwdError,
29 TerminalHistoryError,
2530 TerminalManager,
26 TerminalManagerShape,
27 TerminalSessionState,
28 TerminalStartInput,
31 TerminalNotRunningError,
32 TerminalSessionLookupError,
33 type TerminalManagerShape,
2934} from "../Services/Manager";
35import {
36 PtyAdapter,
37 PtySpawnError,
38 type PtyAdapterShape,
39 type PtyExitEvent,
40 type PtyProcess,
41} from "../Services/PTY";
3042
3143const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
3244const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
......@@ -37,14 +49,128 @@ const DEFAULT_OPEN_COLS = 120;
3749const DEFAULT_OPEN_ROWS = 30;
3850const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
3951
40const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput);
41const decodeTerminalRestartInput = Schema.decodeUnknownSync(TerminalRestartInput);
42const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);
43const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);
44const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);
45const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);
52type TerminalSubprocessChecker = (
53 terminalPid: number,
54) => Effect.Effect<boolean, TerminalSubprocessCheckError>;
55
56class TerminalSubprocessCheckError extends Data.TaggedError("TerminalSubprocessCheckError")<{
57 readonly message: string;
58 readonly cause?: unknown;
59 readonly terminalPid: number;
60 readonly command: "powershell" | "pgrep" | "ps";
61}> {}
62
63class TerminalProcessSignalError extends Data.TaggedError("TerminalProcessSignalError")<{
64 readonly message: string;
65 readonly cause?: unknown;
66 readonly signal: "SIGTERM" | "SIGKILL";
67}> {}
68
69interface ShellCandidate {
70 shell: string;
71 args?: string[];
72}
73
74interface TerminalStartInput {
75 threadId: string;
76 terminalId: string;
77 cwd: string;
78 cols: number;
79 rows: number;
80 env?: Record<string, string>;
81}
82
83interface TerminalSessionState {
84 threadId: string;
85 terminalId: string;
86 cwd: string;
87 status: TerminalSessionStatus;
88 pid: number | null;
89 history: string;
90 pendingHistoryControlSequence: string;
91 pendingProcessEvents: Array<PendingProcessEvent>;
92 pendingProcessEventIndex: number;
93 processEventDrainRunning: boolean;
94 exitCode: number | null;
95 exitSignal: number | null;
96 updatedAt: string;
97 cols: number;
98 rows: number;
99 process: PtyProcess | null;
100 unsubscribeData: (() => void) | null;
101 unsubscribeExit: (() => void) | null;
102 hasRunningSubprocess: boolean;
103 runtimeEnv: Record<string, string> | null;
104}
105
106interface PersistHistoryRequest {
107 history: string;
108 immediate: boolean;
109}
110
111type PendingProcessEvent = { type: "output"; data: string } | { type: "exit"; event: PtyExitEvent };
112
113type DrainProcessEventAction =
114 | { type: "idle" }
115 | {
116 type: "output";
117 threadId: string;
118 terminalId: string;
119 history: string | null;
120 data: string;
121 }
122 | {
123 type: "exit";
124 process: PtyProcess | null;
125 threadId: string;
126 terminalId: string;
127 exitCode: number | null;
128 exitSignal: number | null;
129 };
130
131interface TerminalManagerState {
132 sessions: Map<string, TerminalSessionState>;
133 killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;
134}
135
136function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
137 return {
138 threadId: session.threadId,
139 terminalId: session.terminalId,
140 cwd: session.cwd,
141 status: session.status,
142 pid: session.pid,
143 history: session.history,
144 exitCode: session.exitCode,
145 exitSignal: session.exitSignal,
146 updatedAt: session.updatedAt,
147 };
148}
149
150function cleanupProcessHandles(session: TerminalSessionState): void {
151 session.unsubscribeData?.();
152 session.unsubscribeData = null;
153 session.unsubscribeExit?.();
154 session.unsubscribeExit = null;
155}
156
157function enqueueProcessEvent(
158 session: TerminalSessionState,
159 expectedPid: number,
160 event: PendingProcessEvent,
161): boolean {
162 if (!session.process || session.status !== "running" || session.pid !== expectedPid) {
163 return false;
164 }
165
166 session.pendingProcessEvents.push(event);
167 if (session.processEventDrainRunning) {
168 return false;
169 }
46170
47type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
171 session.processEventDrainRunning = true;
172 return true;
173}
48174
49175function defaultShellResolver(): string {
50176 if (process.platform === "win32") {
......@@ -118,7 +244,7 @@ function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {
118244 ]);
119245}
120246
121function isRetryableShellSpawnError(error: unknown): boolean {
247function isRetryableShellSpawnError(error: PtySpawnError): boolean {
122248 const queue: unknown[] = [error];
123249 const seen = new Set<unknown>();
124250 const messages: string[] = [];
......@@ -165,82 +291,107 @@ function isRetryableShellSpawnError(error: unknown): boolean {
165291 );
166292}
167293
168async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> {
294function checkWindowsSubprocessActivity(
295 terminalPid: number,
296): Effect.Effect<boolean, TerminalSubprocessCheckError> {
169297 const command = [
170298 `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,
171299 "if ($children) { exit 0 }",
172300 "exit 1",
173301 ].join("; ");
174 try {
175 const result = await runProcess(
176 "powershell.exe",
177 ["-NoProfile", "-NonInteractive", "-Command", command],
178 {
302 return Effect.tryPromise({
303 try: () =>
304 runProcess("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
179305 timeoutMs: 1_500,
180306 allowNonZeroExit: true,
181307 maxBufferBytes: 32_768,
182308 outputMode: "truncate",
183 },
184 );
185 return result.code === 0;
186 } catch {
187 return false;
188 }
309 }),
310 catch: (cause) =>
311 new TerminalSubprocessCheckError({
312 message: "Failed to check Windows terminal subprocess activity.",
313 cause,
314 terminalPid,
315 command: "powershell",
316 }),
317 }).pipe(Effect.map((result) => result.code === 0));
189318}
190319
191async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> {
192 try {
193 const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], {
194 timeoutMs: 1_000,
195 allowNonZeroExit: true,
196 maxBufferBytes: 32_768,
197 outputMode: "truncate",
198 });
199 if (pgrepResult.code === 0) {
200 return pgrepResult.stdout.trim().length > 0;
320const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (
321 terminalPid: number,
322): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {
323 const runPgrep = Effect.tryPromise({
324 try: () =>
325 runProcess("pgrep", ["-P", String(terminalPid)], {
326 timeoutMs: 1_000,
327 allowNonZeroExit: true,
328 maxBufferBytes: 32_768,
329 outputMode: "truncate",
330 }),
331 catch: (cause) =>
332 new TerminalSubprocessCheckError({
333 message: "Failed to inspect terminal subprocesses with pgrep.",
334 cause,
335 terminalPid,
336 command: "pgrep",
337 }),
338 });
339
340 const runPs = Effect.tryPromise({
341 try: () =>
342 runProcess("ps", ["-eo", "pid=,ppid="], {
343 timeoutMs: 1_000,
344 allowNonZeroExit: true,
345 maxBufferBytes: 262_144,
346 outputMode: "truncate",
347 }),
348 catch: (cause) =>
349 new TerminalSubprocessCheckError({
350 message: "Failed to inspect terminal subprocesses with ps.",
351 cause,
352 terminalPid,
353 command: "ps",
354 }),
355 });
356
357 const pgrepResult = yield* Effect.exit(runPgrep);
358 if (pgrepResult._tag === "Success") {
359 if (pgrepResult.value.code === 0) {
360 return pgrepResult.value.stdout.trim().length > 0;
201361 }
202 if (pgrepResult.code === 1) {
362 if (pgrepResult.value.code === 1) {
203363 return false;
204364 }
205 } catch {
206 // Fall back to ps when pgrep is unavailable.
207365 }
208366
209 try {
210 const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], {
211 timeoutMs: 1_000,
212 allowNonZeroExit: true,
213 maxBufferBytes: 262_144,
214 outputMode: "truncate",
215 });
216 if (psResult.code !== 0) {
217 return false;
218 }
367 const psResult = yield* Effect.exit(runPs);
368 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
369 return false;
370 }
219371
220 for (const line of psResult.stdout.split(/\r?\n/g)) {
221 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
222 const pid = Number(pidRaw);
223 const ppid = Number(ppidRaw);
224 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
225 if (ppid === terminalPid) {
226 return true;
227 }
372 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
373 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
374 const pid = Number(pidRaw);
375 const ppid = Number(ppidRaw);
376 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
377 if (ppid === terminalPid) {
378 return true;
228379 }
229 return false;
230 } catch {
231 return false;
232380 }
233}
381 return false;
382});
234383
235async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> {
384const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* (
385 terminalPid: number,
386): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {
236387 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
237388 return false;
238389 }
239390 if (process.platform === "win32") {
240 return checkWindowsSubprocessActivity(terminalPid);
391 return yield* checkWindowsSubprocessActivity(terminalPid);
241392 }
242 return checkPosixSubprocessActivity(terminalPid);
243}
393 return yield* checkPosixSubprocessActivity(terminalPid);
394});
244395
245396function capHistory(history: string, maxLines: number): string {
246397 if (history.length === 0) return history;
......@@ -482,12 +633,8 @@ function normalizedRuntimeEnv(
482633 return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right)));
483634}
484635
485interface TerminalManagerEvents {
486 event: [event: TerminalEvent];
487}
488
489636interface TerminalManagerOptions {
490 logsDir?: string;
637 logsDir: string;
491638 historyLineLimit?: number;
492639 ptyAdapter: PtyAdapterShape;
493640 shellResolver?: () => string;
......@@ -497,917 +644,1192 @@ interface TerminalManagerOptions {
497644 maxRetainedInactiveSessions?: number;
498645}
499646
500export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> {
501 private readonly sessions = new Map<string, TerminalSessionState>();
502 private readonly logsDir: string;
503 private readonly historyLineLimit: number;
504 private readonly ptyAdapter: PtyAdapterShape;
505 private readonly shellResolver: () => string;
506 private readonly persistQueues = new Map<string, Promise<void>>();
507 private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
508 private readonly pendingPersistHistory = new Map<string, string>();
509 private readonly threadLocks = new Map<string, Promise<void>>();
510 private readonly persistDebounceMs: number;
511 private readonly subprocessChecker: TerminalSubprocessChecker;
512 private readonly subprocessPollIntervalMs: number;
513 private readonly processKillGraceMs: number;
514 private readonly maxRetainedInactiveSessions: number;
515 private subprocessPollTimer: ReturnType<typeof setInterval> | null = null;
516 private subprocessPollInFlight = false;
517 private readonly killEscalationTimers = new Map<PtyProcess, ReturnType<typeof setTimeout>>();
518 private readonly logger = createLogger("terminal");
519
520 constructor(options: TerminalManagerOptions) {
521 super();
522 this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals");
523 this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
524 this.ptyAdapter = options.ptyAdapter;
525 this.shellResolver = options.shellResolver ?? defaultShellResolver;
526 this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS;
527 this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
528 this.subprocessPollIntervalMs =
647const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () {
648 const { terminalLogsDir } = yield* ServerConfig;
649 const ptyAdapter = yield* PtyAdapter;
650 return yield* makeTerminalManagerWithOptions({
651 logsDir: terminalLogsDir,
652 ptyAdapter,
653 });
654});
655
656export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")(
657 function* (options: TerminalManagerOptions) {
658 const fileSystem = yield* FileSystem.FileSystem;
659 const services = yield* Effect.services();
660 const runFork = Effect.runForkWith(services);
661
662 const logsDir = options.logsDir;
663 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
664 const shellResolver = options.shellResolver ?? defaultShellResolver;
665 const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
666 const subprocessPollIntervalMs =
529667 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
530 this.processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
531 this.maxRetainedInactiveSessions =
668 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
669 const maxRetainedInactiveSessions =
532670 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;
533 fs.mkdirSync(this.logsDir, { recursive: true });
534 }
535671
536 async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
537 const input = decodeTerminalOpenInput(raw);
538 return this.runWithThreadLock(input.threadId, async () => {
539 await this.assertValidCwd(input.cwd);
540
541 const sessionKey = toSessionKey(input.threadId, input.terminalId);
542 const existing = this.sessions.get(sessionKey);
543 if (!existing) {
544 await this.flushPersistQueue(input.threadId, input.terminalId);
545 const history = await this.readHistory(input.threadId, input.terminalId);
546 const cols = input.cols ?? DEFAULT_OPEN_COLS;
547 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
548 const session: TerminalSessionState = {
549 threadId: input.threadId,
550 terminalId: input.terminalId,
551 cwd: input.cwd,
552 status: "starting",
553 pid: null,
554 history,
555 pendingHistoryControlSequence: "",
556 exitCode: null,
557 exitSignal: null,
558 updatedAt: new Date().toISOString(),
559 cols,
560 rows,
561 process: null,
562 unsubscribeData: null,
563 unsubscribeExit: null,
564 hasRunningSubprocess: false,
565 runtimeEnv: normalizedRuntimeEnv(input.env),
566 };
567 this.sessions.set(sessionKey, session);
568 this.evictInactiveSessionsIfNeeded();
569 await this.startSession(session, { ...input, cols, rows }, "started");
570 return this.snapshot(session);
571 }
672 yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie);
572673
573 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
574 const currentRuntimeEnv = existing.runtimeEnv;
575 const targetCols = input.cols ?? existing.cols;
576 const targetRows = input.rows ?? existing.rows;
577 const runtimeEnvChanged =
578 JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv);
579
580 if (existing.cwd !== input.cwd || runtimeEnvChanged) {
581 this.stopProcess(existing);
582 existing.cwd = input.cwd;
583 existing.runtimeEnv = nextRuntimeEnv;
584 existing.history = "";
585 existing.pendingHistoryControlSequence = "";
586 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
587 } else if (existing.status === "exited" || existing.status === "error") {
588 existing.runtimeEnv = nextRuntimeEnv;
589 existing.history = "";
590 existing.pendingHistoryControlSequence = "";
591 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
592 } else if (currentRuntimeEnv !== nextRuntimeEnv) {
593 existing.runtimeEnv = nextRuntimeEnv;
674 const managerStateRef = yield* SynchronizedRef.make<TerminalManagerState>({
675 sessions: new Map(),
676 killFibers: new Map(),
677 });
678 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
679 const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
680 const workerScope = yield* Scope.make("sequential");
681 yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void));
682
683 const publishEvent = (event: TerminalEvent) =>
684 Effect.gen(function* () {
685 for (const listener of terminalEventListeners) {
686 yield* listener(event).pipe(Effect.ignoreCause({ log: true }));
687 }
688 });
689
690 const historyPath = (threadId: string, terminalId: string) => {
691 const threadPart = toSafeThreadId(threadId);
692 if (terminalId === DEFAULT_TERMINAL_ID) {
693 return path.join(logsDir, `${threadPart}.log`);
594694 }
695 return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`);
696 };
697
698 const legacyHistoryPath = (threadId: string) =>
699 path.join(logsDir, `${legacySafeThreadId(threadId)}.log`);
595700
596 if (!existing.process) {
597 await this.startSession(
598 existing,
599 { ...input, cols: targetCols, rows: targetRows },
600 "started",
701 const toTerminalHistoryError =
702 (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) =>
703 (cause: unknown) =>
704 new TerminalHistoryError({
705 operation,
706 threadId,
707 terminalId,
708 cause,
709 });
710
711 const readManagerState = SynchronizedRef.get(managerStateRef);
712
713 const modifyManagerState = <A>(
714 f: (state: TerminalManagerState) => readonly [A, TerminalManagerState],
715 ) => SynchronizedRef.modify(managerStateRef, f);
716
717 const getThreadSemaphore = (threadId: string) =>
718 SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
719 const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr(
720 current.get(threadId),
601721 );
602 return this.snapshot(existing);
603 }
722 return Option.match(existing, {
723 onNone: () =>
724 Semaphore.make(1).pipe(
725 Effect.map((semaphore) => {
726 const next = new Map(current);
727 next.set(threadId, semaphore);
728 return [semaphore, next] as const;
729 }),
730 ),
731 onSome: (semaphore) => Effect.succeed([semaphore, current] as const),
732 });
733 });
604734
605 if (existing.cols !== targetCols || existing.rows !== targetRows) {
606 existing.cols = targetCols;
607 existing.rows = targetRows;
608 existing.process.resize(targetCols, targetRows);
609 existing.updatedAt = new Date().toISOString();
735 const withThreadLock = <A, E, R>(
736 threadId: string,
737 effect: Effect.Effect<A, E, R>,
738 ): Effect.Effect<A, E, R> =>
739 Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
740
741 const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* (
742 process: PtyProcess | null,
743 ) {
744 if (!process) return;
745 const fiber: Option.Option<Fiber.Fiber<void, never>> = yield* modifyManagerState<
746 Option.Option<Fiber.Fiber<void, never>>
747 >((state) => {
748 const existing: Option.Option<Fiber.Fiber<void, never>> = Option.fromNullishOr(
749 state.killFibers.get(process),
750 );
751 if (Option.isNone(existing)) {
752 return [Option.none<Fiber.Fiber<void, never>>(), state] as const;
753 }
754 const killFibers = new Map(state.killFibers);
755 killFibers.delete(process);
756 return [existing, { ...state, killFibers }] as const;
757 });
758 if (Option.isSome(fiber)) {
759 yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore);
610760 }
761 });
611762
612 return this.snapshot(existing);
763 const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* (
764 process: PtyProcess,
765 fiber: Fiber.Fiber<void, never>,
766 ) {
767 yield* modifyManagerState((state) => {
768 const killFibers = new Map(state.killFibers);
769 killFibers.set(process, fiber);
770 return [undefined, { ...state, killFibers }] as const;
771 });
613772 });
614 }
615773
616 async write(raw: TerminalWriteInput): Promise<void> {
617 const input = decodeTerminalWriteInput(raw);
618 const session = this.requireSession(input.threadId, input.terminalId);
619 if (!session.process || session.status !== "running") {
620 if (session.status === "exited") {
774 const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* (
775 process: PtyProcess,
776 threadId: string,
777 terminalId: string,
778 ) {
779 const terminated = yield* Effect.try({
780 try: () => process.kill("SIGTERM"),
781 catch: (cause) =>
782 new TerminalProcessSignalError({
783 message: "Failed to send SIGTERM to terminal process.",
784 cause,
785 signal: "SIGTERM",
786 }),
787 }).pipe(
788 Effect.as(true),
789 Effect.catch((error) =>
790 Effect.logWarning("failed to kill terminal process", {
791 threadId,
792 terminalId,
793 signal: "SIGTERM",
794 error: error.message,
795 }).pipe(Effect.as(false)),
796 ),
797 );
798 if (!terminated) {
621799 return;
622800 }
623 throw new Error(
624 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,
801
802 yield* Effect.sleep(processKillGraceMs);
803
804 yield* Effect.try({
805 try: () => process.kill("SIGKILL"),
806 catch: (cause) =>
807 new TerminalProcessSignalError({
808 message: "Failed to send SIGKILL to terminal process.",
809 cause,
810 signal: "SIGKILL",
811 }),
812 }).pipe(
813 Effect.catch((error) =>
814 Effect.logWarning("failed to force-kill terminal process", {
815 threadId,
816 terminalId,
817 signal: "SIGKILL",
818 error: error.message,
819 }),
820 ),
625821 );
626 }
627 session.process.write(input.data);
628 }
822 });
629823
630 async resize(raw: TerminalResizeInput): Promise<void> {
631 const input = decodeTerminalResizeInput(raw);
632 const session = this.requireSession(input.threadId, input.terminalId);
633 if (!session.process || session.status !== "running") {
634 throw new Error(
635 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,
824 const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* (
825 process: PtyProcess,
826 threadId: string,
827 terminalId: string,
828 ) {
829 const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe(
830 Effect.ensuring(
831 modifyManagerState((state) => {
832 if (!state.killFibers.has(process)) {
833 return [undefined, state] as const;
834 }
835 const killFibers = new Map(state.killFibers);
836 killFibers.delete(process);
837 return [undefined, { ...state, killFibers }] as const;
838 }),
839 ),
840 Effect.forkIn(workerScope),
636841 );
637 }
638 session.cols = input.cols;
639 session.rows = input.rows;
640 session.updatedAt = new Date().toISOString();
641 session.process.resize(input.cols, input.rows);
642 }
643842
644 async clear(raw: TerminalClearInput): Promise<void> {
645 const input = decodeTerminalClearInput(raw);
646 await this.runWithThreadLock(input.threadId, async () => {
647 const session = this.requireSession(input.threadId, input.terminalId);
648 session.history = "";
649 session.pendingHistoryControlSequence = "";
650 session.updatedAt = new Date().toISOString();
651 await this.persistHistory(input.threadId, input.terminalId, session.history);
652 this.emitEvent({
653 type: "cleared",
654 threadId: input.threadId,
655 terminalId: input.terminalId,
656 createdAt: new Date().toISOString(),
843 yield* registerKillFiber(process, fiber);
844 });
845
846 const persistWorker = yield* makeKeyedCoalescingWorker<
847 string,
848 PersistHistoryRequest,
849 never,
850 never
851 >({
852 merge: (current, next) => ({
853 history: next.history,
854 immediate: current.immediate || next.immediate,
855 }),
856 process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) {
857 if (!request.immediate) {
858 yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS);
859 }
860
861 const [threadId, terminalId] = sessionKey.split("\u0000");
862 if (!threadId || !terminalId) {
863 return;
864 }
865
866 yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe(
867 Effect.catch((error) =>
868 Effect.logWarning("failed to persist terminal history", {
869 threadId,
870 terminalId,
871 error: error instanceof Error ? error.message : String(error),
872 }),
873 ),
874 );
875 }),
876 });
877
878 const queuePersist = Effect.fn("terminal.queuePersist")(function* (
879 threadId: string,
880 terminalId: string,
881 history: string,
882 ) {
883 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
884 history,
885 immediate: false,
657886 });
658887 });
659 }
660888
661 async restart(raw: TerminalRestartInput): Promise<TerminalSessionSnapshot> {
662 const input = decodeTerminalRestartInput(raw);
663 return this.runWithThreadLock(input.threadId, async () => {
664 await this.assertValidCwd(input.cwd);
665
666 const sessionKey = toSessionKey(input.threadId, input.terminalId);
667 let session = this.sessions.get(sessionKey);
668 if (!session) {
669 const cols = input.cols ?? DEFAULT_OPEN_COLS;
670 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
671 session = {
672 threadId: input.threadId,
673 terminalId: input.terminalId,
674 cwd: input.cwd,
675 status: "starting",
676 pid: null,
677 history: "",
678 pendingHistoryControlSequence: "",
679 exitCode: null,
680 exitSignal: null,
681 updatedAt: new Date().toISOString(),
682 cols,
683 rows,
684 process: null,
685 unsubscribeData: null,
686 unsubscribeExit: null,
687 hasRunningSubprocess: false,
688 runtimeEnv: normalizedRuntimeEnv(input.env),
689 };
690 this.sessions.set(sessionKey, session);
691 this.evictInactiveSessionsIfNeeded();
692 } else {
693 this.stopProcess(session);
694 session.cwd = input.cwd;
695 session.runtimeEnv = normalizedRuntimeEnv(input.env);
889 const flushPersist = Effect.fn("terminal.flushPersist")(function* (
890 threadId: string,
891 terminalId: string,
892 ) {
893 yield* persistWorker.drainKey(toSessionKey(threadId, terminalId));
894 });
895
896 const persistHistory = Effect.fn("terminal.persistHistory")(function* (
897 threadId: string,
898 terminalId: string,
899 history: string,
900 ) {
901 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
902 history,
903 immediate: true,
904 });
905 yield* flushPersist(threadId, terminalId);
906 });
907
908 const readHistory = Effect.fn("terminal.readHistory")(function* (
909 threadId: string,
910 terminalId: string,
911 ) {
912 const nextPath = historyPath(threadId, terminalId);
913 if (
914 yield* fileSystem
915 .exists(nextPath)
916 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)))
917 ) {
918 const raw = yield* fileSystem
919 .readFileString(nextPath)
920 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)));
921 const capped = capHistory(raw, historyLineLimit);
922 if (capped !== raw) {
923 yield* fileSystem
924 .writeFileString(nextPath, capped)
925 .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId)));
926 }
927 return capped;
928 }
929
930 if (terminalId !== DEFAULT_TERMINAL_ID) {
931 return "";
696932 }
697933
698 const cols = input.cols ?? session.cols;
699 const rows = input.rows ?? session.rows;
934 const legacyPath = legacyHistoryPath(threadId);
935 if (
936 !(yield* fileSystem
937 .exists(legacyPath)
938 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))))
939 ) {
940 return "";
941 }
700942
701 session.history = "";
702 session.pendingHistoryControlSequence = "";
703 await this.persistHistory(input.threadId, input.terminalId, session.history);
704 await this.startSession(session, { ...input, cols, rows }, "restarted");
705 return this.snapshot(session);
943 const raw = yield* fileSystem
944 .readFileString(legacyPath)
945 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
946 const capped = capHistory(raw, historyLineLimit);
947 yield* fileSystem
948 .writeFileString(nextPath, capped)
949 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
950 yield* fileSystem.remove(legacyPath, { force: true }).pipe(
951 Effect.catch((cleanupError) =>
952 Effect.logWarning("failed to remove legacy terminal history", {
953 threadId,
954 error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
955 }),
956 ),
957 );
958 return capped;
706959 });
707 }
708960
709 async close(raw: TerminalCloseInput): Promise<void> {
710 const input = decodeTerminalCloseInput(raw);
711 await this.runWithThreadLock(input.threadId, async () => {
712 if (input.terminalId) {
713 await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true);
714 return;
961 const deleteHistory = Effect.fn("terminal.deleteHistory")(function* (
962 threadId: string,
963 terminalId: string,
964 ) {
965 yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe(
966 Effect.catch((error) =>
967 Effect.logWarning("failed to delete terminal history", {
968 threadId,
969 terminalId,
970 error: error instanceof Error ? error.message : String(error),
971 }),
972 ),
973 );
974 if (terminalId === DEFAULT_TERMINAL_ID) {
975 yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe(
976 Effect.catch((error) =>
977 Effect.logWarning("failed to delete terminal history", {
978 threadId,
979 terminalId,
980 error: error instanceof Error ? error.message : String(error),
981 }),
982 ),
983 );
715984 }
985 });
716986
717 const threadSessions = this.sessionsForThread(input.threadId);
718 for (const session of threadSessions) {
719 this.stopProcess(session);
720 this.sessions.delete(toSessionKey(session.threadId, session.terminalId));
721 }
722 await Promise.all(
723 threadSessions.map((session) =>
724 this.flushPersistQueue(session.threadId, session.terminalId),
987 const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* (
988 threadId: string,
989 ) {
990 const threadPrefix = `${toSafeThreadId(threadId)}_`;
991 const entries = yield* fileSystem
992 .readDirectory(logsDir, { recursive: false })
993 .pipe(Effect.catch(() => Effect.succeed([] as Array<string>)));
994 yield* Effect.forEach(
995 entries.filter(
996 (name) =>
997 name === `${toSafeThreadId(threadId)}.log` ||
998 name === `${legacySafeThreadId(threadId)}.log` ||
999 name.startsWith(threadPrefix),
7251000 ),
1001 (name) =>
1002 fileSystem.remove(path.join(logsDir, name), { force: true }).pipe(
1003 Effect.catch((error) =>
1004 Effect.logWarning("failed to delete terminal histories for thread", {
1005 threadId,
1006 error: error instanceof Error ? error.message : String(error),
1007 }),
1008 ),
1009 ),
1010 { discard: true },
7261011 );
1012 });
7271013
728 if (input.deleteHistory) {
729 await this.deleteAllHistoryForThread(input.threadId);
1014 const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) {
1015 const stats = yield* fileSystem.stat(cwd).pipe(
1016 Effect.mapError(
1017 (cause) =>
1018 new TerminalCwdError({
1019 cwd,
1020 reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed",
1021 cause,
1022 }),
1023 ),
1024 );
1025 if (stats.type !== "Directory") {
1026 return yield* new TerminalCwdError({
1027 cwd,
1028 reason: "notDirectory",
1029 });
7301030 }
731 this.updateSubprocessPollingState();
7321031 });
733 }
7341032
735 dispose(): void {
736 this.stopSubprocessPolling();
737 const sessions = [...this.sessions.values()];
738 this.sessions.clear();
739 for (const session of sessions) {
740 this.stopProcess(session);
741 }
742 for (const timer of this.persistTimers.values()) {
743 clearTimeout(timer);
744 }
745 this.persistTimers.clear();
746 for (const timer of this.killEscalationTimers.values()) {
747 clearTimeout(timer);
748 }
749 this.killEscalationTimers.clear();
750 this.pendingPersistHistory.clear();
751 this.threadLocks.clear();
752 this.persistQueues.clear();
753 }
1033 const getSession = Effect.fn("terminal.getSession")(function* (
1034 threadId: string,
1035 terminalId: string,
1036 ): Effect.fn.Return<Option.Option<TerminalSessionState>> {
1037 return yield* Effect.map(readManagerState, (state) =>
1038 Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))),
1039 );
1040 });
7541041
755 private async startSession(
756 session: TerminalSessionState,
757 input: TerminalStartInput,
758 eventType: "started" | "restarted",
759 ): Promise<void> {
760 this.stopProcess(session);
761
762 session.status = "starting";
763 session.cwd = input.cwd;
764 session.cols = input.cols;
765 session.rows = input.rows;
766 session.exitCode = null;
767 session.exitSignal = null;
768 session.hasRunningSubprocess = false;
769 session.updatedAt = new Date().toISOString();
770
771 let ptyProcess: PtyProcess | null = null;
772 let startedShell: string | null = null;
773 try {
774 const shellCandidates = resolveShellCandidates(this.shellResolver);
775 const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv);
776 let lastSpawnError: unknown = null;
777
778 const spawnWithCandidate = (candidate: ShellCandidate) =>
779 Effect.runPromise(
780 this.ptyAdapter.spawn({
781 shell: candidate.shell,
782 ...(candidate.args ? { args: candidate.args } : {}),
783 cwd: session.cwd,
784 cols: session.cols,
785 rows: session.rows,
786 env: terminalEnv,
787 }),
788 );
1042 const requireSession = Effect.fn("terminal.requireSession")(function* (
1043 threadId: string,
1044 terminalId: string,
1045 ): Effect.fn.Return<TerminalSessionState, TerminalSessionLookupError> {
1046 return yield* Effect.flatMap(getSession(threadId, terminalId), (session) =>
1047 Option.match(session, {
1048 onNone: () =>
1049 Effect.fail(
1050 new TerminalSessionLookupError({
1051 threadId,
1052 terminalId,
1053 }),
1054 ),
1055 onSome: Effect.succeed,
1056 }),
1057 );
1058 });
7891059
790 const trySpawn = async (
791 candidates: ShellCandidate[],
792 index = 0,
793 ): Promise<{ process: PtyProcess; shellLabel: string } | null> => {
794 if (index >= candidates.length) {
795 return null;
796 }
797 const candidate = candidates[index];
798 if (!candidate) {
799 return null;
1060 const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) {
1061 return yield* readManagerState.pipe(
1062 Effect.map((state) =>
1063 [...state.sessions.values()].filter((session) => session.threadId === threadId),
1064 ),
1065 );
1066 });
1067
1068 const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")(
1069 function* () {
1070 yield* modifyManagerState((state) => {
1071 const inactiveSessions = [...state.sessions.values()].filter(
1072 (session) => session.status !== "running",
1073 );
1074 if (inactiveSessions.length <= maxRetainedInactiveSessions) {
1075 return [undefined, state] as const;
1076 }
1077
1078 inactiveSessions.sort(
1079 (left, right) =>
1080 left.updatedAt.localeCompare(right.updatedAt) ||
1081 left.threadId.localeCompare(right.threadId) ||
1082 left.terminalId.localeCompare(right.terminalId),
1083 );
1084
1085 const sessions = new Map(state.sessions);
1086
1087 const toEvict = inactiveSessions.length - maxRetainedInactiveSessions;
1088 for (const session of inactiveSessions.slice(0, toEvict)) {
1089 const key = toSessionKey(session.threadId, session.terminalId);
1090 sessions.delete(key);
1091 }
1092
1093 return [undefined, { ...state, sessions }] as const;
1094 });
1095 },
1096 );
1097
1098 const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* (
1099 session: TerminalSessionState,
1100 expectedPid: number,
1101 ) {
1102 while (true) {
1103 const action: DrainProcessEventAction = yield* Effect.sync(() => {
1104 if (session.pid !== expectedPid || !session.process || session.status !== "running") {
1105 session.pendingProcessEvents = [];
1106 session.pendingProcessEventIndex = 0;
1107 session.processEventDrainRunning = false;
1108 return { type: "idle" } as const;
1109 }
1110
1111 const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex];
1112 if (!nextEvent) {
1113 session.pendingProcessEvents = [];
1114 session.pendingProcessEventIndex = 0;
1115 session.processEventDrainRunning = false;
1116 return { type: "idle" } as const;
1117 }
1118
1119 session.pendingProcessEventIndex += 1;
1120 if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) {
1121 session.pendingProcessEvents = [];
1122 session.pendingProcessEventIndex = 0;
1123 }
1124
1125 if (nextEvent.type === "output") {
1126 const sanitized = sanitizeTerminalHistoryChunk(
1127 session.pendingHistoryControlSequence,
1128 nextEvent.data,
1129 );
1130 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
1131 if (sanitized.visibleText.length > 0) {
1132 session.history = capHistory(
1133 `${session.history}${sanitized.visibleText}`,
1134 historyLineLimit,
1135 );
1136 }
1137 session.updatedAt = new Date().toISOString();
1138
1139 return {
1140 type: "output",
1141 threadId: session.threadId,
1142 terminalId: session.terminalId,
1143 history: sanitized.visibleText.length > 0 ? session.history : null,
1144 data: nextEvent.data,
1145 } as const;
1146 }
1147
1148 const process = session.process;
1149 cleanupProcessHandles(session);
1150 session.process = null;
1151 session.pid = null;
1152 session.hasRunningSubprocess = false;
1153 session.status = "exited";
1154 session.pendingHistoryControlSequence = "";
1155 session.pendingProcessEvents = [];
1156 session.pendingProcessEventIndex = 0;
1157 session.processEventDrainRunning = false;
1158 session.exitCode = Number.isInteger(nextEvent.event.exitCode)
1159 ? nextEvent.event.exitCode
1160 : null;
1161 session.exitSignal = Number.isInteger(nextEvent.event.signal)
1162 ? nextEvent.event.signal
1163 : null;
1164 session.updatedAt = new Date().toISOString();
1165
1166 return {
1167 type: "exit",
1168 process,
1169 threadId: session.threadId,
1170 terminalId: session.terminalId,
1171 exitCode: session.exitCode,
1172 exitSignal: session.exitSignal,
1173 } as const;
1174 });
1175
1176 if (action.type === "idle") {
1177 return;
8001178 }
8011179
802 try {
803 const process = await spawnWithCandidate(candidate);
804 return { process, shellLabel: formatShellCandidate(candidate) };
805 } catch (error) {
806 lastSpawnError = error;
807 if (!isRetryableShellSpawnError(error)) {
808 throw error;
1180 if (action.type === "output") {
1181 if (action.history !== null) {
1182 yield* queuePersist(action.threadId, action.terminalId, action.history);
8091183 }
810 return trySpawn(candidates, index + 1);
1184
1185 yield* publishEvent({
1186 type: "output",
1187 threadId: action.threadId,
1188 terminalId: action.terminalId,
1189 createdAt: new Date().toISOString(),
1190 data: action.data,
1191 });
1192 continue;
8111193 }
812 };
8131194
814 const spawnResult = await trySpawn(shellCandidates);
815 if (spawnResult) {
816 ptyProcess = spawnResult.process;
817 startedShell = spawnResult.shellLabel;
1195 yield* clearKillFiber(action.process);
1196 yield* publishEvent({
1197 type: "exited",
1198 threadId: action.threadId,
1199 terminalId: action.terminalId,
1200 createdAt: new Date().toISOString(),
1201 exitCode: action.exitCode,
1202 exitSignal: action.exitSignal,
1203 });
1204 yield* evictInactiveSessionsIfNeeded();
1205 return;
8181206 }
1207 });
1208
1209 const stopProcess = Effect.fn("terminal.stopProcess")(function* (
1210 session: TerminalSessionState,
1211 ) {
1212 const process = session.process;
1213 if (!process) return;
1214
1215 yield* modifyManagerState((state) => {
1216 cleanupProcessHandles(session);
1217 session.process = null;
1218 session.pid = null;
1219 session.hasRunningSubprocess = false;
1220 session.status = "exited";
1221 session.pendingHistoryControlSequence = "";
1222 session.pendingProcessEvents = [];
1223 session.pendingProcessEventIndex = 0;
1224 session.processEventDrainRunning = false;
1225 session.updatedAt = new Date().toISOString();
1226 return [undefined, state] as const;
1227 });
8191228
820 if (!ptyProcess) {
821 const detail =
822 lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed";
1229 yield* clearKillFiber(process);
1230 yield* startKillEscalation(process, session.threadId, session.terminalId);
1231 yield* evictInactiveSessionsIfNeeded();
1232 });
1233
1234 const trySpawn = Effect.fn("terminal.trySpawn")(function* (
1235 shellCandidates: ReadonlyArray<ShellCandidate>,
1236 spawnEnv: NodeJS.ProcessEnv,
1237 session: TerminalSessionState,
1238 index = 0,
1239 lastError: PtySpawnError | null = null,
1240 ): Effect.fn.Return<{ process: PtyProcess; shellLabel: string }, PtySpawnError> {
1241 if (index >= shellCandidates.length) {
1242 const detail = lastError?.message ?? "Failed to spawn PTY process";
8231243 const tried =
8241244 shellCandidates.length > 0
8251245 ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.`
8261246 : "";
827 throw new Error(`${detail}.${tried}`.trim());
1247 return yield* new PtySpawnError({
1248 adapter: "terminal-manager",
1249 message: `${detail}.${tried}`.trim(),
1250 ...(lastError ? { cause: lastError } : {}),
1251 });
8281252 }
8291253
830 session.process = ptyProcess;
831 session.pid = ptyProcess.pid;
832 session.status = "running";
833 session.updatedAt = new Date().toISOString();
834 session.unsubscribeData = ptyProcess.onData((data) => {
835 this.onProcessData(session, data);
836 });
837 session.unsubscribeExit = ptyProcess.onExit((event) => {
838 this.onProcessExit(session, event);
839 });
840 this.updateSubprocessPollingState();
841 this.emitEvent({
842 type: eventType,
843 threadId: session.threadId,
844 terminalId: session.terminalId,
845 createdAt: new Date().toISOString(),
846 snapshot: this.snapshot(session),
847 });
848 } catch (error) {
849 if (ptyProcess) {
850 this.killProcessWithEscalation(ptyProcess, session.threadId, session.terminalId);
1254 const candidate = shellCandidates[index];
1255 if (!candidate) {
1256 return yield* (
1257 lastError ??
1258 new PtySpawnError({
1259 adapter: "terminal-manager",
1260 message: "No shell candidate available for PTY spawn.",
1261 })
1262 );
8511263 }
852 session.status = "error";
853 session.pid = null;
854 session.process = null;
855 session.hasRunningSubprocess = false;
856 session.updatedAt = new Date().toISOString();
857 this.evictInactiveSessionsIfNeeded();
858 this.updateSubprocessPollingState();
859 const message = error instanceof Error ? error.message : "Terminal start failed";
860 this.emitEvent({
861 type: "error",
862 threadId: session.threadId,
863 terminalId: session.terminalId,
864 createdAt: new Date().toISOString(),
865 message,
866 });
867 this.logger.error("failed to start terminal", {
868 threadId: session.threadId,
869 terminalId: session.terminalId,
870 error: message,
871 ...(startedShell ? { shell: startedShell } : {}),
872 });
873 }
874 }
8751264
876 private onProcessData(session: TerminalSessionState, data: string): void {
877 const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, data);
878 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
879 if (sanitized.visibleText.length > 0) {
880 session.history = capHistory(
881 `${session.history}${sanitized.visibleText}`,
882 this.historyLineLimit,
1265 const attempt = yield* Effect.result(
1266 options.ptyAdapter.spawn({
1267 shell: candidate.shell,
1268 ...(candidate.args ? { args: candidate.args } : {}),
1269 cwd: session.cwd,
1270 cols: session.cols,
1271 rows: session.rows,
1272 env: spawnEnv,
1273 }),
8831274 );
884 this.queuePersist(session.threadId, session.terminalId, session.history);
885 }
886 session.updatedAt = new Date().toISOString();
887 this.emitEvent({
888 type: "output",
889 threadId: session.threadId,
890 terminalId: session.terminalId,
891 createdAt: new Date().toISOString(),
892 data,
893 });
894 }
8951275
896 private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void {
897 this.clearKillEscalationTimer(session.process);
898 this.cleanupProcessHandles(session);
899 session.process = null;
900 session.pid = null;
901 session.hasRunningSubprocess = false;
902 session.status = "exited";
903 session.pendingHistoryControlSequence = "";
904 session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;
905 session.exitSignal = Number.isInteger(event.signal) ? event.signal : null;
906 session.updatedAt = new Date().toISOString();
907 this.emitEvent({
908 type: "exited",
909 threadId: session.threadId,
910 terminalId: session.terminalId,
911 createdAt: new Date().toISOString(),
912 exitCode: session.exitCode,
913 exitSignal: session.exitSignal,
914 });
915 this.evictInactiveSessionsIfNeeded();
916 this.updateSubprocessPollingState();
917 }
1276 if (attempt._tag === "Success") {
1277 return {
1278 process: attempt.success,
1279 shellLabel: formatShellCandidate(candidate),
1280 };
1281 }
9181282
919 private stopProcess(session: TerminalSessionState): void {
920 const process = session.process;
921 if (!process) return;
922 this.cleanupProcessHandles(session);
923 session.process = null;
924 session.pid = null;
925 session.hasRunningSubprocess = false;
926 session.status = "exited";
927 session.pendingHistoryControlSequence = "";
928 session.updatedAt = new Date().toISOString();
929 this.killProcessWithEscalation(process, session.threadId, session.terminalId);
930 this.evictInactiveSessionsIfNeeded();
931 this.updateSubprocessPollingState();
932 }
1283 const spawnError = attempt.failure;
1284 if (!isRetryableShellSpawnError(spawnError)) {
1285 return yield* spawnError;
1286 }
9331287
934 private cleanupProcessHandles(session: TerminalSessionState): void {
935 session.unsubscribeData?.();
936 session.unsubscribeData = null;
937 session.unsubscribeExit?.();
938 session.unsubscribeExit = null;
939 }
1288 return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError);
1289 });
9401290
941 private clearKillEscalationTimer(process: PtyProcess | null): void {
942 if (!process) return;
943 const timer = this.killEscalationTimers.get(process);
944 if (!timer) return;
945 clearTimeout(timer);
946 this.killEscalationTimers.delete(process);
947 }
1291 const startSession = Effect.fn("terminal.startSession")(function* (
1292 session: TerminalSessionState,
1293 input: TerminalStartInput,
1294 eventType: "started" | "restarted",
1295 ) {
1296 yield* stopProcess(session);
9481297
949 private killProcessWithEscalation(
950 process: PtyProcess,
951 threadId: string,
952 terminalId: string,
953 ): void {
954 this.clearKillEscalationTimer(process);
955 try {
956 process.kill("SIGTERM");
957 } catch (error) {
958 const message = error instanceof Error ? error.message : String(error);
959 this.logger.warn("failed to kill terminal process", {
960 threadId,
961 terminalId,
962 signal: "SIGTERM",
963 error: message,
1298 yield* modifyManagerState((state) => {
1299 session.status = "starting";
1300 session.cwd = input.cwd;
1301 session.cols = input.cols;
1302 session.rows = input.rows;
1303 session.exitCode = null;
1304 session.exitSignal = null;
1305 session.hasRunningSubprocess = false;
1306 session.pendingProcessEvents = [];
1307 session.pendingProcessEventIndex = 0;
1308 session.processEventDrainRunning = false;
1309 session.updatedAt = new Date().toISOString();
1310 return [undefined, state] as const;
9641311 });
965 return;
966 }
9671312
968 const timer = setTimeout(() => {
969 this.killEscalationTimers.delete(process);
970 try {
971 process.kill("SIGKILL");
972 } catch (error) {
973 const message = error instanceof Error ? error.message : String(error);
974 this.logger.warn("failed to force-kill terminal process", {
975 threadId,
976 terminalId,
977 signal: "SIGKILL",
978 error: message,
979 });
980 }
981 }, this.processKillGraceMs);
982 timer.unref?.();
983 this.killEscalationTimers.set(process, timer);
984 }
1313 let ptyProcess: PtyProcess | null = null;
1314 let startedShell: string | null = null;
1315
1316 const startResult = yield* Effect.result(
1317 Effect.gen(function* () {
1318 const shellCandidates = resolveShellCandidates(shellResolver);
1319 const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv);
1320 const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session);
1321 ptyProcess = spawnResult.process;
1322 startedShell = spawnResult.shellLabel;
1323
1324 const processPid = ptyProcess.pid;
1325 const unsubscribeData = ptyProcess.onData((data) => {
1326 if (!enqueueProcessEvent(session, processPid, { type: "output", data })) {
1327 return;
1328 }
1329 runFork(drainProcessEvents(session, processPid));
1330 });
1331 const unsubscribeExit = ptyProcess.onExit((event) => {
1332 if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) {
1333 return;
1334 }
1335 runFork(drainProcessEvents(session, processPid));
1336 });
9851337
986 private evictInactiveSessionsIfNeeded(): void {
987 const inactiveSessions = [...this.sessions.values()].filter(
988 (session) => session.status !== "running",
989 );
990 if (inactiveSessions.length <= this.maxRetainedInactiveSessions) {
991 return;
992 }
1338 yield* modifyManagerState((state) => {
1339 session.process = ptyProcess;
1340 session.pid = processPid;
1341 session.status = "running";
1342 session.updatedAt = new Date().toISOString();
1343 session.unsubscribeData = unsubscribeData;
1344 session.unsubscribeExit = unsubscribeExit;
1345 return [undefined, state] as const;
1346 });
9931347
994 inactiveSessions.sort(
995 (left, right) =>
996 left.updatedAt.localeCompare(right.updatedAt) ||
997 left.threadId.localeCompare(right.threadId) ||
998 left.terminalId.localeCompare(right.terminalId),
999 );
1000 const toEvict = inactiveSessions.length - this.maxRetainedInactiveSessions;
1001 for (const session of inactiveSessions.slice(0, toEvict)) {
1002 const key = toSessionKey(session.threadId, session.terminalId);
1003 this.sessions.delete(key);
1004 this.clearPersistTimer(session.threadId, session.terminalId);
1005 this.pendingPersistHistory.delete(key);
1006 this.persistQueues.delete(key);
1007 this.clearKillEscalationTimer(session.process);
1008 }
1009 }
1348 yield* publishEvent({
1349 type: eventType,
1350 threadId: session.threadId,
1351 terminalId: session.terminalId,
1352 createdAt: new Date().toISOString(),
1353 snapshot: snapshot(session),
1354 });
1355 }),
1356 );
10101357
1011 private queuePersist(threadId: string, terminalId: string, history: string): void {
1012 const persistenceKey = toSessionKey(threadId, terminalId);
1013 this.pendingPersistHistory.set(persistenceKey, history);
1014 this.schedulePersist(threadId, terminalId);
1015 }
1358 if (startResult._tag === "Success") {
1359 return;
1360 }
10161361
1017 private async persistHistory(
1018 threadId: string,
1019 terminalId: string,
1020 history: string,
1021 ): Promise<void> {
1022 const persistenceKey = toSessionKey(threadId, terminalId);
1023 this.clearPersistTimer(threadId, terminalId);
1024 this.pendingPersistHistory.delete(persistenceKey);
1025 await this.enqueuePersistWrite(threadId, terminalId, history);
1026 }
1362 {
1363 const error = startResult.failure;
1364 if (ptyProcess) {
1365 yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId);
1366 }
10271367
1028 private enqueuePersistWrite(
1029 threadId: string,
1030 terminalId: string,
1031 history: string,
1032 ): Promise<void> {
1033 const persistenceKey = toSessionKey(threadId, terminalId);
1034 const task = async () => {
1035 await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8");
1036 };
1037 const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve();
1038 const next = previous
1039 .catch(() => undefined)
1040 .then(task)
1041 .catch((error) => {
1042 this.logger.warn("failed to persist terminal history", {
1043 threadId,
1044 terminalId,
1045 error: error instanceof Error ? error.message : String(error),
1368 yield* modifyManagerState((state) => {
1369 session.status = "error";
1370 session.pid = null;
1371 session.process = null;
1372 session.unsubscribeData = null;
1373 session.unsubscribeExit = null;
1374 session.hasRunningSubprocess = false;
1375 session.pendingProcessEvents = [];
1376 session.pendingProcessEventIndex = 0;
1377 session.processEventDrainRunning = false;
1378 session.updatedAt = new Date().toISOString();
1379 return [undefined, state] as const;
1380 });
1381
1382 yield* evictInactiveSessionsIfNeeded();
1383
1384 const message = error.message;
1385 yield* publishEvent({
1386 type: "error",
1387 threadId: session.threadId,
1388 terminalId: session.terminalId,
1389 createdAt: new Date().toISOString(),
1390 message,
1391 });
1392 yield* Effect.logError("failed to start terminal", {
1393 threadId: session.threadId,
1394 terminalId: session.terminalId,
1395 error: message,
1396 ...(startedShell ? { shell: startedShell } : {}),
10461397 });
1047 });
1048 this.persistQueues.set(persistenceKey, next);
1049 const finalized = next.finally(() => {
1050 if (this.persistQueues.get(persistenceKey) === next) {
1051 this.persistQueues.delete(persistenceKey);
1052 }
1053 if (
1054 this.pendingPersistHistory.has(persistenceKey) &&
1055 !this.persistTimers.has(persistenceKey)
1056 ) {
1057 this.schedulePersist(threadId, terminalId);
10581398 }
10591399 });
1060 void finalized.catch(() => undefined);
1061 return finalized;
1062 }
10631400
1064 private schedulePersist(threadId: string, terminalId: string): void {
1065 const persistenceKey = toSessionKey(threadId, terminalId);
1066 if (this.persistTimers.has(persistenceKey)) return;
1067 const timer = setTimeout(() => {
1068 this.persistTimers.delete(persistenceKey);
1069 const pendingHistory = this.pendingPersistHistory.get(persistenceKey);
1070 if (pendingHistory === undefined) return;
1071 this.pendingPersistHistory.delete(persistenceKey);
1072 void this.enqueuePersistWrite(threadId, terminalId, pendingHistory);
1073 }, this.persistDebounceMs);
1074 this.persistTimers.set(persistenceKey, timer);
1075 }
1401 const closeSession = Effect.fn("terminal.closeSession")(function* (
1402 threadId: string,
1403 terminalId: string,
1404 deleteHistoryOnClose: boolean,
1405 ) {
1406 const key = toSessionKey(threadId, terminalId);
1407 const session = yield* getSession(threadId, terminalId);
1408
1409 if (Option.isSome(session)) {
1410 yield* stopProcess(session.value);
1411 yield* persistHistory(threadId, terminalId, session.value.history);
1412 }
10761413
1077 private clearPersistTimer(threadId: string, terminalId: string): void {
1078 const persistenceKey = toSessionKey(threadId, terminalId);
1079 const timer = this.persistTimers.get(persistenceKey);
1080 if (!timer) return;
1081 clearTimeout(timer);
1082 this.persistTimers.delete(persistenceKey);
1083 }
1414 yield* flushPersist(threadId, terminalId);
10841415
1085 private async readHistory(threadId: string, terminalId: string): Promise<string> {
1086 const nextPath = this.historyPath(threadId, terminalId);
1087 try {
1088 const raw = await fs.promises.readFile(nextPath, "utf8");
1089 const capped = capHistory(raw, this.historyLineLimit);
1090 if (capped !== raw) {
1091 await fs.promises.writeFile(nextPath, capped, "utf8");
1092 }
1093 return capped;
1094 } catch (error) {
1095 if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1096 throw error;
1416 yield* modifyManagerState((state) => {
1417 if (!state.sessions.has(key)) {
1418 return [undefined, state] as const;
1419 }
1420 const sessions = new Map(state.sessions);
1421 sessions.delete(key);
1422 return [undefined, { ...state, sessions }] as const;
1423 });
1424
1425 if (deleteHistoryOnClose) {
1426 yield* deleteHistory(threadId, terminalId);
10971427 }
1098 }
1428 });
10991429
1100 if (terminalId !== DEFAULT_TERMINAL_ID) {
1101 return "";
1102 }
1430 const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () {
1431 const state = yield* readManagerState;
1432 const runningSessions = [...state.sessions.values()].filter(
1433 (session): session is TerminalSessionState & { pid: number } =>
1434 session.status === "running" && Number.isInteger(session.pid),
1435 );
11031436
1104 const legacyPath = this.legacyHistoryPath(threadId);
1105 try {
1106 const raw = await fs.promises.readFile(legacyPath, "utf8");
1107 const capped = capHistory(raw, this.historyLineLimit);
1108
1109 // Migrate legacy transcript filename to the terminal-scoped path.
1110 await fs.promises.writeFile(nextPath, capped, "utf8");
1111 try {
1112 await fs.promises.rm(legacyPath, { force: true });
1113 } catch (cleanupError) {
1114 this.logger.warn("failed to remove legacy terminal history", {
1115 threadId,
1116 error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
1117 });
1437 if (runningSessions.length === 0) {
1438 return;
11181439 }
11191440
1120 return capped;
1121 } catch (error) {
1122 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
1123 return "";
1124 }
1125 throw error;
1126 }
1127 }
1441 const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* (
1442 session: TerminalSessionState & { pid: number },
1443 ) {
1444 const terminalPid = session.pid;
1445 const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe(
1446 Effect.map(Option.some),
1447 Effect.catch((error) =>
1448 Effect.logWarning("failed to check terminal subprocess activity", {
1449 threadId: session.threadId,
1450 terminalId: session.terminalId,
1451 terminalPid,
1452 error: error instanceof Error ? error.message : String(error),
1453 }).pipe(Effect.as(Option.none<boolean>())),
1454 ),
1455 );
11281456
1129 private async deleteHistory(threadId: string, terminalId: string): Promise<void> {
1130 const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })];
1131 if (terminalId === DEFAULT_TERMINAL_ID) {
1132 deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }));
1133 }
1134 try {
1135 await Promise.all(deletions);
1136 } catch (error) {
1137 this.logger.warn("failed to delete terminal history", {
1138 threadId,
1139 terminalId,
1140 error: error instanceof Error ? error.message : String(error),
1141 });
1142 }
1143 }
1457 if (Option.isNone(hasRunningSubprocess)) {
1458 return;
1459 }
11441460
1145 private async flushPersistQueue(threadId: string, terminalId: string): Promise<void> {
1146 const persistenceKey = toSessionKey(threadId, terminalId);
1147 this.clearPersistTimer(threadId, terminalId);
1461 const event = yield* modifyManagerState((state) => {
1462 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
1463 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),
1464 );
1465 if (
1466 Option.isNone(liveSession) ||
1467 liveSession.value.status !== "running" ||
1468 liveSession.value.pid !== terminalPid ||
1469 liveSession.value.hasRunningSubprocess === hasRunningSubprocess.value
1470 ) {
1471 return [Option.none(), state] as const;
1472 }
11481473
1149 while (true) {
1150 const pendingHistory = this.pendingPersistHistory.get(persistenceKey);
1151 if (pendingHistory !== undefined) {
1152 this.pendingPersistHistory.delete(persistenceKey);
1153 await this.enqueuePersistWrite(threadId, terminalId, pendingHistory);
1154 }
1474 liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value;
1475 liveSession.value.updatedAt = new Date().toISOString();
1476
1477 return [
1478 Option.some({
1479 type: "activity" as const,
1480 threadId: liveSession.value.threadId,
1481 terminalId: liveSession.value.terminalId,
1482 createdAt: new Date().toISOString(),
1483 hasRunningSubprocess: hasRunningSubprocess.value,
1484 }),
1485 state,
1486 ] as const;
1487 });
11551488
1156 const pending = this.persistQueues.get(persistenceKey);
1157 if (!pending) {
1158 return;
1159 }
1160 await pending.catch(() => undefined);
1161 }
1162 }
1489 if (Option.isSome(event)) {
1490 yield* publishEvent(event.value);
1491 }
1492 });
11631493
1164 private updateSubprocessPollingState(): void {
1165 const hasRunningSessions = [...this.sessions.values()].some(
1166 (session) => session.status === "running" && session.pid !== null,
1167 );
1168 if (hasRunningSessions) {
1169 this.ensureSubprocessPolling();
1170 return;
1171 }
1172 this.stopSubprocessPolling();
1173 }
1494 yield* Effect.forEach(runningSessions, checkSubprocessActivity, {
1495 concurrency: "unbounded",
1496 discard: true,
1497 });
1498 });
11741499
1175 private ensureSubprocessPolling(): void {
1176 if (this.subprocessPollTimer) return;
1177 this.subprocessPollTimer = setInterval(() => {
1178 void this.pollSubprocessActivity();
1179 }, this.subprocessPollIntervalMs);
1180 this.subprocessPollTimer.unref?.();
1181 void this.pollSubprocessActivity();
1182 }
1500 const hasRunningSessions = readManagerState.pipe(
1501 Effect.map((state) =>
1502 [...state.sessions.values()].some((session) => session.status === "running"),
1503 ),
1504 );
11831505
1184 private stopSubprocessPolling(): void {
1185 if (!this.subprocessPollTimer) return;
1186 clearInterval(this.subprocessPollTimer);
1187 this.subprocessPollTimer = null;
1188 }
1506 yield* Effect.forever(
1507 hasRunningSessions.pipe(
1508 Effect.flatMap((active) =>
1509 active
1510 ? pollSubprocessActivity().pipe(
1511 Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)),
1512 )
1513 : Effect.sleep(subprocessPollIntervalMs),
1514 ),
1515 ),
1516 ).pipe(Effect.forkIn(workerScope));
1517
1518 yield* Effect.addFinalizer(() =>
1519 Effect.gen(function* () {
1520 const sessions = yield* modifyManagerState(
1521 (state) =>
1522 [
1523 [...state.sessions.values()],
1524 {
1525 ...state,
1526 sessions: new Map(),
1527 },
1528 ] as const,
1529 );
11891530
1190 private async pollSubprocessActivity(): Promise<void> {
1191 if (this.subprocessPollInFlight) return;
1531 const cleanupSession = Effect.fn("terminal.cleanupSession")(function* (
1532 session: TerminalSessionState,
1533 ) {
1534 cleanupProcessHandles(session);
1535 if (!session.process) return;
1536 yield* clearKillFiber(session.process);
1537 yield* runKillEscalation(session.process, session.threadId, session.terminalId);
1538 });
11921539
1193 const runningSessions = [...this.sessions.values()].filter(
1194 (session): session is TerminalSessionState & { pid: number } =>
1195 session.status === "running" && Number.isInteger(session.pid),
1540 yield* Effect.forEach(sessions, cleanupSession, {
1541 concurrency: "unbounded",
1542 discard: true,
1543 });
1544 }).pipe(Effect.ignoreCause({ log: true })),
11961545 );
1197 if (runningSessions.length === 0) {
1198 this.stopSubprocessPolling();
1199 return;
1200 }
12011546
1202 this.subprocessPollInFlight = true;
1203 try {
1204 await Promise.all(
1205 runningSessions.map(async (session) => {
1206 const terminalPid = session.pid;
1207 let hasRunningSubprocess = false;
1208 try {
1209 hasRunningSubprocess = await this.subprocessChecker(terminalPid);
1210 } catch (error) {
1211 this.logger.warn("failed to check terminal subprocess activity", {
1212 threadId: session.threadId,
1213 terminalId: session.terminalId,
1214 terminalPid,
1215 error: error instanceof Error ? error.message : String(error),
1547 const open: TerminalManagerShape["open"] = (input) =>
1548 withThreadLock(
1549 input.threadId,
1550 Effect.gen(function* () {
1551 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1552 yield* assertValidCwd(input.cwd);
1553
1554 const sessionKey = toSessionKey(input.threadId, terminalId);
1555 const existing = yield* getSession(input.threadId, terminalId);
1556 if (Option.isNone(existing)) {
1557 yield* flushPersist(input.threadId, terminalId);
1558 const history = yield* readHistory(input.threadId, terminalId);
1559 const cols = input.cols ?? DEFAULT_OPEN_COLS;
1560 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
1561 const session: TerminalSessionState = {
1562 threadId: input.threadId,
1563 terminalId,
1564 cwd: input.cwd,
1565 status: "starting",
1566 pid: null,
1567 history,
1568 pendingHistoryControlSequence: "",
1569 pendingProcessEvents: [],
1570 pendingProcessEventIndex: 0,
1571 processEventDrainRunning: false,
1572 exitCode: null,
1573 exitSignal: null,
1574 updatedAt: new Date().toISOString(),
1575 cols,
1576 rows,
1577 process: null,
1578 unsubscribeData: null,
1579 unsubscribeExit: null,
1580 hasRunningSubprocess: false,
1581 runtimeEnv: normalizedRuntimeEnv(input.env),
1582 };
1583
1584 const createdSession = session;
1585 yield* modifyManagerState((state) => {
1586 const sessions = new Map(state.sessions);
1587 sessions.set(sessionKey, createdSession);
1588 return [undefined, { ...state, sessions }] as const;
12161589 });
1217 return;
1590
1591 yield* evictInactiveSessionsIfNeeded();
1592 yield* startSession(
1593 session,
1594 {
1595 threadId: input.threadId,
1596 terminalId,
1597 cwd: input.cwd,
1598 cols,
1599 rows,
1600 ...(input.env ? { env: input.env } : {}),
1601 },
1602 "started",
1603 );
1604 return snapshot(session);
12181605 }
12191606
1220 const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId));
1221 if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) {
1222 return;
1607 const liveSession = existing.value;
1608 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
1609 const currentRuntimeEnv = liveSession.runtimeEnv;
1610 const targetCols = input.cols ?? liveSession.cols;
1611 const targetRows = input.rows ?? liveSession.rows;
1612 const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv);
1613
1614 if (liveSession.cwd !== input.cwd || runtimeEnvChanged) {
1615 yield* stopProcess(liveSession);
1616 liveSession.cwd = input.cwd;
1617 liveSession.runtimeEnv = nextRuntimeEnv;
1618 liveSession.history = "";
1619 liveSession.pendingHistoryControlSequence = "";
1620 liveSession.pendingProcessEvents = [];
1621 liveSession.pendingProcessEventIndex = 0;
1622 liveSession.processEventDrainRunning = false;
1623 yield* persistHistory(
1624 liveSession.threadId,
1625 liveSession.terminalId,
1626 liveSession.history,
1627 );
1628 } else if (liveSession.status === "exited" || liveSession.status === "error") {
1629 liveSession.runtimeEnv = nextRuntimeEnv;
1630 liveSession.history = "";
1631 liveSession.pendingHistoryControlSequence = "";
1632 liveSession.pendingProcessEvents = [];
1633 liveSession.pendingProcessEventIndex = 0;
1634 liveSession.processEventDrainRunning = false;
1635 yield* persistHistory(
1636 liveSession.threadId,
1637 liveSession.terminalId,
1638 liveSession.history,
1639 );
12231640 }
1224 if (liveSession.hasRunningSubprocess === hasRunningSubprocess) {
1225 return;
1641
1642 if (!liveSession.process) {
1643 yield* startSession(
1644 liveSession,
1645 {
1646 threadId: input.threadId,
1647 terminalId,
1648 cwd: input.cwd,
1649 cols: targetCols,
1650 rows: targetRows,
1651 ...(input.env ? { env: input.env } : {}),
1652 },
1653 "started",
1654 );
1655 return snapshot(liveSession);
12261656 }
12271657
1228 liveSession.hasRunningSubprocess = hasRunningSubprocess;
1229 liveSession.updatedAt = new Date().toISOString();
1230 this.emitEvent({
1231 type: "activity",
1232 threadId: liveSession.threadId,
1233 terminalId: liveSession.terminalId,
1234 createdAt: new Date().toISOString(),
1235 hasRunningSubprocess,
1236 });
1658 if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) {
1659 liveSession.cols = targetCols;
1660 liveSession.rows = targetRows;
1661 liveSession.updatedAt = new Date().toISOString();
1662 liveSession.process.resize(targetCols, targetRows);
1663 }
1664
1665 return snapshot(liveSession);
12371666 }),
12381667 );
1239 } finally {
1240 this.subprocessPollInFlight = false;
1241 }
1242 }
12431668
1244 private async assertValidCwd(cwd: string): Promise<void> {
1245 let stats: fs.Stats;
1246 try {
1247 stats = await fs.promises.stat(cwd);
1248 } catch (error) {
1249 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
1250 throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error });
1669 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {
1670 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1671 const session = yield* requireSession(input.threadId, terminalId);
1672 const process = session.process;
1673 if (!process || session.status !== "running") {
1674 if (session.status === "exited") return;
1675 return yield* new TerminalNotRunningError({
1676 threadId: input.threadId,
1677 terminalId,
1678 });
12511679 }
1252 throw error;
1253 }
1254 if (!stats.isDirectory()) {
1255 throw new Error(`Terminal cwd is not a directory: ${cwd}`);
1256 }
1257 }
1258
1259 private async closeSession(
1260 threadId: string,
1261 terminalId: string,
1262 deleteHistory: boolean,
1263 ): Promise<void> {
1264 const key = toSessionKey(threadId, terminalId);
1265 const session = this.sessions.get(key);
1266 if (session) {
1267 this.stopProcess(session);
1268 this.sessions.delete(key);
1269 }
1270 this.updateSubprocessPollingState();
1271 await this.flushPersistQueue(threadId, terminalId);
1272 if (deleteHistory) {
1273 await this.deleteHistory(threadId, terminalId);
1274 }
1275 }
1276
1277 private sessionsForThread(threadId: string): TerminalSessionState[] {
1278 return [...this.sessions.values()].filter((session) => session.threadId === threadId);
1279 }
1280
1281 private async deleteAllHistoryForThread(threadId: string): Promise<void> {
1282 const threadPrefix = `${toSafeThreadId(threadId)}_`;
1283 try {
1284 const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true });
1285 const removals = entries
1286 .filter((entry) => entry.isFile())
1287 .map((entry) => entry.name)
1288 .filter(
1289 (name) =>
1290 name === `${toSafeThreadId(threadId)}.log` ||
1291 name === `${legacySafeThreadId(threadId)}.log` ||
1292 name.startsWith(threadPrefix),
1293 )
1294 .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true }));
1295 await Promise.all(removals);
1296 } catch (error) {
1297 this.logger.warn("failed to delete terminal histories for thread", {
1298 threadId,
1299 error: error instanceof Error ? error.message : String(error),
1300 });
1301 }
1302 }
1303
1304 private requireSession(threadId: string, terminalId: string): TerminalSessionState {
1305 const session = this.sessions.get(toSessionKey(threadId, terminalId));
1306 if (!session) {
1307 throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`);
1308 }
1309 return session;
1310 }
1680 yield* Effect.sync(() => process.write(input.data));
1681 });
13111682
1312 private snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
1313 return {
1314 threadId: session.threadId,
1315 terminalId: session.terminalId,
1316 cwd: session.cwd,
1317 status: session.status,
1318 pid: session.pid,
1319 history: session.history,
1320 exitCode: session.exitCode,
1321 exitSignal: session.exitSignal,
1322 updatedAt: session.updatedAt,
1323 };
1324 }
1683 const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) {
1684 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1685 const session = yield* requireSession(input.threadId, terminalId);
1686 const process = session.process;
1687 if (!process || session.status !== "running") {
1688 return yield* new TerminalNotRunningError({
1689 threadId: input.threadId,
1690 terminalId,
1691 });
1692 }
1693 session.cols = input.cols;
1694 session.rows = input.rows;
1695 session.updatedAt = new Date().toISOString();
1696 yield* Effect.sync(() => process.resize(input.cols, input.rows));
1697 });
13251698
1326 private emitEvent(event: TerminalEvent): void {
1327 this.emit("event", event);
1328 }
1699 const clear: TerminalManagerShape["clear"] = (input) =>
1700 withThreadLock(
1701 input.threadId,
1702 Effect.gen(function* () {
1703 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1704 const session = yield* requireSession(input.threadId, terminalId);
1705 session.history = "";
1706 session.pendingHistoryControlSequence = "";
1707 session.pendingProcessEvents = [];
1708 session.pendingProcessEventIndex = 0;
1709 session.processEventDrainRunning = false;
1710 session.updatedAt = new Date().toISOString();
1711 yield* persistHistory(input.threadId, terminalId, session.history);
1712 yield* publishEvent({
1713 type: "cleared",
1714 threadId: input.threadId,
1715 terminalId,
1716 createdAt: new Date().toISOString(),
1717 });
1718 }),
1719 );
13291720
1330 private historyPath(threadId: string, terminalId: string): string {
1331 const threadPart = toSafeThreadId(threadId);
1332 if (terminalId === DEFAULT_TERMINAL_ID) {
1333 return path.join(this.logsDir, `${threadPart}.log`);
1334 }
1335 return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`);
1336 }
1721 const restart: TerminalManagerShape["restart"] = (input) =>
1722 withThreadLock(
1723 input.threadId,
1724 Effect.gen(function* () {
1725 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1726 yield* assertValidCwd(input.cwd);
1727
1728 const sessionKey = toSessionKey(input.threadId, terminalId);
1729 const existingSession = yield* getSession(input.threadId, terminalId);
1730 let session: TerminalSessionState;
1731 if (Option.isNone(existingSession)) {
1732 const cols = input.cols ?? DEFAULT_OPEN_COLS;
1733 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
1734 session = {
1735 threadId: input.threadId,
1736 terminalId,
1737 cwd: input.cwd,
1738 status: "starting",
1739 pid: null,
1740 history: "",
1741 pendingHistoryControlSequence: "",
1742 pendingProcessEvents: [],
1743 pendingProcessEventIndex: 0,
1744 processEventDrainRunning: false,
1745 exitCode: null,
1746 exitSignal: null,
1747 updatedAt: new Date().toISOString(),
1748 cols,
1749 rows,
1750 process: null,
1751 unsubscribeData: null,
1752 unsubscribeExit: null,
1753 hasRunningSubprocess: false,
1754 runtimeEnv: normalizedRuntimeEnv(input.env),
1755 };
1756 const createdSession = session;
1757 yield* modifyManagerState((state) => {
1758 const sessions = new Map(state.sessions);
1759 sessions.set(sessionKey, createdSession);
1760 return [undefined, { ...state, sessions }] as const;
1761 });
1762 yield* evictInactiveSessionsIfNeeded();
1763 } else {
1764 session = existingSession.value;
1765 yield* stopProcess(session);
1766 session.cwd = input.cwd;
1767 session.runtimeEnv = normalizedRuntimeEnv(input.env);
1768 }
13371769
1338 private legacyHistoryPath(threadId: string): string {
1339 return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`);
1340 }
1770 const cols = input.cols ?? session.cols;
1771 const rows = input.rows ?? session.rows;
1772
1773 session.history = "";
1774 session.pendingHistoryControlSequence = "";
1775 session.pendingProcessEvents = [];
1776 session.pendingProcessEventIndex = 0;
1777 session.processEventDrainRunning = false;
1778 yield* persistHistory(input.threadId, terminalId, session.history);
1779 yield* startSession(
1780 session,
1781 {
1782 threadId: input.threadId,
1783 terminalId,
1784 cwd: input.cwd,
1785 cols,
1786 rows,
1787 ...(input.env ? { env: input.env } : {}),
1788 },
1789 "restarted",
1790 );
1791 return snapshot(session);
1792 }),
1793 );
13411794
1342 private async runWithThreadLock<T>(threadId: string, task: () => Promise<T>): Promise<T> {
1343 const previous = this.threadLocks.get(threadId) ?? Promise.resolve();
1344 let release!: () => void;
1345 const current = new Promise<void>((resolve) => {
1346 release = resolve;
1347 });
1348 this.threadLocks.set(threadId, current);
1349 await previous.catch(() => undefined);
1350 try {
1351 return await task();
1352 } finally {
1353 release();
1354 if (this.threadLocks.get(threadId) === current) {
1355 this.threadLocks.delete(threadId);
1356 }
1357 }
1358 }
1359}
1795 const close: TerminalManagerShape["close"] = (input) =>
1796 withThreadLock(
1797 input.threadId,
1798 Effect.gen(function* () {
1799 if (input.terminalId) {
1800 yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true);
1801 return;
1802 }
13601803
1361export const TerminalManagerLive = Layer.effect(
1362 TerminalManager,
1363 Effect.gen(function* () {
1364 const { terminalLogsDir } = yield* ServerConfig;
1804 const threadSessions = yield* sessionsForThread(input.threadId);
1805 yield* Effect.forEach(
1806 threadSessions,
1807 (session) => closeSession(input.threadId, session.terminalId, false),
1808 { discard: true },
1809 );
13651810
1366 const ptyAdapter = yield* PtyAdapter;
1367 const runtime = yield* Effect.acquireRelease(
1368 Effect.sync(() => new TerminalManagerRuntime({ logsDir: terminalLogsDir, ptyAdapter })),
1369 (r) => Effect.sync(() => r.dispose()),
1370 );
1811 if (input.deleteHistory) {
1812 yield* deleteAllHistoryForThread(input.threadId);
1813 }
1814 }),
1815 );
13711816
13721817 return {
1373 open: (input) =>
1374 Effect.tryPromise({
1375 try: () => runtime.open(input),
1376 catch: (cause) => new TerminalError({ message: "Failed to open terminal", cause }),
1377 }),
1378 write: (input) =>
1379 Effect.tryPromise({
1380 try: () => runtime.write(input),
1381 catch: (cause) => new TerminalError({ message: "Failed to write to terminal", cause }),
1382 }),
1383 resize: (input) =>
1384 Effect.tryPromise({
1385 try: () => runtime.resize(input),
1386 catch: (cause) => new TerminalError({ message: "Failed to resize terminal", cause }),
1387 }),
1388 clear: (input) =>
1389 Effect.tryPromise({
1390 try: () => runtime.clear(input),
1391 catch: (cause) => new TerminalError({ message: "Failed to clear terminal", cause }),
1392 }),
1393 restart: (input) =>
1394 Effect.tryPromise({
1395 try: () => runtime.restart(input),
1396 catch: (cause) => new TerminalError({ message: "Failed to restart terminal", cause }),
1397 }),
1398 close: (input) =>
1399 Effect.tryPromise({
1400 try: () => runtime.close(input),
1401 catch: (cause) => new TerminalError({ message: "Failed to close terminal", cause }),
1402 }),
1818 open,
1819 write,
1820 resize,
1821 clear,
1822 restart,
1823 close,
14031824 subscribe: (listener) =>
14041825 Effect.sync(() => {
1405 runtime.on("event", listener);
1826 terminalEventListeners.add(listener);
14061827 return () => {
1407 runtime.off("event", listener);
1828 terminalEventListeners.delete(listener);
14081829 };
14091830 }),
1410 dispose: Effect.sync(() => runtime.dispose()),
14111831 } satisfies TerminalManagerShape;
1412 }),
1832 },
14131833);
1834
1835export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager());
apps/server/src/terminal/Services/Manager.ts+70-37
......@@ -14,47 +14,81 @@ import {
1414 TerminalResizeInput,
1515 TerminalRestartInput,
1616 TerminalSessionSnapshot,
17 TerminalSessionStatus,
1817 TerminalWriteInput,
1918} from "@t3tools/contracts";
20import { PtyProcess } from "./PTY";
2119import { Effect, Schema, ServiceMap } from "effect";
2220
23export class TerminalError extends Schema.TaggedErrorClass<TerminalError>()("TerminalError", {
24 message: Schema.String,
25 cause: Schema.optional(Schema.Defect),
26}) {}
21export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()(
22 "TerminalCwdError",
23 {
24 cwd: Schema.String,
25 reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]),
26 cause: Schema.optional(Schema.Defect),
27 },
28) {
29 override get message() {
30 if (this.reason === "notDirectory") {
31 return `Terminal cwd is not a directory: ${this.cwd}`;
32 }
33 if (this.reason === "notFound") {
34 return `Terminal cwd does not exist: ${this.cwd}`;
35 }
36 const causeMessage =
37 this.cause && typeof this.cause === "object" && "message" in this.cause
38 ? this.cause.message
39 : undefined;
40 return causeMessage
41 ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})`
42 : `Failed to access terminal cwd: ${this.cwd}`;
43 }
44}
2745
28export interface TerminalSessionState {
29 threadId: string;
30 terminalId: string;
31 cwd: string;
32 status: TerminalSessionStatus;
33 pid: number | null;
34 history: string;
35 pendingHistoryControlSequence: string;
36 exitCode: number | null;
37 exitSignal: number | null;
38 updatedAt: string;
39 cols: number;
40 rows: number;
41 process: PtyProcess | null;
42 unsubscribeData: (() => void) | null;
43 unsubscribeExit: (() => void) | null;
44 hasRunningSubprocess: boolean;
45 runtimeEnv: Record<string, string> | null;
46export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistoryError>()(
47 "TerminalHistoryError",
48 {
49 operation: Schema.Literals(["read", "truncate", "migrate"]),
50 threadId: Schema.String,
51 terminalId: Schema.String,
52 cause: Schema.optional(Schema.Defect),
53 },
54) {
55 override get message() {
56 return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`;
57 }
4658}
4759
48export interface ShellCandidate {
49 shell: string;
50 args?: string[];
60export class TerminalSessionLookupError extends Schema.TaggedErrorClass<TerminalSessionLookupError>()(
61 "TerminalSessionLookupError",
62 {
63 threadId: Schema.String,
64 terminalId: Schema.String,
65 },
66) {
67 override get message() {
68 return `Unknown terminal thread: ${this.threadId}, terminal: ${this.terminalId}`;
69 }
5170}
5271
53export interface TerminalStartInput extends TerminalOpenInput {
54 cols: number;
55 rows: number;
72export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()(
73 "TerminalNotRunningError",
74 {
75 threadId: Schema.String,
76 terminalId: Schema.String,
77 },
78) {
79 override get message() {
80 return `Terminal is not running for thread: ${this.threadId}, terminal: ${this.terminalId}`;
81 }
5682}
5783
84export const TerminalError = Schema.Union([
85 TerminalCwdError,
86 TerminalHistoryError,
87 TerminalSessionLookupError,
88 TerminalNotRunningError,
89]);
90export type TerminalError = typeof TerminalError.Type;
91
5892/**
5993 * TerminalManagerShape - Service API for terminal session lifecycle operations.
6094 */
......@@ -101,14 +135,13 @@ export interface TerminalManagerShape {
101135 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
102136
103137 /**
104 * Subscribe to terminal runtime events.
105 */
106 readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>;
107
108 /**
109 * Dispose all managed terminal resources.
138 * Subscribe to terminal runtime events with a direct callback.
139 *
140 * Returns an unsubscribe function.
110141 */
111 readonly dispose: Effect.Effect<void>;
142 readonly subscribe: (
143 listener: (event: TerminalEvent) => Effect.Effect<void>,
144 ) => Effect.Effect<() => void>;
112145}
113146
114147/**
apps/server/src/wsServer.test.ts+21-14
......@@ -4,7 +4,7 @@ import os from "node:os";
44import path from "node:path";
55
66import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effect";
7import { Effect, Exit, Fiber, Layer, PlatformError, PubSub, Scope, Stream } from "effect";
88import { describe, expect, it, afterEach, vi } from "vitest";
99import { createServer } from "./wsServer";
1010import WebSocket from "ws";
......@@ -90,20 +90,19 @@ const defaultServerSettings = DEFAULT_SERVER_SETTINGS;
9090
9191class MockTerminalManager implements TerminalManagerShape {
9292 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
93 private readonly listeners = new Set<(event: TerminalEvent) => void>();
93 private readonly eventPubSub = Effect.runSync(PubSub.unbounded<TerminalEvent>());
94 private activeSubscriptions = 0;
9495
9596 private key(threadId: string, terminalId: string): string {
9697 return `${threadId}\u0000${terminalId}`;
9798 }
9899
99100 emitEvent(event: TerminalEvent): void {
100 for (const listener of this.listeners) {
101 listener(event);
102 }
101 Effect.runSync(PubSub.publish(this.eventPubSub, event));
103102 }
104103
105104 subscriptionCount(): number {
106 return this.listeners.size;
105 return this.activeSubscriptions;
107106 }
108107
109108 readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) =>
......@@ -210,13 +209,15 @@ class MockTerminalManager implements TerminalManagerShape {
210209
211210 readonly subscribe: TerminalManagerShape["subscribe"] = (listener) =>
212211 Effect.sync(() => {
213 this.listeners.add(listener);
212 this.activeSubscriptions += 1;
213 const fiber = Effect.runFork(
214 Stream.runForEach(Stream.fromPubSub(this.eventPubSub), (event) => listener(event)),
215 );
214216 return () => {
215 this.listeners.delete(listener);
217 this.activeSubscriptions -= 1;
218 Effect.runFork(Fiber.interrupt(fiber).pipe(Effect.ignore));
216219 };
217220 });
218
219 readonly dispose: TerminalManagerShape["dispose"] = Effect.void;
220221}
221222
222223// ---------------------------------------------------------------------------
......@@ -1454,19 +1455,25 @@ describe("WebSocket Server", () => {
14541455 expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
14551456 });
14561457
1457 it("detaches terminal event listener on stop for injected manager", async () => {
1458 it("shuts down cleanly for injected terminal managers", async () => {
14581459 const terminalManager = new MockTerminalManager();
14591460 server = await createTestServer({
14601461 cwd: "/test",
14611462 terminalManager,
14621463 });
14631464
1464 expect(terminalManager.subscriptionCount()).toBe(1);
1465
14661465 await closeTestServer();
14671466 server = null;
14681467
1469 expect(terminalManager.subscriptionCount()).toBe(0);
1468 expect(() =>
1469 terminalManager.emitEvent({
1470 type: "output",
1471 threadId: "thread-1",
1472 terminalId: DEFAULT_TERMINAL_ID,
1473 createdAt: new Date().toISOString(),
1474 data: "after shutdown\n",
1475 }),
1476 ).not.toThrow();
14701477 });
14711478
14721479 it("returns validation errors for invalid terminal open params", async () => {
apps/server/src/wsServer.ts+2-2
......@@ -719,9 +719,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
719719 }
720720
721721 const unsubscribeTerminalEvents = yield* terminalManager.subscribe((event) =>
722 runPromise(pushBus.publishAll(WS_CHANNELS.terminalEvent, event)),
722 pushBus.publishAll(WS_CHANNELS.terminalEvent, event),
723723 );
724 yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents()));
724 yield* Scope.addFinalizer(subscriptionsScope, Effect.sync(unsubscribeTerminalEvents));
725725 yield* readiness.markTerminalSubscriptionsReady;
726726
727727 yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
packages/contracts/src/ipc.ts+6-6
......@@ -130,12 +130,12 @@ export interface NativeApi {
130130 confirm: (message: string) => Promise<boolean>;
131131 };
132132 terminal: {
133 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
134 write: (input: TerminalWriteInput) => Promise<void>;
135 resize: (input: TerminalResizeInput) => Promise<void>;
136 clear: (input: TerminalClearInput) => Promise<void>;
137 restart: (input: TerminalRestartInput) => Promise<TerminalSessionSnapshot>;
138 close: (input: TerminalCloseInput) => Promise<void>;
133 open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>;
134 write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>;
135 resize: (input: typeof TerminalResizeInput.Encoded) => Promise<void>;
136 clear: (input: typeof TerminalClearInput.Encoded) => Promise<void>;
137 restart: (input: typeof TerminalRestartInput.Encoded) => Promise<TerminalSessionSnapshot>;
138 close: (input: typeof TerminalCloseInput.Encoded) => Promise<void>;
139139 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
140140 };
141141 projects: {
packages/contracts/src/terminal.ts+2-2
......@@ -26,7 +26,7 @@ const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
2626export const TerminalThreadInput = Schema.Struct({
2727 threadId: TrimmedNonEmptyStringSchema,
2828});
29export type TerminalThreadInput = Schema.Codec.Encoded<typeof TerminalThreadInput>;
29export type TerminalThreadInput = typeof TerminalThreadInput.Type;
3030
3131const TerminalSessionInput = Schema.Struct({
3232 ...TerminalThreadInput.fields,
......@@ -73,7 +73,7 @@ export const TerminalCloseInput = Schema.Struct({
7373 terminalId: Schema.optional(TerminalIdSchema),
7474 deleteHistory: Schema.optional(Schema.Boolean),
7575});
76export type TerminalCloseInput = Schema.Codec.Encoded<typeof TerminalCloseInput>;
76export type TerminalCloseInput = typeof TerminalCloseInput.Type;
7777
7878export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]);
7979export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;
packages/shared/package.json+4
......@@ -28,6 +28,10 @@
2828 "types": "./src/DrainableWorker.ts",
2929 "import": "./src/DrainableWorker.ts"
3030 },
31 "./KeyedCoalescingWorker": {
32 "types": "./src/KeyedCoalescingWorker.ts",
33 "import": "./src/KeyedCoalescingWorker.ts"
34 },
3135 "./schemaJson": {
3236 "types": "./src/schemaJson.ts",
3337 "import": "./src/schemaJson.ts"
packages/shared/src/KeyedCoalescingWorker.test.ts created+96
......@@ -0,0 +1,96 @@
1import { it } from "@effect/vitest";
2import { describe, expect } from "vitest";
3import { Deferred, Effect } from "effect";
4
5import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker";
6
7describe("makeKeyedCoalescingWorker", () => {
8 it.live("waits for latest work enqueued during active processing before draining the key", () =>
9 Effect.scoped(
10 Effect.gen(function* () {
11 const processed: string[] = [];
12 const firstStarted = yield* Deferred.make<void>();
13 const releaseFirst = yield* Deferred.make<void>();
14 const secondStarted = yield* Deferred.make<void>();
15 const releaseSecond = yield* Deferred.make<void>();
16
17 const worker = yield* makeKeyedCoalescingWorker<string, string, never, never>({
18 merge: (_current, next) => next,
19 process: (key, value) =>
20 Effect.gen(function* () {
21 processed.push(`${key}:${value}`);
22
23 if (value === "first") {
24 yield* Deferred.succeed(firstStarted, undefined).pipe(Effect.orDie);
25 yield* Deferred.await(releaseFirst);
26 }
27
28 if (value === "second") {
29 yield* Deferred.succeed(secondStarted, undefined).pipe(Effect.orDie);
30 yield* Deferred.await(releaseSecond);
31 }
32 }),
33 });
34
35 yield* worker.enqueue("terminal-1", "first");
36 yield* Deferred.await(firstStarted);
37
38 const drained = yield* Deferred.make<void>();
39 yield* Effect.forkChild(
40 worker
41 .drainKey("terminal-1")
42 .pipe(Effect.tap(() => Deferred.succeed(drained, undefined).pipe(Effect.orDie))),
43 );
44
45 yield* worker.enqueue("terminal-1", "second");
46 yield* Deferred.succeed(releaseFirst, undefined);
47 yield* Deferred.await(secondStarted);
48
49 expect(yield* Deferred.isDone(drained)).toBe(false);
50
51 yield* Deferred.succeed(releaseSecond, undefined);
52 yield* Deferred.await(drained);
53
54 expect(processed).toEqual(["terminal-1:first", "terminal-1:second"]);
55 }),
56 ),
57 );
58
59 it.live("requeues pending work for a key after a processor failure and keeps draining", () =>
60 Effect.scoped(
61 Effect.gen(function* () {
62 const processed: string[] = [];
63 const firstStarted = yield* Deferred.make<void>();
64 const releaseFailure = yield* Deferred.make<void>();
65 const secondProcessed = yield* Deferred.make<void>();
66
67 const worker = yield* makeKeyedCoalescingWorker<string, string, string, never>({
68 merge: (_current, next) => next,
69 process: (key, value) =>
70 Effect.gen(function* () {
71 processed.push(`${key}:${value}`);
72
73 if (value === "first") {
74 yield* Deferred.succeed(firstStarted, undefined).pipe(Effect.orDie);
75 yield* Deferred.await(releaseFailure);
76 yield* Effect.fail("boom");
77 }
78
79 if (value === "second") {
80 yield* Deferred.succeed(secondProcessed, undefined).pipe(Effect.orDie);
81 }
82 }),
83 });
84
85 yield* worker.enqueue("terminal-1", "first");
86 yield* Deferred.await(firstStarted);
87 yield* worker.enqueue("terminal-1", "second");
88 yield* Deferred.succeed(releaseFailure, undefined);
89 yield* Deferred.await(secondProcessed);
90 yield* worker.drainKey("terminal-1");
91
92 expect(processed).toEqual(["terminal-1:first", "terminal-1:second"]);
93 }),
94 ),
95 );
96});
packages/shared/src/KeyedCoalescingWorker.ts created+140
......@@ -0,0 +1,140 @@
1/**
2 * KeyedCoalescingWorker - A keyed worker that keeps only the latest value per key.
3 *
4 * Enqueues for an active or already-queued key are merged atomically instead of
5 * creating duplicate queued items. `drainKey()` resolves only when that key has
6 * no queued, pending, or active work left.
7 *
8 * @module KeyedCoalescingWorker
9 */
10import type { Scope } from "effect";
11import { Effect, TxQueue, TxRef } from "effect";
12
13export interface KeyedCoalescingWorker<K, V> {
14 readonly enqueue: (key: K, value: V) => Effect.Effect<void>;
15 readonly drainKey: (key: K) => Effect.Effect<void>;
16}
17
18interface KeyedCoalescingWorkerState<K, V> {
19 readonly latestByKey: Map<K, V>;
20 readonly queuedKeys: Set<K>;
21 readonly activeKeys: Set<K>;
22}
23
24export const makeKeyedCoalescingWorker = <K, V, E, R>(options: {
25 readonly merge: (current: V, next: V) => V;
26 readonly process: (key: K, value: V) => Effect.Effect<void, E, R>;
27}): Effect.Effect<KeyedCoalescingWorker<K, V>, never, Scope.Scope | R> =>
28 Effect.gen(function* () {
29 const queue = yield* Effect.acquireRelease(TxQueue.unbounded<K>(), TxQueue.shutdown);
30 const stateRef = yield* TxRef.make<KeyedCoalescingWorkerState<K, V>>({
31 latestByKey: new Map(),
32 queuedKeys: new Set(),
33 activeKeys: new Set(),
34 });
35
36 const processKey = (key: K, value: V): Effect.Effect<void, E, R> =>
37 options.process(key, value).pipe(
38 Effect.flatMap(() =>
39 TxRef.modify(stateRef, (state) => {
40 const nextValue = state.latestByKey.get(key);
41 if (nextValue === undefined) {
42 const activeKeys = new Set(state.activeKeys);
43 activeKeys.delete(key);
44 return [null, { ...state, activeKeys }] as const;
45 }
46
47 const latestByKey = new Map(state.latestByKey);
48 latestByKey.delete(key);
49 return [nextValue, { ...state, latestByKey }] as const;
50 }).pipe(Effect.tx),
51 ),
52 Effect.flatMap((nextValue) =>
53 nextValue === null ? Effect.void : processKey(key, nextValue),
54 ),
55 );
56
57 const cleanupFailedKey = (key: K): Effect.Effect<void> =>
58 TxRef.modify(stateRef, (state) => {
59 const activeKeys = new Set(state.activeKeys);
60 activeKeys.delete(key);
61
62 if (state.latestByKey.has(key) && !state.queuedKeys.has(key)) {
63 const queuedKeys = new Set(state.queuedKeys);
64 queuedKeys.add(key);
65 return [true, { ...state, activeKeys, queuedKeys }] as const;
66 }
67
68 return [false, { ...state, activeKeys }] as const;
69 }).pipe(
70 Effect.tx,
71 Effect.flatMap((shouldRequeue) =>
72 shouldRequeue ? TxQueue.offer(queue, key) : Effect.void,
73 ),
74 );
75
76 yield* TxQueue.take(queue).pipe(
77 Effect.flatMap((key) =>
78 TxRef.modify(stateRef, (state) => {
79 const queuedKeys = new Set(state.queuedKeys);
80 queuedKeys.delete(key);
81
82 const value = state.latestByKey.get(key);
83 if (value === undefined) {
84 return [null, { ...state, queuedKeys }] as const;
85 }
86
87 const latestByKey = new Map(state.latestByKey);
88 latestByKey.delete(key);
89 const activeKeys = new Set(state.activeKeys);
90 activeKeys.add(key);
91
92 return [
93 { key, value } as const,
94 { ...state, latestByKey, queuedKeys, activeKeys },
95 ] as const;
96 }).pipe(Effect.tx),
97 ),
98 Effect.flatMap((item) =>
99 item === null
100 ? Effect.void
101 : processKey(item.key, item.value).pipe(
102 Effect.catchCause(() => cleanupFailedKey(item.key)),
103 ),
104 ),
105 Effect.forever,
106 Effect.forkScoped,
107 );
108
109 const enqueue: KeyedCoalescingWorker<K, V>["enqueue"] = (key, value) =>
110 TxRef.modify(stateRef, (state) => {
111 const latestByKey = new Map(state.latestByKey);
112 const existing = latestByKey.get(key);
113 latestByKey.set(key, existing === undefined ? value : options.merge(existing, value));
114
115 if (state.queuedKeys.has(key) || state.activeKeys.has(key)) {
116 return [false, { ...state, latestByKey }] as const;
117 }
118
119 const queuedKeys = new Set(state.queuedKeys);
120 queuedKeys.add(key);
121 return [true, { ...state, latestByKey, queuedKeys }] as const;
122 }).pipe(
123 Effect.flatMap((shouldOffer) => (shouldOffer ? TxQueue.offer(queue, key) : Effect.void)),
124 Effect.tx,
125 Effect.asVoid,
126 );
127
128 const drainKey: KeyedCoalescingWorker<K, V>["drainKey"] = (key) =>
129 TxRef.get(stateRef).pipe(
130 Effect.tap((state) =>
131 state.latestByKey.has(key) || state.queuedKeys.has(key) || state.activeKeys.has(key)
132 ? Effect.txRetry
133 : Effect.void,
134 ),
135 Effect.asVoid,
136 Effect.tx,
137 );
138
139 return { enqueue, drainKey } satisfies KeyedCoalescingWorker<K, V>;
140 });