authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 17:57:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 02:12:40-08:00
log3bfda73dcec029957c6894dc68ffff7f78a03047
tree0e232a4955d2fa53dd74738fc644fcc1a6f3da86
parent0133d9582c7bdf047654e4fee532f21655872e68

Address PR review comments for terminal integration

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

8 files changed, 374 insertions(+), 147 deletions(-)

apps/server/src/git.test.ts+15
......@@ -53,6 +53,21 @@ async function initRepoWithCommit(cwd: string): Promise<void> {
5353// ── Tests ──
5454
5555describe("git integration", () => {
56 describe("runTerminalCommand", () => {
57 it("caps captured output when maxOutputBytes is exceeded", async () => {
58 const result = await runTerminalCommand({
59 command: `node -e "process.stdout.write('x'.repeat(2000))"`,
60 cwd: process.cwd(),
61 timeoutMs: 10_000,
62 maxOutputBytes: 128,
63 });
64
65 expect(result.code).toBe(0);
66 expect(result.stdout.length).toBeLessThanOrEqual(128);
67 expect(result.stderr).toContain("output truncated");
68 });
69 });
70
5671 // ── initGitRepo ──
5772
5873 describe("initGitRepo", () => {
apps/server/src/git.ts+61-4
......@@ -18,6 +18,7 @@ export interface TerminalCommandInput {
1818 command: string;
1919 cwd: string;
2020 timeoutMs?: number;
21 maxOutputBytes?: number;
2122}
2223
2324export interface TerminalCommandResult {
......@@ -28,6 +29,36 @@ export interface TerminalCommandResult {
2829 timedOut: boolean;
2930}
3031
32const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
33
34function appendChunkWithinLimit(
35 target: string,
36 currentBytes: number,
37 chunk: Buffer,
38 maxBytes: number,
39): {
40 next: string;
41 nextBytes: number;
42 truncated: boolean;
43} {
44 const remaining = maxBytes - currentBytes;
45 if (remaining <= 0) {
46 return { next: target, nextBytes: currentBytes, truncated: true };
47 }
48 if (chunk.length <= remaining) {
49 return {
50 next: `${target}${chunk.toString()}`,
51 nextBytes: currentBytes + chunk.length,
52 truncated: false,
53 };
54 }
55 return {
56 next: `${target}${chunk.subarray(0, remaining).toString()}`,
57 nextBytes: currentBytes + remaining,
58 truncated: true,
59 };
60}
61
3162/** Spawn git directly with an argv array — no shell, no quoting needed. */
3263function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
3364 return new Promise((resolve, reject) => {
......@@ -37,9 +68,13 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
3768 stdio: ["ignore", "pipe", "pipe"],
3869 });
3970
71 const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES;
4072 let stdout = "";
4173 let stderr = "";
74 let stdoutBytes = 0;
75 let stderrBytes = 0;
4276 let timedOut = false;
77 let outputTruncated = false;
4378
4479 const timeout = setTimeout(() => {
4580 timedOut = true;
......@@ -50,10 +85,16 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
5085 }, timeoutMs);
5186
5287 child.stdout?.on("data", (chunk: Buffer) => {
53 stdout += chunk.toString();
88 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
89 stdout = appended.next;
90 stdoutBytes = appended.nextBytes;
91 outputTruncated = outputTruncated || appended.truncated;
5492 });
5593 child.stderr?.on("data", (chunk: Buffer) => {
56 stderr += chunk.toString();
94 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
95 stderr = appended.next;
96 stderrBytes = appended.nextBytes;
97 outputTruncated = outputTruncated || appended.truncated;
5798 });
5899 child.on("error", (error) => {
59100 clearTimeout(timeout);
......@@ -61,6 +102,9 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
61102 });
62103 child.on("close", (code, signal) => {
63104 clearTimeout(timeout);
105 if (outputTruncated) {
106 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
107 }
64108 resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });
65109 });
66110 });
......@@ -69,6 +113,7 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
69113export async function runTerminalCommand(
70114 input: TerminalCommandInput,
71115): Promise<TerminalCommandResult> {
116 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
72117 const shellPath =
73118 process.platform === "win32"
74119 ? (process.env.ComSpec ?? "cmd.exe")
......@@ -86,7 +131,10 @@ export async function runTerminalCommand(
86131
87132 let stdout = "";
88133 let stderr = "";
134 let stdoutBytes = 0;
135 let stderrBytes = 0;
89136 let timedOut = false;
137 let outputTruncated = false;
90138
91139 const timeout = setTimeout(() => {
92140 timedOut = true;
......@@ -99,11 +147,17 @@ export async function runTerminalCommand(
99147 }, input.timeoutMs ?? 30_000);
100148
101149 child.stdout?.on("data", (chunk: Buffer) => {
102 stdout += chunk.toString();
150 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
151 stdout = appended.next;
152 stdoutBytes = appended.nextBytes;
153 outputTruncated = outputTruncated || appended.truncated;
103154 });
104155
105156 child.stderr?.on("data", (chunk: Buffer) => {
106 stderr += chunk.toString();
157 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
158 stderr = appended.next;
159 stderrBytes = appended.nextBytes;
160 outputTruncated = outputTruncated || appended.truncated;
107161 });
108162
109163 child.on("error", (error) => {
......@@ -113,6 +167,9 @@ export async function runTerminalCommand(
113167
114168 child.on("close", (code, signal) => {
115169 clearTimeout(timeout);
170 if (outputTruncated) {
171 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
172 }
116173 resolve({
117174 stdout,
118175 stderr,
apps/server/src/terminalManager.test.ts+32-10
......@@ -100,6 +100,14 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu
100100 };
101101}
102102
103function historyLogName(threadId: string): string {
104 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;
105}
106
107function historyLogPath(logsDir: string, threadId = "thread-1"): string {
108 return path.join(logsDir, historyLogName(threadId));
109}
110
103111describe("TerminalManager", () => {
104112 const tempDirs: string[] = [];
105113
......@@ -124,11 +132,12 @@ describe("TerminalManager", () => {
124132
125133 it("spawns lazily and reuses running terminal per thread", async () => {
126134 const { manager, ptyAdapter } = makeManager();
127 const first = await manager.open(openInput());
128 const second = await manager.open(openInput());
135 const [first, second] = await Promise.all([manager.open(openInput()), manager.open(openInput())]);
136 const third = await manager.open(openInput());
129137
130138 expect(first.threadId).toBe("thread-1");
131139 expect(second.threadId).toBe("thread-1");
140 expect(third.threadId).toBe("thread-1");
132141 expect(ptyAdapter.spawnInputs).toHaveLength(1);
133142
134143 manager.dispose();
......@@ -162,9 +171,9 @@ describe("TerminalManager", () => {
162171 if (!process) return;
163172
164173 process.emitData("hello\n");
165 await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
174 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
166175 await manager.clear({ threadId: "thread-1" });
167 await waitFor(() => fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8") === "");
176 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");
168177
169178 expect(events.some((event) => event.type === "cleared")).toBe(true);
170179
......@@ -178,13 +187,13 @@ describe("TerminalManager", () => {
178187 expect(firstProcess).toBeDefined();
179188 if (!firstProcess) return;
180189 firstProcess.emitData("before restart\n");
181 await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
190 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
182191
183192 const snapshot = await manager.restart(openInput());
184193 expect(snapshot.history).toBe("");
185194 expect(snapshot.status).toBe("running");
186195 expect(ptyAdapter.spawnInputs).toHaveLength(2);
187 await waitFor(() => fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8") === "");
196 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");
188197
189198 manager.dispose();
190199 });
......@@ -200,7 +209,7 @@ describe("TerminalManager", () => {
200209 expect(process).toBeDefined();
201210 if (!process) return;
202211 process.emitData("old data\n");
203 await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
212 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
204213 process.emitExit({ exitCode: 0, signal: 0 });
205214
206215 await waitFor(() => events.some((event) => event.type === "exited"));
......@@ -208,7 +217,7 @@ describe("TerminalManager", () => {
208217
209218 expect(reopened.history).toBe("");
210219 expect(ptyAdapter.spawnInputs).toHaveLength(2);
211 expect(fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8")).toBe("");
220 expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe("");
212221
213222 manager.dispose();
214223 });
......@@ -237,10 +246,23 @@ describe("TerminalManager", () => {
237246 expect(process).toBeDefined();
238247 if (!process) return;
239248 process.emitData("bye\n");
240 await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
249 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
241250
242251 await manager.close({ threadId: "thread-1", deleteHistory: true });
243 expect(fs.existsSync(path.join(logsDir, "thread-1.log"))).toBe(false);
252 expect(fs.existsSync(historyLogPath(logsDir))).toBe(false);
253
254 manager.dispose();
255 });
256
257 it("loads existing legacy transcript filenames and keeps new naming for writes", async () => {
258 const { manager, logsDir } = makeManager();
259 const legacyPath = path.join(logsDir, "thread-1.log");
260 fs.writeFileSync(legacyPath, "legacy-line\n", "utf8");
261
262 const snapshot = await manager.open(openInput());
263
264 expect(snapshot.history).toBe("legacy-line\n");
265 expect(fs.existsSync(legacyPath)).toBe(true);
244266
245267 manager.dispose();
246268 });
apps/server/src/terminalManager.ts+223-109
......@@ -21,6 +21,7 @@ import { createLogger } from "./logger";
2121import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
2222
2323const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
24const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
2425
2526export interface TerminalManagerEvents {
2627 event: [event: TerminalEvent];
......@@ -68,10 +69,14 @@ function capHistory(history: string, maxLines: number): string {
6869 return hasTrailingNewline ? `${capped}\n` : capped;
6970}
7071
71function toSafeThreadId(threadId: string): string {
72function legacySafeThreadId(threadId: string): string {
7273 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
7374}
7475
76function toSafeThreadId(threadId: string): string {
77 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
78}
79
7580export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
7681 private readonly sessions = new Map<string, TerminalSessionState>();
7782 private readonly logsDir: string;
......@@ -79,6 +84,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
7984 private readonly ptyAdapter: PtyAdapter;
8085 private readonly shellResolver: () => string;
8186 private readonly persistQueues = new Map<string, Promise<void>>();
87 private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
88 private readonly pendingPersistHistory = new Map<string, string>();
89 private readonly threadLocks = new Map<string, Promise<void>>();
90 private readonly persistDebounceMs: number;
8291 private readonly logger = createLogger("terminal");
8392
8493 constructor(options: TerminalManagerOptions = {}) {
......@@ -87,60 +96,63 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
8796 this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
8897 this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter();
8998 this.shellResolver = options.shellResolver ?? defaultShellResolver;
99 this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS;
90100 fs.mkdirSync(this.logsDir, { recursive: true });
91101 }
92102
93103 async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
94104 const input = terminalOpenInputSchema.parse(raw);
95 await this.assertValidCwd(input.cwd);
96 await this.flushPersistQueue(input.threadId);
97
98 const existing = this.sessions.get(input.threadId);
99 if (!existing) {
100 const history = await this.readHistory(input.threadId);
101 const session: TerminalSessionState = {
102 threadId: input.threadId,
103 cwd: input.cwd,
104 status: "starting",
105 pid: null,
106 history,
107 exitCode: null,
108 exitSignal: null,
109 updatedAt: new Date().toISOString(),
110 cols: input.cols,
111 rows: input.rows,
112 process: null,
113 unsubscribeData: null,
114 unsubscribeExit: null,
115 };
116 this.sessions.set(input.threadId, session);
117 this.startSession(session, input, "started");
118 return this.snapshot(session);
119 }
105 return this.runWithThreadLock(input.threadId, async () => {
106 await this.assertValidCwd(input.cwd);
107
108 const existing = this.sessions.get(input.threadId);
109 if (!existing) {
110 await this.flushPersistQueue(input.threadId);
111 const history = await this.readHistory(input.threadId);
112 const session: TerminalSessionState = {
113 threadId: input.threadId,
114 cwd: input.cwd,
115 status: "starting",
116 pid: null,
117 history,
118 exitCode: null,
119 exitSignal: null,
120 updatedAt: new Date().toISOString(),
121 cols: input.cols,
122 rows: input.rows,
123 process: null,
124 unsubscribeData: null,
125 unsubscribeExit: null,
126 };
127 this.sessions.set(input.threadId, session);
128 this.startSession(session, input, "started");
129 return this.snapshot(session);
130 }
120131
121 if (existing.cwd !== input.cwd) {
122 this.stopProcess(existing);
123 existing.cwd = input.cwd;
124 existing.history = "";
125 await this.persistHistory(existing.threadId, existing.history);
126 } else if (existing.status === "exited" || existing.status === "error") {
127 existing.history = "";
128 await this.persistHistory(existing.threadId, existing.history);
129 }
132 if (existing.cwd !== input.cwd) {
133 this.stopProcess(existing);
134 existing.cwd = input.cwd;
135 existing.history = "";
136 await this.persistHistory(existing.threadId, existing.history);
137 } else if (existing.status === "exited" || existing.status === "error") {
138 existing.history = "";
139 await this.persistHistory(existing.threadId, existing.history);
140 }
130141
131 if (!existing.process) {
132 this.startSession(existing, input, "started");
133 return this.snapshot(existing);
134 }
142 if (!existing.process) {
143 this.startSession(existing, input, "started");
144 return this.snapshot(existing);
145 }
135146
136 if (existing.cols !== input.cols || existing.rows !== input.rows) {
137 existing.cols = input.cols;
138 existing.rows = input.rows;
139 existing.process.resize(input.cols, input.rows);
140 existing.updatedAt = new Date().toISOString();
141 }
147 if (existing.cols !== input.cols || existing.rows !== input.rows) {
148 existing.cols = input.cols;
149 existing.rows = input.rows;
150 existing.process.resize(input.cols, input.rows);
151 existing.updatedAt = new Date().toISOString();
152 }
142153
143 return this.snapshot(existing);
154 return this.snapshot(existing);
155 });
144156 }
145157
146158 async write(raw: TerminalWriteInput): Promise<void> {
......@@ -166,61 +178,67 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
166178
167179 async clear(raw: TerminalThreadInput): Promise<void> {
168180 const input = terminalThreadInputSchema.parse(raw);
169 const session = this.requireSession(input.threadId);
170 session.history = "";
171 session.updatedAt = new Date().toISOString();
172 await this.persistHistory(input.threadId, session.history);
173 this.emitEvent({
174 type: "cleared",
175 threadId: input.threadId,
176 createdAt: new Date().toISOString(),
181 await this.runWithThreadLock(input.threadId, async () => {
182 const session = this.requireSession(input.threadId);
183 session.history = "";
184 session.updatedAt = new Date().toISOString();
185 await this.persistHistory(input.threadId, session.history);
186 this.emitEvent({
187 type: "cleared",
188 threadId: input.threadId,
189 createdAt: new Date().toISOString(),
190 });
177191 });
178192 }
179193
180194 async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
181195 const input = terminalOpenInputSchema.parse(raw);
182 await this.assertValidCwd(input.cwd);
183
184 let session = this.sessions.get(input.threadId);
185 if (!session) {
186 session = {
187 threadId: input.threadId,
188 cwd: input.cwd,
189 status: "starting",
190 pid: null,
191 history: "",
192 exitCode: null,
193 exitSignal: null,
194 updatedAt: new Date().toISOString(),
195 cols: input.cols,
196 rows: input.rows,
197 process: null,
198 unsubscribeData: null,
199 unsubscribeExit: null,
200 };
201 this.sessions.set(input.threadId, session);
202 } else {
203 this.stopProcess(session);
204 session.cwd = input.cwd;
205 }
196 return this.runWithThreadLock(input.threadId, async () => {
197 await this.assertValidCwd(input.cwd);
198
199 let session = this.sessions.get(input.threadId);
200 if (!session) {
201 session = {
202 threadId: input.threadId,
203 cwd: input.cwd,
204 status: "starting",
205 pid: null,
206 history: "",
207 exitCode: null,
208 exitSignal: null,
209 updatedAt: new Date().toISOString(),
210 cols: input.cols,
211 rows: input.rows,
212 process: null,
213 unsubscribeData: null,
214 unsubscribeExit: null,
215 };
216 this.sessions.set(input.threadId, session);
217 } else {
218 this.stopProcess(session);
219 session.cwd = input.cwd;
220 }
206221
207 session.history = "";
208 await this.persistHistory(input.threadId, session.history);
209 this.startSession(session, input, "restarted");
210 return this.snapshot(session);
222 session.history = "";
223 await this.persistHistory(input.threadId, session.history);
224 this.startSession(session, input, "restarted");
225 return this.snapshot(session);
226 });
211227 }
212228
213229 async close(raw: TerminalCloseInput): Promise<void> {
214230 const input = terminalCloseInputSchema.parse(raw);
215 await this.flushPersistQueue(input.threadId);
216 const session = this.sessions.get(input.threadId);
217 if (session) {
218 this.stopProcess(session);
219 this.sessions.delete(input.threadId);
220 }
221 if (input.deleteHistory) {
222 await this.deleteHistory(input.threadId);
223 }
231 await this.runWithThreadLock(input.threadId, async () => {
232 const session = this.sessions.get(input.threadId);
233 if (session) {
234 this.stopProcess(session);
235 this.sessions.delete(input.threadId);
236 }
237 await this.flushPersistQueue(input.threadId);
238 if (input.deleteHistory) {
239 await this.deleteHistory(input.threadId);
240 }
241 });
224242 }
225243
226244 dispose(): void {
......@@ -228,6 +246,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
228246 this.stopProcess(session);
229247 }
230248 this.sessions.clear();
249 for (const timer of this.persistTimers.values()) {
250 clearTimeout(timer);
251 }
252 this.persistTimers.clear();
253 this.pendingPersistHistory.clear();
254 this.threadLocks.clear();
231255 this.persistQueues.clear();
232256 }
233257
......@@ -247,8 +271,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
247271 session.updatedAt = new Date().toISOString();
248272
249273 const shell = this.shellResolver();
274 let ptyProcess: PtyProcess | null = null;
250275 try {
251 const ptyProcess = this.ptyAdapter.spawn({
276 ptyProcess = this.ptyAdapter.spawn({
252277 shell,
253278 cwd: session.cwd,
254279 cols: session.cols,
......@@ -272,6 +297,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
272297 snapshot: this.snapshot(session),
273298 });
274299 } catch (error) {
300 if (ptyProcess) {
301 try {
302 ptyProcess.kill();
303 } catch {
304 // Ignore kill errors during failed startup cleanup.
305 }
306 }
275307 session.status = "error";
276308 session.pid = null;
277309 session.process = null;
......@@ -346,31 +378,60 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
346378 }
347379
348380 private queuePersist(threadId: string, history: string): void {
349 const task = async () => {
350 await fs.promises.writeFile(this.historyPath(threadId), history, "utf8");
351 };
352 const previous = this.persistQueues.get(threadId) ?? Promise.resolve();
353 const next = previous.catch(() => undefined).then(task);
354 this.persistQueues.set(threadId, next);
355 void next.finally(() => {
356 if (this.persistQueues.get(threadId) === next) {
357 this.persistQueues.delete(threadId);
358 }
359 });
381 this.pendingPersistHistory.set(threadId, history);
382 this.schedulePersist(threadId);
360383 }
361384
362385 private async persistHistory(threadId: string, history: string): Promise<void> {
386 this.clearPersistTimer(threadId);
387 this.pendingPersistHistory.delete(threadId);
388 await this.enqueuePersistWrite(threadId, history);
389 }
390
391 private enqueuePersistWrite(threadId: string, history: string): Promise<void> {
363392 const task = async () => {
364393 await fs.promises.writeFile(this.historyPath(threadId), history, "utf8");
365394 };
366395 const previous = this.persistQueues.get(threadId) ?? Promise.resolve();
367 const next = previous.catch(() => undefined).then(task);
396 const next = previous
397 .catch(() => undefined)
398 .then(task)
399 .catch((error) => {
400 this.logger.warn("failed to persist terminal history", {
401 threadId,
402 error: error instanceof Error ? error.message : String(error),
403 });
404 });
368405 this.persistQueues.set(threadId, next);
369 await next.finally(() => {
406 const finalized = next.finally(() => {
370407 if (this.persistQueues.get(threadId) === next) {
371408 this.persistQueues.delete(threadId);
372409 }
410 if (this.pendingPersistHistory.has(threadId) && !this.persistTimers.has(threadId)) {
411 this.schedulePersist(threadId);
412 }
373413 });
414 void finalized.catch(() => undefined);
415 return finalized;
416 }
417
418 private schedulePersist(threadId: string): void {
419 if (this.persistTimers.has(threadId)) return;
420 const timer = setTimeout(() => {
421 this.persistTimers.delete(threadId);
422 const pendingHistory = this.pendingPersistHistory.get(threadId);
423 if (pendingHistory === undefined) return;
424 this.pendingPersistHistory.delete(threadId);
425 void this.enqueuePersistWrite(threadId, pendingHistory);
426 }, this.persistDebounceMs);
427 this.persistTimers.set(threadId, timer);
428 }
429
430 private clearPersistTimer(threadId: string): void {
431 const timer = this.persistTimers.get(threadId);
432 if (!timer) return;
433 clearTimeout(timer);
434 this.persistTimers.delete(threadId);
374435 }
375436
376437 private async readHistory(threadId: string): Promise<string> {
......@@ -381,6 +442,19 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
381442 await fs.promises.writeFile(this.historyPath(threadId), capped, "utf8");
382443 }
383444 return capped;
445 } catch (error) {
446 if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
447 throw error;
448 }
449 }
450
451 try {
452 const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8");
453 const capped = capHistory(raw, this.historyLineLimit);
454 if (capped !== raw) {
455 await fs.promises.writeFile(this.legacyHistoryPath(threadId), capped, "utf8");
456 }
457 return capped;
384458 } catch (error) {
385459 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
386460 return "";
......@@ -391,7 +465,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
391465
392466 private async deleteHistory(threadId: string): Promise<void> {
393467 try {
394 await fs.promises.rm(this.historyPath(threadId), { force: true });
468 await Promise.all([
469 fs.promises.rm(this.historyPath(threadId), { force: true }),
470 fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }),
471 ]);
395472 } catch (error) {
396473 this.logger.warn("failed to delete terminal history", {
397474 threadId,
......@@ -401,9 +478,21 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
401478 }
402479
403480 private async flushPersistQueue(threadId: string): Promise<void> {
404 const pending = this.persistQueues.get(threadId);
405 if (!pending) return;
406 await pending.catch(() => undefined);
481 this.clearPersistTimer(threadId);
482
483 while (true) {
484 const pendingHistory = this.pendingPersistHistory.get(threadId);
485 if (pendingHistory !== undefined) {
486 this.pendingPersistHistory.delete(threadId);
487 await this.enqueuePersistWrite(threadId, pendingHistory);
488 }
489
490 const pending = this.persistQueues.get(threadId);
491 if (!pending) {
492 return;
493 }
494 await pending.catch(() => undefined);
495 }
407496 }
408497
409498 private async assertValidCwd(cwd: string): Promise<void> {
......@@ -449,4 +538,29 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
449538 private historyPath(threadId: string): string {
450539 return path.join(this.logsDir, `${toSafeThreadId(threadId)}.log`);
451540 }
541
542 private legacyHistoryPath(threadId: string): string {
543 return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`);
544 }
545
546 private async runWithThreadLock<T>(
547 threadId: string,
548 task: () => Promise<T>,
549 ): Promise<T> {
550 const previous = this.threadLocks.get(threadId) ?? Promise.resolve();
551 let release: () => void = () => {};
552 const current = new Promise<void>((resolve) => {
553 release = resolve;
554 });
555 this.threadLocks.set(threadId, current);
556 await previous.catch(() => undefined);
557 try {
558 return await task();
559 } finally {
560 release();
561 if (this.threadLocks.get(threadId) === current) {
562 this.threadLocks.delete(threadId);
563 }
564 }
565 }
452566}
apps/server/src/wsServer.test.ts+16
......@@ -382,6 +382,22 @@ describe("WebSocket Server", () => {
382382 expect((push.data as TerminalEvent).type).toBe("output");
383383 });
384384
385 it("detaches terminal event listener on stop for injected manager", async () => {
386 const terminalManager = new MockTerminalManager();
387 server = createTestServer({
388 cwd: "/test",
389 terminalManager: terminalManager as unknown as TerminalManager,
390 });
391 await server.start();
392
393 expect(terminalManager.listenerCount("event")).toBe(1);
394
395 await server.stop();
396 server = null;
397
398 expect(terminalManager.listenerCount("event")).toBe(0);
399 });
400
385401 it("returns validation errors for invalid terminal open params", async () => {
386402 server = createTestServer({ cwd: "/test" });
387403 await server.start();
apps/server/src/wsServer.ts+5-2
......@@ -9,6 +9,7 @@ import {
99 EDITORS,
1010 WS_CHANNELS,
1111 WS_METHODS,
12 type TerminalEvent,
1213 type WsPush,
1314 type WsRequest,
1415 type WsResponse,
......@@ -112,7 +113,7 @@ export function createServer(options: ServerOptions) {
112113 logOutgoingPush(push, recipients);
113114 });
114115
115 terminalManager.on("event", (event) => {
116 const onTerminalEvent = (event: TerminalEvent) => {
116117 const push: WsPush = {
117118 type: "push",
118119 channel: WS_CHANNELS.terminalEvent,
......@@ -127,7 +128,8 @@ export function createServer(options: ServerOptions) {
127128 }
128129 }
129130 logOutgoingPush(push, recipients);
130 });
131 };
132 terminalManager.on("event", onTerminalEvent);
131133
132134 // HTTP server — serves static files or redirects to Vite dev server
133135 const httpServer = http.createServer((req, res) => {
......@@ -401,6 +403,7 @@ export function createServer(options: ServerOptions) {
401403 }
402404
403405 async function stop(): Promise<void> {
406 terminalManager.off("event", onTerminalEvent);
404407 providerManager.stopAll();
405408 providerManager.dispose();
406409 terminalManager.dispose();
apps/web/src/components/ChatView.tsx-1
......@@ -1207,7 +1207,6 @@ export default function ChatView() {
12071207 </form>
12081208 </div>
12091209
1210 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
12111210 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
12121211
12131212 {activeThread.terminalOpen && api && activeProject && (
apps/web/src/components/ThreadTerminalDrawer.tsx+22-21
......@@ -152,6 +152,20 @@ export default function ThreadTerminalDrawer({
152152 [],
153153 );
154154
155 const fitAndResizeTerminal = useCallback(() => {
156 const activeTerminal = terminalRef.current;
157 const activeFitAddon = fitAddonRef.current;
158 if (!activeTerminal || !activeFitAddon) return;
159 activeFitAddon.fit();
160 void api.terminal
161 .resize({
162 threadId,
163 cols: activeTerminal.cols,
164 rows: activeTerminal.rows,
165 })
166 .catch(() => undefined);
167 }, [api, threadId]);
168
155169 useEffect(() => {
156170 const clampedHeight = clampDrawerHeight(height);
157171 setDrawerHeight(clampedHeight);
......@@ -201,8 +215,11 @@ export default function ThreadTerminalDrawer({
201215 useEffect(() => {
202216 const onWindowResize = () => {
203217 const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
204 if (clampedHeight !== drawerHeightRef.current) {
218 const changed = clampedHeight !== drawerHeightRef.current;
219 if (changed) {
205220 setDrawerHeight(clampedHeight);
221 } else {
222 fitAndResizeTerminal();
206223 }
207224 if (!resizeStateRef.current) {
208225 syncHeight(clampedHeight);
......@@ -212,7 +229,7 @@ export default function ThreadTerminalDrawer({
212229 return () => {
213230 window.removeEventListener("resize", onWindowResize);
214231 };
215 }, [syncHeight]);
232 }, [fitAndResizeTerminal, syncHeight]);
216233
217234 useEffect(() => {
218235 return () => {
......@@ -264,20 +281,6 @@ export default function ThreadTerminalDrawer({
264281 return false;
265282 });
266283
267 const resizeAndSync = () => {
268 const activeTerminal = terminalRef.current;
269 const activeFitAddon = fitAddonRef.current;
270 if (!activeTerminal || !activeFitAddon) return;
271 activeFitAddon.fit();
272 void api.terminal
273 .resize({
274 threadId,
275 cols: activeTerminal.cols,
276 rows: activeTerminal.rows,
277 })
278 .catch(() => undefined);
279 };
280
281284 const inputDisposable = terminal.onData((data) => {
282285 void api.terminal
283286 .write({ threadId, data })
......@@ -363,14 +366,12 @@ export default function ThreadTerminalDrawer({
363366 }
364367 });
365368
366 window.addEventListener("resize", resizeAndSync);
367 const fitTimer = window.setTimeout(resizeAndSync, 30);
369 const fitTimer = window.setTimeout(fitAndResizeTerminal, 30);
368370 void openTerminal();
369371
370372 return () => {
371373 disposed = true;
372374 window.clearTimeout(fitTimer);
373 window.removeEventListener("resize", resizeAndSync);
374375 unsubscribe();
375376 inputDisposable.dispose();
376377 themeObserver.disconnect();
......@@ -378,7 +379,7 @@ export default function ThreadTerminalDrawer({
378379 fitAddonRef.current = null;
379380 terminal.dispose();
380381 };
381 }, [api, cwd, threadId]);
382 }, [api, cwd, fitAndResizeTerminal, threadId]);
382383
383384 useEffect(() => {
384385 const terminal = terminalRef.current;
......@@ -408,7 +409,7 @@ export default function ThreadTerminalDrawer({
408409 return () => {
409410 window.cancelAnimationFrame(frame);
410411 };
411 }, [api, drawerHeight, threadId]);
412 }, [api, cwd, drawerHeight, threadId]);
412413
413414 return (
414415 <aside