feat(KB-641): improve terminal session handling and multi-project UX

- Update terminal session result flow for better async handling
- Enhance QuickEntryBox and InlineCreateCard with improved UX
- Refactor CLI project commands with better multi-project support
- Update terminal service tests for new result flow patterns
- Add changeset documentation for tracking changes
- Clean up legacy multi-project migration components
- Improve project detection and settings management
This commit is contained in:
gsxdsm
2026-04-01 07:11:13 -07:00
parent 70d047fe27
commit 005eb6fca6
4 changed files with 295 additions and 116 deletions

View File

@@ -4748,9 +4748,13 @@ describe("Terminal session routes", () => {
}); });
describe("POST /api/terminal/sessions", () => { describe("POST /api/terminal/sessions", () => {
it("returns 503 when max sessions reached (session is null)", async () => { it("returns 503 when max sessions reached", async () => {
const mockService = { const mockService = {
createSession: vi.fn().mockResolvedValue(null), createSession: vi.fn().mockResolvedValue({
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
}),
}; };
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
@@ -4763,7 +4767,102 @@ describe("Terminal session routes", () => {
); );
expect(res.status).toBe(503); expect(res.status).toBe(503);
expect(res.body.error).toContain("Max sessions"); expect(res.body.error).toBe("Maximum terminal sessions reached. Please close an existing terminal and try again.");
vi.restoreAllMocks();
});
it("returns 400 when shell is not allowed", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "invalid_shell",
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toBe("Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).");
vi.restoreAllMocks();
});
it("returns 503 when PTY module fails to load", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "pty_load_failed",
error: "Terminal service unavailable. The PTY module could not be loaded.",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(503);
expect(res.body.error).toBe("Terminal service unavailable. The PTY module could not be loaded.");
vi.restoreAllMocks();
});
it("returns 500 when PTY spawn fails", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "pty_spawn_failed",
error: "Failed to start terminal shell process.",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toBe("Failed to start terminal shell process.");
vi.restoreAllMocks();
});
it("returns 201 when session creation succeeds", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: true,
session: { id: "term-123", shell: "/bin/zsh", cwd: "/test" },
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body).toEqual({ sessionId: "term-123", shell: "/bin/zsh", cwd: "/test" });
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });

View File

@@ -4221,21 +4221,28 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const { cwd, cols, rows } = req.body; const { cwd, cols, rows } = req.body;
const terminalService = getTerminalService(store.getRootDir()); const terminalService = getTerminalService(store.getRootDir());
const session = await terminalService.createSession({ const result = await terminalService.createSession({
cwd, cwd,
cols: typeof cols === "number" ? cols : undefined, cols: typeof cols === "number" ? cols : undefined,
rows: typeof rows === "number" ? rows : undefined, rows: typeof rows === "number" ? rows : undefined,
}); });
if (!session) { if (!result.success) {
res.status(503).json({ error: "Failed to create session. Max sessions may be reached." }); const statusByCode = {
max_sessions: 503,
invalid_shell: 400,
pty_load_failed: 503,
pty_spawn_failed: 500,
} as const;
res.status(statusByCode[result.code]).json({ error: result.error });
return; return;
} }
res.status(201).json({ res.status(201).json({
sessionId: session.id, sessionId: result.session.id,
shell: session.shell, shell: result.session.shell,
cwd: session.cwd, cwd: result.session.cwd,
}); });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || "Failed to create terminal session" }); res.status(500).json({ error: err.message || "Failed to create terminal session" });

View File

