authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 19:01:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log470f282819079be867cdc1eaa52b0eeb632c14e7
tree59847f8a9a68bba2c6bd18bfa6c44ab9cf85c794
parent3fbd02c6cfc667dbf239cb325f00caa2e4ac662c

terminal


11 files changed, 398 insertions(+), 319 deletions(-)

.plans/terminal-runtime-state-plan.md deleted-154
...@@ -1,154 +0,0 @@
1# Terminal Runtime And State Plan
2
3## Summary
4
5Terminals appear to "hang" because PTY processes are exiting immediately at startup in the current runtime.
6
7Repro in this branch:
8
9- `node` + `node-pty`: interactive shell stays alive and accepts writes.
10- `bun` + `node-pty`: shell exits immediately (`exitCode: 0`, `signal: 1`), so the UI opens a terminal pane but no live process remains.
11
12This means the current breakage is primarily a runtime compatibility issue, plus missing reconciliation between desired terminal state and observed process state.
13
14## Root Cause (Current)
15
161. Server dev runtime uses Bun (`apps/server/package.json` `dev` script).
172. Terminal PTY implementation depends on `node-pty`.
183. Under Bun, spawned PTY shells exit immediately.
194. Frontend still mounts terminals, so UX looks like startup hang/blank terminal.
20
21## Design Goals
22
231. Do not store full terminal output in SQLite.
242. Preserve terminal topology and intent across reconnects/restarts:
25 - open/split/new/close
26 - active terminal/group
27 - cwd/env profile
283. Keep runtime truth accurate even when processes change outside the app.
294. Support deterministic recovery and idempotent command handling.
30
31## Proposed Architecture
32
33### 1) Runtime Boundary
34
35- Run terminal PTY host on Node runtime only.
36- If main server remains Bun, isolate PTY behind a Node sidecar process (JSON-RPC/IPC).
37- Treat PTY host as volatile runtime, not source of durable truth.
38
39### 2) Event-Sourced In SQLite (Control Plane Only)
40
41Persist only control/state events, not output payloads.
42
43Recommended event families:
44
45- `terminal.layout.updated`
46 - terminal ids, split groups, active terminal id/group id
47- `terminal.open.requested`
48 - threadId, terminalId, cwd, envHash/envProfileId, cols, rows
49- `terminal.close.requested`
50 - terminalId, deleteHistory
51- `terminal.resize.requested`
52 - cols, rows
53- `terminal.clear.requested`
54- `terminal.input.sent`
55 - input metadata only (`byteLength`, optional hash, optional commandBoundary flag), no raw payload
56- `terminal.runtime.started`
57 - pid, startedAt, runtime instance id
58- `terminal.runtime.exited`
59 - pid, exitCode, exitSignal, observedAt, reason (`normal|killed|external|crash|unknown`)
60- `terminal.runtime.error`
61 - normalized error code/message
62- `terminal.output.segment.recorded`
63 - segment file id + byte offsets only
64
65Use idempotency keys/command ids for each client command so reconnect retries are safe.
66
67### 3) Output Storage In Log Files (Data Plane)
68
69- Store raw output only in append-only files (per thread+terminal).
70- Rotate into segments (size-based and/or time-based).
71- Keep lightweight SQLite index rows:
72 - `segment_id`, `thread_id`, `terminal_id`, `start_offset`, `end_offset`, `created_at`, checksum.
73- Optional retention policy (max bytes / days / segment count per terminal).
74
75This keeps SQLite small while preserving replay capability.
76
77### 4) Reconciliation Loop (Runtime Truth)
78
79Do not treat client dispatch as sole truth.
80
81Maintain dual state:
82
83- Desired state: from event stream/projection.
84- Observed runtime state: from PTY host watchers (process exit, health checks, poll).
85
86On mismatch, emit runtime observation events:
87
88- process missing but desired running -> `terminal.runtime.exited` with reason `external|unknown`
89- process exists but projection says closed -> terminate process and emit correction event
90
91This makes out-of-band kills/restarts explicit and recoverable.
92
93### 5) Startup/Recovery Flow
94
951. Rebuild terminal projection from SQLite control events.
962. Load terminal layout/topology immediately for UI.
973. Reconcile each desired running terminal with runtime:
98 - if process alive: attach stream + tail recent output from logs
99 - if process dead: mark exited; auto-restart only if policy says so
1004. Resume incremental output from last persisted offset.
101
102## Data Model Additions
103
104Add dedicated tables (or equivalent repos):
105
106- `terminal_control_events` (or reuse orchestration_events with terminal aggregate kind)
107- `terminal_output_segments` (index only, no raw output)
108- `terminal_runtime_snapshot` (latest observed runtime state cache)
109
110Avoid embedding output blobs in projection snapshots.
111
112## Implementation Plan
113
114### Phase 0: Stop the bleeding
115
1161. Run PTY on Node runtime in all dev/prod paths (or ship Node sidecar now).
1172. Add startup diagnostics: runtime, PTY spawn result, immediate-exit telemetry.
1183. Surface clear UI error when PTY host unavailable.
119
120### Phase 1: Durable control events
121
1221. Introduce terminal aggregate + commands/events.
1232. Persist layout and terminal lifecycle intent events.
1243. Build projection for terminal UI state from events.
125
126### Phase 2: File-backed output + index
127
1281. Implement segmented append-only output logger.
1292. Persist only segment metadata/offsets in SQLite.
1303. Update terminal history restore to tail from segments (bounded bytes/lines).
131
132### Phase 3: Runtime reconciliation
133
1341. Add process observer + periodic verifier.
1352. Emit runtime observation events on drift.
1363. Add recovery tests for out-of-band process termination.
137
138### Phase 4: Hardening
139
1401. Backpressure and truncation policies for high-volume output.
1412. Crash-safe fsync/flush strategy for segment writes.
1423. Retention and compaction jobs.
143
144## Testing Strategy
145
146Backend tests should cover:
147
1481. Rebuild projection from control events (open/split/close/resize/active terminal).
1492. Runtime reconciliation when process is killed externally.
1503. Recovery after restart with existing segment files.
1514. Idempotent command handling on duplicate dispatches.
1525. Segment index consistency and retention behavior.
153
154Use real runtime-layer tests for terminal adapter behavior where possible; only mock external process/service boundaries.
apps/server/package.json+1
...@@ -31,6 +31,7 @@...@@ -31,6 +31,7 @@
31 "@effect/vitest": "4.0.0-beta.10",31 "@effect/vitest": "4.0.0-beta.10",
32 "@t3tools/contracts": "workspace:*",32 "@t3tools/contracts": "workspace:*",
33 "@t3tools/web": "workspace:*",33 "@t3tools/web": "workspace:*",
34 "@types/bun": "^1.3.9",
34 "@types/node": "^22.10.2",35 "@types/node": "^22.10.2",
35 "@types/ws": "^8.5.13",36 "@types/ws": "^8.5.13",
36 "tsdown": "^0.20.3",37 "tsdown": "^0.20.3",
apps/server/src/ptyAdapter.test.ts+43-32
...@@ -2,7 +2,9 @@ import fs from "node:fs";...@@ -2,7 +2,9 @@ import fs from "node:fs";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import { afterEach, describe, expect, it } from "vitest";5import { assert, it, vi } from "@effect/vitest";
6import { Effect, Layer } from "effect";
7import { afterEach } from "vitest";
68
7import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter";9import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter";
810
...@@ -10,45 +12,54 @@ function fileMode(filePath: string): number {...@@ -10,45 +12,54 @@ function fileMode(filePath: string): number {
10 return fs.statSync(filePath).mode & 0o777;12 return fs.statSync(filePath).mode & 0o777;
11}13}
1214
13describe("ensureNodePtySpawnHelperExecutable", () => {15const tempDirs: string[] = [];
14 const tempDirs: string[] = [];
1516
16 afterEach(() => {17afterEach(() => {
17 for (const dir of tempDirs.splice(0, tempDirs.length)) {18 vi.unstubAllGlobals();
18 fs.rmSync(dir, { recursive: true, force: true });19 for (const dir of tempDirs.splice(0, tempDirs.length)) {
19 }20 fs.rmSync(dir, { recursive: true, force: true });
20 });21 }
22});
23
24const helperLayer = it.layer(Layer.empty);
2125
22 it("adds executable bits when helper exists but is not executable", () => {26helperLayer("ensureNodePtySpawnHelperExecutable", (it) => {
23 if (process.platform === "win32") return;27 it.effect("adds executable bits when helper exists but is not executable", () =>
28 Effect.sync(() => {
29 if (process.platform === "win32") return;
2430
25 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));31 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));
26 tempDirs.push(dir);32 tempDirs.push(dir);
27 const helperPath = path.join(dir, "spawn-helper");33 const helperPath = path.join(dir, "spawn-helper");
28 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");34 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");
29 fs.chmodSync(helperPath, 0o644);35 fs.chmodSync(helperPath, 0o644);
3036
31 ensureNodePtySpawnHelperExecutable(helperPath);37 ensureNodePtySpawnHelperExecutable(helperPath);
3238
33 expect(fileMode(helperPath) & 0o111).toBe(0o111);39 assert.equal(fileMode(helperPath) & 0o111, 0o111);
34 });40 }),
41 );
3542
36 it("keeps executable helper as executable", () => {43 it.effect("keeps executable helper as executable", () =>
37 if (process.platform === "win32") return;44 Effect.sync(() => {
45 if (process.platform === "win32") return;
3846
39 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));47 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));
40 tempDirs.push(dir);48 tempDirs.push(dir);
41 const helperPath = path.join(dir, "spawn-helper");49 const helperPath = path.join(dir, "spawn-helper");
42 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");50 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");
43 fs.chmodSync(helperPath, 0o755);51 fs.chmodSync(helperPath, 0o755);
4452
45 ensureNodePtySpawnHelperExecutable(helperPath);53 ensureNodePtySpawnHelperExecutable(helperPath);
4654
47 expect(fileMode(helperPath) & 0o111).toBe(0o111);55 assert.equal(fileMode(helperPath) & 0o111, 0o111);
48 });56 }),
57 );
4958
50 it("does not throw when helper path is missing", () => {59 it.effect("does not throw when helper path is missing", () =>
51 const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`);60 Effect.sync(() => {
52 expect(() => ensureNodePtySpawnHelperExecutable(missing)).not.toThrow();61 const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`);
53 });62 ensureNodePtySpawnHelperExecutable(missing);
63 }),
64 );
54});65});
apps/server/src/ptyAdapter.ts+139-14
...@@ -2,7 +2,7 @@ import fs from "node:fs";...@@ -2,7 +2,7 @@ import fs from "node:fs";
2import { createRequire } from "node:module";2import { createRequire } from "node:module";
3import path from "node:path";3import path from "node:path";
44
5import * as nodePty from "node-pty";5import { Effect, Layer, ServiceMap } from "effect";
66
7export interface PtyExitEvent {7export interface PtyExitEvent {
8 exitCode: number;8 exitCode: number;
...@@ -27,10 +27,17 @@ export interface PtySpawnInput {...@@ -27,10 +27,17 @@ export interface PtySpawnInput {
27 env: NodeJS.ProcessEnv;27 env: NodeJS.ProcessEnv;
28}28}
2929
30export interface PtyAdapter {30export interface PtyAdapterShape {
31 spawn(input: PtySpawnInput): PtyProcess;31 spawn(input: PtySpawnInput): PtyProcess;
32}32}
3333
34export class PtyAdapter extends ServiceMap.Service<PtyAdapter, PtyAdapterShape>()(
35 "terminal/PtyAdapter",
36) {
37 static readonly layer = () =>
38 typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive;
39}
40
34const requireForNodePty = createRequire(import.meta.url);41const requireForNodePty = createRequire(import.meta.url);
35let didEnsureSpawnHelperExecutable = false;42let didEnsureSpawnHelperExecutable = false;
3643
...@@ -78,7 +85,7 @@ export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void...@@ -78,7 +85,7 @@ export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void
78}85}
7986
80class NodePtyProcess implements PtyProcess {87class NodePtyProcess implements PtyProcess {
81 constructor(private readonly process: nodePty.IPty) {}88 constructor(private readonly process: import("node-pty").IPty) {}
8289
83 get pid(): number {90 get pid(): number {
84 return this.process.pid;91 return this.process.pid;
...@@ -116,16 +123,134 @@ class NodePtyProcess implements PtyProcess {...@@ -116,16 +123,134 @@ class NodePtyProcess implements PtyProcess {
116 }123 }
117}124}
118125
119export class NodePtyAdapter implements PtyAdapter {126class BunPtyProcess implements PtyProcess {
120 spawn(input: PtySpawnInput): PtyProcess {127 private readonly dataListeners = new Set<(data: string) => void>();
121 ensureNodePtySpawnHelperExecutable();128 private readonly exitListeners = new Set<(event: PtyExitEvent) => void>();
122 const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], {129 private readonly decoder = new TextDecoder();
123 cwd: input.cwd,130 private didExit = false;
124 cols: input.cols,131
125 rows: input.rows,132 constructor(private readonly process: Bun.Subprocess) {
126 env: input.env,133 void this.process.exited
127 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",134 .then((exitCode) => {
128 });135 this.emitExit({
129 return new NodePtyProcess(ptyProcess);136 exitCode: Number.isInteger(exitCode) ? exitCode : 0,
137 signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null,
138 });
139 })
140 .catch(() => {
141 this.emitExit({ exitCode: 1, signal: null });
142 });
143 }
144
145 get pid(): number {
146 return this.process.pid;
147 }
148
149 write(data: string): void {
150 if (!this.process.terminal) {
151 throw new Error("Bun PTY terminal handle is unavailable");
152 }
153 this.process.terminal.write(data);
154 }
155
156 resize(cols: number, rows: number): void {
157 if (!this.process.terminal?.resize) {
158 throw new Error("Bun PTY resize is unavailable");
159 }
160 this.process.terminal.resize(cols, rows);
161 }
162
163 kill(signal?: string): void {
164 if (!signal) {
165 this.process.kill();
166 return;
167 }
168 this.process.kill(signal as NodeJS.Signals);
169 }
170
171 onData(callback: (data: string) => void): () => void {
172 this.dataListeners.add(callback);
173 return () => {
174 this.dataListeners.delete(callback);
175 };
176 }
177
178 onExit(callback: (event: PtyExitEvent) => void): () => void {
179 this.exitListeners.add(callback);
180 return () => {
181 this.exitListeners.delete(callback);
182 };
183 }
184
185 emitData(data: Uint8Array): void {
186 if (this.didExit) return;
187 const text = this.decoder.decode(data, { stream: true });
188 if (text.length === 0) return;
189 for (const listener of this.dataListeners) {
190 listener(text);
191 }
192 }
193
194 private emitExit(event: PtyExitEvent): void {
195 if (this.didExit) return;
196 this.didExit = true;
197
198 const remainder = this.decoder.decode();
199 if (remainder.length > 0) {
200 for (const listener of this.dataListeners) {
201 listener(remainder);
202 }
203 }
204
205 for (const listener of this.exitListeners) {
206 listener(event);
207 }
130 }208 }
131}209}
210export const NodePtyAdapterLive = Layer.effect(
211 PtyAdapter,
212 Effect.gen(function* () {
213 const nodePty = yield* Effect.promise(() => import("node-pty"));
214 return {
215 spawn(input: PtySpawnInput): PtyProcess {
216 ensureNodePtySpawnHelperExecutable();
217 const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], {
218 cwd: input.cwd,
219 cols: input.cols,
220 rows: input.rows,
221 env: input.env,
222 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",
223 });
224 return new NodePtyProcess(ptyProcess);
225 },
226 };
227 }),
228);
229
230export const BunPtyAdapterLive = Layer.effect(
231 PtyAdapter,
232 Effect.gen(function* () {
233 if (process.platform === "win32") {
234 return yield* Effect.die("Bun PTY terminal support is unavailable on Windows.");
235 }
236 return {
237 spawn(input: PtySpawnInput): PtyProcess {
238 let processHandle: BunPtyProcess | null = null;
239 const command = [input.shell, ...(input.args ?? [])];
240 const subprocess = Bun.spawn(command, {
241 cwd: input.cwd,
242 env: input.env,
243 terminal: {
244 cols: input.cols,
245 rows: input.rows,
246 data: (_terminal, data) => {
247 processHandle?.emitData(data);
248 },
249 },
250 });
251 processHandle = new BunPtyProcess(subprocess);
252 return processHandle;
253 },
254 };
255 }),
256);
apps/server/src/serverLayers.ts+17-3
...@@ -26,6 +26,9 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService";...@@ -26,6 +26,9 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
26import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";26import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
27import { ProviderService } from "./provider/Services/ProviderService";27import { ProviderService } from "./provider/Services/ProviderService";
2828
29import { makeTerminalManagerLive, TerminalManager } from "./terminalManager";
30import { PtyAdapter } from "./ptyAdapter";
31
29export function makeServerProviderLayer(): Layer.Layer<32export function makeServerProviderLayer(): Layer.Layer<
30 ProviderService,33 ProviderService,
31 unknown,34 unknown,
...@@ -48,9 +51,13 @@ export function makeServerProviderLayer(): Layer.Layer<...@@ -48,9 +51,13 @@ export function makeServerProviderLayer(): Layer.Layer<
48}51}
4952
50export function makeServerRuntimeServicesLayer(): Layer.Layer<53export function makeServerRuntimeServicesLayer(): Layer.Layer<
51 OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor,54 | OrchestrationEngineService
55 | ProjectionSnapshotQuery
56 | CheckpointStore
57 | OrchestrationReactor
58 | TerminalManager,
52 unknown,59 unknown,
53 SqlClient.SqlClient | ProviderService60 SqlClient.SqlClient | ProviderService | ServerConfig
54> {61> {
55 const orchestrationLayer = OrchestrationEngineLive.pipe(62 const orchestrationLayer = OrchestrationEngineLive.pipe(
56 Layer.provide(OrchestrationProjectionPipelineLive),63 Layer.provide(OrchestrationProjectionPipelineLive),
...@@ -79,5 +86,12 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<...@@ -79,5 +86,12 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<
79 Layer.provideMerge(checkpointReactorLayer),86 Layer.provideMerge(checkpointReactorLayer),
80 );87 );
8188
82 return orchestrationReactorLayer;89 const terminalLayer = Effect.gen(function* () {
90 const { stateDir } = yield* ServerConfig;
91 return makeTerminalManagerLive({
92 logsDir: path.join(stateDir, "logs", "terminals"),
93 }).pipe(Layer.provide(PtyAdapter.layer()));
94 }).pipe(Layer.unwrap);
95
96 return Layer.merge(orchestrationReactorLayer, terminalLayer);
83}97}
apps/server/src/terminalManager.test.ts+4-4
...@@ -9,8 +9,8 @@ import {...@@ -9,8 +9,8 @@ import {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it } from "vitest";10import { afterEach, describe, expect, it } from "vitest";
1111
12import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";12import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
13import { TerminalManager } from "./terminalManager";13import { TerminalManagerRuntime } from "./terminalManager";
1414
15class FakePtyProcess implements PtyProcess {15class FakePtyProcess implements PtyProcess {
16 readonly writes: string[] = [];16 readonly writes: string[] = [];
...@@ -60,7 +60,7 @@ class FakePtyProcess implements PtyProcess {...@@ -60,7 +60,7 @@ class FakePtyProcess implements PtyProcess {
60 }60 }
61}61}
6262
63class FakePtyAdapter implements PtyAdapter {63class FakePtyAdapter implements PtyAdapterShape {
64 readonly spawnInputs: PtySpawnInput[] = [];64 readonly spawnInputs: PtySpawnInput[] = [];
65 readonly processes: FakePtyProcess[] = [];65 readonly processes: FakePtyProcess[] = [];
66 readonly spawnFailures: Error[] = [];66 readonly spawnFailures: Error[] = [];
...@@ -150,7 +150,7 @@ describe("TerminalManager", () => {...@@ -150,7 +150,7 @@ describe("TerminalManager", () => {
150 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));150 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
151 tempDirs.push(logsDir);151 tempDirs.push(logsDir);
152 const ptyAdapter = new FakePtyAdapter();152 const ptyAdapter = new FakePtyAdapter();
153 const manager = new TerminalManager({153 const manager = new TerminalManagerRuntime({
154 logsDir,154 logsDir,
155 ptyAdapter,155 ptyAdapter,
156 historyLineLimit,156 historyLineLimit,
apps/server/src/terminalManager.ts+57-10
...@@ -13,10 +13,10 @@ import {...@@ -13,10 +13,10 @@ import {
13 type TerminalSessionSnapshot,13 type TerminalSessionSnapshot,
14 type TerminalSessionStatus,14 type TerminalSessionStatus,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { Schema } from "effect";16import { Effect, Layer, Schema, ServiceMap } from "effect";
1717
18import { createLogger } from "./logger";18import { createLogger } from "./logger";
19import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";19import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
20import { runProcess } from "./processRunner";20import { runProcess } from "./processRunner";
2121
22const DEFAULT_HISTORY_LINE_LIMIT = 5_000;22const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
...@@ -41,12 +41,27 @@ export interface TerminalManagerEvents {...@@ -41,12 +41,27 @@ export interface TerminalManagerEvents {
41export interface TerminalManagerOptions {41export interface TerminalManagerOptions {
42 logsDir?: string;42 logsDir?: string;
43 historyLineLimit?: number;43 historyLineLimit?: number;
44 ptyAdapter?: PtyAdapter;44 ptyAdapter: PtyAdapterShape;
45 shellResolver?: () => string;45 shellResolver?: () => string;
46 subprocessChecker?: TerminalSubprocessChecker;46 subprocessChecker?: TerminalSubprocessChecker;
47 subprocessPollIntervalMs?: number;47 subprocessPollIntervalMs?: number;
48}48}
4949
50export interface TerminalManagerShape {
51 readonly open: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>;
52 readonly write: (input: TerminalWriteInput) => Effect.Effect<void, unknown>;
53 readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, unknown>;
54 readonly clear: (input: TerminalClearInput) => Effect.Effect<void, unknown>;
55 readonly restart: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>;
56 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, unknown>;
57 readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>;
58 readonly dispose: () => Effect.Effect<void>;
59}
60
61export class TerminalManager extends ServiceMap.Service<TerminalManager, TerminalManagerShape>()(
62 "terminal/TerminalManager",
63) {}
64
50interface TerminalSessionState {65interface TerminalSessionState {
51 threadId: string;66 threadId: string;
52 terminalId: string;67 terminalId: string;
...@@ -73,8 +88,8 @@ interface ShellCandidate {...@@ -73,8 +88,8 @@ interface ShellCandidate {
7388
74interface TerminalStartInput extends TerminalOpenInput {89interface TerminalStartInput extends TerminalOpenInput {
75 cols: number;90 cols: number;
76 rows: number91 rows: number;
77};92}
7893
79function defaultShellResolver(): string {94function defaultShellResolver(): string {
80 if (process.platform === "win32") {95 if (process.platform === "win32") {
...@@ -300,14 +315,14 @@ function normalizedRuntimeEnv(...@@ -300,14 +315,14 @@ function normalizedRuntimeEnv(
300 if (!env) return null;315 if (!env) return null;
301 const entries = Object.entries(env);316 const entries = Object.entries(env);
302 if (entries.length === 0) return null;317 if (entries.length === 0) return null;
303 return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right)));318 return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right)));
304}319}
305320
306export class TerminalManager extends EventEmitter<TerminalManagerEvents> {321export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> {
307 private readonly sessions = new Map<string, TerminalSessionState>();322 private readonly sessions = new Map<string, TerminalSessionState>();
308 private readonly logsDir: string;323 private readonly logsDir: string;
309 private readonly historyLineLimit: number;324 private readonly historyLineLimit: number;
310 private readonly ptyAdapter: PtyAdapter;325 private readonly ptyAdapter: PtyAdapterShape;
311 private readonly shellResolver: () => string;326 private readonly shellResolver: () => string;
312 private readonly persistQueues = new Map<string, Promise<void>>();327 private readonly persistQueues = new Map<string, Promise<void>>();
313 private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>();328 private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
...@@ -320,11 +335,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -320,11 +335,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
320 private subprocessPollInFlight = false;335 private subprocessPollInFlight = false;
321 private readonly logger = createLogger("terminal");336 private readonly logger = createLogger("terminal");
322337
323 constructor(options: TerminalManagerOptions = {}) {338 constructor(options: TerminalManagerOptions) {
324 super();339 super();
325 this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals");340 this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals");
326 this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;341 this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
327 this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter();342 this.ptyAdapter = options.ptyAdapter;
328 this.shellResolver = options.shellResolver ?? defaultShellResolver;343 this.shellResolver = options.shellResolver ?? defaultShellResolver;
329 this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS;344 this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS;
330 this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;345 this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
...@@ -1046,3 +1061,35 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -1046,3 +1061,35 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
1046 }1061 }
1047 }1062 }
1048}1063}
1064
1065export function makeTerminalManagerLive(
1066 options: Omit<TerminalManagerOptions, "ptyAdapter"> = {},
1067): Layer.Layer<TerminalManager, never, PtyAdapter> {
1068 return Layer.effect(
1069 TerminalManager,
1070 Effect.gen(function* () {
1071 const ptyAdapter = yield* PtyAdapter;
1072 const runtime = new TerminalManagerRuntime({ ...options, ptyAdapter });
1073
1074 return {
1075 open: (input) => Effect.promise(() => runtime.open(input)),
1076 write: (input) => Effect.promise(() => runtime.write(input)),
1077 resize: (input) => Effect.promise(() => runtime.resize(input)),
1078 clear: (input) => Effect.promise(() => runtime.clear(input)),
1079 restart: (input) => Effect.promise(() => runtime.restart(input)),
1080 close: (input) => Effect.promise(() => runtime.close(input)),
1081 subscribe: (listener) =>
1082 Effect.sync(() => {
1083 runtime.on("event", listener);
1084 return () => {
1085 runtime.off("event", listener);
1086 };
1087 }),
1088 dispose: () =>
1089 Effect.sync(() => {
1090 runtime.dispose();
1091 }),
1092 } satisfies TerminalManagerShape;
1093 }),
1094 );
1095}
apps/server/src/wsServer.test.ts+111-88
...@@ -2,7 +2,6 @@ import * as Http from "node:http";...@@ -2,7 +2,6 @@ import * as Http from "node:http";
2import fs from "node:fs";2import fs from "node:fs";
3import os from "node:os";3import os from "node:os";
4import path from "node:path";4import path from "node:path";
5import { EventEmitter } from "node:events";
65
7import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";6import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";
8import { describe, expect, it, afterEach, vi } from "vitest";7import { describe, expect, it, afterEach, vi } from "vitest";
...@@ -38,7 +37,7 @@ import type {...@@ -38,7 +37,7 @@ import type {
38 TerminalSessionSnapshot,37 TerminalSessionSnapshot,
39 TerminalWriteInput,38 TerminalWriteInput,
40} from "@t3tools/contracts";39} from "@t3tools/contracts";
41import type { TerminalManager } from "./terminalManager";40import type { TerminalManagerShape } from "./terminalManager";
42import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";41import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
43import { SqlClient } from "effect/unstable/sql";42import { SqlClient } from "effect/unstable/sql";
44import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";43import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
...@@ -63,111 +62,135 @@ const defaultOpenService: OpenShape = {...@@ -63,111 +62,135 @@ const defaultOpenService: OpenShape = {
63 openInEditor: () => Effect.void,62 openInEditor: () => Effect.void,
64};63};
6564
66class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {65class MockTerminalManager implements TerminalManagerShape {
67 private readonly sessions = new Map<string, TerminalSessionSnapshot>();66 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
67 private readonly listeners = new Set<(event: TerminalEvent) => void>();
6868
69 private key(threadId: string, terminalId: string): string {69 private key(threadId: string, terminalId: string): string {
70 return `${threadId}\u0000${terminalId}`;70 return `${threadId}\u0000${terminalId}`;
71 }71 }
7272
73 async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {73 emitEvent(event: TerminalEvent): void {
74 const now = new Date().toISOString();74 for (const listener of this.listeners) {
75 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;75 listener(event);
76 const snapshot: TerminalSessionSnapshot = {76 }
77 threadId: input.threadId,77 }
78 terminalId,78
79 cwd: input.cwd,79 subscriptionCount(): number {
80 status: "running",80 return this.listeners.size;
81 pid: 4242,81 }
82 history: "",82
83 exitCode: null,83 readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) =>
84 exitSignal: null,84 Effect.sync(() => {
85 updatedAt: now,85 const now = new Date().toISOString();
86 };86 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
87 this.sessions.set(this.key(input.threadId, terminalId), snapshot);87 const snapshot: TerminalSessionSnapshot = {
88 queueMicrotask(() => {
89 this.emit("event", {
90 type: "started",
91 threadId: input.threadId,88 threadId: input.threadId,
92 terminalId,89 terminalId,
93 createdAt: now,90 cwd: input.cwd,
94 snapshot,91 status: "running",
92 pid: 4242,
93 history: "",
94 exitCode: null,
95 exitSignal: null,
96 updatedAt: now,
97 };
98 this.sessions.set(this.key(input.threadId, terminalId), snapshot);
99 queueMicrotask(() => {
100 this.emitEvent({
101 type: "started",
102 threadId: input.threadId,
103 terminalId,
104 createdAt: now,
105 snapshot,
106 });
95 });107 });
108 return snapshot;
96 });109 });
97 return snapshot;
98 }
99110
100 async write(input: TerminalWriteInput): Promise<void> {111 readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) =>
101 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;112 Effect.sync(() => {
102 const existing = this.sessions.get(this.key(input.threadId, terminalId));113 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
103 if (!existing) {114 const existing = this.sessions.get(this.key(input.threadId, terminalId));
104 throw new Error(`Unknown terminal thread: ${input.threadId}`);115 if (!existing) {
105 }116 throw new Error(`Unknown terminal thread: ${input.threadId}`);
106 queueMicrotask(() => {117 }
107 this.emit("event", {118 queueMicrotask(() => {
108 type: "output",119 this.emitEvent({
109 threadId: input.threadId,120 type: "output",
110 terminalId,121 threadId: input.threadId,
111 createdAt: new Date().toISOString(),122 terminalId,
112 data: input.data,123 createdAt: new Date().toISOString(),
124 data: input.data,
125 });
113 });126 });
114 });127 });
115 }
116128
117 async resize(_input: TerminalResizeInput): Promise<void> {}129 readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void;
118130
119 async clear(input: TerminalClearInput): Promise<void> {131 readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) =>
120 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;132 Effect.sync(() => {
121 queueMicrotask(() => {133 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
122 this.emit("event", {134 queueMicrotask(() => {
123 type: "cleared",135 this.emitEvent({
124 threadId: input.threadId,136 type: "cleared",
125 terminalId,137 threadId: input.threadId,
126 createdAt: new Date().toISOString(),138 terminalId,
139 createdAt: new Date().toISOString(),
140 });
127 });141 });
128 });142 });
129 }
130143
131 async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {144 readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) =>
132 const now = new Date().toISOString();145 Effect.sync(() => {
133 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;146 const now = new Date().toISOString();
134 const snapshot: TerminalSessionSnapshot = {147 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
135 threadId: input.threadId,148 const snapshot: TerminalSessionSnapshot = {
136 terminalId,
137 cwd: input.cwd,
138 status: "running",
139 pid: 5252,
140 history: "",
141 exitCode: null,
142 exitSignal: null,
143 updatedAt: now,
144 };
145 this.sessions.set(this.key(input.threadId, terminalId), snapshot);
146 queueMicrotask(() => {
147 this.emit("event", {
148 type: "restarted",
149 threadId: input.threadId,149 threadId: input.threadId,
150 terminalId,150 terminalId,
151 createdAt: now,151 cwd: input.cwd,
152 snapshot,152 status: "running",
153 pid: 5252,
154 history: "",
155 exitCode: null,
156 exitSignal: null,
157 updatedAt: now,
158 };
159 this.sessions.set(this.key(input.threadId, terminalId), snapshot);
160 queueMicrotask(() => {
161 this.emitEvent({
162 type: "restarted",
163 threadId: input.threadId,
164 terminalId,
165 createdAt: now,
166 snapshot,
167 });
153 });168 });
169 return snapshot;
154 });170 });
155 return snapshot;
156 }
157171
158 async close(input: TerminalCloseInput): Promise<void> {172 readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) =>
159 if (input.terminalId) {173 Effect.sync(() => {
160 this.sessions.delete(this.key(input.threadId, input.terminalId));174 if (input.terminalId) {
161 return;175 this.sessions.delete(this.key(input.threadId, input.terminalId));
162 }176 return;
163 for (const key of [...this.sessions.keys()]) {
164 if (key.startsWith(`${input.threadId}\u0000`)) {
165 this.sessions.delete(key);
166 }177 }
167 }178 for (const key of [...this.sessions.keys()]) {
168 }179 if (key.startsWith(`${input.threadId}\u0000`)) {
180 this.sessions.delete(key);
181 }
182 }
183 });
184
185 readonly subscribe: TerminalManagerShape["subscribe"] = (listener) =>
186 Effect.sync(() => {
187 this.listeners.add(listener);
188 return () => {
189 this.listeners.delete(listener);
190 };
191 });
169192
170 dispose(): void {}193 readonly dispose: TerminalManagerShape["dispose"] = () => Effect.void;
171}194}
172195
173function connectWs(port: number, token?: string): Promise<WebSocket> {196function connectWs(port: number, token?: string): Promise<WebSocket> {
...@@ -320,7 +343,7 @@ describe("WebSocket Server", () => {...@@ -320,7 +343,7 @@ describe("WebSocket Server", () => {
320 status: (input: { cwd: string }) => Promise<unknown>;343 status: (input: { cwd: string }) => Promise<unknown>;
321 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;344 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;
322 };345 };
323 terminalManager?: TerminalManager;346 terminalManager?: TerminalManagerShape;
324 } = {},347 } = {},
325 ): Promise<Http.Server> {348 ): Promise<Http.Server> {
326 if (serverScope) {349 if (serverScope) {
...@@ -990,7 +1013,7 @@ describe("WebSocket Server", () => {...@@ -990,7 +1013,7 @@ describe("WebSocket Server", () => {
990 const terminalManager = new MockTerminalManager();1013 const terminalManager = new MockTerminalManager();
991 server = await createTestServer({1014 server = await createTestServer({
992 cwd: "/test",1015 cwd: "/test",
993 terminalManager: terminalManager as unknown as TerminalManager,1016 terminalManager,
994 });1017 });
995 const addr = server.address();1018 const addr = server.address();
996 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1019 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
...@@ -1048,7 +1071,7 @@ describe("WebSocket Server", () => {...@@ -1048,7 +1071,7 @@ describe("WebSocket Server", () => {
1048 createdAt: new Date().toISOString(),1071 createdAt: new Date().toISOString(),
1049 data: "manual test output\n",1072 data: "manual test output\n",
1050 };1073 };
1051 terminalManager.emit("event", manualEvent);1074 terminalManager.emitEvent(manualEvent);
10521075
1053 const push = (await waitForMessage(ws)) as WsPush;1076 const push = (await waitForMessage(ws)) as WsPush;
1054 expect(push.type).toBe("push");1077 expect(push.type).toBe("push");
...@@ -1060,15 +1083,15 @@ describe("WebSocket Server", () => {...@@ -1060,15 +1083,15 @@ describe("WebSocket Server", () => {
1060 const terminalManager = new MockTerminalManager();1083 const terminalManager = new MockTerminalManager();
1061 server = await createTestServer({1084 server = await createTestServer({
1062 cwd: "/test",1085 cwd: "/test",
1063 terminalManager: terminalManager as unknown as TerminalManager,1086 terminalManager,
1064 });1087 });
10651088
1066 expect(terminalManager.listenerCount("event")).toBe(1);1089 expect(terminalManager.subscriptionCount()).toBe(1);
10671090
1068 await closeTestServer();1091 await closeTestServer();
1069 server = null;1092 server = null;
10701093
1071 expect(terminalManager.listenerCount("event")).toBe(0);1094 expect(terminalManager.subscriptionCount()).toBe(0);
1072 });1095 });
10731096
1074 it("returns validation errors for invalid terminal open params", async () => {1097 it("returns validation errors for invalid terminal open params", async () => {
apps/server/src/wsServer.ts+16-13
...@@ -30,7 +30,7 @@ import {...@@ -30,7 +30,7 @@ import {
30 pullGitBranch,30 pullGitBranch,
31 removeGitWorktree,31 removeGitWorktree,
32} from "./git";32} from "./git";
33import { TerminalManager } from "./terminalManager";33import { TerminalManager, type TerminalManagerShape } from "./terminalManager";
34import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";34import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
35import { searchWorkspaceEntries } from "./workspaceEntries";35import { searchWorkspaceEntries } from "./workspaceEntries";
36import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";36import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
...@@ -61,7 +61,7 @@ export interface ServerOptions {...@@ -61,7 +61,7 @@ export interface ServerOptions {
61 autoBootstrapProjectFromCwd?: boolean | undefined;61 autoBootstrapProjectFromCwd?: boolean | undefined;
62 logWebSocketEvents?: boolean | undefined;62 logWebSocketEvents?: boolean | undefined;
63 gitManager?: GitManager | undefined;63 gitManager?: GitManager | undefined;
64 terminalManager?: TerminalManager | undefined;64 terminalManager?: TerminalManagerShape | undefined;
65}65}
6666
67export interface ServerShape {67export interface ServerShape {
...@@ -152,7 +152,7 @@ export type ServerCoreRuntimeServices =...@@ -152,7 +152,7 @@ export type ServerCoreRuntimeServices =
152 | OrchestrationReactor152 | OrchestrationReactor
153 | Open;153 | Open;
154154
155export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService;155export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService | TerminalManager;
156156
157export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(157export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
158 "ServerLifecycleError",158 "ServerLifecycleError",
...@@ -172,10 +172,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -172,10 +172,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
172 Scope.Scope | ServerRuntimeServices | ServerConfig172 Scope.Scope | ServerRuntimeServices | ServerConfig
173> {173> {
174 const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig;174 const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig;
175 const defaultTerminalManager = yield* TerminalManager;
175 const {176 const {
176 logWebSocketEvents: explicitLogWsEvents,177 logWebSocketEvents: explicitLogWsEvents,
177 gitManager = new GitManager(),178 gitManager = new GitManager(),
178 terminalManager = new TerminalManager(),179 terminalManager = defaultTerminalManager,
179 } = options;180 } = options;
180 const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web";181 const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web";
181182
...@@ -541,32 +542,32 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -541,32 +542,32 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
541542
542 case WS_METHODS.terminalOpen: {543 case WS_METHODS.terminalOpen: {
543 const body = stripRequestTag(request.body);544 const body = stripRequestTag(request.body);
544 return yield* Effect.promise(() => terminalManager.open(body));545 return yield* terminalManager.open(body);
545 }546 }
546547
547 case WS_METHODS.terminalWrite: {548 case WS_METHODS.terminalWrite: {
548 const body = stripRequestTag(request.body);549 const body = stripRequestTag(request.body);
549 return yield* Effect.promise(() => terminalManager.write(body));550 return yield* terminalManager.write(body);
550 }551 }
551552
552 case WS_METHODS.terminalResize: {553 case WS_METHODS.terminalResize: {
553 const body = stripRequestTag(request.body);554 const body = stripRequestTag(request.body);
554 return yield* Effect.promise(() => terminalManager.resize(body));555 return yield* terminalManager.resize(body);
555 }556 }
556557
557 case WS_METHODS.terminalClear: {558 case WS_METHODS.terminalClear: {
558 const body = stripRequestTag(request.body);559 const body = stripRequestTag(request.body);
559 return yield* Effect.promise(() => terminalManager.clear(body));560 return yield* terminalManager.clear(body);
560 }561 }
561562
562 case WS_METHODS.terminalRestart: {563 case WS_METHODS.terminalRestart: {
563 const body = stripRequestTag(request.body);564 const body = stripRequestTag(request.body);
564 return yield* Effect.promise(() => terminalManager.restart(body));565 return yield* terminalManager.restart(body);
565 }566 }
566567
567 case WS_METHODS.terminalClose: {568 case WS_METHODS.terminalClose: {
568 const body = stripRequestTag(request.body);569 const body = stripRequestTag(request.body);
569 return yield* Effect.promise(() => terminalManager.close(body));570 return yield* terminalManager.close(body);
570 }571 }
571572
572 case WS_METHODS.serverGetConfig:573 case WS_METHODS.serverGetConfig:
...@@ -690,13 +691,15 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -690,13 +691,15 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
690 ),691 ),
691 );692 );
692693
693 terminalManager.on("event", onTerminalEvent);694 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(onTerminalEvent);
694 yield* Effect.addFinalizer(() =>695 yield* Effect.addFinalizer(() =>
695 Effect.sync(() => {696 Effect.sync(() => {
696 terminalManager.off("event", onTerminalEvent);697 unsubscribeTerminalEvents();
697 terminalManager.dispose();
698 }),698 }),
699 );699 );
700 yield* Effect.addFinalizer(() =>
701 terminalManager.dispose().pipe(Effect.orElseSucceed(() => undefined)),
702 );
700703
701 yield* NodeHttpServer.make(() => httpServer, listenOptions);704 yield* NodeHttpServer.make(() => httpServer, listenOptions);
702705
apps/server/tsconfig.json+1-1
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2 "extends": "../../tsconfig.base.json",2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {3 "compilerOptions": {
4 "composite": true,4 "composite": true,
5 "types": ["node"],5 "types": ["node", "bun"],
6 "lib": ["ES2023", "esnext.disposable"],6 "lib": ["ES2023", "esnext.disposable"],
7 "noEmit": true,7 "noEmit": true,
8 "allowImportingTsExtensions": true,8 "allowImportingTsExtensions": true,
bun.lock+9
...@@ -48,6 +48,7 @@...@@ -48,6 +48,7 @@
48 "@effect/vitest": "4.0.0-beta.10",48 "@effect/vitest": "4.0.0-beta.10",
49 "@t3tools/contracts": "workspace:*",49 "@t3tools/contracts": "workspace:*",
50 "@t3tools/web": "workspace:*",50 "@t3tools/web": "workspace:*",
51 "@types/bun": "^1.3.9",
51 "@types/node": "^22.10.2",52 "@types/node": "^22.10.2",
52 "@types/ws": "^8.5.13",53 "@types/ws": "^8.5.13",
53 "tsdown": "^0.20.3",54 "tsdown": "^0.20.3",
...@@ -527,6 +528,8 @@...@@ -527,6 +528,8 @@
527528
528 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],529 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
529530
531 "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
532
530 "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],533 "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
531534
532 "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],535 "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
...@@ -629,6 +632,8 @@...@@ -629,6 +632,8 @@
629632
630 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],633 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
631634
635 "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
636
632 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],637 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
633638
634 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],639 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
...@@ -1451,6 +1456,8 @@...@@ -1451,6 +1456,8 @@
14511456
1452 "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],1457 "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
14531458
1459 "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="],
1460
1454 "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],1461 "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
14551462
1456 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],1463 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
...@@ -1501,6 +1508,8 @@...@@ -1501,6 +1508,8 @@
15011508
1502 "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],1509 "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
15031510
1511 "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
1512
1504 "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],1513 "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
15051514
1506 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],1515 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],