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 @@...@@ -1,24 +1,38 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";1import path from "node:path";
42
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, it } from "@effect/vitest";
5import {5import {
6 DEFAULT_TERMINAL_ID,6 DEFAULT_TERMINAL_ID,
7 type TerminalEvent,7 type TerminalEvent,
8 type TerminalOpenInput,8 type TerminalOpenInput,
9 type TerminalRestartInput,9 type TerminalRestartInput,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { afterEach, describe, expect, it } from "vitest";
12
13import {11import {
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 {
15 type PtyAdapterShape,29 type PtyAdapterShape,
16 type PtyExitEvent,30 type PtyExitEvent,
17 type PtyProcess,31 type PtyProcess,
18 type PtySpawnInput,32 type PtySpawnInput,
33 PtySpawnError,
19} from "../Services/PTY";34} from "../Services/PTY";
20import { TerminalManagerRuntime } from "./Manager";35import { makeTerminalManagerWithOptions } from "./Manager";
21import { Effect, Encoding } from "effect";
2236
23class FakePtyProcess implements PtyProcess {37class FakePtyProcess implements PtyProcess {
24 readonly writes: string[] = [];38 readonly writes: string[] = [];
...@@ -107,27 +121,29 @@ class FakePtyAdapter implements PtyAdapterShape {...@@ -107,27 +121,29 @@ class FakePtyAdapter implements PtyAdapterShape {
107 }121 }
108}122}
109123
110function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> {124const waitFor = <E, R>(
111 const started = Date.now();125 predicate: Effect.Effect<boolean, E, R>,
112 return new Promise((resolve, reject) => {126 timeout: Duration.Input = 800,
113 const poll = () => {127): Effect.Effect<void, Error | E, R> =>
114 if (predicate()) {128 predicate.pipe(
115 resolve();129 Effect.filterOrFail(
116 return;130 (done) => done,
117 }131 () => new Error("Condition not met"),
118 if (Date.now() - started > timeoutMs) {132 ),
119 reject(new Error("Timed out waiting for condition"));133 Effect.retry(Schedule.spaced("15 millis")),
120 return;134 Effect.timeoutOption(timeout),
121 }135 Effect.flatMap((result) =>
122 setTimeout(poll, 15);136 Option.match(result, {
123 };137 onNone: () => Effect.fail(new Error("Timed out waiting for condition")),
124 poll();138 onSome: () => Effect.void,
125 });139 }),
126}140 ),
141 );
127142
128function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {143function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {
129 return {144 return {
130 threadId: "thread-1",145 threadId: "thread-1",
146 terminalId: DEFAULT_TERMINAL_ID,
131 cwd: process.cwd(),147 cwd: process.cwd(),
132 cols: 100,148 cols: 100,
133 rows: 24,149 rows: 24,
...@@ -138,6 +154,7 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu...@@ -138,6 +154,7 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu
138function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput {154function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput {
139 return {155 return {
140 threadId: "thread-1",156 threadId: "thread-1",
157 terminalId: DEFAULT_TERMINAL_ID,
141 cwd: process.cwd(),158 cwd: process.cwd(),
142 cols: 100,159 cols: 100,
143 rows: 24,160 rows: 24,
...@@ -169,598 +186,777 @@ function multiTerminalHistoryLogPath(...@@ -169,598 +186,777 @@ function multiTerminalHistoryLogPath(
169 return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));186 return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));
170}187}
171188
172describe("TerminalManager", () => {189interface CreateManagerOptions {
173 const tempDirs: string[] = [];190 shellResolver?: () => string;
174191 subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>;
175 afterEach(() => {192 subprocessPollIntervalMs?: number;
176 for (const dir of tempDirs.splice(0, tempDirs.length)) {193 processKillGraceMs?: number;
177 fs.rmSync(dir, { recursive: true, force: true });194 maxRetainedInactiveSessions?: number;
178 }195 ptyAdapter?: FakePtyAdapter;
179 });196}
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 }
211197
212 it("spawns lazily and reuses running terminal per thread", async () => {198interface ManagerFixture {
213 const { manager, ptyAdapter } = makeManager();199 readonly baseDir: string;
214 const [first, second] = await Promise.all([200 readonly logsDir: string;
215 manager.open(openInput()),201 readonly ptyAdapter: FakePtyAdapter;
216 manager.open(openInput()),202 readonly manager: TerminalManagerShape;
217 ]);203 readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>;
218 const third = await manager.open(openInput());204}
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 );
444205
445 hasRunningSubprocess = false;206const createManager = (
446 await waitFor(207 historyLineLimit = 5,
447 () =>208 options: CreateManagerOptions = {},
448 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false),209): Effect.Effect<
449 1_200,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 }),
450 );276 );
451277
452 manager.dispose();278 const chmod = (filePath: string, mode: number) =>
453 });279 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode));
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 );
513280
514 manager.dispose();281 const pathExists = (filePath: string) =>
515 });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 () => {284 const readFileString = (filePath: string) =>
518 const { manager, ptyAdapter } = makeManager();285 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath));
519 await manager.open(openInput());
520 const process = ptyAdapter.processes[0];
521 expect(process).toBeDefined();
522 if (!process) return;
523286
524 process.emitData("before ");287 const writeFileString = (filePath: string, contents: string) =>
525 process.emitData("\u001b(B");288 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
526 process.emitData("after\n");289 fs.writeFileString(filePath, contents),
290 );
527291
528 await manager.close({ threadId: "thread-1" });292 it.effect("preserves non-notFound cwd stat failures", () =>
529293 Effect.gen(function* () {
530 const reopened = await manager.open(openInput());294 const { manager, baseDir } = yield* createManager();
531 expect(reopened.history).toBe("before \u001b(Bafter\n");295 const blockedRoot = path.join(baseDir, "blocked-root");
532296 const blockedCwd = path.join(blockedRoot, "cwd");
533 manager.dispose();297 yield* makeDirectory(blockedCwd);
534 });298 yield* chmod(blockedRoot, 0o000);
535299
536 it("preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", async () => {300 const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe(
537 const { manager, ptyAdapter } = makeManager();301 Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)),
538 await manager.open(openInput());302 );
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");
546303
547 await manager.close({ threadId: "thread-1" });304 expect(error).toMatchObject({
548305 _tag: "TerminalCwdError",
549 const reopened = await manager.open(openInput());306 cwd: blockedCwd,
550 expect(reopened.history).toBe("before \u001b(Bafter\n");307 reason: "statFailed",
308 });
309 }),
310 );
551311
552 manager.dispose();312 it.effect("supports asynchronous PTY spawn effects", () =>
553 });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 () => {318 const snapshot = yield* manager.open(openInput());
556 const { manager, ptyAdapter, logsDir } = makeManager();319
557 await manager.open(openInput());320 assert.equal(snapshot.status, "running");
558 const process = ptyAdapter.processes[0];321 expect(ptyAdapter.spawnInputs).toHaveLength(1);
559 expect(process).toBeDefined();322 expect(ptyAdapter.processes).toHaveLength(1);
560 if (!process) return;323 }),
561 process.emitData("bye\n");324 );
562 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));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 });346 expect(process.writes).toEqual(["ls\n"]);
565 expect(fs.existsSync(historyLogPath(logsDir))).toBe(false);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();480 yield* manager.open(openInput());
568 });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 () => {483 hasRunningSubprocess = true;
571 const { manager, ptyAdapter, logsDir } = makeManager();484 yield* waitFor(
572 await manager.open(openInput({ terminalId: "default" }));485 Effect.map(getEvents, (events) =>
573 await manager.open(openInput({ terminalId: "sidecar" }));486 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true),
574 const defaultProcess = ptyAdapter.processes[0];487 ),
575 const sidecarProcess = ptyAdapter.processes[1];488 "1200 millis",
576 expect(defaultProcess).toBeDefined();489 );
577 expect(sidecarProcess).toBeDefined();
578 if (!defaultProcess || !sidecarProcess) return;
579490
580 defaultProcess.emitData("default\n");491 hasRunningSubprocess = false;
581 sidecarProcess.emitData("sidecar\n");492 yield* waitFor(
582 await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default")));493 Effect.map(getEvents, (events) =>
583 await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar")));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);515 yield* manager.open(openInput());
588 expect(sidecarProcess.killed).toBe(true);516 yield* waitFor(
589 expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false);517 Effect.sync(() => checks > 0),
590 expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false);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();696 yield* manager.open(openInput({ threadId: "thread-1" }));
593 });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 () => {719 const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" }));
596 const { manager, ptyAdapter } = makeManager(5, { processKillGraceMs: 10 });720 const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" }));
597 await manager.open(openInput());721
598 const process = ptyAdapter.processes[0];722 assert.equal(reopenedFirst.history, "first-history\n");
599 expect(process).toBeDefined();723 assert.equal(reopenedSecond.history, "");
600 if (!process) return;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" });833 assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo");
603 await waitFor(() => process.killSignals.includes("SIGKILL"));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");850 expect(spawnInput.args).toEqual(["-o", "nopromptsp"]);
606 expect(process.killSignals).toContain("SIGKILL");851 }),
852 );
607853
608 manager.dispose();854 it.effect("bridges PTY callbacks back into Effect-managed event streaming", () =>
609 });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 () => {860 yield* manager.open(openInput());
612 const { manager, ptyAdapter } = makeManager(5, { maxRetainedInactiveSessions: 1 });861 const process = ptyAdapter.processes[0];
862 expect(process).toBeDefined();
863 if (!process) return;
613864
614 await manager.open(openInput({ threadId: "thread-1" }));865 process.emitData("hello from callback\n");
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;
622866
623 first.emitExit({ exitCode: 0, signal: 0 });867 yield* waitFor(
624 await new Promise((resolve) => setTimeout(resolve, 5));868 Effect.map(getEvents, (events) =>
625 second.emitExit({ exitCode: 0, signal: 0 });869 events.some((event) => event.type === "output" && event.data === "hello from callback\n"),
870 ),
871 "1200 millis",
872 );
873 }),
874 );
626875
627 await waitFor(() => {876 it.effect("pushes PTY callbacks to direct event subscribers", () =>
628 const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions;877 Effect.gen(function* () {
629 return sessions.size === 1;878 const { manager, ptyAdapter } = yield* createManager(5, {
630 });879 ptyAdapter: new FakePtyAdapter("async"),
631880 });
632 const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions;881 const scope = yield* Effect.scope;
633 const keys = [...sessions.keys()];882 const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
634 expect(keys).toEqual(["thread-2\u0000default"]);883 const unsubscribe = yield* manager.subscribe((event) =>
635884 Ref.update(subscriberEvents, (events) => [...events, event]),
636 manager.dispose();885 );
637 });886 yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
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());
646887
647 expect(snapshot.history).toBe("legacy-line\n");888 yield* manager.open(openInput());
648 expect(fs.existsSync(nextPath)).toBe(true);889 const process = ptyAdapter.processes[0];
649 expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n");890 expect(process).toBeDefined();
650 expect(fs.existsSync(legacyPath)).toBe(false);891 if (!process) return;
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());
662892
663 expect(snapshot.status).toBe("running");893 process.emitData("hello from subscriber\n");
664 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
665 expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell");
666894
667 if (process.platform === "win32") {895 yield* waitFor(
668 expect(896 Effect.map(Ref.get(subscriberEvents), (events) =>
669 ptyAdapter.spawnInputs.some(897 events.some(
670 (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe",898 (event) => event.type === "output" && event.data === "hello from subscriber\n",
899 ),
671 ),900 ),
672 ).toBe(true);901 "1200 millis",
673 } else {902 );
674 expect(903 }),
675 ptyAdapter.spawnInputs904 );
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");
710905
711 try {906 it.effect("preserves queued PTY output ordering through exit callbacks", () =>
712 const { manager, ptyAdapter } = makeManager();907 Effect.gen(function* () {
713 await manager.open(openInput());908 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
714 const spawnInput = ptyAdapter.spawnInputs[0];909 ptyAdapter: new FakePtyAdapter("async"),
715 expect(spawnInput).toBeDefined();910 });
716 if (!spawnInput) return;
717911
718 expect(spawnInput.env.PORT).toBeUndefined();912 yield* manager.open(openInput());
719 expect(spawnInput.env.T3CODE_PORT).toBeUndefined();913 const process = ptyAdapter.processes[0];
720 expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined();914 expect(process).toBeDefined();
721 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");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();931 const relevant = (yield* getEvents).filter(
724 } finally {932 (event) => event.type === "output" || event.type === "exited",
725 restoreEnv();933 );
726 }934 expect(relevant).toEqual([
727 });935 expect.objectContaining({ type: "output", data: "first\n" }),
728936 expect.objectContaining({ type: "output", data: "second\n" }),
729 it("injects runtime env overrides into spawned terminals", async () => {937 expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0 }),
730 const { manager, ptyAdapter } = makeManager();938 ]);
731 await manager.open(939 }),
732 openInput({940 );
733 env: {941
734 T3CODE_PROJECT_ROOT: "/repo",942 it.effect("scoped runtime shutdown stops active terminals cleanly", () =>
735 T3CODE_WORKTREE_PATH: "/repo/worktree-a",943 Effect.gen(function* () {
736 CUSTOM_FLAG: "1",944 const scope = yield* Scope.make("sequential");
737 },945 const { manager, ptyAdapter } = yield* createManager(5, {
738 }),946 processKillGraceMs: 10,
739 );947 }).pipe(Effect.provideService(Scope.Scope, scope));
740 const spawnInput = ptyAdapter.spawnInputs[0];948 yield* manager.open(openInput());
741 expect(spawnInput).toBeDefined();949 const process = ptyAdapter.processes[0];
742 if (!spawnInput) return;950 expect(process).toBeDefined();
743951 if (!process) return;
744 expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo");952
745 expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a");953 const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped);
746 expect(spawnInput.env.CUSTOM_FLAG).toBe("1");954 yield* Effect.yieldNow;
747955 yield* TestClock.adjust("10 millis");
748 manager.dispose();956 yield* Fiber.join(closeScope);
749 });957
750958 assert.equal(process.killSignals[0], "SIGTERM");
751 it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => {959 expect(process.killSignals).toContain("SIGKILL");
752 if (process.platform === "win32") return;960 }).pipe(Effect.provide(TestClock.layer())),
753 const { manager, ptyAdapter } = makeManager(5, {961 );
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 });
766});962});
apps/server/src/terminal/Layers/Manager.ts+1305-883
...@@ -1,32 +1,44 @@...@@ -1,32 +1,44 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";1import path from "node:path";
42
5import {3import {
6 DEFAULT_TERMINAL_ID,4 DEFAULT_TERMINAL_ID,
7 TerminalClearInput,
8 TerminalCloseInput,
9 TerminalOpenInput,
10 TerminalResizeInput,
11 TerminalRestartInput,
12 TerminalWriteInput,
13 type TerminalEvent,5 type TerminalEvent,
14 type TerminalSessionSnapshot,6 type TerminalSessionSnapshot,
7 type TerminalSessionStatus,
15} from "@t3tools/contracts";8} 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";
21import { ServerConfig } from "../../config";25import { ServerConfig } from "../../config";
26import { runProcess } from "../../processRunner";
22import {27import {
23 ShellCandidate,28 TerminalCwdError,
24 TerminalError,29 TerminalHistoryError,
25 TerminalManager,30 TerminalManager,
26 TerminalManagerShape,31 TerminalNotRunningError,
27 TerminalSessionState,32 TerminalSessionLookupError,
28 TerminalStartInput,33 type TerminalManagerShape,
29} from "../Services/Manager";34} from "../Services/Manager";
35import {
36 PtyAdapter,
37 PtySpawnError,
38 type PtyAdapterShape,
39 type PtyExitEvent,
40 type PtyProcess,
41} from "../Services/PTY";
3042
31const DEFAULT_HISTORY_LINE_LIMIT = 5_000;43const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
32const DEFAULT_PERSIST_DEBOUNCE_MS = 40;44const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
...@@ -37,14 +49,128 @@ const DEFAULT_OPEN_COLS = 120;...@@ -37,14 +49,128 @@ const DEFAULT_OPEN_COLS = 120;
37const DEFAULT_OPEN_ROWS = 30;49const DEFAULT_OPEN_ROWS = 30;
38const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);50const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
3951
40const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput);52type TerminalSubprocessChecker = (
41const decodeTerminalRestartInput = Schema.decodeUnknownSync(TerminalRestartInput);53 terminalPid: number,
42const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);54) => Effect.Effect<boolean, TerminalSubprocessCheckError>;
43const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);55
44const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);56class TerminalSubprocessCheckError extends Data.TaggedError("TerminalSubprocessCheckError")<{
45const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);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
49function defaultShellResolver(): string {175function defaultShellResolver(): string {
50 if (process.platform === "win32") {176 if (process.platform === "win32") {
...@@ -118,7 +244,7 @@ function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {...@@ -118,7 +244,7 @@ function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {
118 ]);244 ]);
119}245}
120246
121function isRetryableShellSpawnError(error: unknown): boolean {247function isRetryableShellSpawnError(error: PtySpawnError): boolean {
122 const queue: unknown[] = [error];248 const queue: unknown[] = [error];
123 const seen = new Set<unknown>();249 const seen = new Set<unknown>();
124 const messages: string[] = [];250 const messages: string[] = [];
...@@ -165,82 +291,107 @@ function isRetryableShellSpawnError(error: unknown): boolean {...@@ -165,82 +291,107 @@ function isRetryableShellSpawnError(error: unknown): boolean {
165 );291 );
166}292}
167293
168async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> {294function checkWindowsSubprocessActivity(
295 terminalPid: number,
296): Effect.Effect<boolean, TerminalSubprocessCheckError> {
169 const command = [297 const command = [
170 `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,298 `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,
171 "if ($children) { exit 0 }",299 "if ($children) { exit 0 }",
172 "exit 1",300 "exit 1",
173 ].join("; ");301 ].join("; ");
174 try {302 return Effect.tryPromise({
175 const result = await runProcess(303 try: () =>
176 "powershell.exe",304 runProcess("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
177 ["-NoProfile", "-NonInteractive", "-Command", command],
178 {
179 timeoutMs: 1_500,305 timeoutMs: 1_500,
180 allowNonZeroExit: true,306 allowNonZeroExit: true,
181 maxBufferBytes: 32_768,307 maxBufferBytes: 32_768,
182 outputMode: "truncate",308 outputMode: "truncate",
183 },309 }),
184 );310 catch: (cause) =>
185 return result.code === 0;311 new TerminalSubprocessCheckError({
186 } catch {312 message: "Failed to check Windows terminal subprocess activity.",
187 return false;313 cause,
188 }314 terminalPid,
315 command: "powershell",
316 }),
317 }).pipe(Effect.map((result) => result.code === 0));
189}318}
190319
191async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> {320const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (
192 try {321 terminalPid: number,
193 const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], {322): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {
194 timeoutMs: 1_000,323 const runPgrep = Effect.tryPromise({
195 allowNonZeroExit: true,324 try: () =>
196 maxBufferBytes: 32_768,325 runProcess("pgrep", ["-P", String(terminalPid)], {
197 outputMode: "truncate",326 timeoutMs: 1_000,
198 });327 allowNonZeroExit: true,
199 if (pgrepResult.code === 0) {328 maxBufferBytes: 32_768,
200 return pgrepResult.stdout.trim().length > 0;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;
201 }361 }
202 if (pgrepResult.code === 1) {362 if (pgrepResult.value.code === 1) {
203 return false;363 return false;
204 }364 }
205 } catch {
206 // Fall back to ps when pgrep is unavailable.
207 }365 }
208366
209 try {367 const psResult = yield* Effect.exit(runPs);
210 const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], {368 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
211 timeoutMs: 1_000,369 return false;
212 allowNonZeroExit: true,370 }
213 maxBufferBytes: 262_144,
214 outputMode: "truncate",
215 });
216 if (psResult.code !== 0) {
217 return false;
218 }
219371
220 for (const line of psResult.stdout.split(/\r?\n/g)) {372 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
221 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);373 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
222 const pid = Number(pidRaw);374 const pid = Number(pidRaw);
223 const ppid = Number(ppidRaw);375 const ppid = Number(ppidRaw);
224 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;376 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
225 if (ppid === terminalPid) {377 if (ppid === terminalPid) {
226 return true;378 return true;
227 }
228 }379 }
229 return false;
230 } catch {
231 return false;
232 }380 }
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> {
236 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {387 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
237 return false;388 return false;
238 }389 }
239 if (process.platform === "win32") {390 if (process.platform === "win32") {
240 return checkWindowsSubprocessActivity(terminalPid);391 return yield* checkWindowsSubprocessActivity(terminalPid);
241 }392 }
242 return checkPosixSubprocessActivity(terminalPid);393 return yield* checkPosixSubprocessActivity(terminalPid);
243}394});
244395
245function capHistory(history: string, maxLines: number): string {396function capHistory(history: string, maxLines: number): string {
246 if (history.length === 0) return history;397 if (history.length === 0) return history;
...@@ -482,12 +633,8 @@ function normalizedRuntimeEnv(...@@ -482,12 +633,8 @@ function normalizedRuntimeEnv(
482 return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right)));633 return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right)));
483}634}
484635
485interface TerminalManagerEvents {
486 event: [event: TerminalEvent];
487}
488
489interface TerminalManagerOptions {636interface TerminalManagerOptions {
490 logsDir?: string;637 logsDir: string;
491 historyLineLimit?: number;638 historyLineLimit?: number;
492 ptyAdapter: PtyAdapterShape;639 ptyAdapter: PtyAdapterShape;
493 shellResolver?: () => string;640 shellResolver?: () => string;
...@@ -497,917 +644,1192 @@ interface TerminalManagerOptions {...@@ -497,917 +644,1192 @@ interface TerminalManagerOptions {
497 maxRetainedInactiveSessions?: number;644 maxRetainedInactiveSessions?: number;
498}645}
499646
500export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> {647const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () {
501 private readonly sessions = new Map<string, TerminalSessionState>();648 const { terminalLogsDir } = yield* ServerConfig;
502 private readonly logsDir: string;649 const ptyAdapter = yield* PtyAdapter;
503 private readonly historyLineLimit: number;650 return yield* makeTerminalManagerWithOptions({
504 private readonly ptyAdapter: PtyAdapterShape;651 logsDir: terminalLogsDir,
505 private readonly shellResolver: () => string;652 ptyAdapter,
506 private readonly persistQueues = new Map<string, Promise<void>>();653 });
507 private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>();654});
508 private readonly pendingPersistHistory = new Map<string, string>();655
509 private readonly threadLocks = new Map<string, Promise<void>>();656export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")(
510 private readonly persistDebounceMs: number;657 function* (options: TerminalManagerOptions) {
511 private readonly subprocessChecker: TerminalSubprocessChecker;658 const fileSystem = yield* FileSystem.FileSystem;
512 private readonly subprocessPollIntervalMs: number;659 const services = yield* Effect.services();
513 private readonly processKillGraceMs: number;660 const runFork = Effect.runForkWith(services);
514 private readonly maxRetainedInactiveSessions: number;661
515 private subprocessPollTimer: ReturnType<typeof setInterval> | null = null;662 const logsDir = options.logsDir;
516 private subprocessPollInFlight = false;663 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
517 private readonly killEscalationTimers = new Map<PtyProcess, ReturnType<typeof setTimeout>>();664 const shellResolver = options.shellResolver ?? defaultShellResolver;
518 private readonly logger = createLogger("terminal");665 const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
519666 const subprocessPollIntervalMs =
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 =
529 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;667 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
530 this.processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;668 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
531 this.maxRetainedInactiveSessions =669 const maxRetainedInactiveSessions =
532 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;670 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;
533 fs.mkdirSync(this.logsDir, { recursive: true });
534 }
535671
536 async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {672 yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie);
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 }
572673
573 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);674 const managerStateRef = yield* SynchronizedRef.make<TerminalManagerState>({
574 const currentRuntimeEnv = existing.runtimeEnv;675 sessions: new Map(),
575 const targetCols = input.cols ?? existing.cols;676 killFibers: new Map(),
576 const targetRows = input.rows ?? existing.rows;677 });
577 const runtimeEnvChanged =678 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
578 JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv);679 const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
579680 const workerScope = yield* Scope.make("sequential");
580 if (existing.cwd !== input.cwd || runtimeEnvChanged) {681 yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void));
581 this.stopProcess(existing);682
582 existing.cwd = input.cwd;683 const publishEvent = (event: TerminalEvent) =>
583 existing.runtimeEnv = nextRuntimeEnv;684 Effect.gen(function* () {
584 existing.history = "";685 for (const listener of terminalEventListeners) {
585 existing.pendingHistoryControlSequence = "";686 yield* listener(event).pipe(Effect.ignoreCause({ log: true }));
586 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);687 }
587 } else if (existing.status === "exited" || existing.status === "error") {688 });
588 existing.runtimeEnv = nextRuntimeEnv;689
589 existing.history = "";690 const historyPath = (threadId: string, terminalId: string) => {
590 existing.pendingHistoryControlSequence = "";691 const threadPart = toSafeThreadId(threadId);
591 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);692 if (terminalId === DEFAULT_TERMINAL_ID) {
592 } else if (currentRuntimeEnv !== nextRuntimeEnv) {693 return path.join(logsDir, `${threadPart}.log`);
593 existing.runtimeEnv = nextRuntimeEnv;
594 }694 }
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) {701 const toTerminalHistoryError =
597 await this.startSession(702 (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) =>
598 existing,703 (cause: unknown) =>
599 { ...input, cols: targetCols, rows: targetRows },704 new TerminalHistoryError({
600 "started",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),
601 );721 );
602 return this.snapshot(existing);722 return Option.match(existing, {
603 }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) {735 const withThreadLock = <A, E, R>(
606 existing.cols = targetCols;736 threadId: string,
607 existing.rows = targetRows;737 effect: Effect.Effect<A, E, R>,
608 existing.process.resize(targetCols, targetRows);738 ): Effect.Effect<A, E, R> =>
609 existing.updatedAt = new Date().toISOString();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);
610 }760 }
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 });
613 });772 });
614 }
615773
616 async write(raw: TerminalWriteInput): Promise<void> {774 const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* (
617 const input = decodeTerminalWriteInput(raw);775 process: PtyProcess,
618 const session = this.requireSession(input.threadId, input.terminalId);776 threadId: string,
619 if (!session.process || session.status !== "running") {777 terminalId: string,
620 if (session.status === "exited") {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) {
621 return;799 return;
622 }800 }
623 throw new Error(801
624 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,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 ),
625 );821 );
626 }822 });
627 session.process.write(input.data);
628 }
629823
630 async resize(raw: TerminalResizeInput): Promise<void> {824 const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* (
631 const input = decodeTerminalResizeInput(raw);825 process: PtyProcess,
632 const session = this.requireSession(input.threadId, input.terminalId);826 threadId: string,
633 if (!session.process || session.status !== "running") {827 terminalId: string,
634 throw new Error(828 ) {
635 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,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),
636 );841 );
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> {843 yield* registerKillFiber(process, fiber);
645 const input = decodeTerminalClearInput(raw);844 });
646 await this.runWithThreadLock(input.threadId, async () => {845
647 const session = this.requireSession(input.threadId, input.terminalId);846 const persistWorker = yield* makeKeyedCoalescingWorker<
648 session.history = "";847 string,
649 session.pendingHistoryControlSequence = "";848 PersistHistoryRequest,
650 session.updatedAt = new Date().toISOString();849 never,
651 await this.persistHistory(input.threadId, input.terminalId, session.history);850 never
652 this.emitEvent({851 >({
653 type: "cleared",852 merge: (current, next) => ({
654 threadId: input.threadId,853 history: next.history,
655 terminalId: input.terminalId,854 immediate: current.immediate || next.immediate,
656 createdAt: new Date().toISOString(),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,
657 });886 });
658 });887 });
659 }
660888
661 async restart(raw: TerminalRestartInput): Promise<TerminalSessionSnapshot> {889 const flushPersist = Effect.fn("terminal.flushPersist")(function* (
662 const input = decodeTerminalRestartInput(raw);890 threadId: string,
663 return this.runWithThreadLock(input.threadId, async () => {891 terminalId: string,
664 await this.assertValidCwd(input.cwd);892 ) {
665893 yield* persistWorker.drainKey(toSessionKey(threadId, terminalId));
666 const sessionKey = toSessionKey(input.threadId, input.terminalId);894 });
667 let session = this.sessions.get(sessionKey);895
668 if (!session) {896 const persistHistory = Effect.fn("terminal.persistHistory")(function* (
669 const cols = input.cols ?? DEFAULT_OPEN_COLS;897 threadId: string,
670 const rows = input.rows ?? DEFAULT_OPEN_ROWS;898 terminalId: string,
671 session = {899 history: string,
672 threadId: input.threadId,900 ) {
673 terminalId: input.terminalId,901 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
674 cwd: input.cwd,902 history,
675 status: "starting",903 immediate: true,
676 pid: null,904 });
677 history: "",905 yield* flushPersist(threadId, terminalId);
678 pendingHistoryControlSequence: "",906 });
679 exitCode: null,907
680 exitSignal: null,908 const readHistory = Effect.fn("terminal.readHistory")(function* (
681 updatedAt: new Date().toISOString(),909 threadId: string,
682 cols,910 terminalId: string,
683 rows,911 ) {
684 process: null,912 const nextPath = historyPath(threadId, terminalId);
685 unsubscribeData: null,913 if (
686 unsubscribeExit: null,914 yield* fileSystem
687 hasRunningSubprocess: false,915 .exists(nextPath)
688 runtimeEnv: normalizedRuntimeEnv(input.env),916 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)))
689 };917 ) {
690 this.sessions.set(sessionKey, session);918 const raw = yield* fileSystem
691 this.evictInactiveSessionsIfNeeded();919 .readFileString(nextPath)
692 } else {920 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)));
693 this.stopProcess(session);921 const capped = capHistory(raw, historyLineLimit);
694 session.cwd = input.cwd;922 if (capped !== raw) {
695 session.runtimeEnv = normalizedRuntimeEnv(input.env);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 "";
696 }932 }
697933
698 const cols = input.cols ?? session.cols;934 const legacyPath = legacyHistoryPath(threadId);
699 const rows = input.rows ?? session.rows;935 if (
936 !(yield* fileSystem
937 .exists(legacyPath)
938 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))))
939 ) {
940 return "";
941 }
700942
701 session.history = "";943 const raw = yield* fileSystem
702 session.pendingHistoryControlSequence = "";944 .readFileString(legacyPath)
703 await this.persistHistory(input.threadId, input.terminalId, session.history);945 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
704 await this.startSession(session, { ...input, cols, rows }, "restarted");946 const capped = capHistory(raw, historyLineLimit);
705 return this.snapshot(session);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;
706 });959 });
707 }
708960
709 async close(raw: TerminalCloseInput): Promise<void> {961 const deleteHistory = Effect.fn("terminal.deleteHistory")(function* (
710 const input = decodeTerminalCloseInput(raw);962 threadId: string,
711 await this.runWithThreadLock(input.threadId, async () => {963 terminalId: string,
712 if (input.terminalId) {964 ) {
713 await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true);965 yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe(
714 return;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 );
715 }984 }
985 });
716986
717 const threadSessions = this.sessionsForThread(input.threadId);987 const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* (
718 for (const session of threadSessions) {988 threadId: string,
719 this.stopProcess(session);989 ) {
720 this.sessions.delete(toSessionKey(session.threadId, session.terminalId));990 const threadPrefix = `${toSafeThreadId(threadId)}_`;
721 }991 const entries = yield* fileSystem
722 await Promise.all(992 .readDirectory(logsDir, { recursive: false })
723 threadSessions.map((session) =>993 .pipe(Effect.catch(() => Effect.succeed([] as Array<string>)));
724 this.flushPersistQueue(session.threadId, session.terminalId),994 yield* Effect.forEach(
995 entries.filter(
996 (name) =>
997 name === `${toSafeThreadId(threadId)}.log` ||
998 name === `${legacySafeThreadId(threadId)}.log` ||
999 name.startsWith(threadPrefix),
725 ),1000 ),
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 },
726 );1011 );
1012 });
7271013
728 if (input.deleteHistory) {1014 const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) {
729 await this.deleteAllHistoryForThread(input.threadId);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 });
730 }1030 }
731 this.updateSubprocessPollingState();
732 });1031 });
733 }
7341032
735 dispose(): void {1033 const getSession = Effect.fn("terminal.getSession")(function* (
736 this.stopSubprocessPolling();1034 threadId: string,
737 const sessions = [...this.sessions.values()];1035 terminalId: string,
738 this.sessions.clear();1036 ): Effect.fn.Return<Option.Option<TerminalSessionState>> {
739 for (const session of sessions) {1037 return yield* Effect.map(readManagerState, (state) =>
740 this.stopProcess(session);1038 Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))),
741 }1039 );
742 for (const timer of this.persistTimers.values()) {1040 });
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 }
7541041
755 private async startSession(1042 const requireSession = Effect.fn("terminal.requireSession")(function* (
756 session: TerminalSessionState,1043 threadId: string,
757 input: TerminalStartInput,1044 terminalId: string,
758 eventType: "started" | "restarted",1045 ): Effect.fn.Return<TerminalSessionState, TerminalSessionLookupError> {
759 ): Promise<void> {1046 return yield* Effect.flatMap(getSession(threadId, terminalId), (session) =>
760 this.stopProcess(session);1047 Option.match(session, {
7611048 onNone: () =>
762 session.status = "starting";1049 Effect.fail(
763 session.cwd = input.cwd;1050 new TerminalSessionLookupError({
764 session.cols = input.cols;1051 threadId,
765 session.rows = input.rows;1052 terminalId,
766 session.exitCode = null;1053 }),
767 session.exitSignal = null;1054 ),
768 session.hasRunningSubprocess = false;1055 onSome: Effect.succeed,
769 session.updatedAt = new Date().toISOString();1056 }),
7701057 );
771 let ptyProcess: PtyProcess | null = null;1058 });
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 );
7891059
790 const trySpawn = async (1060 const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) {
791 candidates: ShellCandidate[],1061 return yield* readManagerState.pipe(
792 index = 0,1062 Effect.map((state) =>
793 ): Promise<{ process: PtyProcess; shellLabel: string } | null> => {1063 [...state.sessions.values()].filter((session) => session.threadId === threadId),
794 if (index >= candidates.length) {1064 ),
795 return null;1065 );
796 }1066 });
797 const candidate = candidates[index];1067
798 if (!candidate) {1068 const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")(
799 return null;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;
800 }1178 }
8011179
802 try {1180 if (action.type === "output") {
803 const process = await spawnWithCandidate(candidate);1181 if (action.history !== null) {
804 return { process, shellLabel: formatShellCandidate(candidate) };1182 yield* queuePersist(action.threadId, action.terminalId, action.history);
805 } catch (error) {
806 lastSpawnError = error;
807 if (!isRetryableShellSpawnError(error)) {
808 throw error;
809 }1183 }
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;
811 }1193 }
812 };
8131194
814 const spawnResult = await trySpawn(shellCandidates);1195 yield* clearKillFiber(action.process);
815 if (spawnResult) {1196 yield* publishEvent({
816 ptyProcess = spawnResult.process;1197 type: "exited",
817 startedShell = spawnResult.shellLabel;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;
818 }1206 }
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) {1229 yield* clearKillFiber(process);
821 const detail =1230 yield* startKillEscalation(process, session.threadId, session.terminalId);
822 lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed";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";
823 const tried =1243 const tried =
824 shellCandidates.length > 01244 shellCandidates.length > 0
825 ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.`1245 ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.`
826 : "";1246 : "";
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 });
828 }1252 }
8291253
830 session.process = ptyProcess;1254 const candidate = shellCandidates[index];
831 session.pid = ptyProcess.pid;1255 if (!candidate) {
832 session.status = "running";1256 return yield* (
833 session.updatedAt = new Date().toISOString();1257 lastError ??
834 session.unsubscribeData = ptyProcess.onData((data) => {1258 new PtySpawnError({
835 this.onProcessData(session, data);1259 adapter: "terminal-manager",
836 });1260 message: "No shell candidate available for PTY spawn.",
837 session.unsubscribeExit = ptyProcess.onExit((event) => {1261 })
838 this.onProcessExit(session, event);1262 );
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);
851 }1263 }
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 {1265 const attempt = yield* Effect.result(
877 const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, data);1266 options.ptyAdapter.spawn({
878 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;1267 shell: candidate.shell,
879 if (sanitized.visibleText.length > 0) {1268 ...(candidate.args ? { args: candidate.args } : {}),
880 session.history = capHistory(1269 cwd: session.cwd,
881 `${session.history}${sanitized.visibleText}`,1270 cols: session.cols,
882 this.historyLineLimit,1271 rows: session.rows,
1272 env: spawnEnv,
1273 }),
883 );1274 );
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 {1276 if (attempt._tag === "Success") {
897 this.clearKillEscalationTimer(session.process);1277 return {
898 this.cleanupProcessHandles(session);1278 process: attempt.success,
899 session.process = null;1279 shellLabel: formatShellCandidate(candidate),
900 session.pid = null;1280 };
901 session.hasRunningSubprocess = false;1281 }
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 }
9181282
919 private stopProcess(session: TerminalSessionState): void {1283 const spawnError = attempt.failure;
920 const process = session.process;1284 if (!isRetryableShellSpawnError(spawnError)) {
921 if (!process) return;1285 return yield* spawnError;
922 this.cleanupProcessHandles(session);1286 }
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 }
9331287
934 private cleanupProcessHandles(session: TerminalSessionState): void {1288 return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError);
935 session.unsubscribeData?.();1289 });
936 session.unsubscribeData = null;
937 session.unsubscribeExit?.();
938 session.unsubscribeExit = null;
939 }
9401290
941 private clearKillEscalationTimer(process: PtyProcess | null): void {1291 const startSession = Effect.fn("terminal.startSession")(function* (
942 if (!process) return;1292 session: TerminalSessionState,
943 const timer = this.killEscalationTimers.get(process);1293 input: TerminalStartInput,
944 if (!timer) return;1294 eventType: "started" | "restarted",
945 clearTimeout(timer);1295 ) {
946 this.killEscalationTimers.delete(process);1296 yield* stopProcess(session);
947 }
9481297
949 private killProcessWithEscalation(1298 yield* modifyManagerState((state) => {
950 process: PtyProcess,1299 session.status = "starting";
951 threadId: string,1300 session.cwd = input.cwd;
952 terminalId: string,1301 session.cols = input.cols;
953 ): void {1302 session.rows = input.rows;
954 this.clearKillEscalationTimer(process);1303 session.exitCode = null;
955 try {1304 session.exitSignal = null;
956 process.kill("SIGTERM");1305 session.hasRunningSubprocess = false;
957 } catch (error) {1306 session.pendingProcessEvents = [];
958 const message = error instanceof Error ? error.message : String(error);1307 session.pendingProcessEventIndex = 0;
959 this.logger.warn("failed to kill terminal process", {1308 session.processEventDrainRunning = false;
960 threadId,1309 session.updatedAt = new Date().toISOString();
961 terminalId,1310 return [undefined, state] as const;
962 signal: "SIGTERM",
963 error: message,
964 });1311 });
965 return;
966 }
9671312
968 const timer = setTimeout(() => {1313 let ptyProcess: PtyProcess | null = null;
969 this.killEscalationTimers.delete(process);1314 let startedShell: string | null = null;
970 try {1315
971 process.kill("SIGKILL");1316 const startResult = yield* Effect.result(
972 } catch (error) {1317 Effect.gen(function* () {
973 const message = error instanceof Error ? error.message : String(error);1318 const shellCandidates = resolveShellCandidates(shellResolver);
974 this.logger.warn("failed to force-kill terminal process", {1319 const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv);
975 threadId,1320 const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session);
976 terminalId,1321 ptyProcess = spawnResult.process;
977 signal: "SIGKILL",1322 startedShell = spawnResult.shellLabel;
978 error: message,1323
979 });1324 const processPid = ptyProcess.pid;
980 }1325 const unsubscribeData = ptyProcess.onData((data) => {
981 }, this.processKillGraceMs);1326 if (!enqueueProcessEvent(session, processPid, { type: "output", data })) {
982 timer.unref?.();1327 return;
983 this.killEscalationTimers.set(process, timer);1328 }
984 }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 {1338 yield* modifyManagerState((state) => {
987 const inactiveSessions = [...this.sessions.values()].filter(1339 session.process = ptyProcess;
988 (session) => session.status !== "running",1340 session.pid = processPid;
989 );1341 session.status = "running";
990 if (inactiveSessions.length <= this.maxRetainedInactiveSessions) {1342 session.updatedAt = new Date().toISOString();
991 return;1343 session.unsubscribeData = unsubscribeData;
992 }1344 session.unsubscribeExit = unsubscribeExit;
1345 return [undefined, state] as const;
1346 });
9931347
994 inactiveSessions.sort(1348 yield* publishEvent({
995 (left, right) =>1349 type: eventType,
996 left.updatedAt.localeCompare(right.updatedAt) ||1350 threadId: session.threadId,
997 left.threadId.localeCompare(right.threadId) ||1351 terminalId: session.terminalId,
998 left.terminalId.localeCompare(right.terminalId),1352 createdAt: new Date().toISOString(),
999 );1353 snapshot: snapshot(session),
1000 const toEvict = inactiveSessions.length - this.maxRetainedInactiveSessions;1354 });
1001 for (const session of inactiveSessions.slice(0, toEvict)) {1355 }),
1002 const key = toSessionKey(session.threadId, session.terminalId);1356 );
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 }
10101357
1011 private queuePersist(threadId: string, terminalId: string, history: string): void {1358 if (startResult._tag === "Success") {
1012 const persistenceKey = toSessionKey(threadId, terminalId);1359 return;
1013 this.pendingPersistHistory.set(persistenceKey, history);1360 }
1014 this.schedulePersist(threadId, terminalId);
1015 }
10161361
1017 private async persistHistory(1362 {
1018 threadId: string,1363 const error = startResult.failure;
1019 terminalId: string,1364 if (ptyProcess) {
1020 history: string,1365 yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId);
1021 ): Promise<void> {1366 }
1022 const persistenceKey = toSessionKey(threadId, terminalId);
1023 this.clearPersistTimer(threadId, terminalId);
1024 this.pendingPersistHistory.delete(persistenceKey);
1025 await this.enqueuePersistWrite(threadId, terminalId, history);
1026 }
10271367
1028 private enqueuePersistWrite(1368 yield* modifyManagerState((state) => {
1029 threadId: string,1369 session.status = "error";
1030 terminalId: string,1370 session.pid = null;
1031 history: string,1371 session.process = null;
1032 ): Promise<void> {1372 session.unsubscribeData = null;
1033 const persistenceKey = toSessionKey(threadId, terminalId);1373 session.unsubscribeExit = null;
1034 const task = async () => {1374 session.hasRunningSubprocess = false;
1035 await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8");1375 session.pendingProcessEvents = [];
1036 };1376 session.pendingProcessEventIndex = 0;
1037 const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve();1377 session.processEventDrainRunning = false;
1038 const next = previous1378 session.updatedAt = new Date().toISOString();
1039 .catch(() => undefined)1379 return [undefined, state] as const;
1040 .then(task)1380 });
1041 .catch((error) => {1381
1042 this.logger.warn("failed to persist terminal history", {1382 yield* evictInactiveSessionsIfNeeded();
1043 threadId,1383
1044 terminalId,1384 const message = error.message;
1045 error: error instanceof Error ? error.message : String(error),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 } : {}),
1046 });1397 });
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);
1058 }1398 }
1059 });1399 });
1060 void finalized.catch(() => undefined);
1061 return finalized;
1062 }
10631400
1064 private schedulePersist(threadId: string, terminalId: string): void {1401 const closeSession = Effect.fn("terminal.closeSession")(function* (
1065 const persistenceKey = toSessionKey(threadId, terminalId);1402 threadId: string,
1066 if (this.persistTimers.has(persistenceKey)) return;1403 terminalId: string,
1067 const timer = setTimeout(() => {1404 deleteHistoryOnClose: boolean,
1068 this.persistTimers.delete(persistenceKey);1405 ) {
1069 const pendingHistory = this.pendingPersistHistory.get(persistenceKey);1406 const key = toSessionKey(threadId, terminalId);
1070 if (pendingHistory === undefined) return;1407 const session = yield* getSession(threadId, terminalId);
1071 this.pendingPersistHistory.delete(persistenceKey);1408
1072 void this.enqueuePersistWrite(threadId, terminalId, pendingHistory);1409 if (Option.isSome(session)) {
1073 }, this.persistDebounceMs);1410 yield* stopProcess(session.value);
1074 this.persistTimers.set(persistenceKey, timer);1411 yield* persistHistory(threadId, terminalId, session.value.history);
1075 }1412 }
10761413
1077 private clearPersistTimer(threadId: string, terminalId: string): void {1414 yield* flushPersist(threadId, terminalId);
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 }
10841415
1085 private async readHistory(threadId: string, terminalId: string): Promise<string> {1416 yield* modifyManagerState((state) => {
1086 const nextPath = this.historyPath(threadId, terminalId);1417 if (!state.sessions.has(key)) {
1087 try {1418 return [undefined, state] as const;
1088 const raw = await fs.promises.readFile(nextPath, "utf8");1419 }
1089 const capped = capHistory(raw, this.historyLineLimit);1420 const sessions = new Map(state.sessions);
1090 if (capped !== raw) {1421 sessions.delete(key);
1091 await fs.promises.writeFile(nextPath, capped, "utf8");1422 return [undefined, { ...state, sessions }] as const;
1092 }1423 });
1093 return capped;1424
1094 } catch (error) {1425 if (deleteHistoryOnClose) {
1095 if ((error as NodeJS.ErrnoException).code !== "ENOENT") {1426 yield* deleteHistory(threadId, terminalId);
1096 throw error;
1097 }1427 }
1098 }1428 });
10991429
1100 if (terminalId !== DEFAULT_TERMINAL_ID) {1430 const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () {
1101 return "";1431 const state = yield* readManagerState;
1102 }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);1437 if (runningSessions.length === 0) {
1105 try {1438 return;
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 });
1118 }1439 }
11191440
1120 return capped;1441 const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* (
1121 } catch (error) {1442 session: TerminalSessionState & { pid: number },
1122 if ((error as NodeJS.ErrnoException).code === "ENOENT") {1443 ) {
1123 return "";1444 const terminalPid = session.pid;
1124 }1445 const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe(
1125 throw error;1446 Effect.map(Option.some),
1126 }1447 Effect.catch((error) =>
1127 }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> {1457 if (Option.isNone(hasRunningSubprocess)) {
1130 const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })];1458 return;
1131 if (terminalId === DEFAULT_TERMINAL_ID) {1459 }
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 }
11441460
1145 private async flushPersistQueue(threadId: string, terminalId: string): Promise<void> {1461 const event = yield* modifyManagerState((state) => {
1146 const persistenceKey = toSessionKey(threadId, terminalId);1462 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
1147 this.clearPersistTimer(threadId, terminalId);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) {1474 liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value;
1150 const pendingHistory = this.pendingPersistHistory.get(persistenceKey);1475 liveSession.value.updatedAt = new Date().toISOString();
1151 if (pendingHistory !== undefined) {1476
1152 this.pendingPersistHistory.delete(persistenceKey);1477 return [
1153 await this.enqueuePersistWrite(threadId, terminalId, pendingHistory);1478 Option.some({
1154 }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);1489 if (Option.isSome(event)) {
1157 if (!pending) {1490 yield* publishEvent(event.value);
1158 return;1491 }
1159 }1492 });
1160 await pending.catch(() => undefined);
1161 }
1162 }
11631493
1164 private updateSubprocessPollingState(): void {1494 yield* Effect.forEach(runningSessions, checkSubprocessActivity, {
1165 const hasRunningSessions = [...this.sessions.values()].some(1495 concurrency: "unbounded",
1166 (session) => session.status === "running" && session.pid !== null,1496 discard: true,
1167 );1497 });
1168 if (hasRunningSessions) {1498 });
1169 this.ensureSubprocessPolling();
1170 return;
1171 }
1172 this.stopSubprocessPolling();
1173 }
11741499
1175 private ensureSubprocessPolling(): void {1500 const hasRunningSessions = readManagerState.pipe(
1176 if (this.subprocessPollTimer) return;1501 Effect.map((state) =>
1177 this.subprocessPollTimer = setInterval(() => {1502 [...state.sessions.values()].some((session) => session.status === "running"),
1178 void this.pollSubprocessActivity();1503 ),
1179 }, this.subprocessPollIntervalMs);1504 );
1180 this.subprocessPollTimer.unref?.();
1181 void this.pollSubprocessActivity();
1182 }
11831505
1184 private stopSubprocessPolling(): void {1506 yield* Effect.forever(
1185 if (!this.subprocessPollTimer) return;1507 hasRunningSessions.pipe(
1186 clearInterval(this.subprocessPollTimer);1508 Effect.flatMap((active) =>
1187 this.subprocessPollTimer = null;1509 active
1188 }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> {1531 const cleanupSession = Effect.fn("terminal.cleanupSession")(function* (
1191 if (this.subprocessPollInFlight) return;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(1540 yield* Effect.forEach(sessions, cleanupSession, {
1194 (session): session is TerminalSessionState & { pid: number } =>1541 concurrency: "unbounded",
1195 session.status === "running" && Number.isInteger(session.pid),1542 discard: true,
1543 });
1544 }).pipe(Effect.ignoreCause({ log: true })),
1196 );1545 );
1197 if (runningSessions.length === 0) {
1198 this.stopSubprocessPolling();
1199 return;
1200 }
12011546
1202 this.subprocessPollInFlight = true;1547 const open: TerminalManagerShape["open"] = (input) =>
1203 try {1548 withThreadLock(
1204 await Promise.all(1549 input.threadId,
1205 runningSessions.map(async (session) => {1550 Effect.gen(function* () {
1206 const terminalPid = session.pid;1551 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1207 let hasRunningSubprocess = false;1552 yield* assertValidCwd(input.cwd);
1208 try {1553
1209 hasRunningSubprocess = await this.subprocessChecker(terminalPid);1554 const sessionKey = toSessionKey(input.threadId, terminalId);
1210 } catch (error) {1555 const existing = yield* getSession(input.threadId, terminalId);
1211 this.logger.warn("failed to check terminal subprocess activity", {1556 if (Option.isNone(existing)) {
1212 threadId: session.threadId,1557 yield* flushPersist(input.threadId, terminalId);
1213 terminalId: session.terminalId,1558 const history = yield* readHistory(input.threadId, terminalId);
1214 terminalPid,1559 const cols = input.cols ?? DEFAULT_OPEN_COLS;
1215 error: error instanceof Error ? error.message : String(error),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;
1216 });1589 });
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);
1218 }1605 }
12191606
1220 const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId));1607 const liveSession = existing.value;
1221 if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) {1608 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
1222 return;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 );
1223 }1640 }
1224 if (liveSession.hasRunningSubprocess === hasRunningSubprocess) {1641
1225 return;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);
1226 }1656 }
12271657
1228 liveSession.hasRunningSubprocess = hasRunningSubprocess;1658 if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) {
1229 liveSession.updatedAt = new Date().toISOString();1659 liveSession.cols = targetCols;
1230 this.emitEvent({1660 liveSession.rows = targetRows;
1231 type: "activity",1661 liveSession.updatedAt = new Date().toISOString();
1232 threadId: liveSession.threadId,1662 liveSession.process.resize(targetCols, targetRows);
1233 terminalId: liveSession.terminalId,1663 }
1234 createdAt: new Date().toISOString(),1664
1235 hasRunningSubprocess,1665 return snapshot(liveSession);
1236 });
1237 }),1666 }),
1238 );1667 );
1239 } finally {
1240 this.subprocessPollInFlight = false;
1241 }
1242 }
12431668
1244 private async assertValidCwd(cwd: string): Promise<void> {1669 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {
1245 let stats: fs.Stats;1670 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1246 try {1671 const session = yield* requireSession(input.threadId, terminalId);
1247 stats = await fs.promises.stat(cwd);1672 const process = session.process;
1248 } catch (error) {1673 if (!process || session.status !== "running") {
1249 if ((error as NodeJS.ErrnoException).code === "ENOENT") {1674 if (session.status === "exited") return;
1250 throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error });1675 return yield* new TerminalNotRunningError({
1676 threadId: input.threadId,
1677 terminalId,
1678 });
1251 }1679 }
1252 throw error;1680 yield* Effect.sync(() => process.write(input.data));
1253 }1681 });
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 }
13111682
1312 private snapshot(session: TerminalSessionState): TerminalSessionSnapshot {1683 const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) {
1313 return {1684 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1314 threadId: session.threadId,1685 const session = yield* requireSession(input.threadId, terminalId);
1315 terminalId: session.terminalId,1686 const process = session.process;
1316 cwd: session.cwd,1687 if (!process || session.status !== "running") {
1317 status: session.status,1688 return yield* new TerminalNotRunningError({
1318 pid: session.pid,1689 threadId: input.threadId,
1319 history: session.history,1690 terminalId,
1320 exitCode: session.exitCode,1691 });
1321 exitSignal: session.exitSignal,1692 }
1322 updatedAt: session.updatedAt,1693 session.cols = input.cols;
1323 };1694 session.rows = input.rows;
1324 }1695 session.updatedAt = new Date().toISOString();
1696 yield* Effect.sync(() => process.resize(input.cols, input.rows));
1697 });
13251698
1326 private emitEvent(event: TerminalEvent): void {1699 const clear: TerminalManagerShape["clear"] = (input) =>
1327 this.emit("event", event);1700 withThreadLock(
1328 }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 {1721 const restart: TerminalManagerShape["restart"] = (input) =>
1331 const threadPart = toSafeThreadId(threadId);1722 withThreadLock(
1332 if (terminalId === DEFAULT_TERMINAL_ID) {1723 input.threadId,
1333 return path.join(this.logsDir, `${threadPart}.log`);1724 Effect.gen(function* () {
1334 }1725 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1335 return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`);1726 yield* assertValidCwd(input.cwd);
1336 }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 {1770 const cols = input.cols ?? session.cols;
1339 return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`);1771 const rows = input.rows ?? session.rows;
1340 }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> {1795 const close: TerminalManagerShape["close"] = (input) =>
1343 const previous = this.threadLocks.get(threadId) ?? Promise.resolve();1796 withThreadLock(
1344 let release!: () => void;1797 input.threadId,
1345 const current = new Promise<void>((resolve) => {1798 Effect.gen(function* () {
1346 release = resolve;1799 if (input.terminalId) {
1347 });1800 yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true);
1348 this.threadLocks.set(threadId, current);1801 return;
1349 await previous.catch(() => undefined);1802 }
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}
13601803
1361export const TerminalManagerLive = Layer.effect(1804 const threadSessions = yield* sessionsForThread(input.threadId);
1362 TerminalManager,1805 yield* Effect.forEach(
1363 Effect.gen(function* () {1806 threadSessions,
1364 const { terminalLogsDir } = yield* ServerConfig;1807 (session) => closeSession(input.threadId, session.terminalId, false),
1808 { discard: true },
1809 );
13651810
1366 const ptyAdapter = yield* PtyAdapter;1811 if (input.deleteHistory) {
1367 const runtime = yield* Effect.acquireRelease(1812 yield* deleteAllHistoryForThread(input.threadId);
1368 Effect.sync(() => new TerminalManagerRuntime({ logsDir: terminalLogsDir, ptyAdapter })),1813 }
1369 (r) => Effect.sync(() => r.dispose()),1814 }),
1370 );1815 );
13711816
1372 return {1817 return {
1373 open: (input) =>1818 open,
1374 Effect.tryPromise({1819 write,
1375 try: () => runtime.open(input),1820 resize,
1376 catch: (cause) => new TerminalError({ message: "Failed to open terminal", cause }),1821 clear,
1377 }),1822 restart,
1378 write: (input) =>1823 close,
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 }),
1403 subscribe: (listener) =>1824 subscribe: (listener) =>
1404 Effect.sync(() => {1825 Effect.sync(() => {
1405 runtime.on("event", listener);1826 terminalEventListeners.add(listener);
1406 return () => {1827 return () => {
1407 runtime.off("event", listener);1828 terminalEventListeners.delete(listener);
1408 };1829 };
1409 }),1830 }),
1410 dispose: Effect.sync(() => runtime.dispose()),
1411 } satisfies TerminalManagerShape;1831 } satisfies TerminalManagerShape;
1412 }),1832 },
1413);1833);
1834
1835export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager());
apps/server/src/terminal/Services/Manager.ts+70-37
...@@ -14,47 +14,81 @@ import {...@@ -14,47 +14,81 @@ import {
14 TerminalResizeInput,14 TerminalResizeInput,
15 TerminalRestartInput,15 TerminalRestartInput,
16 TerminalSessionSnapshot,16 TerminalSessionSnapshot,
17 TerminalSessionStatus,
18 TerminalWriteInput,17 TerminalWriteInput,
19} from "@t3tools/contracts";18} from "@t3tools/contracts";
20import { PtyProcess } from "./PTY";
21import { Effect, Schema, ServiceMap } from "effect";19import { Effect, Schema, ServiceMap } from "effect";
2220
23export class TerminalError extends Schema.TaggedErrorClass<TerminalError>()("TerminalError", {21export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()(
24 message: Schema.String,22 "TerminalCwdError",
25 cause: Schema.optional(Schema.Defect),23 {
26}) {}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 {46export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistoryError>()(
29 threadId: string;47 "TerminalHistoryError",
30 terminalId: string;48 {
31 cwd: string;49 operation: Schema.Literals(["read", "truncate", "migrate"]),
32 status: TerminalSessionStatus;50 threadId: Schema.String,
33 pid: number | null;51 terminalId: Schema.String,
34 history: string;52 cause: Schema.optional(Schema.Defect),
35 pendingHistoryControlSequence: string;53 },
36 exitCode: number | null;54) {
37 exitSignal: number | null;55 override get message() {
38 updatedAt: string;56 return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`;
39 cols: number;57 }
40 rows: number;
41 process: PtyProcess | null;
42 unsubscribeData: (() => void) | null;
43 unsubscribeExit: (() => void) | null;
44 hasRunningSubprocess: boolean;
45 runtimeEnv: Record<string, string> | null;
46}58}
4759
48export interface ShellCandidate {60export class TerminalSessionLookupError extends Schema.TaggedErrorClass<TerminalSessionLookupError>()(
49 shell: string;61 "TerminalSessionLookupError",
50 args?: string[];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 }
51}70}
5271
53export interface TerminalStartInput extends TerminalOpenInput {72export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()(
54 cols: number;73 "TerminalNotRunningError",
55 rows: number;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 }
56}82}
5783
84export const TerminalError = Schema.Union([
85 TerminalCwdError,
86 TerminalHistoryError,
87 TerminalSessionLookupError,
88 TerminalNotRunningError,
89]);
90export type TerminalError = typeof TerminalError.Type;
91
58/**92/**
59 * TerminalManagerShape - Service API for terminal session lifecycle operations.93 * TerminalManagerShape - Service API for terminal session lifecycle operations.
60 */94 */
...@@ -101,14 +135,13 @@ export interface TerminalManagerShape {...@@ -101,14 +135,13 @@ export interface TerminalManagerShape {
101 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;135 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
102136
103 /**137 /**
104 * Subscribe to terminal runtime events.138 * Subscribe to terminal runtime events with a direct callback.
105 */139 *
106 readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>;140 * Returns an unsubscribe function.
107
108 /**
109 * Dispose all managed terminal resources.
110 */141 */
111 readonly dispose: Effect.Effect<void>;142 readonly subscribe: (
143 listener: (event: TerminalEvent) => Effect.Effect<void>,
144 ) => Effect.Effect<() => void>;
112}145}
113146
114/**147/**
apps/server/src/wsServer.test.ts+21-14
...@@ -4,7 +4,7 @@ import os from "node:os";...@@ -4,7 +4,7 @@ import os from "node:os";
4import path from "node:path";4import path from "node:path";
55
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * 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";
8import { describe, expect, it, afterEach, vi } from "vitest";8import { describe, expect, it, afterEach, vi } from "vitest";
9import { createServer } from "./wsServer";9import { createServer } from "./wsServer";
10import WebSocket from "ws";10import WebSocket from "ws";
...@@ -90,20 +90,19 @@ const defaultServerSettings = DEFAULT_SERVER_SETTINGS;...@@ -90,20 +90,19 @@ const defaultServerSettings = DEFAULT_SERVER_SETTINGS;
9090
91class MockTerminalManager implements TerminalManagerShape {91class MockTerminalManager implements TerminalManagerShape {
92 private readonly sessions = new Map<string, TerminalSessionSnapshot>();92 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
95 private key(threadId: string, terminalId: string): string {96 private key(threadId: string, terminalId: string): string {
96 return `${threadId}\u0000${terminalId}`;97 return `${threadId}\u0000${terminalId}`;
97 }98 }
9899
99 emitEvent(event: TerminalEvent): void {100 emitEvent(event: TerminalEvent): void {
100 for (const listener of this.listeners) {101 Effect.runSync(PubSub.publish(this.eventPubSub, event));
101 listener(event);
102 }
103 }102 }
104103
105 subscriptionCount(): number {104 subscriptionCount(): number {
106 return this.listeners.size;105 return this.activeSubscriptions;
107 }106 }
108107
109 readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) =>108 readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) =>
...@@ -210,13 +209,15 @@ class MockTerminalManager implements TerminalManagerShape {...@@ -210,13 +209,15 @@ class MockTerminalManager implements TerminalManagerShape {
210209
211 readonly subscribe: TerminalManagerShape["subscribe"] = (listener) =>210 readonly subscribe: TerminalManagerShape["subscribe"] = (listener) =>
212 Effect.sync(() => {211 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 );
214 return () => {216 return () => {
215 this.listeners.delete(listener);217 this.activeSubscriptions -= 1;
218 Effect.runFork(Fiber.interrupt(fiber).pipe(Effect.ignore));
216 };219 };
217 });220 });
218
219 readonly dispose: TerminalManagerShape["dispose"] = Effect.void;
220}221}
221222
222// ---------------------------------------------------------------------------223// ---------------------------------------------------------------------------
...@@ -1454,19 +1455,25 @@ describe("WebSocket Server", () => {...@@ -1454,19 +1455,25 @@ describe("WebSocket Server", () => {
1454 expect(push.channel).toBe(WS_CHANNELS.terminalEvent);1455 expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
1455 });1456 });
14561457
1457 it("detaches terminal event listener on stop for injected manager", async () => {1458 it("shuts down cleanly for injected terminal managers", async () => {
1458 const terminalManager = new MockTerminalManager();1459 const terminalManager = new MockTerminalManager();
1459 server = await createTestServer({1460 server = await createTestServer({
1460 cwd: "/test",1461 cwd: "/test",
1461 terminalManager,1462 terminalManager,
1462 });1463 });
14631464
1464 expect(terminalManager.subscriptionCount()).toBe(1);
1465
1466 await closeTestServer();1465 await closeTestServer();
1467 server = null;1466 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();
1470 });1477 });
14711478
1472 it("returns validation errors for invalid terminal open params", async () => {1479 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<...@@ -719,9 +719,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
719 }719 }
720720
721 const unsubscribeTerminalEvents = yield* terminalManager.subscribe((event) =>721 const unsubscribeTerminalEvents = yield* terminalManager.subscribe((event) =>
722 runPromise(pushBus.publishAll(WS_CHANNELS.terminalEvent, event)),722 pushBus.publishAll(WS_CHANNELS.terminalEvent, event),
723 );723 );
724 yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents()));724 yield* Scope.addFinalizer(subscriptionsScope, Effect.sync(unsubscribeTerminalEvents));
725 yield* readiness.markTerminalSubscriptionsReady;725 yield* readiness.markTerminalSubscriptionsReady;
726726
727 yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(727 yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
packages/contracts/src/ipc.ts+6-6
...@@ -130,12 +130,12 @@ export interface NativeApi {...@@ -130,12 +130,12 @@ export interface NativeApi {
130 confirm: (message: string) => Promise<boolean>;130 confirm: (message: string) => Promise<boolean>;
131 };131 };
132 terminal: {132 terminal: {
133 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;133 open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>;
134 write: (input: TerminalWriteInput) => Promise<void>;134 write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>;
135 resize: (input: TerminalResizeInput) => Promise<void>;135 resize: (input: typeof TerminalResizeInput.Encoded) => Promise<void>;
136 clear: (input: TerminalClearInput) => Promise<void>;136 clear: (input: typeof TerminalClearInput.Encoded) => Promise<void>;
137 restart: (input: TerminalRestartInput) => Promise<TerminalSessionSnapshot>;137 restart: (input: typeof TerminalRestartInput.Encoded) => Promise<TerminalSessionSnapshot>;
138 close: (input: TerminalCloseInput) => Promise<void>;138 close: (input: typeof TerminalCloseInput.Encoded) => Promise<void>;
139 onEvent: (callback: (event: TerminalEvent) => void) => () => void;139 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
140 };140 };
141 projects: {141 projects: {
packages/contracts/src/terminal.ts+2-2
...@@ -26,7 +26,7 @@ const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(...@@ -26,7 +26,7 @@ const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
26export const TerminalThreadInput = Schema.Struct({26export const TerminalThreadInput = Schema.Struct({
27 threadId: TrimmedNonEmptyStringSchema,27 threadId: TrimmedNonEmptyStringSchema,
28});28});
29export type TerminalThreadInput = Schema.Codec.Encoded<typeof TerminalThreadInput>;29export type TerminalThreadInput = typeof TerminalThreadInput.Type;
3030
31const TerminalSessionInput = Schema.Struct({31const TerminalSessionInput = Schema.Struct({
32 ...TerminalThreadInput.fields,32 ...TerminalThreadInput.fields,
...@@ -73,7 +73,7 @@ export const TerminalCloseInput = Schema.Struct({...@@ -73,7 +73,7 @@ export const TerminalCloseInput = Schema.Struct({
73 terminalId: Schema.optional(TerminalIdSchema),73 terminalId: Schema.optional(TerminalIdSchema),
74 deleteHistory: Schema.optional(Schema.Boolean),74 deleteHistory: Schema.optional(Schema.Boolean),
75});75});
76export type TerminalCloseInput = Schema.Codec.Encoded<typeof TerminalCloseInput>;76export type TerminalCloseInput = typeof TerminalCloseInput.Type;
7777
78export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]);78export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]);
79export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;79export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;
packages/shared/package.json+4
...@@ -28,6 +28,10 @@...@@ -28,6 +28,10 @@
28 "types": "./src/DrainableWorker.ts",28 "types": "./src/DrainableWorker.ts",
29 "import": "./src/DrainableWorker.ts"29 "import": "./src/DrainableWorker.ts"
30 },30 },
31 "./KeyedCoalescingWorker": {
32 "types": "./src/KeyedCoalescingWorker.ts",
33 "import": "./src/KeyedCoalescingWorker.ts"
34 },
31 "./schemaJson": {35 "./schemaJson": {
32 "types": "./src/schemaJson.ts",36 "types": "./src/schemaJson.ts",
33 "import": "./src/schemaJson.ts"37 "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 });