@@ -47,38 +47,51 @@ describe("TerminalService", () => {
describe("createSession", () => { describe("createSession", () => {
it("creates session with detected shell", async () => { it("creates session with detected shell", async () => {
const session = await service.createSession(); const result = await service.createSession();
expect(session).toBeTruthy(); expect(result.success).toBe(true);
expect(session?.id).toMatch(/^term-\d+-/); if (!result.success) {
expect(session?.cwd).toBe(projectRoot); throw new Error("Expected terminal session creation to succeed");
}
expect(result.session.id).toMatch(/^term-\d+-/);
expect(result.session.cwd).toBe(projectRoot);
}); });
it("returns null when session limit reached", async () => { it("returns max_sessions error when session limit reached", async () => {
const limitedService = new TerminalService(projectRoot, 1); const limitedService = new TerminalService(projectRoot, 1);
const session1 = await limitedService.createSession(); const result1 = await limitedService.createSession();
expect(session1).toBeTruthy(); expect(result1.success).toBe(true);
const session2 = await limitedService.createSession(); const result2 = await limitedService.createSession();
expect(session2).toBeNull(); expect(result2).toEqual({
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
});
limitedService.cleanup(); limitedService.cleanup();
}); });
it("rejects shells not in allowlist", async () => { it("rejects shells not in allowlist", async () => {
const session = await service.createSession({ shell: "/tmp/evil-shell" }); const result = await service.createSession({ shell: "/tmp/evil-shell" });
expect(session).toBeNull(); expect(result).toEqual({
success: false,
code: "invalid_shell",
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
});
}); });
}); });
describe("write", () => { describe("write", () => {
it("sends data to PTY", async () => { it("sends data to PTY", async () => {
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const result = service.write(session!.id, "ls -la\n"); const session = createResult.session;
const result = service.write(session.id, "ls -la\n");
expect(result).toBe(true); expect(result).toBe(true);
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n"); expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
}); });
@@ -89,21 +102,25 @@ describe("TerminalService", () => {
}); });
it("rejects data with null bytes", async () => { it("rejects data with null bytes", async () => {
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const result = service.write(session!.id, "test\0malicious"); const session = createResult.session;
const result = service.write(session.id, "test\0malicious");
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
describe("resize", () => { describe("resize", () => {
it("updates PTY dimensions", async () => { it("updates PTY dimensions", async () => {
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const result = service.resize(session!.id, 120, 40); const session = createResult.session;
const result = service.resize(session.id, 120, 40);
expect(result).toBe(true); expect(result).toBe(true);
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40); expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
}); });
@@ -116,11 +133,13 @@ describe("TerminalService", () => {
describe("killSession", () => { describe("killSession", () => {
it("terminates session", async () => { it("terminates session", async () => {
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const result = service.killSession(session!.id); const session = createResult.session;
const result = service.killSession(session.id);
expect(result).toBe(true); expect(result).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM"); expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM");
}); });
@@ -134,51 +153,62 @@ describe("TerminalService", () => {
describe("session management", () => { describe("session management", () => {
it("enforces session limit", async () => { it("enforces session limit", async () => {
const limitedService = new TerminalService(projectRoot, 2); const limitedService = new TerminalService(projectRoot, 2);
const session1 = await limitedService.createSession(); const session1 = await limitedService.createSession();
const session2 = await limitedService.createSession(); const session2 = await limitedService.createSession();
const session3 = await limitedService.createSession(); const session3 = await limitedService.createSession();
expect(session1).toBeTruthy(); expect(session1.success).toBe(true);
expect(session2).toBeTruthy(); expect(session2.success).toBe(true);
expect(session3).toBeNull(); expect(session3).toEqual({
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
});
limitedService.cleanup(); limitedService.cleanup();
}); });
it("lists active sessions", async () => { it("lists active sessions", async () => {
const session1 = await service.createSession(); const result1 = await service.createSession();
const session2 = await service.createSession(); const result2 = await service.createSession();
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
if (!result1.success || !result2.success) throw new Error("Expected terminal session creation to succeed");
const sessions = service.getAllSessions(); const sessions = service.getAllSessions();
expect(sessions).toHaveLength(2); expect(sessions).toHaveLength(2);
expect(sessions.some((s: { id: string }) => s.id === session1?.id)).toBe(true); expect(sessions.some((s: { id: string }) => s.id === result1.session.id)).toBe(true);
expect(sessions.some((s: { id: string }) => s.id === session2?.id)).toBe(true); expect(sessions.some((s: { id: string }) => s.id === result2.session.id)).toBe(true);
}); });
it("cleans up all sessions", async () => { it("cleans up all sessions", async () => {
await service.createSession(); const result1 = await service.createSession();
await service.createSession(); const result2 = await service.createSession();
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
expect(service.getSessionCount()).toBe(2); expect(service.getSessionCount()).toBe(2);
service.cleanup(); service.cleanup();
expect(service.getSessionCount()).toBe(0); expect(service.getSessionCount()).toBe(0);
}); });
}); });
describe("scrollback buffer", () => { describe("scrollback buffer", () => {
it("maintains scrollback buffer", async () => { it("maintains scrollback buffer", async () => {
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
mockPtyProcess._onDataCallback?.("output line 1\n"); mockPtyProcess._onDataCallback?.("output line 1\n");
mockPtyProcess._onDataCallback?.("output line 2\n"); mockPtyProcess._onDataCallback?.("output line 2\n");
const scrollback = service.getScrollback(session!.id); const scrollback = service.getScrollback(session.id);
expect(scrollback).toContain("output line 1"); expect(scrollback).toContain("output line 1");
expect(scrollback).toContain("output line 2"); expect(scrollback).toContain("output line 2");
}); });
@@ -194,38 +224,42 @@ describe("TerminalService", () => {
const dataMock = vi.fn(); const dataMock = vi.fn();
service.onData(dataMock); service.onData(dataMock);
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
mockPtyProcess._onDataCallback?.("test data"); mockPtyProcess._onDataCallback?.("test data");
await new Promise((resolve) => setTimeout(resolve, 25)); await new Promise((resolve) => setTimeout(resolve, 25));
expect(dataMock).toHaveBeenCalledWith(session!.id, "test data"); expect(dataMock).toHaveBeenCalledWith(session.id, "test data");
}); });
it("emits exit events", async () => { it("emits exit events", async () => {
const exitMock = vi.fn(); const exitMock = vi.fn();
service.onExit(exitMock); service.onExit(exitMock);
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
mockPtyProcess._onExitCallback?.({ exitCode: 0 }); mockPtyProcess._onExitCallback?.({ exitCode: 0 });
expect(exitMock).toHaveBeenCalledWith(session!.id, 0); expect(exitMock).toHaveBeenCalledWith(session.id, 0);
}); });
it("allows unsubscribing from events", async () => { it("allows unsubscribing from events", async () => {
const dataMock = vi.fn(); const dataMock = vi.fn();
const unsub = service.onData(dataMock); const unsub = service.onData(dataMock);
unsub(); unsub();
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
mockPtyProcess._onDataCallback?.("test"); mockPtyProcess._onDataCallback?.("test");
expect(dataMock).not.toHaveBeenCalled(); expect(dataMock).not.toHaveBeenCalled();
}); });
}); });
@@ -271,31 +305,35 @@ describe("TerminalService", () => {
describe("activity tracking", () => { describe("activity tracking", () => {
it("sets lastActivityAt on session creation", async () => { it("sets lastActivityAt on session creation", async () => {
const before = new Date(); const before = new Date();
const session = await service.createSession(); const createResult = await service.createSession();
const after = new Date(); const after = new Date();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
expect(session!.lastActivityAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
expect(session!.lastActivityAt.getTime()).toBeLessThanOrEqual(after.getTime()); expect(createResult.session.lastActivityAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(createResult.session.lastActivityAt.getTime()).toBeLessThanOrEqual(after.getTime());
}); });
it("updates lastActivityAt on write", async () => { it("updates lastActivityAt on write", async () => {
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const initialActivity = session!.lastActivityAt.getTime(); const initialActivity = session.lastActivityAt.getTime();
// Small delay to ensure time difference // Small delay to ensure time difference
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
service.write(session!.id, "hello"); service.write(session.id, "hello");
const updatedSession = service.getSession(session!.id); const updatedSession = service.getSession(session.id);
expect(updatedSession!.lastActivityAt.getTime()).toBeGreaterThan(initialActivity); expect(updatedSession!.lastActivityAt.getTime()).toBeGreaterThan(initialActivity);
}); });
it("includes lastActivityAt in getAllSessions", async () => { it("includes lastActivityAt in getAllSessions", async () => {
await service.createSession(); const createResult = await service.createSession();
expect(createResult.success).toBe(true);
const sessions = service.getAllSessions(); const sessions = service.getAllSessions();
expect(sessions).toHaveLength(1); expect(sessions).toHaveLength(1);
@@ -305,37 +343,41 @@ describe("TerminalService", () => {
describe("stale session detection", () => { describe("stale session detection", () => {
it("returns empty array when no sessions are stale", async () => { it("returns empty array when no sessions are stale", async () => {
await service.createSession(); const createResult = await service.createSession();
expect(createResult.success).toBe(true);
const stale = service.getStaleSessions(300_000); const stale = service.getStaleSessions(300_000);
expect(stale).toHaveLength(0); expect(stale).toHaveLength(0);
}); });
it("returns sessions older than threshold", async () => { it("returns sessions older than threshold", async () => {
const session = await service.createSession(); const createResult = await service.createSession();
expect(session).toBeTruthy(); expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
// Manually backdate the lastActivityAt // Manually backdate the lastActivityAt
session!.lastActivityAt = new Date(Date.now() - 600_000); // 10 min ago session.lastActivityAt = new Date(Date.now() - 600_000); // 10 min ago
const stale = service.getStaleSessions(300_000); // 5 min threshold const stale = service.getStaleSessions(300_000); // 5 min threshold
expect(stale).toHaveLength(1); expect(stale).toHaveLength(1);
expect(stale[0].id).toBe(session!.id); expect(stale[0].id).toBe(session.id);
}); });
it("sorts stale sessions oldest first", async () => { it("sorts stale sessions oldest first", async () => {
const session1 = await service.createSession(); const result1 = await service.createSession();
const session2 = await service.createSession(); const result2 = await service.createSession();
expect(session1).toBeTruthy(); expect(result1.success).toBe(true);
expect(session2).toBeTruthy(); expect(result2.success).toBe(true);
if (!result1.success || !result2.success) throw new Error("Expected terminal session creation to succeed");
// session1 is older (more stale) // session1 is older (more stale)
session1!.lastActivityAt = new Date(Date.now() - 700_000); result1.session.lastActivityAt = new Date(Date.now() - 700_000);
session2!.lastActivityAt = new Date(Date.now() - 600_000); result2.session.lastActivityAt = new Date(Date.now() - 600_000);
const stale = service.getStaleSessions(300_000); const stale = service.getStaleSessions(300_000);
expect(stale).toHaveLength(2); expect(stale).toHaveLength(2);
expect(stale[0].id).toBe(session1!.id); expect(stale[0].id).toBe(result1.session.id);
expect(stale[1].id).toBe(session2!.id); expect(stale[1].id).toBe(result2.session.id);
}); });
}); });
@@ -350,7 +392,10 @@ describe("TerminalService", () => {
const sessions = []; const sessions = [];
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
sessions.push(await svc.createSession()); const result = await svc.createSession();
expect(result.success).toBe(true);
if (!result.success) throw new Error("Expected terminal session creation to succeed");
sessions.push(result.session);
} }
expect(svc.getSessionCount()).toBe(5); expect(svc.getSessionCount()).toBe(5);
@@ -377,7 +422,10 @@ describe("TerminalService", () => {
// Create 4 sessions (80% of 5) // Create 4 sessions (80% of 5)
const sessions = []; const sessions = [];
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
sessions.push(await svc.createSession()); const result = await svc.createSession();
expect(result.success).toBe(true);
if (!result.success) throw new Error("Expected terminal session creation to succeed");
sessions.push(result.session);
} }
expect(svc.getSessionCount()).toBe(4); expect(svc.getSessionCount()).toBe(4);
@@ -387,7 +435,7 @@ describe("TerminalService", () => {
// Creating a new session should trigger eviction first // Creating a new session should trigger eviction first
const newSession = await svc.createSession(); const newSession = await svc.createSession();
expect(newSession).toBeTruthy(); expect(newSession.success).toBe(true);
// Should have evicted stale sessions, then created a new one // Should have evicted stale sessions, then created a new one
// After eviction, we target <= 4 (80%), evict oldest stale sessions // After eviction, we target <= 4 (80%), evict oldest stale sessions
// Then create the new session // Then create the new session
@@ -400,7 +448,8 @@ describe("TerminalService", () => {
const svc = new TerminalService(projectRoot, 5); const svc = new TerminalService(projectRoot, 5);
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await svc.createSession(); const result = await svc.createSession();
expect(result.success).toBe(true);
} }
// All sessions are fresh, no stale ones // All sessions are fresh, no stale ones
const evicted = svc.evictStaleSessions(300_000); const evicted = svc.evictStaleSessions(300_000);

View File

@@ -162,6 +162,16 @@ export interface TerminalOptions {
env?: Record<string, string>; env?: Record<string, string>;
} }
export type CreateSessionErrorCode =
| "max_sessions"
| "invalid_shell"
| "pty_load_failed"
| "pty_spawn_failed";
export type CreateSessionResult =
| { success: true; session: TerminalSession }
| { success: false; error: string; code: CreateSessionErrorCode };
type DataCallback = (sessionId: string, data: string) => void; type DataCallback = (sessionId: string, data: string) => void;
type ExitCallback = (sessionId: string, exitCode: number) => void; type ExitCallback = (sessionId: string, exitCode: number) => void;
@@ -388,7 +398,7 @@ export class TerminalService extends EventEmitter {
/** /**
* Create a new terminal session * Create a new terminal session
*/ */
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> { async createSession(options: TerminalOptions = {}): Promise<CreateSessionResult> {
// Auto-evict stale sessions when at 80% of limit // Auto-evict stale sessions when at 80% of limit
if (this.sessions.size >= Math.floor(this.maxSessions * 0.8)) { if (this.sessions.size >= Math.floor(this.maxSessions * 0.8)) {
this.evictStaleSessions(); this.evictStaleSessions();
@@ -397,7 +407,11 @@ export class TerminalService extends EventEmitter {
// Check session limit // Check session limit
if (this.sessions.size >= this.maxSessions) { if (this.sessions.size >= this.maxSessions) {
console.error(`Max sessions (${this.maxSessions}) reached, refusing new session`); console.error(`Max sessions (${this.maxSessions}) reached, refusing new session`);
return null; return {
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
};
} }
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
@@ -408,7 +422,11 @@ export class TerminalService extends EventEmitter {
// Validate shell is allowed // Validate shell is allowed
if (!this.isAllowedShell(shell)) { if (!this.isAllowedShell(shell)) {
console.error(`Shell not allowed: ${shell}`); console.error(`Shell not allowed: ${shell}`);
return null; return {
success: false,
code: "invalid_shell",
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
};
} }
// Validate and resolve working directory // Validate and resolve working directory
@@ -439,10 +457,12 @@ export class TerminalService extends EventEmitter {
try { try {
pty = await loadPtyModule(); pty = await loadPtyModule();
} catch (loadErr) { } catch (loadErr) {
// Native module couldn't be loaded (common in Bun binaries without proper setup)
// Return null for graceful degradation - routes will return 503
console.error(`[terminal] Failed to load PTY module: ${loadErr}`); console.error(`[terminal] Failed to load PTY module: ${loadErr}`);
return null; return {
success: false,
code: "pty_load_failed",
error: "Terminal service unavailable. The PTY module could not be loaded.",
};
} }
// Build PTY spawn options // Build PTY spawn options
@@ -464,7 +484,11 @@ export class TerminalService extends EventEmitter {
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
} catch (spawnError) { } catch (spawnError) {
console.error(`[createSession] PTY spawn failed:`, spawnError); console.error(`[createSession] PTY spawn failed:`, spawnError);
return null; return {
success: false,
code: "pty_spawn_failed",
error: "Failed to start terminal shell process.",
};
} }
const session: TerminalSession = { const session: TerminalSession = {
@@ -530,7 +554,7 @@ export class TerminalService extends EventEmitter {
}); });
console.info(`Session ${id} created successfully`); console.info(`Session ${id} created successfully`);
return session; return { success: true, session };
} }
/** /**