1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import {
5 DEFAULT_TERMINAL_ID,
6 type TerminalAttachStreamEvent,
7 type TerminalEvent,
8 type TerminalMetadataStreamEvent,
9 type TerminalOpenInput,
10 type TerminalRestartInput,
11 ProviderDriverKind,
12 ProviderInstanceId,
13 ServerSettingsError,
14 TerminalProviderInstanceNotFoundError,
15} from "@t3tools/contracts";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17import * as Data from "effect/Data";
18import * as Clock from "effect/Clock";
19import * as Deferred from "effect/Deferred";
20import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";
22import * as Base64Url from "effect/encoding/Base64Url";
23import * as Exit from "effect/Exit";
24import * as Fiber from "effect/Fiber";
25import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as PlatformError from "effect/PlatformError";
29import * as Path from "effect/Path";
30import * as Queue from "effect/Queue";
31import * as Ref from "effect/Ref";
32import * as Schedule from "effect/Schedule";
33import * as Schema from "effect/Schema";
34import * as Scope from "effect/Scope";
35import * as Stream from "effect/Stream";
36import * as TestClock from "effect/testing/TestClock";
37import { ChildProcessSpawner } from "effect/process";
38import { expect } from "vite-plus/test";
39
40import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
41import * as ServerConfig from "../config.ts";
42import * as SqlitePersistence from "../persistence/Sqlite.ts";
43import * as ProcessRunner from "../processRunner.ts";
44import * as ServerSettings from "../serverSettings.ts";
45import * as TerminalManager from "./Manager.ts";
46import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
47
48const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
49
50class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{
51 readonly message: string;
52}> {}
53
54class FakePtyProcess implements PtyAdapter.PtyProcess {
55 readonly writes: string[] = [];
56 readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
57 readonly killSignals: Array<string | undefined> = [];
58 readonly pid: number;
59 writeFailure: unknown | undefined;
60 resizeFailure: unknown | undefined;
61 private readonly dataListeners = new Set<(data: string) => void>();
62 private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>();
63 killed = false;
64 exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined;
65
66 constructor(pid: number) {
67 this.pid = pid;
68 }
69
70 write(data: string): void {
71 if (this.writeFailure !== undefined) {
72 throw this.writeFailure;
73 }
74 this.writes.push(data);
75 }
76
77 resize(cols: number, rows: number): void {
78 if (this.resizeFailure !== undefined) {
79 throw this.resizeFailure;
80 }
81 this.resizeCalls.push({ cols, rows });
82 }
83
84 kill(signal?: string): void {
85 this.killed = true;
86 this.killSignals.push(signal);
87 }
88
89 onData(callback: (data: string) => void): () => void {
90 this.dataListeners.add(callback);
91 return () => {
92 this.dataListeners.delete(callback);
93 };
94 }
95
96 onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void {
97 if (this.exitOnSubscribe) callback(this.exitOnSubscribe);
98 this.exitListeners.add(callback);
99 return () => {
100 this.exitListeners.delete(callback);
101 };
102 }
103
104 emitData(data: string): void {
105 for (const listener of this.dataListeners) {
106 listener(data);
107 }
108 }
109
110 emitExit(event: PtyAdapter.PtyExitEvent): void {
111 for (const listener of this.exitListeners) {
112 listener(event);
113 }
114 }
115}
116
117class FakePtyAdapter {
118 readonly spawnInputs: PtyAdapter.PtySpawnInput[] = [];
119 readonly processes: FakePtyProcess[] = [];
120 readonly spawnFailures: Error[] = [];
121 private readonly mode: "sync" | "async";
122 private nextPid = 9000;
123 exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined;
124
125 constructor(mode: "sync" | "async" = "sync") {
126 this.mode = mode;
127 }
128
129 spawn(
130 input: PtyAdapter.PtySpawnInput,
131 ): Effect.Effect<PtyAdapter.PtyProcess, PtyAdapter.PtySpawnError> {
132 this.spawnInputs.push(input);
133 const failure = this.spawnFailures.shift();
134 if (failure) {
135 return Effect.fail(
136 new PtyAdapter.PtySpawnError({
137 adapter: "fake",
138 shell: input.shell,
139 cause: failure,
140 }),
141 );
142 }
143 const process = new FakePtyProcess(this.nextPid++);
144 process.exitOnSubscribe = this.exitOnSubscribe;
145 this.processes.push(process);
146 if (this.mode === "async") {
147 return Effect.tryPromise({
148 try: async () => process,
149 catch: (cause) =>
150 new PtyAdapter.PtySpawnError({
151 adapter: "fake",
152 shell: input.shell,
153 cause,
154 }),
155 });
156 }
157 return Effect.succeed(process);
158 }
159}
160
161const waitFor = <E, R>(
162 predicate: Effect.Effect<boolean, E, R>,
163 timeout: Duration.Input = 800,
164): Effect.Effect<void, WaitForConditionError | E, R> =>
165 predicate.pipe(
166 Effect.filterOrFail(
167 (done) => done,
168 () => new WaitForConditionError({ message: "Condition not met" }),
169 ),
170 Effect.retry(Schedule.spaced("15 millis")),
171 Effect.timeoutOption(timeout),
172 Effect.flatMap((result) =>
173 Option.match(result, {
174 onNone: () =>
175 Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })),
176 onSome: () => Effect.void,
177 }),
178 ),
179 );
180
181function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {
182 return {
183 threadId: "thread-1",
184 terminalId: DEFAULT_TERMINAL_ID,
185 cwd: process.cwd(),
186 cols: 100,
187 rows: 24,
188 ...overrides,
189 };
190}
191
192function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput {
193 return {
194 threadId: "thread-1",
195 terminalId: DEFAULT_TERMINAL_ID,
196 cwd: process.cwd(),
197 cols: 100,
198 rows: 24,
199 ...overrides,
200 };
201}
202
203const historyLogPath = (logsDir: string, threadId = "thread-1") =>
204 Effect.service(Path.Path).pipe(
205 Effect.map(({ join }) => join(logsDir, `terminal_${Base64Url.encode(threadId)}.log`)),
206 );
207
208const multiTerminalHistoryLogPath = (
209 logsDir: string,
210 threadId = "thread-1",
211 terminalId = DEFAULT_TERMINAL_ID,
212) =>
213 Effect.service(Path.Path).pipe(
214 Effect.map(({ join }) => {
215 const threadPart = `terminal_${Base64Url.encode(threadId)}`;
216 return join(
217 logsDir,
218 terminalId === DEFAULT_TERMINAL_ID
219 ? `${threadPart}.log`
220 : `${threadPart}_${Base64Url.encode(terminalId)}.log`,
221 );
222 }),
223 );
224
225interface CreateManagerOptions {
226 shellResolver?: () => string;
227 env?: NodeJS.ProcessEnv;
228 subprocessInspector?: (terminalPid: number) => Effect.Effect<{
229 readonly hasRunningSubprocess: boolean;
230 readonly childCommand: string | null;
231 readonly processIds: ReadonlyArray<number>;
232 }>;
233 processTable?: Effect.Effect<
234 ReadonlyArray<{ readonly pid: number; readonly ppid: number; readonly name: string }>,
235 never
236 >;
237 subprocessPollIntervalMs?: number;
238 processKillGraceMs?: number;
239 maxRetainedInactiveSessions?: number;
240 historyByteLimit?: number;
241 ptyAdapter?: FakePtyAdapter;
242 resolveProviderInstanceEnvironment?: Parameters<
243 typeof TerminalManager.makeWithOptions
244 >[0]["resolveProviderInstanceEnvironment"];
245 managedBinaryCacheDir?: string;
246 managedBinaryToolsDir?: string;
247}
248
249interface ManagerFixture {
250 readonly baseDir: string;
251 readonly logsDir: string;
252 readonly ptyAdapter: FakePtyAdapter;
253 readonly manager: TerminalManager.TerminalManager["Service"];
254 readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>;
255}
256
257const createManager = (
258 historyLineLimit = 5,
259 options: CreateManagerOptions = {},
260): Effect.Effect<
261 ManagerFixture,
262 PlatformError.PlatformError,
263 FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner
264> =>
265 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
266 Effect.gen(function* () {
267 const { join } = yield* Path.Path;
268 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" });
269 const logsDir = join(baseDir, "userdata", "logs", "terminals");
270 const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter();
271
272 const manager = yield* TerminalManager.makeWithOptions({
273 logsDir,
274 historyLineLimit,
275 ptyAdapter,
276 ...(options.historyByteLimit !== undefined
277 ? { historyByteLimit: options.historyByteLimit }
278 : {}),
279 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
280 ...(options.env !== undefined ? { env: options.env } : {}),
281 ...(options.subprocessInspector !== undefined
282 ? { subprocessInspector: options.subprocessInspector }
283 : {}),
284 ...(options.processTable !== undefined ? { processTable: options.processTable } : {}),
285 ...(options.subprocessPollIntervalMs !== undefined
286 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
287 : {}),
288 processKillGraceMs: options.processKillGraceMs ?? 1,
289 ...(options.maxRetainedInactiveSessions !== undefined
290 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
291 : {}),
292 ...(options.resolveProviderInstanceEnvironment !== undefined
293 ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment }
294 : {}),
295 ...(options.managedBinaryCacheDir === undefined
296 ? {}
297 : {
298 managedBinaryCacheDir: options.managedBinaryCacheDir,
299 managedBinaryToolsDir: options.managedBinaryToolsDir,
300 }),
301 }).pipe(Effect.provide(ScriptProgress.layerUnavailable));
302 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
303 const unsubscribe = yield* manager.subscribe((event) =>
304 Ref.update(eventsRef, (events) => [...events, event]),
305 );
306 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
307
308 return {
309 baseDir,
310 logsDir,
311 join,
312 ptyAdapter,
313 manager,
314 getEvents: Ref.get(eventsRef),
315 };
316 }),
317 );
318
319const layerWithHostPlatform = (platform: NodeJS.Platform) =>
320 Layer.succeed(HostProcess.Platform, platform);
321
322// Apply the existing line policy, then find the longest code-point-aligned byte tail.
323function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): string {
324 const terminated = text.endsWith("\n");
325 const lines = text.split("\n");
326 if (terminated) lines.pop();
327 const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n");
328 const capped = terminated ? `${retained}\n` : retained;
329 if (Buffer.byteLength(capped) <= maxBytes) return capped;
330 const points = Array.from(capped);
331 let start = points.length;
332 let bytes = 0;
333 while (start > 0) {
334 const next = Buffer.byteLength(points[start - 1]!);
335 if (bytes + next > maxBytes) break;
336 bytes += next;
337 start -= 1;
338 }
339 return points.slice(start).join("");
340}
341
342it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequences, and clear", () => {
343 let randomSeed = 0x20260904;
344 const fragments = [
345 "",
346 "a",
347 "\n",
348 "\n\n",
349 "\r",
350 "\r\n",
351 "café",
352 "名",
353 "🚀",
354 "\u001b[31m",
355 "\u001b[0m",
356 "\u001b]8;;url\u0007",
357 "\ud83d",
358 "\ude80",
359 ];
360 const nextFragment = () => {
361 randomSeed = (Math.imul(randomSeed, 1_664_525) + 1_013_904_223) >>> 0;
362 return fragments[randomSeed % fragments.length]!;
363 };
364
365 for (const maxBytes of [0, 3, 8, 64, Infinity]) {
366 for (const maxLines of [0, 1, 3, 5, 5_000]) {
367 let expected = retainedHistory("before\ninitial\n", maxLines, maxBytes);
368 const history = new TerminalManager.BoundedTerminalHistory(
369 maxLines,
370 "before\ninitial\n",
371 maxBytes,
372 );
373 expect(history.value()).toBe(expected);
374
375 for (let step = 0; step < 300; step += 1) {
376 if (step % 73 === 0) {
377 history.clear();
378 expected = "";
379 expect(history.value()).toBe(expected);
380 }
381 const chunk = nextFragment() + nextFragment();
382 history.append(chunk);
383 expected = retainedHistory(expected + chunk, maxLines, maxBytes);
384 expect(history.value()).toBe(expected);
385 }
386 }
387 }
388});
389
390it("bounds long partial lines and joins surrogate pairs across chunk boundaries", () => {
391 const maxBytes = 65_539;
392 let expected = "";
393 const history = new TerminalManager.BoundedTerminalHistory(5_000, "", maxBytes);
394 const writes = [
395 "a".repeat(16_383) + "😀" + "b".repeat(70_000),
396 "\r" + "c".repeat(70_000) + "\ud83d",
397 "\ude80" + "d".repeat(100),
398 "\uFEFF" + "名".repeat(30_000),
399 ];
400 for (const text of writes) {
401 history.append(text);
402 expected = retainedHistory(expected + text, 5_000, maxBytes);
403 expect(history.value()).toBe(expected);
404 expect(Buffer.byteLength(history.value())).toBeLessThanOrEqual(maxBytes);
405 }
406});
407
408it("preserves retained lines as older storage is compacted", () => {
409 for (const maxLines of [3, 5_000]) {
410 let expected = "";
411 const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected);
412 for (let batch = 0; batch < 40; batch += 1) {
413 const chunk = Array.from({ length: 300 }, (_, line) => `${batch}:${line}\n`).join("");
414 history.append(chunk);
415 expected = retainedHistory(expected + chunk, maxLines);
416 expect(history.value()).toBe(expected);
417 }
418 }
419});
420
421it.layer(
422 Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))),
423 { excludeTestServices: true },
424)("TerminalManager", (it) => {
425 it.effect("spawns lazily and reuses running terminal per thread", () =>
426 Effect.gen(function* () {
427 const { manager, ptyAdapter } = yield* createManager();
428 const [first, second] = yield* Effect.all(
429 [manager.open(openInput()), manager.open(openInput())],
430 { concurrency: "unbounded" },
431 );
432 const third = yield* manager.open(openInput());
433
434 assert.equal(first.threadId, "thread-1");
435 assert.equal(first.terminalId, DEFAULT_TERMINAL_ID);
436 assert.equal(second.threadId, "thread-1");
437 assert.equal(third.threadId, "thread-1");
438 expect(ptyAdapter.spawnInputs).toHaveLength(1);
439 }),
440 );
441
442 it.effect("attaches to running sessions without restarting them", () =>
443 Effect.gen(function* () {
444 const { manager, ptyAdapter } = yield* createManager();
445
446 yield* manager.open(openInput());
447 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
448 const unsubscribe = yield* manager.attachStream(
449 {
450 threadId: "thread-1",
451 terminalId: DEFAULT_TERMINAL_ID,
452 cols: 100,
453 rows: 40,
454 },
455 (event) => Ref.update(attachEvents, (events) => [...events, event]),
456 );
457 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
458
459 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
460 expect(snapshot).toBeDefined();
461 if (!snapshot || snapshot.type !== "snapshot") return;
462 assert.equal(snapshot.snapshot.threadId, "thread-1");
463 assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID);
464 expect(ptyAdapter.spawnInputs).toHaveLength(1);
465 }),
466 );
467
468 it.effect("keeps attach streams live when a terminal id is closed and reopened", () =>
469 Effect.gen(function* () {
470 const { manager, ptyAdapter } = yield* createManager();
471 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
472 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
473 Ref.update(attachEvents, (events) => [...events, event]),
474 );
475 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
476
477 yield* manager.close({
478 threadId: "thread-1",
479 terminalId: DEFAULT_TERMINAL_ID,
480 deleteHistory: true,
481 });
482 yield* manager.open(openInput());
483
484 const events = yield* Ref.get(attachEvents);
485 expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]);
486 expect(
487 events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status),
488 ).toEqual(["running", "running"]);
489 expect(ptyAdapter.spawnInputs).toHaveLength(2);
490 }),
491 );
492
493 it.effect("attaches to exited sessions without restarting them", () =>
494 Effect.gen(function* () {
495 const { manager, ptyAdapter, getEvents } = yield* createManager();
496
497 yield* manager.open(openInput());
498 const process = ptyAdapter.processes[0];
499 expect(process).toBeDefined();
500 if (!process) return;
501
502 process.emitExit({ exitCode: 0, signal: 0 });
503
504 yield* waitFor(
505 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
506 "1200 millis",
507 );
508
509 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
510 const unsubscribe = yield* manager.attachStream(
511 openInput({
512 env: {
513 T3CODE_WORKTREE_PATH: "/tmp/should-not-restart",
514 },
515 worktreePath: "/tmp/should-not-restart",
516 }),
517 (event) => Ref.update(attachEvents, (events) => [...events, event]),
518 );
519 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
520
521 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
522 expect(snapshot).toBeDefined();
523 if (!snapshot || snapshot.type !== "snapshot") return;
524 assert.equal(snapshot.snapshot.status, "exited");
525 assert.equal(snapshot.snapshot.worktreePath, null);
526 expect(ptyAdapter.spawnInputs).toHaveLength(1);
527 }),
528 );
529
530 it.effect("restarts inactive sessions from attach only when requested", () =>
531 Effect.gen(function* () {
532 const { manager, ptyAdapter, getEvents } = yield* createManager();
533
534 yield* manager.open(openInput());
535 const process = ptyAdapter.processes[0];
536 expect(process).toBeDefined();
537 if (!process) return;
538
539 process.emitExit({ exitCode: 0, signal: 0 });
540
541 yield* waitFor(
542 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
543 "1200 millis",
544 );
545
546 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
547 const unsubscribe = yield* manager.attachStream(
548 {
549 ...openInput({
550 env: {
551 T3CODE_WORKTREE_PATH: "/tmp/restart-requested",
552 },
553 worktreePath: "/tmp/restart-requested",
554 }),
555 restartIfNotRunning: true,
556 },
557 (event) => Ref.update(attachEvents, (events) => [...events, event]),
558 );
559 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
560
561 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
562 expect(snapshot).toBeDefined();
563 if (!snapshot || snapshot.type !== "snapshot") return;
564 assert.equal(snapshot.snapshot.status, "running");
565 assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested");
566 expect(ptyAdapter.spawnInputs).toHaveLength(2);
567 }),
568 );
569
570 const makeDirectory = (filePath: string) =>
571 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
572 fs.makeDirectory(filePath, { recursive: true }),
573 );
574
575 const chmod = (filePath: string, mode: number) =>
576 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode));
577
578 const pathExists = (filePath: string) =>
579 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath));
580
581 const readFileString = (filePath: string) =>
582 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath));
583
584 const writeFileString = (filePath: string, contents: string) =>
585 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
586 fs.writeFileString(filePath, contents),
587 );
588
589 it.effect("reports a missing cwd without an artificial cause", () =>
590 Effect.gen(function* () {
591 const path = yield* Path.Path;
592
593 const { manager, baseDir } = yield* createManager();
594 const cwd = path.join(baseDir, "missing-cwd");
595 const error = yield* Effect.flip(manager.open(openInput({ cwd })));
596
597 expect(error).toMatchObject({
598 _tag: "TerminalCwdNotFoundError",
599 cwd,
600 });
601 expect("cause" in error).toBe(false);
602 }),
603 );
604
605 it.effect("reports a cwd that is not a directory", () =>
606 Effect.gen(function* () {
607 const path = yield* Path.Path;
608
609 const { manager, baseDir } = yield* createManager();
610 const cwd = path.join(baseDir, "cwd-file");
611 yield* writeFileString(cwd, "not a directory");
612 const error = yield* Effect.flip(manager.open(openInput({ cwd })));
613
614 expect(error).toMatchObject({
615 _tag: "TerminalCwdNotDirectoryError",
616 cwd,
617 });
618 expect("cause" in error).toBe(false);
619 }),
620 );
621
622 it.effect("preserves non-notFound cwd stat failures", () =>
623 Effect.gen(function* () {
624 if ((yield* HostProcess.Platform) === "win32") return;
625
626 const path = yield* Path.Path;
627
628 const { manager, baseDir } = yield* createManager();
629 const blockedRoot = path.join(baseDir, "blocked-root");
630 const blockedCwd = path.join(blockedRoot, "cwd");
631 yield* makeDirectory(blockedCwd);
632 yield* chmod(blockedRoot, 0o000);
633
634 const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe(
635 Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)),
636 );
637
638 expect(error).toMatchObject({
639 _tag: "TerminalCwdStatError",
640 cwd: blockedCwd,
641 cause: {
642 _tag: "PlatformError",
643 },
644 });
645 }),
646 );
647
648 it.effect("handles an exit replayed during subscription after publishing startup", () =>
649 Effect.gen(function* () {
650 const ptyAdapter = new FakePtyAdapter();
651 ptyAdapter.exitOnSubscribe = { exitCode: 7, signal: null };
652 const { manager, getEvents } = yield* createManager(5, { ptyAdapter });
653 const exited = yield* Deferred.make<void>();
654 const unsubscribe = yield* manager.subscribe((event) =>
655 event.type === "exited"
656 ? Deferred.succeed(exited, undefined).pipe(Effect.asVoid)
657 : Effect.void,
658 );
659 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
660 yield* manager.open(openInput());
661 yield* Deferred.await(exited);
662 const events = yield* getEvents;
663 expect(events.map((event) => event.type)).toEqual(["started", "exited"]);
664 expect(events[1]).toMatchObject({ exitCode: 7 });
665 const attached: TerminalAttachStreamEvent[] = [];
666 const stopAttach = yield* manager.attachStream(openInput(), (event) =>
667 Effect.sync(() => {
668 attached.push(event);
669 }),
670 );
671 yield* Effect.addFinalizer(() => Effect.sync(stopAttach));
672 expect(attached.find((event) => event.type === "snapshot")).toMatchObject({
673 snapshot: { status: "exited", exitCode: 7 },
674 });
675 }),
676 );
677
678 it.effect("supports asynchronous PTY spawn effects", () =>
679 Effect.gen(function* () {
680 const { manager, ptyAdapter } = yield* createManager(5, {
681 ptyAdapter: new FakePtyAdapter("async"),
682 });
683
684 const snapshot = yield* manager.open(openInput());
685
686 assert.equal(snapshot.status, "running");
687 expect(ptyAdapter.spawnInputs).toHaveLength(1);
688 expect(ptyAdapter.processes).toHaveLength(1);
689 }),
690 );
691
692 it.effect("forwards write and resize to active pty process", () =>
693 Effect.gen(function* () {
694 const { manager, ptyAdapter } = yield* createManager();
695 yield* manager.open(openInput());
696 const process = ptyAdapter.processes[0];
697 expect(process).toBeDefined();
698 if (!process) return;
699
700 yield* manager.write({
701 threadId: "thread-1",
702 terminalId: DEFAULT_TERMINAL_ID,
703 data: "ls\n",
704 });
705 yield* manager.resize({
706 threadId: "thread-1",
707 terminalId: DEFAULT_TERMINAL_ID,
708 cols: 120,
709 rows: 30,
710 });
711
712 expect(process.writes).toEqual(["ls\n"]);
713 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
714 }),
715 );
716
717 it.effect("preserves structured context and causes for PTY I/O failures", () =>
718 Effect.gen(function* () {
719 const { manager, ptyAdapter } = yield* createManager();
720 yield* manager.open(openInput());
721 const process = ptyAdapter.processes[0];
722 expect(process).toBeDefined();
723 if (!process) return;
724
725 const writeCause = new Error("PTY input handle is unavailable");
726 process.writeFailure = writeCause;
727 const writeError = yield* Effect.flip(
728 manager.write({
729 threadId: "thread-1",
730 terminalId: DEFAULT_TERMINAL_ID,
731 data: "secret input that must not be attached to the error",
732 }),
733 );
734
735 expect(writeError).toMatchObject({
736 _tag: "TerminalWriteError",
737 threadId: "thread-1",
738 terminalId: DEFAULT_TERMINAL_ID,
739 terminalPid: process.pid,
740 });
741 expect(writeError.cause).toBe(writeCause);
742 expect(writeError).not.toHaveProperty("data");
743
744 const resizeCause = new Error("PTY resize handle is unavailable");
745 process.resizeFailure = resizeCause;
746 const resizeError = yield* Effect.flip(
747 manager.resize({
748 threadId: "thread-1",
749 terminalId: DEFAULT_TERMINAL_ID,
750 cols: 132,
751 rows: 40,
752 }),
753 );
754
755 expect(resizeError).toMatchObject({
756 _tag: "TerminalResizeError",
757 threadId: "thread-1",
758 terminalId: DEFAULT_TERMINAL_ID,
759 terminalPid: process.pid,
760 cols: 132,
761 rows: 40,
762 });
763 expect(resizeError.cause).toBe(resizeCause);
764
765 process.resizeFailure = undefined;
766 yield* manager.open(openInput({ cols: 132, rows: 40 }));
767 expect(process.resizeCalls).toEqual([{ cols: 132, rows: 40 }]);
768 }),
769 );
770
771 it.effect("ignores delayed resize requests after a terminal closes", () =>
772 Effect.gen(function* () {
773 const { manager, ptyAdapter } = yield* createManager();
774 yield* manager.open(openInput());
775 const process = ptyAdapter.processes[0];
776 expect(process).toBeDefined();
777 if (!process) return;
778
779 yield* manager.close({
780 threadId: "thread-1",
781 terminalId: DEFAULT_TERMINAL_ID,
782 deleteHistory: true,
783 });
784 yield* manager.resize({
785 threadId: "thread-1",
786 terminalId: DEFAULT_TERMINAL_ID,
787 cols: 120,
788 rows: 30,
789 });
790
791 expect(process.resizeCalls).toEqual([]);
792 }),
793 );
794
795 it.effect("resizes running terminal on open when a different size is requested", () =>
796 Effect.gen(function* () {
797 const { manager, ptyAdapter } = yield* createManager();
798 yield* manager.open(openInput({ cols: 100, rows: 24 }));
799 const process = ptyAdapter.processes[0];
800 expect(process).toBeDefined();
801 if (!process) return;
802
803 const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 }));
804
805 assert.equal(reopened.status, "running");
806 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
807 }),
808 );
809
810 it.effect("supports multiple terminals per thread independently", () =>
811 Effect.gen(function* () {
812 const { manager, ptyAdapter } = yield* createManager();
813 yield* manager.open(openInput({ terminalId: "default" }));
814 yield* manager.open(openInput({ terminalId: "term-2" }));
815
816 const first = ptyAdapter.processes[0];
817 const second = ptyAdapter.processes[1];
818 expect(first).toBeDefined();
819 expect(second).toBeDefined();
820 if (!first || !second) return;
821
822 yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" });
823 yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" });
824
825 expect(first.writes).toEqual(["pwd\n"]);
826 expect(second.writes).toEqual(["ls\n"]);
827 expect(ptyAdapter.spawnInputs).toHaveLength(2);
828 }),
829 );
830
831 it.effect("clears transcript and emits cleared event", () =>
832 Effect.gen(function* () {
833 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
834 const path = yield* Path.Path;
835 yield* manager.open(openInput());
836 const process = ptyAdapter.processes[0];
837 expect(process).toBeDefined();
838 if (!process) return;
839
840 process.emitData("hello\n");
841 yield* waitFor(
842 historyLogPath(logsDir).pipe(
843 Effect.provideService(Path.Path, path),
844 Effect.flatMap(pathExists),
845 ),
846 );
847 yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID });
848 yield* waitFor(
849 historyLogPath(logsDir).pipe(
850 Effect.provideService(Path.Path, path),
851 Effect.flatMap(readFileString),
852 Effect.map((text) => text === ""),
853 ),
854 );
855
856 const events = yield* getEvents;
857 expect(events.some((event) => event.type === "cleared")).toBe(true);
858 expect(
859 events.some(
860 (event) =>
861 event.type === "cleared" &&
862 event.threadId === "thread-1" &&
863 event.terminalId === DEFAULT_TERMINAL_ID,
864 ),
865 ).toBe(true);
866 }),
867 );
868
869 it.effect("restarts terminal with empty transcript and respawns pty", () =>
870 Effect.gen(function* () {
871 const { manager, ptyAdapter, logsDir } = yield* createManager();
872 yield* manager.open(openInput());
873 const firstProcess = ptyAdapter.processes[0];
874 expect(firstProcess).toBeDefined();
875 if (!firstProcess) return;
876 firstProcess.emitData("before restart\n");
877 const path = yield* Path.Path;
878 yield* waitFor(
879 historyLogPath(logsDir).pipe(
880 Effect.provideService(Path.Path, path),
881 Effect.flatMap(pathExists),
882 ),
883 );
884
885 const snapshot = yield* manager.restart(restartInput());
886 assert.equal(snapshot.history, "");
887 assert.equal(snapshot.status, "running");
888 expect(ptyAdapter.spawnInputs).toHaveLength(2);
889 yield* waitFor(
890 historyLogPath(logsDir).pipe(
891 Effect.provideService(Path.Path, path),
892 Effect.flatMap(readFileString),
893 Effect.map((text) => text === ""),
894 ),
895 );
896 }),
897 );
898
899 it.effect("restarts a running session when open is called with a different cwd", () =>
900 Effect.gen(function* () {
901 const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager();
902 const path = yield* Path.Path;
903 const originalCwd = path.join(baseDir, "original");
904 const differentCwd = path.join(baseDir, "different");
905 yield* makeDirectory(originalCwd);
906 yield* makeDirectory(differentCwd);
907
908 yield* manager.open(openInput({ cwd: originalCwd }));
909 const firstProcess = ptyAdapter.processes[0];
910 expect(firstProcess).toBeDefined();
911 if (!firstProcess) return;
912
913 firstProcess.emitData("before reopen\n");
914 const logPath = yield* historyLogPath(logsDir);
915 yield* waitFor(pathExists(logPath));
916
917 const reopened = yield* manager.open(openInput({ cwd: differentCwd }));
918
919 expect(ptyAdapter.spawnInputs).toHaveLength(2);
920 assert.equal(firstProcess.killed, true);
921 assert.equal(reopened.cwd, differentCwd);
922 assert.equal(reopened.history, "");
923 yield* waitFor(Effect.map(readFileString(logPath), (text) => text === ""));
924 }),
925 );
926
927 it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () =>
928 Effect.gen(function* () {
929 const { manager, getEvents, baseDir } = yield* createManager();
930 const path = yield* Path.Path;
931 const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a");
932 const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b");
933 yield* makeDirectory(firstWorktreePath);
934 yield* makeDirectory(secondWorktreePath);
935 const startedSnapshot = yield* manager.open(
936 openInput({
937 cwd: firstWorktreePath,
938 worktreePath: firstWorktreePath,
939 }),
940 );
941 const restartedSnapshot = yield* manager.restart(
942 restartInput({
943 cwd: secondWorktreePath,
944 worktreePath: secondWorktreePath,
945 }),
946 );
947
948 assert.equal(startedSnapshot.worktreePath, firstWorktreePath);
949 assert.equal(restartedSnapshot.worktreePath, secondWorktreePath);
950
951 const events = yield* getEvents;
952 const startedEvent = events.find(
953 (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started",
954 );
955 const restartedEvent = events.find(
956 (event): event is Extract<TerminalEvent, { type: "restarted" }> =>
957 event.type === "restarted",
958 );
959
960 assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath);
961 assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath);
962 }),
963 );
964
965 it.effect("preserves worktree metadata when reopening an exited session", () =>
966 Effect.gen(function* () {
967 const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager();
968 const path = yield* Path.Path;
969 const worktreePath = path.join(baseDir, "worktrees", "feature-a");
970 yield* makeDirectory(worktreePath);
971
972 yield* manager.open(
973 openInput({
974 cwd: worktreePath,
975 worktreePath,
976 }),
977 );
978
979 const process = ptyAdapter.processes[0];
980 expect(process).toBeDefined();
981 if (!process) return;
982 process.emitExit({ exitCode: 0, signal: 0 });
983
984 yield* waitFor(
985 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
986 );
987
988 const reopenedSnapshot = yield* manager.open(
989 openInput({
990 cwd: worktreePath,
991 worktreePath,
992 }),
993 );
994
995 assert.equal(reopenedSnapshot.worktreePath, worktreePath);
996
997 const events = yield* getEvents;
998 const reopenedEvent = events
999 .toReversed()
1000 .find(
1001 (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started",
1002 );
1003
1004 assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath);
1005 }),
1006 );
1007
1008 it.effect("emits exited event and reopens with clean transcript after exit", () =>
1009 Effect.gen(function* () {
1010 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
1011 const path = yield* Path.Path;
1012 yield* manager.open(openInput());
1013 const process = ptyAdapter.processes[0];
1014 expect(process).toBeDefined();
1015 if (!process) return;
1016 process.emitData("old data\n");
1017 yield* waitFor(
1018 historyLogPath(logsDir).pipe(
1019 Effect.provideService(Path.Path, path),
1020 Effect.flatMap(pathExists),
1021 ),
1022 );
1023 process.emitExit({ exitCode: 0, signal: 0 });
1024
1025 yield* waitFor(
1026 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
1027 );
1028 const reopened = yield* manager.open(openInput());
1029
1030 assert.equal(reopened.history, "");
1031 expect(ptyAdapter.spawnInputs).toHaveLength(2);
1032 expect(
1033 yield* historyLogPath(logsDir).pipe(
1034 Effect.provideService(Path.Path, path),
1035 Effect.flatMap(readFileString),
1036 ),
1037 ).toBe("");
1038 }),
1039 );
1040
1041 it.effect("ignores trailing writes after terminal exit", () =>
1042 Effect.gen(function* () {
1043 const { manager, ptyAdapter } = yield* createManager();
1044 yield* manager.open(openInput());
1045 const process = ptyAdapter.processes[0];
1046 expect(process).toBeDefined();
1047 if (!process) return;
1048
1049 process.emitExit({ exitCode: 0, signal: 0 });
1050
1051 yield* manager.write({
1052 threadId: "thread-1",
1053 terminalId: DEFAULT_TERMINAL_ID,
1054 data: "\r",
1055 });
1056 expect(process.writes).toEqual([]);
1057 }),
1058 );
1059
1060 it.effect("emits subprocess activity events when child-process state changes", () =>
1061 Effect.gen(function* () {
1062 let inspect: {
1063 readonly hasRunningSubprocess: boolean;
1064 readonly childCommand: string | null;
1065 readonly processIds: ReadonlyArray<number>;
1066 } = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
1067 const { manager, getEvents } = yield* createManager(5, {
1068 subprocessInspector: () => Effect.succeed(inspect),
1069 subprocessPollIntervalMs: 20,
1070 });
1071
1072 yield* manager.open(openInput());
1073 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);
1074
1075 inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] };
1076 yield* waitFor(
1077 Effect.map(getEvents, (events) =>
1078 events.some(
1079 (event) =>
1080 event.type === "activity" &&
1081 event.hasRunningSubprocess === true &&
1082 event.label === "vim",
1083 ),
1084 ),
1085 "1200 millis",
1086 );
1087
1088 inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
1089 yield* waitFor(
1090 Effect.map(getEvents, (events) =>
1091 events.some(
1092 (event) =>
1093 event.type === "activity" &&
1094 event.hasRunningSubprocess === false &&
1095 event.label === "Terminal 1",
1096 ),
1097 ),
1098 "1200 millis",
1099 );
1100 }),
1101 );
1102
1103 it.effect("does not invoke subprocess polling until a terminal session is running", () =>
1104 Effect.gen(function* () {
1105 let checks = 0;
1106 const { manager } = yield* createManager(5, {
1107 subprocessInspector: () => {
1108 checks += 1;
1109 return Effect.succeed({
1110 hasRunningSubprocess: false,
1111 childCommand: null,
1112 processIds: [],
1113 });
1114 },
1115 subprocessPollIntervalMs: 20,
1116 });
1117
1118 yield* Effect.sleep("80 millis");
1119 assert.equal(checks, 0);
1120
1121 yield* manager.open(openInput());
1122 yield* waitFor(
1123 Effect.sync(() => checks > 0),
1124 "1200 millis",
1125 );
1126 }),
1127 );
1128
1129 it.effect("derives subprocess activity for every terminal from one shared process snapshot", () =>
1130 Effect.gen(function* () {
1131 const runCalls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
1132 // FakePtyAdapter assigns pids starting at 9000, so the two terminals
1133 // opened below run as pids 9000 and 9001.
1134 const psStdout = [" 100 9000 vim", " 101 100 git", " 200 9001 /usr/bin/python3"].join(
1135 "\n",
1136 );
1137 const processRunner: ProcessRunner.ProcessRunner["Service"] = {
1138 run: (input) =>
1139 Effect.sync(() => {
1140 runCalls.push({ command: input.command, args: input.args });
1141 return {
1142 stdout: psStdout,
1143 stderr: "",
1144 code: ChildProcessSpawner.ExitCode(0),
1145 timedOut: false,
1146 stdoutTruncated: false,
1147 stderrTruncated: false,
1148 stdoutInvalidUtf8: false,
1149 stderrInvalidUtf8: false,
1150 };
1151 }),
1152 };
1153
1154 const { manager, getEvents } = yield* createManager(5, {
1155 subprocessPollIntervalMs: 20,
1156 }).pipe(
1157 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
1158 Effect.provide(layerWithHostPlatform("linux")),
1159 );
1160
1161 yield* manager.open(openInput());
1162 yield* manager.open(openInput({ threadId: "thread-2" }));
1163
1164 yield* waitFor(
1165 Effect.map(
1166 getEvents,
1167 (events) =>
1168 events.some(
1169 (event) =>
1170 event.type === "activity" &&
1171 event.hasRunningSubprocess === true &&
1172 event.label === "vim",
1173 ) &&
1174 events.some(
1175 (event) =>
1176 event.type === "activity" &&
1177 event.hasRunningSubprocess === true &&
1178 event.label === "python3",
1179 ),
1180 ),
1181 "1200 millis",
1182 );
1183 yield* waitFor(
1184 Effect.sync(() => runCalls.length >= 3),
1185 "1200 millis",
1186 );
1187
1188 // Every spawn is the shared table snapshot — no per-terminal `pgrep`
1189 // or per-child `ps -p` invocations.
1190 expect(runCalls.every((call) => call.args.join(" ") === "-eo pid=,ppid=,comm=")).toBe(true);
1191 }),
1192 );
1193
1194 it.effect("keeps last known subprocess state when the process snapshot fails", () =>
1195 Effect.gen(function* () {
1196 let failSnapshots = false;
1197 let failedCalls = 0;
1198 const processRunner: ProcessRunner.ProcessRunner["Service"] = {
1199 run: () =>
1200 Effect.sync(() => {
1201 if (failSnapshots) failedCalls += 1;
1202 return {
1203 stdout: failSnapshots ? "" : " 100 9000 vim",
1204 stderr: "",
1205 code: ChildProcessSpawner.ExitCode(failSnapshots ? 1 : 0),
1206 timedOut: false,
1207 stdoutTruncated: false,
1208 stderrTruncated: false,
1209 stdoutInvalidUtf8: false,
1210 stderrInvalidUtf8: false,
1211 };
1212 }),
1213 };
1214
1215 const { manager, getEvents } = yield* createManager(5, {
1216 subprocessPollIntervalMs: 20,
1217 }).pipe(
1218 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
1219 Effect.provide(layerWithHostPlatform("linux")),
1220 );
1221
1222 yield* manager.open(openInput());
1223 yield* waitFor(
1224 Effect.map(getEvents, (events) =>
1225 events.some(
1226 (event) =>
1227 event.type === "activity" &&
1228 event.hasRunningSubprocess === true &&
1229 event.label === "vim",
1230 ),
1231 ),
1232 "1200 millis",
1233 );
1234
1235 failSnapshots = true;
1236 yield* waitFor(
1237 Effect.sync(() => failedCalls >= 3),
1238 "1200 millis",
1239 );
1240
1241 // A failed snapshot is not authoritative: no terminal flips to idle.
1242 const activityEvents = (yield* getEvents).filter((event) => event.type === "activity");
1243 expect(activityEvents.length).toBeGreaterThan(0);
1244 expect(activityEvents.every((event) => event.hasRunningSubprocess === true)).toBe(true);
1245 }),
1246 );
1247
1248 it("calculates snapshot failure backoff and success reset delays", () => {
1249 assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 0), 1_000);
1250 assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 1), 2_000);
1251 assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 2), 4_000);
1252 assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 30), 60_000);
1253 });
1254
1255 it.effect("uses process snapshots from the resource monitor", () =>
1256 Effect.gen(function* () {
1257 let snapshotCalls = 0;
1258 const { manager, getEvents } = yield* createManager(5, {
1259 subprocessPollIntervalMs: 20,
1260 processTable: Effect.sync(() => {
1261 snapshotCalls += 1;
1262 return [{ pid: 100, ppid: 9000, name: "ping.exe" }];
1263 }),
1264 }).pipe(Effect.provide(layerWithHostPlatform("win32")));
1265
1266 yield* manager.open(openInput());
1267 yield* waitFor(
1268 Effect.map(getEvents, (events) =>
1269 events.some(
1270 (event) =>
1271 event.type === "activity" && event.hasRunningSubprocess && event.label === "ping",
1272 ),
1273 ),
1274 "1200 millis",
1275 );
1276 expect(snapshotCalls).toBeGreaterThan(0);
1277 }),
1278 );
1279
1280 it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () =>
1281 Effect.gen(function* () {
1282 // FakePtyAdapter assigns pids from 9000 in open order.
1283 const { manager, ptyAdapter } = yield* createManager(5, {
1284 processTable: Effect.succeed([
1285 { pid: 9000, ppid: 1, name: "zsh" },
1286 // An async prompt worker: a copy of the shell with no children.
1287 { pid: 100, ppid: 9000, name: "zsh" },
1288 { pid: 9001, ppid: 1, name: "zsh" },
1289 { pid: 200, ppid: 9001, name: "node" },
1290 { pid: 9002, ppid: 1, name: "zsh" },
1291 // A subshell with a child is real work.
1292 { pid: 300, ppid: 9002, name: "zsh" },
1293 { pid: 301, ppid: 300, name: "sleep" },
1294 { pid: 9003, ppid: 1, name: "zsh" },
1295 ]),
1296 }).pipe(Effect.provide(layerWithHostPlatform("linux")));
1297 yield* manager.open(openInput({ terminalId: "idle" }));
1298 yield* manager.open(openInput({ terminalId: "dev-server" }));
1299 yield* manager.open(openInput({ terminalId: "subshell" }));
1300 yield* manager.open(openInput({ threadId: "thread-2" }));
1301
1302 yield* manager.closeIdle({ threadId: "thread-1" });
1303
1304 expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
1305 true,
1306 false,
1307 false,
1308 false,
1309 ]);
1310 }),
1311 );
1312
1313 it.effect("keeps terminals that get input or output while closeIdle checks them", () =>
1314 Effect.gen(function* () {
1315 const ptyAdapter = new FakePtyAdapter();
1316 // The typed command's process misses the snapshot, but its input or echo lands.
1317 let duringCheck: (pid: number) => Effect.Effect<void> = () => Effect.void;
1318 const { manager, getEvents } = yield* createManager(5, {
1319 ptyAdapter,
1320 subprocessPollIntervalMs: 60_000,
1321 subprocessInspector: (pid) =>
1322 duringCheck(pid).pipe(
1323 Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
1324 ),
1325 });
1326 yield* manager.open(openInput({ terminalId: "typed" }));
1327 yield* manager.open(openInput({ terminalId: "echoed" }));
1328 const [typed, echoed] = ptyAdapter.processes;
1329 duringCheck = (pid) =>
1330 pid === typed!.pid
1331 ? manager
1332 .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" })
1333 .pipe(Effect.orDie)
1334 : Effect.gen(function* () {
1335 echoed!.emitData("make build\r\n");
1336 yield* waitFor(
1337 Effect.map(getEvents, (events) => events.some((event) => event.type === "output")),
1338 );
1339 }).pipe(Effect.orDie);
1340
1341 yield* manager.closeIdle({ threadId: "thread-1" });
1342
1343 expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]);
1344 }),
1345 );
1346
1347 it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () =>
1348 Effect.gen(function* () {
1349 const fallbackCalls: Array<number> = [];
1350 const processRunner: ProcessRunner.ProcessRunner["Service"] = {
1351 run: () =>
1352 Clock.currentTimeMillis.pipe(
1353 Effect.map((now) => {
1354 fallbackCalls.push(now);
1355 return {
1356 stdout: " 100 9000 vim",
1357 stderr: "",
1358 code: ChildProcessSpawner.ExitCode(0),
1359 timedOut: false,
1360 stdoutTruncated: false,
1361 stderrInvalidUtf8: false,
1362 stdoutInvalidUtf8: false,
1363 stderrTruncated: false,
1364 };
1365 }),
1366 ),
1367 };
1368
1369 const { manager, getEvents } = yield* createManager(5, {
1370 subprocessPollIntervalMs: 20,
1371 processTable: Effect.fail("sidecar unavailable").pipe(
1372 Effect.mapError((cause) => cause as never),
1373 ),
1374 }).pipe(
1375 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
1376 Effect.provide(layerWithHostPlatform("linux")),
1377 );
1378
1379 yield* manager.open(openInput());
1380 // The fallback data is still applied while the sidecar is down.
1381 yield* waitFor(
1382 Effect.map(getEvents, (events) =>
1383 events.some(
1384 (event) =>
1385 event.type === "activity" &&
1386 event.hasRunningSubprocess === true &&
1387 event.label === "vim",
1388 ),
1389 ),
1390 "1200 millis",
1391 );
1392
1393 yield* waitFor(
1394 Effect.sync(() => fallbackCalls.length >= 4),
1395 "2000 millis",
1396 );
1397 // Four snapshots at the 20 ms base cadence would span ~60 ms. Backoff
1398 // (40 + 80 + 160 ms) stretches the same four snapshots past 150 ms, so
1399 // a stalled sidecar no longer hot-loops the spawned fallback.
1400 const spanMs = fallbackCalls[3]! - fallbackCalls[0]!;
1401 expect(spanMs).toBeGreaterThan(150);
1402 }),
1403 );
1404
1405 it.effect("coalesces live history writes while retaining the latest output on close", () =>
1406 Effect.gen(function* () {
1407 const fs = yield* FileSystem.FileSystem;
1408 const writes = yield* Queue.unbounded<string>();
1409 const output = yield* Queue.unbounded<string>();
1410 const trackedFileSystem = FileSystem.FileSystem.of({
1411 ...fs,
1412 writeFileString: (filePath, contents, options) =>
1413 fs
1414 .writeFileString(filePath, contents, options)
1415 .pipe(
1416 Effect.tap(() => (contents.length > 0 ? Queue.offer(writes, contents) : Effect.void)),
1417 ),
1418 });
1419 const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }).pipe(
1420 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
1421 );
1422 yield* manager.open(openInput());
1423 const unsubscribe = yield* manager.subscribe((event) =>
1424 event.type === "output" ? Queue.offer(output, event.data).pipe(Effect.asVoid) : Effect.void,
1425 );
1426 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1427 const process = ptyAdapter.processes[0]!;
1428
1429 process.emitData("first");
1430 yield* Queue.take(output);
1431 yield* TestClock.adjust("200 millis");
1432 expect(yield* Queue.size(writes)).toBe(0);
1433
1434 process.emitData(" second");
1435 yield* Queue.take(output);
1436 yield* TestClock.adjust("50 millis");
1437 expect(yield* Queue.take(writes)).toBe("first second");
1438
1439 process.emitData(" third");
1440 yield* Queue.take(output);
1441 const close = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped);
1442 yield* Effect.yieldNow;
1443 yield* TestClock.adjust("250 millis");
1444 yield* Fiber.join(close);
1445 expect((yield* manager.open(openInput())).history).toBe("first second third");
1446 }).pipe(Effect.provide(TestClock.layer())),
1447 );
1448
1449 it.effect("caps persisted history to configured line limit", () =>
1450 Effect.gen(function* () {
1451 const { manager, ptyAdapter } = yield* createManager(3);
1452 yield* manager.open(openInput());
1453 const process = ptyAdapter.processes[0];
1454 expect(process).toBeDefined();
1455 if (!process) return;
1456
1457 process.emitData("line1\nline2\nline3\nline4\n");
1458 yield* manager.close({ threadId: "thread-1" });
1459
1460 const reopened = yield* manager.open(openInput());
1461 const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);
1462 expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);
1463 }),
1464 );
1465
1466 it.effect("caps incrementally appended history without losing partial or empty lines", () =>
1467 Effect.gen(function* () {
1468 const { manager, ptyAdapter } = yield* createManager(3);
1469 yield* manager.open(openInput());
1470 const process = ptyAdapter.processes[0];
1471 expect(process).toBeDefined();
1472 if (!process) return;
1473
1474 process.emitData("line1\n");
1475 process.emitData("\n");
1476 process.emitData("line3");
1477 process.emitData("-continued\nline4");
1478 yield* manager.close({ threadId: "thread-1" });
1479
1480 const reopened = yield* manager.open(openInput());
1481 expect(reopened.history).toBe("\nline3-continued\nline4");
1482 }),
1483 );
1484
1485 it.effect("bounds persisted and attached history without truncating live output", () =>
1486 Effect.gen(function* () {
1487 const { manager, ptyAdapter, logsDir } = yield* createManager(5, { historyByteLimit: 10 });
1488 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1489 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
1490 Ref.update(attachEvents, (events) => [...events, event]),
1491 );
1492 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1493 const writes = ["a".repeat(32), "😀\rEND"];
1494 const process = ptyAdapter.processes[0]!;
1495 for (const text of writes) process.emitData(text);
1496 yield* manager.close({ threadId: "thread-1" });
1497 expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("aa😀\rEND");
1498
1499 const reopened = yield* manager.open(openInput());
1500 const events = yield* Ref.get(attachEvents);
1501 expect(events.filter((event) => event.type === "output").map((event) => event.data)).toEqual(
1502 writes,
1503 );
1504 const snapshot = events.filter((event) => event.type === "snapshot").at(-1)?.snapshot;
1505 expect(snapshot?.history).toBe("aa😀\rEND");
1506 expect(snapshot?.sequence).toBe(reopened.sequence);
1507 }),
1508 );
1509
1510 it.effect.each(["current", "legacy"] as const)(
1511 "reads only a Unicode-safe tail from oversized %s history",
1512 (source) =>
1513 Effect.gen(function* () {
1514 const fs = yield* FileSystem.FileSystem;
1515 const path = yield* Path.Path;
1516 let sourcePath: string | undefined;
1517 let closedReads = 0;
1518 const readRequests: number[] = [];
1519 const trackedFileSystem = FileSystem.FileSystem.of({
1520 ...fs,
1521 readFileString: (candidate, encoding) =>
1522 candidate === sourcePath
1523 ? Effect.die("History restoration must not read the whole file")
1524 : fs.readFileString(candidate, encoding),
1525 open: (candidate, options) =>
1526 Effect.gen(function* () {
1527 if (candidate !== sourcePath) return yield* fs.open(candidate, options);
1528 yield* Effect.addFinalizer(() =>
1529 Effect.sync(() => {
1530 closedReads += 1;
1531 }),
1532 );
1533 const file = yield* fs.open(candidate, options);
1534 return new Proxy(file, {
1535 get(target, key) {
1536 if (key === "read") {
1537 return (buffer: Uint8Array) => {
1538 readRequests.push(buffer.byteLength);
1539 return target.read(buffer.subarray(0, 5));
1540 };
1541 }
1542 return Reflect.get(target, key, target);
1543 },
1544 });
1545 }),
1546 });
1547 const { manager, logsDir } = yield* createManager(5, { historyByteLimit: 15 }).pipe(
1548 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
1549 );
1550 const nextPath = yield* historyLogPath(logsDir);
1551 sourcePath = source === "current" ? nextPath : path.join(logsDir, "thread-1.log");
1552 yield* fs.writeFileString(sourcePath, "old".repeat(32_768) + "😀\uFEFFnewest\ré");
1553
1554 const snapshot = yield* manager.open(openInput());
1555 expect(snapshot.history).toBe("\uFEFFnewest\ré");
1556 expect(readRequests).toEqual([15, 10, 5]);
1557 expect(closedReads).toBe(1);
1558 expect(Buffer.from(yield* fs.readFile(nextPath)).toString()).toBe("\uFEFFnewest\ré");
1559 if (source === "legacy") expect(yield* fs.exists(sourcePath)).toBe(false);
1560 yield* manager.close({ threadId: "thread-1" });
1561 expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré");
1562 }),
1563 );
1564
1565 it.effect("retains plain Unicode and control characters around a split query", () =>
1566 Effect.gen(function* () {
1567 const { manager, ptyAdapter } = yield* createManager();
1568 yield* manager.open(openInput());
1569 const process = ptyAdapter.processes[0]!;
1570 const before = "café\t界🙂\u0007\u009c\u0085";
1571 const after = "e\u0301\r\n";
1572
1573 process.emitData(before);
1574 process.emitData("\u001b[5");
1575 process.emitData(`n${after}`);
1576 yield* manager.close({ threadId: "thread-1" });
1577
1578 expect((yield* manager.open(openInput())).history).toBe(before + after);
1579 }),
1580 );
1581
1582 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
1583 Effect.gen(function* () {
1584 const { manager, ptyAdapter } = yield* createManager();
1585 yield* manager.open(openInput());
1586 const process = ptyAdapter.processes[0];
1587 expect(process).toBeDefined();
1588 if (!process) return;
1589
1590 process.emitData("prompt ");
1591 process.emitData("\u001b[32mok\u001b[0m ");
1592 process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007");
1593 process.emitData("\u001b[1;1R");
1594 process.emitData("done\n");
1595
1596 yield* manager.close({ threadId: "thread-1" });
1597
1598 const reopened = yield* manager.open(openInput());
1599 assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n");
1600 }),
1601 );
1602
1603 it.effect("strips replayable CSI and DCS traffic while preserving setters", () =>
1604 Effect.gen(function* () {
1605 const { manager, ptyAdapter } = yield* createManager();
1606 yield* manager.open(openInput());
1607 const process = ptyAdapter.processes[0];
1608 expect(process).toBeDefined();
1609 if (!process) return;
1610
1611 process.emitData("prompt ");
1612 // DECRQM/DECRPM, XTVERSION, and kitty-keyboard CSI query/reply traffic.
1613 process.emitData("\u001b[?2026$p\u001b[?2026;2$y\u001b[>q\u001b[?u\u001b[?31u");
1614 // DECRQSS and XTGETTCAP query/reply traffic in 7-bit DCS form.
1615 process.emitData("\u001bP$q m\u001b\\\u001bP1$r0m\u001b\\");
1616 process.emitData("\u001bP+q544e\u001b\\\u001bP1+r544e=1b\u001b\\");
1617 // The same DCS traffic in 8-bit form.
1618 process.emitData("\u0090$q m\u009c\u00901$r0m\u009c");
1619 process.emitData("\u0090+q544e\u009c\u00901+r544e=1b\u009c");
1620 // Setters and cursor movement share final bytes with query families but
1621 // have visible terminal-state value and must survive replay.
1622 process.emitData('\u001b[!p\u001b["p\u001b[4 q\u001b[u');
1623 process.emitData("done\n");
1624
1625 yield* manager.close({ threadId: "thread-1" });
1626
1627 const reopened = yield* manager.open(openInput());
1628 assert.equal(reopened.history, 'prompt \u001b[!p\u001b["p\u001b[4 q\u001b[udone\n');
1629 }),
1630 );
1631
1632 it.effect("handles CSI and DCS query sequences split across output chunks", () =>
1633 Effect.gen(function* () {
1634 const { manager, ptyAdapter } = yield* createManager();
1635 yield* manager.open(openInput());
1636 const process = ptyAdapter.processes[0];
1637 expect(process).toBeDefined();
1638 if (!process) return;
1639
1640 process.emitData("before ");
1641 process.emitData("\u001b[?2026$");
1642 process.emitData("pafter ");
1643 process.emitData("\u001bP$q ");
1644 process.emitData("m\u001b");
1645 process.emitData("\\after ");
1646 process.emitData("\u009b?3");
1647 process.emitData("1uafter ");
1648 process.emitData("\u0090+q544e");
1649 process.emitData("\u009cafter\n");
1650
1651 yield* manager.close({ threadId: "thread-1" });
1652
1653 const reopened = yield* manager.open(openInput());
1654 assert.equal(reopened.history, "before after after after after\n");
1655 }),
1656 );
1657
1658 it.effect(
1659 "preserves clear and style control sequences while dropping chunk-split query traffic",
1660 () =>
1661 Effect.gen(function* () {
1662 const { manager, ptyAdapter } = yield* createManager();
1663 yield* manager.open(openInput());
1664 const process = ptyAdapter.processes[0];
1665 expect(process).toBeDefined();
1666 if (!process) return;
1667
1668 process.emitData("before clear\n");
1669 process.emitData("\u001b[H\u001b[2J");
1670 process.emitData("prompt ");
1671 process.emitData("\u001b]11;");
1672 process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1");
1673 process.emitData("R\u001b[36mdone\u001b[0m\n");
1674
1675 yield* manager.close({ threadId: "thread-1" });
1676
1677 const reopened = yield* manager.open(openInput());
1678 assert.equal(
1679 reopened.history,
1680 "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n",
1681 );
1682 }),
1683 );
1684
1685 it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () =>
1686 Effect.gen(function* () {
1687 const { manager, ptyAdapter } = yield* createManager();
1688 yield* manager.open(openInput());
1689 const process = ptyAdapter.processes[0];
1690 expect(process).toBeDefined();
1691 if (!process) return;
1692
1693 process.emitData("before ");
1694 process.emitData("\u001b(B");
1695 process.emitData("after\n");
1696
1697 yield* manager.close({ threadId: "thread-1" });
1698
1699 const reopened = yield* manager.open(openInput());
1700 assert.equal(reopened.history, "before \u001b(Bafter\n");
1701 }),
1702 );
1703
1704 it.effect(
1705 "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes",
1706 () =>
1707 Effect.gen(function* () {
1708 const { manager, ptyAdapter } = yield* createManager();
1709 yield* manager.open(openInput());
1710 const process = ptyAdapter.processes[0];
1711 expect(process).toBeDefined();
1712 if (!process) return;
1713
1714 process.emitData("before ");
1715 process.emitData("\u001b(");
1716 process.emitData("Bafter\n");
1717
1718 yield* manager.close({ threadId: "thread-1" });
1719
1720 const reopened = yield* manager.open(openInput());
1721 assert.equal(reopened.history, "before \u001b(Bafter\n");
1722 }),
1723 );
1724
1725 it.effect("deletes history file when close(deleteHistory=true)", () =>
1726 Effect.gen(function* () {
1727 const { manager, ptyAdapter, logsDir } = yield* createManager();
1728 yield* manager.open(openInput());
1729 const process = ptyAdapter.processes[0];
1730 expect(process).toBeDefined();
1731 if (!process) return;
1732 process.emitData("bye\n");
1733 const path = yield* Path.Path;
1734 yield* waitFor(
1735 historyLogPath(logsDir).pipe(
1736 Effect.provideService(Path.Path, path),
1737 Effect.flatMap(pathExists),
1738 ),
1739 );
1740
1741 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
1742 expect(
1743 yield* historyLogPath(logsDir).pipe(
1744 Effect.provideService(Path.Path, path),
1745 Effect.flatMap(pathExists),
1746 ),
1747 ).toBe(false);
1748 }),
1749 );
1750
1751 it.effect("closes all terminals for a thread when close omits terminalId", () =>
1752 Effect.gen(function* () {
1753 const { manager, ptyAdapter, logsDir } = yield* createManager();
1754 yield* manager.open(openInput({ terminalId: "default" }));
1755 yield* manager.open(openInput({ terminalId: "sidecar" }));
1756 const defaultProcess = ptyAdapter.processes[0];
1757 const sidecarProcess = ptyAdapter.processes[1];
1758 expect(defaultProcess).toBeDefined();
1759 expect(sidecarProcess).toBeDefined();
1760 if (!defaultProcess || !sidecarProcess) return;
1761
1762 defaultProcess.emitData("default\n");
1763 sidecarProcess.emitData("sidecar\n");
1764 const path = yield* Path.Path;
1765 yield* waitFor(
1766 multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe(
1767 Effect.provideService(Path.Path, path),
1768 Effect.flatMap(pathExists),
1769 ),
1770 );
1771 yield* waitFor(
1772 multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe(
1773 Effect.provideService(Path.Path, path),
1774 Effect.flatMap(pathExists),
1775 ),
1776 );
1777
1778 yield* manager.close({ threadId: "thread-1", deleteHistory: true });
1779
1780 assert.equal(defaultProcess.killed, true);
1781 assert.equal(sidecarProcess.killed, true);
1782 expect(
1783 yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe(
1784 Effect.provideService(Path.Path, path),
1785 Effect.flatMap(pathExists),
1786 ),
1787 ).toBe(false);
1788 expect(
1789 yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe(
1790 Effect.provideService(Path.Path, path),
1791 Effect.flatMap(pathExists),
1792 ),
1793 ).toBe(false);
1794 }),
1795 );
1796
1797 it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () =>
1798 Effect.gen(function* () {
1799 const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 });
1800 yield* manager.open(openInput());
1801 const process = ptyAdapter.processes[0];
1802 expect(process).toBeDefined();
1803 if (!process) return;
1804
1805 const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped);
1806 yield* Effect.yieldNow;
1807 yield* TestClock.adjust("10 millis");
1808 yield* Fiber.join(closeFiber);
1809
1810 assert.equal(process.killSignals[0], "SIGTERM");
1811 expect(process.killSignals).toContain("SIGKILL");
1812 }).pipe(Effect.provide(TestClock.layer())),
1813 );
1814
1815 it.effect("publishes closed events when terminals are explicitly closed", () =>
1816 Effect.gen(function* () {
1817 const { manager, getEvents } = yield* createManager();
1818 yield* manager.open(openInput({ terminalId: "default" }));
1819 yield* manager.open(openInput({ terminalId: "sidecar" }));
1820
1821 yield* manager.close({ threadId: "thread-1" });
1822
1823 const closedEvents = (yield* getEvents).filter(
1824 (event): event is Extract<TerminalEvent, { type: "closed" }> => event.type === "closed",
1825 );
1826 expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]);
1827 }),
1828 );
1829
1830 it.effect(
1831 "lists and closes one chat's sessions without touching a peer in the same directory",
1832 () =>
1833 Effect.gen(function* () {
1834 const { manager, ptyAdapter } = yield* createManager();
1835 yield* manager.open(openInput({ terminalId: "default" }));
1836 yield* manager.open(openInput({ terminalId: "sidecar" }));
1837 yield* manager.open(openInput({ threadId: "peer-chat", terminalId: "default" }));
1838 const outputProcessed = yield* Deferred.make<void>();
1839 const unsubscribe = yield* manager.subscribe((event) =>
1840 event.type === "output" && event.threadId === "thread-1"
1841 ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid)
1842 : Effect.void,
1843 );
1844 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1845 ptyAdapter.processes[0]!.emitData("valuable history\n");
1846 yield* Deferred.await(outputProcessed);
1847
1848 const listed = yield* manager.list({ threadId: "thread-1" });
1849 expect(listed.map((session) => session.terminalId).sort()).toEqual(["default", "sidecar"]);
1850 expect(listed.every((session) => session.cwd === listed[0]?.cwd)).toBe(true);
1851 yield* manager.close({ threadId: "thread-1" });
1852
1853 expect(yield* manager.list({ threadId: "thread-1" })).toEqual([]);
1854 expect(
1855 (yield* manager.list({ threadId: "peer-chat" })).map((session) => session.terminalId),
1856 ).toEqual(["default"]);
1857 expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([true, true, false]);
1858 expect((yield* manager.open(openInput({ terminalId: "default" }))).history).toBe(
1859 "valuable history\n",
1860 );
1861 }),
1862 );
1863
1864 it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () =>
1865 Effect.gen(function* () {
1866 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, {
1867 maxRetainedInactiveSessions: 1,
1868 });
1869
1870 yield* manager.open(openInput({ threadId: "thread-1" }));
1871 yield* manager.open(openInput({ threadId: "thread-2" }));
1872
1873 const first = ptyAdapter.processes[0];
1874 const second = ptyAdapter.processes[1];
1875 expect(first).toBeDefined();
1876 expect(second).toBeDefined();
1877 if (!first || !second) return;
1878
1879 first.emitData("first-history\n");
1880 second.emitData("second-history\n");
1881 const path = yield* Path.Path;
1882 yield* waitFor(
1883 historyLogPath(logsDir, "thread-1").pipe(
1884 Effect.provideService(Path.Path, path),
1885 Effect.flatMap(pathExists),
1886 ),
1887 );
1888 first.emitExit({ exitCode: 0, signal: 0 });
1889 yield* Effect.sleep(Duration.millis(5));
1890 second.emitExit({ exitCode: 0, signal: 0 });
1891
1892 yield* waitFor(
1893 Effect.map(
1894 getEvents,
1895 (events) => events.filter((event) => event.type === "exited").length === 2,
1896 ),
1897 );
1898
1899 const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" }));
1900 const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" }));
1901
1902 assert.equal(reopenedFirst.history, "first-history\n");
1903 assert.equal(reopenedSecond.history, "");
1904 }),
1905 );
1906
1907 it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () =>
1908 Effect.gen(function* () {
1909 const { manager, logsDir } = yield* createManager();
1910 const path = yield* Path.Path;
1911 const legacyPath = path.join(logsDir, "thread-1.log");
1912 const nextPath = yield* historyLogPath(logsDir);
1913 yield* writeFileString(legacyPath, "legacy-line\n");
1914
1915 const snapshot = yield* manager.open(openInput());
1916
1917 assert.equal(snapshot.history, "legacy-line\n");
1918 expect(yield* pathExists(nextPath)).toBe(true);
1919 expect(yield* readFileString(nextPath)).toBe("legacy-line\n");
1920 expect(yield* pathExists(legacyPath)).toBe(false);
1921 }),
1922 );
1923
1924 it.effect("retries with fallback shells when preferred shell spawn fails", () =>
1925 Effect.gen(function* () {
1926 const platform = yield* HostProcess.Platform;
1927 const missingShell =
1928 platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l";
1929 const { manager, ptyAdapter } = yield* createManager(5, {
1930 shellResolver: () => missingShell,
1931 });
1932 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
1933
1934 const snapshot = yield* manager.open(openInput());
1935
1936 assert.equal(snapshot.status, "running");
1937 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
1938 expect(ptyAdapter.spawnInputs[0]?.shell).toBe(
1939 platform === "win32" ? missingShell : "/definitely/missing-shell",
1940 );
1941
1942 if (platform === "win32") {
1943 expect(
1944 ptyAdapter.spawnInputs.some(
1945 (input) =>
1946 input.shell === "pwsh.exe" ||
1947 input.shell === "powershell.exe" ||
1948 input.shell === "cmd.exe",
1949 ),
1950 ).toBe(true);
1951 } else {
1952 expect(
1953 ptyAdapter.spawnInputs
1954 .slice(1)
1955 .some((input) => input.shell !== "/definitely/missing-shell"),
1956 ).toBe(true);
1957 }
1958 }),
1959 );
1960
1961 it.effect("prefers PowerShell over ComSpec for Windows terminals", () =>
1962 Effect.gen(function* () {
1963 const { manager, ptyAdapter } = yield* createManager(5, {
1964 env: {
1965 ComSpec: "C:\\Windows\\System32\\cmd.exe",
1966 PATH: "C:\\Windows\\System32",
1967 SystemRoot: "C:\\Windows",
1968 },
1969 }).pipe(Effect.provide(layerWithHostPlatform("win32")));
1970
1971 yield* manager.open(openInput());
1972
1973 expect(ptyAdapter.spawnInputs[0]).toEqual(
1974 expect.objectContaining({
1975 shell: "pwsh.exe",
1976 args: ["-NoLogo"],
1977 }),
1978 );
1979 }),
1980 );
1981
1982 it.effect("preserves Windows Path casing when appending managed ACP binaries", () =>
1983 Effect.gen(function* () {
1984 const fileSystem = yield* FileSystem.FileSystem;
1985 const path = yield* Path.Path;
1986 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1987 prefix: "t3-terminal-acp-path-",
1988 });
1989 const installBin = path.join(
1990 cacheDir,
1991 "tools",
1992 "example-agent",
1993 "1.2.3",
1994 "windows-x86_64",
1995 "bin",
1996 );
1997 yield* fileSystem.makeDirectory(installBin, { recursive: true });
1998 yield* fileSystem.makeDirectory(path.join(cacheDir, "acp-registry"), { recursive: true });
1999 yield* fileSystem.writeFileString(
2000 path.join(cacheDir, "acp-registry", "registry.json"),
2001 encodeUnknownJson({
2002 version: "1.0.0",
2003 agents: [
2004 {
2005 id: "example-agent",
2006 name: "Example Agent",
2007 version: "1.2.3",
2008 description: "ACP Registry test agent",
2009 distribution: {
2010 binary: {
2011 "windows-x86_64": {
2012 archive: "https://registry.test/example-agent.zip",
2013 cmd: "bin/example-agent.exe",
2014 },
2015 },
2016 },
2017 },
2018 ],
2019 }),
2020 );
2021 const { manager, ptyAdapter } = yield* createManager(5, {
2022 managedBinaryCacheDir: cacheDir,
2023 managedBinaryToolsDir: path.join(cacheDir, "tools"),
2024 env: {
2025 ComSpec: "C:\\Windows\\System32\\cmd.exe",
2026 Path: "C:\\Windows\\System32",
2027 SystemRoot: "C:\\Windows",
2028 },
2029 }).pipe(
2030 Effect.provide(
2031 Layer.merge(
2032 layerWithHostPlatform("win32"),
2033 Layer.succeed(HostProcess.Architecture, "x64"),
2034 ),
2035 ),
2036 );
2037
2038 yield* manager.open(openInput());
2039
2040 const spawnEnv = ptyAdapter.spawnInputs[0]?.env;
2041 expect(spawnEnv?.PATH).toBeUndefined();
2042 expect(spawnEnv?.Path).toBe(`C:\\Windows\\System32;${installBin}`);
2043 }),
2044 );
2045
2046 it.effect("falls back to built-in PowerShell by absolute path on Windows", () =>
2047 Effect.gen(function* () {
2048 const ptyAdapter = new FakePtyAdapter();
2049 const { manager } = yield* createManager(5, {
2050 ptyAdapter,
2051 shellResolver: () => "C:\\missing\\custom-shell.exe",
2052 env: {
2053 ComSpec: "C:\\Windows\\System32\\cmd.exe",
2054 PATH: "C:\\Windows\\System32",
2055 SystemRoot: "C:\\Windows",
2056 },
2057 }).pipe(Effect.provide(layerWithHostPlatform("win32")));
2058 ptyAdapter.spawnFailures.push(
2059 new Error("spawn custom-shell.exe ENOENT"),
2060 new Error("spawn pwsh.exe ENOENT"),
2061 );
2062
2063 yield* manager.open(openInput());
2064
2065 expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([
2066 "C:\\missing\\custom-shell.exe",
2067 "pwsh.exe",
2068 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
2069 ]);
2070 expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]);
2071 expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]);
2072 }),
2073 );
2074
2075 it.effect.each(["linux", "darwin", "win32"] as const)(
2076 "advertises truecolor before the PTY backend on %s without replacing explicit values",
2077 (platform) =>
2078 Effect.gen(function* () {
2079 for (const [parentColor, runtimeColor, expected] of [
2080 [undefined, undefined, "truecolor"],
2081 ["", undefined, "truecolor"],
2082 ["24bit", undefined, "24bit"],
2083 ["24bit", "", ""],
2084 [undefined, "", ""],
2085 ["24bit", "custom", "custom"],
2086 ] as const) {
2087 const env = Object.freeze({ COLORTERM: parentColor });
2088 const { manager, ptyAdapter } = yield* createManager(5, {
2089 shellResolver: () => "/bin/sh",
2090 env,
2091 }).pipe(Effect.provide(layerWithHostPlatform(platform)));
2092 yield* manager.open(
2093 openInput({ env: runtimeColor === undefined ? {} : { COLORTERM: runtimeColor } }),
2094 );
2095 expect(ptyAdapter.spawnInputs[0]?.env.COLORTERM).toBe(expected);
2096 expect(env.COLORTERM).toBe(parentColor);
2097 }
2098 }),
2099 );
2100
2101 it.effect("filters app runtime env variables from terminal sessions", () =>
2102 Effect.gen(function* () {
2103 const { manager, ptyAdapter } = yield* createManager(5, {
2104 env: {
2105 PORT: "5173",
2106 T3CODE_PORT: "3773",
2107 VITE_DEV_SERVER_URL: "http://localhost:5173",
2108 TEST_TERMINAL_KEEP: "keep-me",
2109 },
2110 });
2111 yield* manager.open(openInput());
2112 const spawnInput = ptyAdapter.spawnInputs[0];
2113 expect(spawnInput).toBeDefined();
2114 if (!spawnInput) return;
2115
2116 expect(spawnInput.env.PORT).toBeUndefined();
2117 expect(spawnInput.env.T3CODE_PORT).toBeUndefined();
2118 expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined();
2119 // Arbitrary host env vars must pass through — terminals inherit the
2120 // user's environment apart from the explicit blocklist.
2121 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");
2122 }),
2123 );
2124
2125 it.effect("expands provider home paths passed to setup terminals", () =>
2126 Effect.gen(function* () {
2127 const { manager, ptyAdapter } = yield* createManager(5);
2128
2129 yield* manager.open({
2130 ...openInput(),
2131 env: {
2132 CODEX_HOME: "~/.codex-work",
2133 CLAUDE_CONFIG_DIR: "~/.claude-work",
2134 CUSTOM_ACCOUNT: "~/leave-this-value-alone",
2135 },
2136 });
2137
2138 const environment = ptyAdapter.spawnInputs[0]?.env;
2139 expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/);
2140 expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/);
2141 expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone");
2142 }),
2143 );
2144
2145 it.effect("strips AppImage runtime env from terminal sessions", () =>
2146 Effect.gen(function* () {
2147 const appDir = "/tmp/.mount_T3Codeabc123";
2148 const { manager, ptyAdapter } = yield* createManager(5, {
2149 env: {
2150 APPIMAGE: "/home/user/T3-Code.AppImage",
2151 APPDIR: appDir,
2152 ARGV0: "/home/user/T3-Code.AppImage",
2153 OWD: "/home/user/project",
2154 PATH: `${appDir}/usr/bin:${appDir}:/usr/local/bin:/usr/bin:/bin`,
2155 LD_LIBRARY_PATH: `${appDir}/usr/lib:/home/user/.local/lib`,
2156 XDG_DATA_DIRS: `${appDir}/usr/share:/usr/local/share:/usr/share`,
2157 GSETTINGS_SCHEMA_DIR: `${appDir}/usr/share/glib-2.0/schemas`,
2158 TEST_TERMINAL_KEEP: "keep-me",
2159 },
2160 });
2161 yield* manager.open(openInput());
2162 const spawnInput = ptyAdapter.spawnInputs[0];
2163 expect(spawnInput).toBeDefined();
2164 if (!spawnInput) return;
2165
2166 // AppImage runtime markers must never reach the PTY — tools inside the
2167 // terminal otherwise resolve against the AppImage mount (e.g. PHP_BINARY
2168 // reporting the AppImage path instead of the real binary).
2169 expect(spawnInput.env.APPIMAGE).toBeUndefined();
2170 expect(spawnInput.env.APPDIR).toBeUndefined();
2171 expect(spawnInput.env.ARGV0).toBeUndefined();
2172 expect(spawnInput.env.OWD).toBeUndefined();
2173 // PATH/LD_LIBRARY_PATH keep the user's real entries but drop the AppImage
2174 // mount segments that the runtime prepended.
2175 expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin");
2176 expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib");
2177 // XDG_DATA_DIRS keeps the host entries but drops the AppImage share dir.
2178 expect(spawnInput.env.XDG_DATA_DIRS).toBe("/usr/local/share:/usr/share");
2179 // GSETTINGS_SCHEMA_DIR pointed only at the mount, so it is removed and
2180 // gsettings falls back to the host schema location.
2181 expect(spawnInput.env.GSETTINGS_SCHEMA_DIR).toBeUndefined();
2182 // Unrelated host vars still pass through untouched.
2183 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");
2184 }),
2185 );
2186
2187 it.effect("leaves the environment untouched when not launched from an AppImage", () =>
2188 Effect.gen(function* () {
2189 const { manager, ptyAdapter } = yield* createManager(5, {
2190 env: {
2191 PATH: "/usr/local/bin:/usr/bin:/bin",
2192 LD_LIBRARY_PATH: "/home/user/.local/lib",
2193 // Without APPIMAGE/APPDIR set, OWD is an ordinary variable and must
2194 // not be stripped — only an AppImage launch gives it special meaning.
2195 OWD: "/home/user/keep-this",
2196 },
2197 });
2198 yield* manager.open(openInput());
2199 const spawnInput = ptyAdapter.spawnInputs[0];
2200 expect(spawnInput).toBeDefined();
2201 if (!spawnInput) return;
2202
2203 expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin");
2204 expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib");
2205 expect(spawnInput.env.OWD).toBe("/home/user/keep-this");
2206 }),
2207 );
2208
2209 it.effect("injects runtime env overrides into spawned terminals", () =>
2210 Effect.gen(function* () {
2211 const { manager, ptyAdapter } = yield* createManager(5, { env: { FORCE_COLOR: "3" } });
2212 yield* manager.open(
2213 openInput({
2214 env: {
2215 T3CODE_PROJECT_ROOT: "/repo",
2216 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
2217 CUSTOM_FLAG: "1",
2218 NO_COLOR: "1",
2219 FORCE_COLOR: "0",
2220 },
2221 }),
2222 );
2223 const spawnInput = ptyAdapter.spawnInputs[0];
2224 expect(spawnInput).toBeDefined();
2225 if (!spawnInput) return;
2226
2227 assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo");
2228 assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a");
2229 assert.equal(spawnInput.env.CUSTOM_FLAG, "1");
2230 assert.equal(spawnInput.env.NO_COLOR, "1");
2231 assert.equal(spawnInput.env.FORCE_COLOR, "0");
2232 }),
2233 );
2234
2235 it.effect("resolves a provider instance environment before spawning", () =>
2236 Effect.gen(function* () {
2237 const providerInstanceId = ProviderInstanceId.make("codex_work");
2238 const { manager, ptyAdapter } = yield* createManager(5, {
2239 env: { T3CODE_SECRET: "server-only" },
2240 resolveProviderInstanceEnvironment: (requestedId, env) =>
2241 Effect.succeed({
2242 ...env,
2243 PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong",
2244 CODEX_HOME: "/accounts/codex-work",
2245 }),
2246 });
2247
2248 const snapshot = yield* manager.open(
2249 openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }),
2250 );
2251
2252 expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value");
2253 expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work");
2254 expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1");
2255 expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined();
2256 expect(snapshot).not.toHaveProperty("env");
2257 expect(snapshot).not.toHaveProperty("providerInstanceId");
2258 }),
2259 );
2260
2261 it.effect("fails closed when a provider instance is missing", () =>
2262 Effect.gen(function* () {
2263 const providerInstanceId = ProviderInstanceId.make("deleted_instance");
2264 const { manager, ptyAdapter } = yield* createManager(5, {
2265 resolveProviderInstanceEnvironment: (requestedId) =>
2266 Effect.fail(
2267 new TerminalProviderInstanceNotFoundError({
2268 providerInstanceId: ProviderInstanceId.make(requestedId),
2269 }),
2270 ),
2271 });
2272
2273 const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip);
2274
2275 assert.deepStrictEqual(
2276 error,
2277 new TerminalProviderInstanceNotFoundError({ providerInstanceId }),
2278 );
2279 expect(ptyAdapter.spawnInputs).toHaveLength(0);
2280 }),
2281 );
2282
2283 it.effect("preserves the settings failure when provider environment resolution fails", () =>
2284 Effect.gen(function* () {
2285 const path = yield* Path.Path;
2286 const providerInstanceId = ProviderInstanceId.make("codex_work");
2287 const settingsCause = new Error("secret store read failed");
2288 const settingsError = new ServerSettingsError({
2289 settingsPath: "/test/settings.json",
2290 operation: "read-secret",
2291 providerInstanceId,
2292 environmentVariable: "OPENROUTER_API_KEY",
2293 cause: settingsCause,
2294 });
2295 const serverSettings = ServerSettings.ServerSettingsService.of({
2296 start: Effect.void,
2297 ready: Effect.void,
2298 getSettings: Effect.fail(settingsError),
2299 updateSettings: () => Effect.fail(settingsError),
2300 updateProviderInstance: () => Effect.fail(settingsError),
2301 withSettingsSnapshot: () => Effect.fail(settingsError),
2302 streamChanges: Stream.empty,
2303 subscribeChanges: Effect.succeed(Stream.empty),
2304 });
2305
2306 const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2307 serverSettings,
2308 path,
2309 rawProviderInstanceId: providerInstanceId,
2310 env: undefined,
2311 }).pipe(Effect.flip);
2312
2313 expect(error).toMatchObject({
2314 _tag: "TerminalProviderEnvironmentError",
2315 providerInstanceId,
2316 });
2317 expect(error.cause).toBe(settingsError);
2318 expect(error.message).not.toContain(settingsError.message);
2319 expect(error.message).not.toContain("OPENROUTER_API_KEY");
2320 }),
2321 );
2322
2323 it.effect.each([
2324 {
2325 name: "Codex home",
2326 driver: "codex",
2327 variable: "CODEX_HOME",
2328 config: { homePath: "/configured/codex" },
2329 expectedHome: "/configured/codex",
2330 },
2331 {
2332 name: "Codex shadow home",
2333 driver: "codex",
2334 variable: "CODEX_HOME",
2335 config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" },
2336 expectedHome: "/configured/codex-shadow",
2337 },
2338 {
2339 name: "Claude home",
2340 driver: "claudeAgent",
2341 variable: "CLAUDE_CONFIG_DIR",
2342 config: { homePath: "/configured/claude" },
2343 expectedHome: "/configured/claude",
2344 },
2345 ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) =>
2346 Effect.gen(function* () {
2347 const path = yield* Path.Path;
2348 const serverSettings = yield* ServerSettings.ServerSettingsService;
2349 const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2350 serverSettings,
2351 path,
2352 rawProviderInstanceId: "configured_home",
2353 env: undefined,
2354 });
2355
2356 expect(environment[variable]).toBe(path.resolve(expectedHome));
2357 }).pipe(
2358 Effect.provide(
2359 ServerSettings.layerTest({
2360 providerInstances: {
2361 [ProviderInstanceId.make("configured_home")]: {
2362 driver: ProviderDriverKind.make(driver),
2363 environment: [{ name: variable, value: "~/.environment-account", sensitive: false }],
2364 config,
2365 },
2366 },
2367 }),
2368 ),
2369 ),
2370 );
2371
2372 it.effect("resolves the Codex default slot", () =>
2373 Effect.gen(function* () {
2374 const path = yield* Path.Path;
2375 const serverSettings = yield* ServerSettings.ServerSettingsService;
2376 const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2377 serverSettings,
2378 path,
2379 rawProviderInstanceId: "codex",
2380 env: undefined,
2381 });
2382
2383 expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-default$/);
2384 }).pipe(
2385 Effect.provide(
2386 ServerSettings.ServerSettingsService.layerTest({
2387 providerInstances: {
2388 [ProviderInstanceId.make("codex")]: {
2389 driver: ProviderDriverKind.make("codex"),
2390 config: { homePath: "~/.codex-default" },
2391 },
2392 },
2393 }),
2394 ),
2395 ),
2396 );
2397
2398 it.effect("resolves the Claude default slot", () =>
2399 Effect.gen(function* () {
2400 const path = yield* Path.Path;
2401 const serverSettings = yield* ServerSettings.ServerSettingsService;
2402 const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2403 serverSettings,
2404 path,
2405 rawProviderInstanceId: "claudeAgent",
2406 env: undefined,
2407 });
2408
2409 expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-default$/);
2410 }).pipe(
2411 Effect.provide(
2412 ServerSettings.ServerSettingsService.layerTest({
2413 providerInstances: {
2414 [ProviderInstanceId.make("claudeAgent")]: {
2415 driver: ProviderDriverKind.make("claudeAgent"),
2416 config: { homePath: "~/.claude-default" },
2417 },
2418 },
2419 }),
2420 ),
2421 ),
2422 );
2423
2424 it.effect("resolves an empty Codex default slot with default config", () =>
2425 Effect.gen(function* () {
2426 const path = yield* Path.Path;
2427 const serverSettings = yield* ServerSettings.ServerSettingsService;
2428 const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2429 serverSettings,
2430 path,
2431 rawProviderInstanceId: "codex",
2432 env: { CODEX_HOME: "/inherited/codex-home" },
2433 });
2434
2435 expect(environment.CODEX_HOME).toBe("/inherited/codex-home");
2436 }).pipe(
2437 Effect.provide(ServerSettings.ServerSettingsService.layerTest({ providerInstances: {} })),
2438 ),
2439 );
2440
2441 it.effect("keeps unknown provider instance ids unavailable after default-slot hydration", () =>
2442 Effect.gen(function* () {
2443 const path = yield* Path.Path;
2444 const serverSettings = yield* ServerSettings.ServerSettingsService;
2445 const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2446 serverSettings,
2447 path,
2448 rawProviderInstanceId: "codex_unknown",
2449 env: undefined,
2450 }).pipe(Effect.flip);
2451
2452 expect(error).toMatchObject({
2453 _tag: "TerminalProviderInstanceNotFoundError",
2454 providerInstanceId: "codex_unknown",
2455 });
2456 }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())),
2457 );
2458
2459 it.effect("restarts a running terminal when the resolved provider environment changes", () =>
2460 Effect.gen(function* () {
2461 const providerInstanceId = ProviderInstanceId.make("codex_work");
2462 let providerSecret = "first-secret";
2463 const { manager, ptyAdapter } = yield* createManager(5, {
2464 resolveProviderInstanceEnvironment: () =>
2465 Effect.succeed({ PROVIDER_SECRET: providerSecret }),
2466 });
2467
2468 yield* manager.open(openInput({ providerInstanceId }));
2469 providerSecret = "second-secret";
2470 yield* manager.open(openInput({ providerInstanceId }));
2471
2472 expect(ptyAdapter.processes[0]?.killed).toBe(true);
2473 expect(ptyAdapter.spawnInputs).toHaveLength(2);
2474 expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret");
2475 }),
2476 );
2477
2478 it.effect("restarts with current provider secrets and clears bounded history", () =>
2479 Effect.gen(function* () {
2480 const serverSettings = yield* ServerSettings.ServerSettingsService;
2481 const path = yield* Path.Path;
2482 const providerInstanceId = ProviderInstanceId.make("codex_restart");
2483 const { manager, ptyAdapter, logsDir } = yield* createManager(2, {
2484 historyByteLimit: 8,
2485 resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) =>
2486 TerminalManager.resolveProviderInstanceTerminalEnvironment({
2487 serverSettings,
2488 path,
2489 rawProviderInstanceId,
2490 env,
2491 }),
2492 });
2493 const homePath = path.join(logsDir, "codex");
2494 const updateSecret = (value: string) =>
2495 serverSettings.updateSettings({
2496 providerInstances: {
2497 [providerInstanceId]: {
2498 driver: ProviderDriverKind.make("codex"),
2499 config: { homePath },
2500 environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }],
2501 },
2502 },
2503 });
2504 const input = {
2505 providerInstanceId,
2506 env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" },
2507 };
2508 const outputProcessed = yield* Deferred.make<void>();
2509 const unsubscribe = yield* manager.subscribe((event) =>
2510 event.type === "output"
2511 ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid)
2512 : Effect.void,
2513 );
2514 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2515
2516 yield* updateSecret("first-secret");
2517 yield* manager.restart(restartInput(input));
2518 const firstProcess = ptyAdapter.processes[0]!;
2519 expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret");
2520 firstProcess.emitData("discarded\nold-one\nold-two\n");
2521 yield* Deferred.await(outputProcessed);
2522 expect((yield* manager.open(openInput(input))).history).toBe("old-two\n");
2523
2524 yield* updateSecret("second-secret");
2525 const restarted = yield* manager.restart(restartInput(input));
2526
2527 expect(firstProcess.killed).toBe(true);
2528 expect(ptyAdapter.spawnInputs).toHaveLength(2);
2529 expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({
2530 PROVIDER_SECRET: "second-secret",
2531 CODEX_HOME: homePath,
2532 CLIENT_FLAG: "1",
2533 });
2534 expect(restarted.history).toBe("");
2535 expect(restarted.status).toBe("running");
2536 expect(restarted).not.toHaveProperty("env");
2537 expect(restarted).not.toHaveProperty("providerInstanceId");
2538 const logPath = yield* historyLogPath(logsDir);
2539 expect(yield* readFileString(logPath)).toBe("");
2540
2541 ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n");
2542 yield* manager.close({ threadId: "thread-1" });
2543 expect(yield* readFileString(logPath)).toBe("new-two\n");
2544 }).pipe(
2545 Effect.provide(
2546 ServerSettings.layer.pipe(
2547 Layer.provide(ServerSecretStore.layer),
2548 Layer.provide(SqlitePersistence.layerMemory),
2549 Layer.provide(
2550 ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }),
2551 ),
2552 ),
2553 ),
2554 ),
2555 );
2556
2557 it.effect("attaches to a running provider terminal without resolving the provider again", () =>
2558 Effect.gen(function* () {
2559 const providerInstanceId = ProviderInstanceId.make("codex_work");
2560 let providerAvailable = true;
2561 const { manager, ptyAdapter } = yield* createManager(5, {
2562 resolveProviderInstanceEnvironment: (requestedId) =>
2563 providerAvailable
2564 ? Effect.succeed({ PROVIDER_SECRET: "secret-value" })
2565 : Effect.fail(
2566 new TerminalProviderInstanceNotFoundError({
2567 providerInstanceId: ProviderInstanceId.make(requestedId),
2568 }),
2569 ),
2570 });
2571 yield* manager.open(openInput({ providerInstanceId }));
2572 providerAvailable = false;
2573 const events: TerminalAttachStreamEvent[] = [];
2574
2575 const unsubscribe = yield* manager.attachStream(
2576 { ...openInput({ providerInstanceId }), restartIfNotRunning: true },
2577 (event) => Effect.sync(() => events.push(event)),
2578 );
2579 unsubscribe();
2580
2581 expect(events[0]?.type).toBe("snapshot");
2582 expect(ptyAdapter.spawnInputs).toHaveLength(1);
2583 expect(ptyAdapter.processes[0]?.killed).toBe(false);
2584 }),
2585 );
2586
2587 it.effect("fails closed when attaching would create a missing provider terminal", () =>
2588 Effect.gen(function* () {
2589 const providerInstanceId = ProviderInstanceId.make("deleted_instance");
2590 const { manager, ptyAdapter } = yield* createManager(5, {
2591 resolveProviderInstanceEnvironment: (requestedId) =>
2592 Effect.fail(
2593 new TerminalProviderInstanceNotFoundError({
2594 providerInstanceId: ProviderInstanceId.make(requestedId),
2595 }),
2596 ),
2597 });
2598
2599 const error = yield* manager
2600 .attachStream(openInput({ providerInstanceId }), () => Effect.void)
2601 .pipe(Effect.flip);
2602
2603 assert.deepStrictEqual(
2604 error,
2605 new TerminalProviderInstanceNotFoundError({ providerInstanceId }),
2606 );
2607 expect(ptyAdapter.spawnInputs).toHaveLength(0);
2608 }),
2609 );
2610
2611 it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>
2612 Effect.gen(function* () {
2613 if ((yield* HostProcess.Platform) === "win32") return;
2614 const { manager, ptyAdapter } = yield* createManager(5, {
2615 shellResolver: () => "/bin/zsh",
2616 });
2617 yield* manager.open(openInput());
2618 const spawnInput = ptyAdapter.spawnInputs[0];
2619 expect(spawnInput).toBeDefined();
2620 if (!spawnInput) return;
2621
2622 expect(spawnInput.args).toEqual(["-o", "nopromptsp"]);
2623 }),
2624 );
2625
2626 it.effect("bridges PTY callbacks back into Effect-managed event streaming", () =>
2627 Effect.gen(function* () {
2628 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
2629 ptyAdapter: new FakePtyAdapter("async"),
2630 });
2631
2632 yield* manager.open(openInput());
2633 const process = ptyAdapter.processes[0];
2634 expect(process).toBeDefined();
2635 if (!process) return;
2636
2637 process.emitData("hello from callback\n");
2638
2639 yield* waitFor(
2640 Effect.map(getEvents, (events) =>
2641 events.some((event) => event.type === "output" && event.data === "hello from callback\n"),
2642 ),
2643 "1200 millis",
2644 );
2645 }),
2646 );
2647
2648 it.effect("pushes PTY callbacks to direct event subscribers", () =>
2649 Effect.gen(function* () {
2650 const { manager, ptyAdapter } = yield* createManager(5, {
2651 ptyAdapter: new FakePtyAdapter("async"),
2652 });
2653 const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
2654 const unsubscribe = yield* manager.subscribe((event) =>
2655 Ref.update(subscriberEvents, (events) => [...events, event]),
2656 );
2657 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2658
2659 yield* manager.open(openInput());
2660 const process = ptyAdapter.processes[0];
2661 expect(process).toBeDefined();
2662 if (!process) return;
2663
2664 process.emitData("hello from subscriber\n");
2665
2666 yield* waitFor(
2667 Effect.map(Ref.get(subscriberEvents), (events) =>
2668 events.some(
2669 (event) => event.type === "output" && event.data === "hello from subscriber\n",
2670 ),
2671 ),
2672 "1200 millis",
2673 );
2674 }),
2675 );
2676
2677 it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () =>
2678 Effect.gen(function* () {
2679 const { manager } = yield* createManager();
2680 yield* manager.open(openInput({ threadId: "existing-thread" }));
2681
2682 const metadataEvents = yield* Ref.make<ReadonlyArray<TerminalMetadataStreamEvent>>([]);
2683 const unsubscribe = yield* manager.subscribeMetadata((event) =>
2684 Ref.update(metadataEvents, (events) => [...events, event]),
2685 );
2686 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2687
2688 const initialEvents = yield* Ref.get(metadataEvents);
2689 expect(initialEvents[0]).toMatchObject({
2690 type: "snapshot",
2691 terminals: [
2692 {
2693 threadId: "existing-thread",
2694 terminalId: DEFAULT_TERMINAL_ID,
2695 },
2696 ],
2697 });
2698
2699 yield* manager.open(openInput({ threadId: "new-thread" }));
2700
2701 yield* waitFor(
2702 Effect.map(Ref.get(metadataEvents), (events) =>
2703 events.some(
2704 (event) =>
2705 event.type === "upsert" &&
2706 event.terminal.threadId === "new-thread" &&
2707 event.terminal.terminalId === DEFAULT_TERMINAL_ID,
2708 ),
2709 ),
2710 "1200 millis",
2711 );
2712
2713 yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID });
2714
2715 yield* waitFor(
2716 Effect.map(Ref.get(metadataEvents), (events) =>
2717 events.some(
2718 (event) =>
2719 event.type === "remove" &&
2720 event.threadId === "new-thread" &&
2721 event.terminalId === DEFAULT_TERMINAL_ID,
2722 ),
2723 ),
2724 "1200 millis",
2725 );
2726 }),
2727 );
2728
2729 it.effect("removes terminal metadata subscriptions when initial delivery fails", () =>
2730 Effect.gen(function* () {
2731 const { manager } = yield* createManager();
2732 yield* manager.open(openInput({ threadId: "existing-thread" }));
2733
2734 const leakedLiveEvents = yield* Ref.make(0);
2735 const exit = yield* Effect.exit(
2736 manager.subscribeMetadata((event) =>
2737 event.type === "snapshot"
2738 ? Effect.die("snapshot listener failed")
2739 : Ref.update(leakedLiveEvents, (count) => count + 1),
2740 ),
2741 );
2742
2743 expect(Exit.isFailure(exit)).toBe(true);
2744
2745 yield* manager.open(openInput({ threadId: "new-thread" }));
2746 expect(yield* Ref.get(leakedLiveEvents)).toBe(0);
2747 }),
2748 );
2749
2750 it.effect(
2751 "streams attach snapshots followed by live events without duplicate start snapshots",
2752 () =>
2753 Effect.gen(function* () {
2754 const { manager, ptyAdapter } = yield* createManager(5, {
2755 ptyAdapter: new FakePtyAdapter("async"),
2756 });
2757 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
2758 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
2759 Ref.update(attachEvents, (events) => [...events, event]),
2760 );
2761 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2762
2763 const process = ptyAdapter.processes[0];
2764 expect(process).toBeDefined();
2765 if (!process) return;
2766
2767 expect(yield* Ref.get(attachEvents)).toMatchObject([
2768 {
2769 type: "snapshot",
2770 snapshot: {
2771 threadId: "thread-1",
2772 terminalId: DEFAULT_TERMINAL_ID,
2773 },
2774 },
2775 ]);
2776
2777 process.emitData("hello from attach\n");
2778
2779 yield* waitFor(
2780 Effect.map(Ref.get(attachEvents), (events) =>
2781 events.some((event) => event.type === "output" && event.data === "hello from attach\n"),
2782 ),
2783 "1200 millis",
2784 );
2785
2786 const events = yield* Ref.get(attachEvents);
2787 expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1);
2788 }),
2789 );
2790
2791 it.effect("observes terminal history and live output without changing the process", () =>
2792 Effect.gen(function* () {
2793 const { manager, ptyAdapter } = yield* createManager();
2794 const opened = yield* manager.open(openInput({ env: { OBSERVER_TEST: "original" } }));
2795 const process = ptyAdapter.processes[0]!;
2796 const historyReceived = yield* Deferred.make<void>();
2797 const unsubscribeHistory = yield* manager.subscribe((event) =>
2798 event.type === "output"
2799 ? Deferred.succeed(historyReceived, undefined).pipe(Effect.asVoid)
2800 : Effect.void,
2801 );
2802 process.emitData("existing history\n");
2803 yield* Deferred.await(historyReceived);
2804 unsubscribeHistory();
2805
2806 const observed = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
2807 const liveReceived = yield* Deferred.make<void>();
2808 const unsubscribe = yield* manager.observeStream(
2809 { threadId: opened.threadId, terminalId: opened.terminalId },
2810 (event) =>
2811 Ref.update(observed, (events) => [...events, event]).pipe(
2812 Effect.andThen(
2813 event.type === "output"
2814 ? Deferred.succeed(liveReceived, undefined).pipe(Effect.asVoid)
2815 : Effect.void,
2816 ),
2817 ),
2818 );
2819 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2820 process.emitData("live output\n");
2821 yield* Deferred.await(liveReceived);
2822
2823 expect(yield* Ref.get(observed)).toMatchObject([
2824 {
2825 type: "snapshot",
2826 snapshot: {
2827 cwd: opened.cwd,
2828 worktreePath: opened.worktreePath,
2829 pid: opened.pid,
2830 history: "existing history\n",
2831 },
2832 },
2833 { type: "output", data: "live output\n" },
2834 ]);
2835 expect(ptyAdapter.spawnInputs).toHaveLength(1);
2836 expect(ptyAdapter.spawnInputs[0]?.env.OBSERVER_TEST).toBe("original");
2837 expect(process.resizeCalls).toEqual([]);
2838 expect(process.writes).toEqual([]);
2839 expect(process.killSignals).toEqual([]);
2840 }),
2841 );
2842
2843 it.effect("observes exited terminals without restarting and rejects missing sessions", () =>
2844 Effect.gen(function* () {
2845 const { manager, ptyAdapter } = yield* createManager();
2846 const missingEvents: TerminalAttachStreamEvent[] = [];
2847 const missing = yield* manager
2848 .observeStream({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }, (event) =>
2849 Effect.sync(() => {
2850 missingEvents.push(event);
2851 }),
2852 )
2853 .pipe(Effect.flip);
2854 expect(missing._tag).toBe("TerminalSessionLookupError");
2855 expect(ptyAdapter.spawnInputs).toEqual([]);
2856
2857 yield* manager.open(openInput());
2858 const process = ptyAdapter.processes[0]!;
2859 const exited = yield* Deferred.make<void>();
2860 const unsubscribeExit = yield* manager.subscribe((event) =>
2861 event.type === "exited"
2862 ? Deferred.succeed(exited, undefined).pipe(Effect.asVoid)
2863 : Effect.void,
2864 );
2865 process.emitExit({ exitCode: 7, signal: 0 });
2866 yield* Deferred.await(exited);
2867 unsubscribeExit();
2868
2869 const events: TerminalAttachStreamEvent[] = [];
2870 const unsubscribe = yield* manager.observeStream(
2871 { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID },
2872 (event) =>
2873 Effect.sync(() => {
2874 events.push(event);
2875 }),
2876 );
2877 unsubscribe();
2878 expect(events).toMatchObject([
2879 { type: "snapshot", snapshot: { status: "exited", exitCode: 7, pid: null } },
2880 ]);
2881 expect(ptyAdapter.spawnInputs).toHaveLength(1);
2882 expect(process.resizeCalls).toEqual([]);
2883 expect(process.writes).toEqual([]);
2884 expect(process.killSignals).toEqual([]);
2885 expect(missingEvents).toEqual([]);
2886 }),
2887 );
2888
2889 it.effect("buffers attach output delivered during the initial snapshot callback", () =>
2890 Effect.gen(function* () {
2891 const { manager, ptyAdapter } = yield* createManager(5, {
2892 ptyAdapter: new FakePtyAdapter("async"),
2893 });
2894 yield* manager.open(openInput());
2895
2896 const process = ptyAdapter.processes[0];
2897 expect(process).toBeDefined();
2898 if (!process) return;
2899
2900 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
2901 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
2902 Effect.gen(function* () {
2903 yield* Ref.update(attachEvents, (events) => [...events, event]);
2904 if (event.type === "snapshot") {
2905 yield* Effect.sync(() => process.emitData("during snapshot\n"));
2906 yield* Effect.yieldNow;
2907 }
2908 }),
2909 );
2910 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2911
2912 yield* waitFor(
2913 Effect.map(Ref.get(attachEvents), (events) =>
2914 events.some((event) => event.type === "output" && event.data === "during snapshot\n"),
2915 ),
2916 "1200 millis",
2917 );
2918
2919 expect(yield* Ref.get(attachEvents)).toMatchObject([
2920 { type: "snapshot" },
2921 { type: "output", data: "during snapshot\n" },
2922 ]);
2923 }),
2924 );
2925
2926 it.effect("preserves queued PTY output ordering through exit callbacks", () =>
2927 Effect.gen(function* () {
2928 const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
2929 ptyAdapter: new FakePtyAdapter("async"),
2930 });
2931
2932 yield* manager.open(openInput());
2933 const process = ptyAdapter.processes[0];
2934 expect(process).toBeDefined();
2935 if (!process) return;
2936
2937 process.emitData("first\n");
2938 process.emitData("second\n");
2939 process.emitExit({ exitCode: 0, signal: 0 });
2940
2941 yield* waitFor(
2942 Effect.map(getEvents, (events) => {
2943 const relevant = events.filter(
2944 (event) => event.type === "output" || event.type === "exited",
2945 );
2946 return relevant.length >= 3;
2947 }),
2948 "1200 millis",
2949 );
2950
2951 const relevant = (yield* getEvents).filter(
2952 (event) => event.type === "output" || event.type === "exited",
2953 );
2954 expect(relevant).toEqual([
2955 expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }),
2956 expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }),
2957 expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }),
2958 ]);
2959
2960 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
2961 const unsubscribe = yield* manager.attachStream(
2962 {
2963 threadId: "thread-1",
2964 terminalId: DEFAULT_TERMINAL_ID,
2965 },
2966 (event) => Ref.update(attachEvents, (events) => [...events, event]),
2967 );
2968 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2969
2970 const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
2971 expect(snapshot).toBeDefined();
2972 if (!snapshot || snapshot.type !== "snapshot") return;
2973 expect(snapshot.snapshot.sequence).toBe(4);
2974 }),
2975 );
2976
2977 it.effect("scoped runtime shutdown stops active terminals cleanly", () =>
2978 Effect.gen(function* () {
2979 const scope = yield* Scope.make("sequential");
2980 const { manager, ptyAdapter } = yield* createManager(5, {
2981 processKillGraceMs: 10,
2982 }).pipe(Effect.provideService(Scope.Scope, scope));
2983 yield* manager.open(openInput());
2984 const process = ptyAdapter.processes[0];
2985 expect(process).toBeDefined();
2986 if (!process) return;
2987
2988 const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped);
2989 yield* Effect.yieldNow;
2990 yield* TestClock.adjust("10 millis");
2991 yield* Fiber.join(closeScope);
2992
2993 assert.equal(process.killSignals[0], "SIGTERM");
2994 expect(process.killSignals).toContain("SIGKILL");
2995 }).pipe(Effect.provide(TestClock.layer())),
2996 );
2997});