History

Address PR review comments for terminal integration

Julius Marminge committed 9 months ago

Commit 3bfda731 parent 0133d958 files · +374 −147Browse files

Unified · Split

8 files
  1. apps/server/src/git.test.ts +15 −0
  2. apps/server/src/git.ts +61 −4
  3. apps/server/src/terminalManager.test.ts +32 −10
  4. apps/server/src/terminalManager.ts +223 −109
  5. apps/server/src/wsServer.test.ts +16 −0
  6. apps/server/src/wsServer.ts +5 −2
  7. apps/web/src/components/ChatView.tsx +0 −1
  8. apps/web/src/components/ThreadTerminalDrawer.tsx +22 −21
apps/server/src/git.test.ts+15−0
@@ -53,6 +53,21 @@ async function initRepoWithCommit(cwd: string): Promise<void> { · 52 unmodified lines
5353 // ── Tests ──
5454
5555 describe("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 { · 17 unmodified lines
1818 command: string;
1919 cwd: string;
2020 timeoutMs?: number;
21+ maxOutputBytes?: number;
2122 }
2223
2324 export interface TerminalCommandResult {
@@ -28,6 +29,36 @@ export interface TerminalCommandResult { · 4 unmodified lines
2829 timedOut: boolean;
2930 }
3031
32+const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
33+
34+function 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. */
3263 function 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 · 3 unmodified lines
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 · 4 unmodified lines
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 · 1 unmodified line
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 · 2 unmodified lines
69113 export 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( · 11 unmodified lines
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( · 6 unmodified lines
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( · 3 unmodified lines
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 · 99 unmodified lines
100100 };
101101 }
102102
103+function historyLogName(threadId: string): string {
104+ return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;
105+}
106+
107+function historyLogPath(logsDir: string, threadId = "thread-1"): string {
108+ return path.join(logsDir, historyLogName(threadId));
109+}
110+
103111 describe("TerminalManager", () => {
104112 const tempDirs: string[] = [];
105113
@@ -124,11 +132,12 @@ describe("TerminalManager", () => { · 18 unmodified lines
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", () => { · 27 unmodified lines
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", () => { · 7 unmodified lines
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", () => { · 9 unmodified lines
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", () => { · 1 unmodified line
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", () => { · 22 unmodified lines
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"; · 20 unmodified lines
2121 import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
2222
2323 const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
24+const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
2425
2526 export interface TerminalManagerEvents {
2627 event: [event: TerminalEvent];
@@ -68,10 +69,14 @@ function capHistory(history: string, maxLines: number): string { · 41 unmodified lines
6869 return hasTrailingNewline ? `${capped}\n` : capped;
6970 }
7071
71−function toSafeThreadId(threadId: string): string {
72+function legacySafeThreadId(threadId: string): string {
7273 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
7374 }
7475
76+function toSafeThreadId(threadId: string): string {
77+ return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
78+}
79+
7580 export 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> { · 1 unmodified line
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> { · 2 unmodified lines
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> { · 19 unmodified lines
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> { · 1 unmodified line
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> { · 13 unmodified lines
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> { · 17 unmodified lines
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> { · 68 unmodified lines
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> { · 4 unmodified lines
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> { · 4 unmodified lines
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> { · 3 unmodified lines
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> { · 39 unmodified lines
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−0
@@ -382,6 +382,22 @@ describe("WebSocket Server", () => { · 381 unmodified lines
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 { · 8 unmodified lines
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) { · 97 unmodified lines
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) { · 8 unmodified lines
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) { · 267 unmodified lines
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+0−1
@@ -1207,7 +1207,6 @@ export default function ChatView() { · 1206 unmodified lines
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({ · 151 unmodified lines
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({ · 43 unmodified lines
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({ · 3 unmodified lines
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({ · 45 unmodified lines
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({ · 79 unmodified lines
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({ · 1 unmodified line
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({ · 23 unmodified lines
408409 return () => {
409410 window.cancelAnimationFrame(frame);
410411 };
411− }, [api, drawerHeight, threadId]);
412+ }, [api, cwd, drawerHeight, threadId]);
412413
413414 return (
414415 <aside

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t