From a2a52620f7494c5f69f17ef5e1596c3ffaede49b Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:28:31 +0200 Subject: [PATCH 01/62] feat(desktop): terminal persistence via daemon process Add terminal session persistence using a background daemon process that survives app restarts. Terminals can now be resumed with full scrollback and TUI state (like Claude Code) exactly where they left off. Key changes: - New terminal host daemon with per-session PTY subprocesses - DaemonTerminalManager as drop-in replacement for TerminalManager - Settings toggle for terminal persistence (requires app restart) - Schema migration for terminal_persistence setting - Smooth workspace/tab switching via CSS visibility (avoids remount) --- apps/desktop/electron.vite.config.ts | 4 + .../src/lib/trpc/routers/settings/index.ts | 21 + .../src/lib/trpc/routers/terminal/terminal.ts | 228 +++- apps/desktop/src/main/index.ts | 20 +- .../__tests__/headless-roundtrip.test.ts | 529 +++++++++ .../src/main/lib/terminal-host/client.ts | 947 ++++++++++++++++ .../lib/terminal-host/headless-emulator.ts | 606 ++++++++++ .../src/main/lib/terminal-host/types.ts | 347 ++++++ .../src/main/lib/terminal/daemon-manager.ts | 931 +++++++++++++++ apps/desktop/src/main/lib/terminal/index.ts | 138 ++- .../src/main/lib/terminal/manager.test.ts | 21 +- apps/desktop/src/main/lib/terminal/manager.ts | 41 +- .../src/main/lib/terminal/port-manager.ts | 135 ++- .../src/main/lib/terminal/pty-write-queue.ts | 159 +++ apps/desktop/src/main/lib/terminal/session.ts | 16 +- apps/desktop/src/main/lib/terminal/types.ts | 46 + .../src/main/terminal-host/daemon.test.ts | 429 +++++++ apps/desktop/src/main/terminal-host/index.ts | 631 +++++++++++ .../main/terminal-host/pty-subprocess-ipc.ts | 128 +++ .../src/main/terminal-host/pty-subprocess.ts | 472 ++++++++ .../terminal-host/session-lifecycle.test.ts | 679 +++++++++++ .../desktop/src/main/terminal-host/session.ts | 978 ++++++++++++++++ .../src/main/terminal-host/terminal-host.ts | 346 ++++++ .../SettingsView/SettingsContent.tsx | 2 + .../SettingsSidebar/GeneralSettings.tsx | 6 + .../SettingsView/TerminalSettings.tsx | 80 ++ .../TabsContent/TabView/TabPane.tsx | 8 +- .../ContentView/TabsContent/TabView/index.tsx | 4 +- .../TabsContent/Terminal/Terminal.tsx | 775 +++++++++++-- .../TabsContent/Terminal/helpers.ts | 225 +++- .../TabsContent/Terminal/hooks/index.ts | 2 + .../Terminal/hooks/useTerminalConnection.ts | 67 ++ .../ContentView/TabsContent/Terminal/types.ts | 5 +- .../ContentView/TabsContent/index.tsx | 94 +- apps/desktop/src/renderer/stores/app-state.ts | 3 +- .../src/renderer/stores/hotkeys/store.ts | 7 + .../drizzle/0011_add_terminal_persistence.sql | 1 + .../local-db/drizzle/meta/0011_snapshot.json | 1006 +++++++++++++++++ packages/local-db/drizzle/meta/_journal.json | 9 +- packages/local-db/src/schema/schema.ts | 13 + 40 files changed, 10000 insertions(+), 159 deletions(-) create mode 100644 apps/desktop/src/main/lib/terminal-host/__tests__/headless-roundtrip.test.ts create mode 100644 apps/desktop/src/main/lib/terminal-host/client.ts create mode 100644 apps/desktop/src/main/lib/terminal-host/headless-emulator.ts create mode 100644 apps/desktop/src/main/lib/terminal-host/types.ts create mode 100644 apps/desktop/src/main/lib/terminal/daemon-manager.ts create mode 100644 apps/desktop/src/main/lib/terminal/pty-write-queue.ts create mode 100644 apps/desktop/src/main/terminal-host/daemon.test.ts create mode 100644 apps/desktop/src/main/terminal-host/index.ts create mode 100644 apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts create mode 100644 apps/desktop/src/main/terminal-host/pty-subprocess.ts create mode 100644 apps/desktop/src/main/terminal-host/session-lifecycle.test.ts create mode 100644 apps/desktop/src/main/terminal-host/session.ts create mode 100644 apps/desktop/src/main/terminal-host/terminal-host.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts create mode 100644 packages/local-db/drizzle/0011_add_terminal_persistence.sql create mode 100644 packages/local-db/drizzle/meta/0011_snapshot.json diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 0040ff74d2f..94fb417115f 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -60,6 +60,10 @@ export default defineConfig({ rollupOptions: { input: { index: resolve("src/main/index.ts"), + // Terminal host daemon process - runs separately for terminal persistence + "terminal-host": resolve("src/main/terminal-host/index.ts"), + // PTY subprocess - spawned by terminal-host for each terminal + "pty-subprocess": resolve("src/main/terminal-host/pty-subprocess.ts"), }, output: { dir: resolve(devPath, "main"), diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 320bf7dcb2b..cc9e847daa6 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -237,5 +237,26 @@ export const createSettingsRouter = () => { return { success: true }; }), + + getTerminalPersistence: publicProcedure.query(() => { + const row = getSettings(); + // Default to false (terminal persistence disabled by default) + return row.terminalPersistence ?? false; + }), + + setTerminalPersistence: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, terminalPersistence: input.enabled }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalPersistence: input.enabled }, + }) + .run(); + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 6a4875f1f9c..7ed70708ed1 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -4,13 +4,19 @@ import { projects, workspaces, worktrees } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; -import { terminalManager } from "main/lib/terminal"; +import { + DaemonTerminalManager, + getActiveTerminalManager, +} from "main/lib/terminal"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveCwd } from "./utils"; +const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; +let createOrAttachCallCounter = 0; + /** * Terminal router using TerminalManager with node-pty * Sessions are keyed by paneId and linked to workspaces for cwd resolution @@ -26,6 +32,15 @@ import { resolveCwd } from "./utils"; * - SUPERSET_PORT: The hooks server port for agent completion notifications */ export const createTerminalRouter = () => { + // Get the active terminal manager (in-process or daemon-based) + const terminalManager = getActiveTerminalManager(); + if (DEBUG_TERMINAL) { + console.log( + "[Terminal Router] Using terminal manager:", + terminalManager.constructor.name, + ); + } + return router({ createOrAttach: publicProcedure .input( @@ -37,9 +52,12 @@ export const createTerminalRouter = () => { rows: z.number().optional(), cwd: z.string().optional(), initialCommands: z.array(z.string()).optional(), + skipColdRestore: z.boolean().optional(), }), ) .mutation(async ({ input }) => { + const callId = ++createOrAttachCallCounter; + const startedAt = Date.now(); const { paneId, tabId, @@ -48,6 +66,7 @@ export const createTerminalRouter = () => { rows, cwd: cwdOverride, initialCommands, + skipColdRestore, } = input; // Resolve cwd: absolute paths stay as-is, relative paths resolve against workspace path @@ -59,16 +78,23 @@ export const createTerminalRouter = () => { const workspacePath = workspace ? (getWorkspacePath(workspace) ?? undefined) : undefined; - - // Guard: For worktree workspaces, ensure the workspace is ready - // (not still initializing or failed). Branch workspaces use the main - // repo path which always exists, so no guard needed. if (workspace?.type === "worktree") { assertWorkspaceUsable(workspaceId, workspacePath); } - const cwd = resolveCwd(cwdOverride, workspacePath); + if (DEBUG_TERMINAL) { + console.log("[Terminal Router] createOrAttach called:", { + paneId, + workspaceId, + workspacePath, + cwdOverride, + resolvedCwd: cwd, + cols, + rows, + }); + } + // Get project info for environment variables const project = workspace ? localDb @@ -78,26 +104,55 @@ export const createTerminalRouter = () => { .get() : undefined; - const result = await terminalManager.createOrAttach({ - paneId, - tabId, - workspaceId, - workspaceName: workspace?.name, - workspacePath, - rootPath: project?.mainRepoPath, - cwd, - cols, - rows, - initialCommands, - }); + try { + const result = await terminalManager.createOrAttach({ + paneId, + tabId, + workspaceId, + workspaceName: workspace?.name, + workspacePath, + rootPath: project?.mainRepoPath, + cwd, + cols, + rows, + initialCommands, + skipColdRestore, + }); - return { - paneId, - isNew: result.isNew, - scrollback: result.scrollback, - wasRecovered: result.wasRecovered, - viewportY: result.viewportY, - }; + if (DEBUG_TERMINAL) { + console.log("[Terminal Router] createOrAttach result:", { + callId, + paneId, + isNew: result.isNew, + wasRecovered: result.wasRecovered, + durationMs: Date.now() - startedAt, + }); + } + + return { + paneId, + isNew: result.isNew, + scrollback: result.scrollback, + wasRecovered: result.wasRecovered, + viewportY: result.viewportY, + // Cold restore fields (for reboot recovery) + isColdRestore: result.isColdRestore, + previousCwd: result.previousCwd, + // Include snapshot for daemon mode (renderer can use for rehydration) + snapshot: result.snapshot, + }; + } catch (error) { + if (DEBUG_TERMINAL) { + console.warn("[Terminal Router] createOrAttach failed:", { + callId, + paneId, + durationMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + }); + } + console.error("[Terminal Router] createOrAttach ERROR:", error); + throw error; + } }), write: publicProcedure @@ -108,7 +163,35 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { - terminalManager.write(input); + try { + terminalManager.write(input); + } catch (error) { + const message = + error instanceof Error ? error.message : "Write failed"; + + // If session is gone, emit exit instead of error. + // This prevents error toast floods when workspaces with terminals are deleted. + if (message.includes("not found or not alive")) { + // SIGTERM (15) - synthetic signal for consistent event typing. + terminalManager.emit(`exit:${input.paneId}`, 0, 15); + return; + } + + terminalManager.emit(`error:${input.paneId}`, { + error: message, + code: "WRITE_FAILED", + }); + } + }), + + /** + * Acknowledge cold restore - clears the sticky cold restore info. + * Call this after displaying the cold restore UI and starting a new shell. + */ + ackColdRestore: publicProcedure + .input(z.object({ paneId: z.string() })) + .mutation(({ input }) => { + terminalManager.ackColdRestore(input.paneId); }), resize: publicProcedure @@ -173,6 +256,54 @@ export const createTerminalRouter = () => { await terminalManager.clearScrollback(input); }), + listDaemonSessions: publicProcedure.query(async () => { + if (!(terminalManager instanceof DaemonTerminalManager)) { + return { daemonModeEnabled: false, sessions: [] }; + } + + const response = await terminalManager.listDaemonSessions(); + return { daemonModeEnabled: true, sessions: response.sessions }; + }), + + killAllDaemonSessions: publicProcedure.mutation(async () => { + if (!(terminalManager instanceof DaemonTerminalManager)) { + return { daemonModeEnabled: false, killedCount: 0 }; + } + + const { sessions } = await terminalManager.listDaemonSessions(); + await terminalManager.forceKillAll(); + return { daemonModeEnabled: true, killedCount: sessions.length }; + }), + + killDaemonSessionsForWorkspace: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .mutation(async ({ input }) => { + if (!(terminalManager instanceof DaemonTerminalManager)) { + return { daemonModeEnabled: false, killedCount: 0 }; + } + + const { sessions } = await terminalManager.listDaemonSessions(); + const toKill = sessions.filter( + (session) => session.workspaceId === input.workspaceId, + ); + + for (const session of toKill) { + await terminalManager.kill({ paneId: session.sessionId }); + } + + return { daemonModeEnabled: true, killedCount: toKill.length }; + }), + + clearTerminalHistory: publicProcedure.mutation(async () => { + // Note: Disk-based terminal history was removed. This is now a no-op + // for non-daemon mode. In daemon mode, it resets the history persistence. + if (terminalManager instanceof DaemonTerminalManager) { + await terminalManager.resetHistoryPersistence(); + } + + return { success: true }; + }), + getSession: publicProcedure .input(z.string()) .query(async ({ input: paneId }) => { @@ -192,11 +323,11 @@ export const createTerminalRouter = () => { .where(eq(workspaces.id, workspaceId)) .get(); if (!workspace) { - return undefined; + return null; } if (!workspace.worktreeId) { - return undefined; + return null; } const worktree = localDb @@ -204,7 +335,7 @@ export const createTerminalRouter = () => { .from(worktrees) .where(eq(worktrees.id, workspace.worktreeId)) .get(); - return worktree?.path; + return worktree?.path ?? null; }), /** @@ -262,23 +393,60 @@ export const createTerminalRouter = () => { return observable< | { type: "data"; data: string } | { type: "exit"; exitCode: number; signal?: number } + | { type: "disconnect"; reason: string } + | { type: "error"; error: string; code?: string } >((emit) => { + if (DEBUG_TERMINAL) { + console.log(`[Terminal Stream] Subscribe: ${paneId}`); + } + + let firstDataReceived = false; + const onData = (data: string) => { + if (DEBUG_TERMINAL && !firstDataReceived) { + firstDataReceived = true; + console.log( + `[Terminal Stream] First data for ${paneId}: ${data.length} bytes`, + ); + } emit.next({ type: "data", data }); }; const onExit = (exitCode: number, signal?: number) => { + // IMPORTANT: Do not `emit.complete()` on exit. + // The renderer uses a stable `paneId` input and `@trpc/react-query` + // won't auto-resubscribe after completion unless the subscription key changes. + // We reuse the same paneId across restarts/cold restore, so completing here + // would strand the pane with no listeners (terminal output never renders again). emit.next({ type: "exit", exitCode, signal }); - emit.complete(); + }; + + const onDisconnect = (reason: string) => { + emit.next({ type: "disconnect", reason }); + }; + + const onError = (payload: { error: string; code?: string }) => { + emit.next({ + type: "error", + error: payload.error, + code: payload.code, + }); }; terminalManager.on(`data:${paneId}`, onData); terminalManager.on(`exit:${paneId}`, onExit); + terminalManager.on(`disconnect:${paneId}`, onDisconnect); + terminalManager.on(`error:${paneId}`, onError); // Cleanup on unsubscribe return () => { + if (DEBUG_TERMINAL) { + console.log(`[Terminal Stream] Unsubscribe: ${paneId}`); + } terminalManager.off(`data:${paneId}`, onData); terminalManager.off(`exit:${paneId}`, onExit); + terminalManager.off(`disconnect:${paneId}`, onDisconnect); + terminalManager.off(`error:${paneId}`, onError); }; }); }), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7517c71e537..92466870389 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -14,7 +14,11 @@ import { authService, parseAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; import { localDb } from "./lib/local-db"; import { ensureShellEnvVars } from "./lib/shell-env"; -import { terminalManager } from "./lib/terminal"; +import { + getActiveTerminalManager, + reconcileDaemonSessions, + shutdownOrphanedDaemon, +} from "./lib/terminal"; import { MainWindow } from "./windows/main"; // Initialize local SQLite database (runs migrations + legacy data migration on import) @@ -169,7 +173,10 @@ app.on("before-quit", async (event) => { quitState = "cleaning"; try { - await Promise.all([terminalManager.cleanup(), posthog?.shutdown()]); + await Promise.all([ + getActiveTerminalManager().cleanup(), + posthog?.shutdown(), + ]); } finally { quitState = "ready-to-quit"; app.quit(); @@ -206,6 +213,15 @@ if (!gotTheLock) { await app.whenReady(); await initAppState(); + + // Clean up stale daemon sessions from previous app runs + // Must happen BEFORE renderer restore runs + await reconcileDaemonSessions(); + + // Shutdown orphaned daemon if persistence is disabled + // (cleans up daemon left from previous session with persistence enabled) + await shutdownOrphanedDaemon(); + await authService.initialize(); // Resolve shell environment before setting up agent hooks diff --git a/apps/desktop/src/main/lib/terminal-host/__tests__/headless-roundtrip.test.ts b/apps/desktop/src/main/lib/terminal-host/__tests__/headless-roundtrip.test.ts new file mode 100644 index 00000000000..c04e6a023f1 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-host/__tests__/headless-roundtrip.test.ts @@ -0,0 +1,529 @@ +/** + * Headless Terminal Round-Trip Test + * + * This test proves that we can: + * 1. Feed terminal output into a headless emulator + * 2. Capture mode state changes (application cursor keys, bracketed paste, mouse tracking) + * 3. Serialize the terminal state + * 4. Apply that state to a fresh emulator + * 5. Verify the restored terminal has matching visual content and mode flags + * + * This is the foundational proof for "perfect resume" - the ability to restore + * terminal sessions across app restarts. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { HeadlessEmulator, modesEqual } from "../headless-emulator"; +import { DEFAULT_MODES } from "../types"; + +// Escape sequences for testing +const ESC = "\x1b"; +const CSI = `${ESC}[`; +const OSC = `${ESC}]`; +const BEL = "\x07"; + +// Mode enable/disable sequences +const ENABLE_APP_CURSOR = `${CSI}?1h`; +const DISABLE_APP_CURSOR = `${CSI}?1l`; +const ENABLE_BRACKETED_PASTE = `${CSI}?2004h`; +const DISABLE_BRACKETED_PASTE = `${CSI}?2004l`; +const ENABLE_MOUSE_SGR = `${CSI}?1006h`; +const DISABLE_MOUSE_SGR = `${CSI}?1006l`; +const ENABLE_MOUSE_NORMAL = `${CSI}?1000h`; +const DISABLE_MOUSE_NORMAL = `${CSI}?1000l`; +const ENABLE_FOCUS_REPORTING = `${CSI}?1004h`; +const HIDE_CURSOR = `${CSI}?25l`; +const SHOW_CURSOR = `${CSI}?25h`; +const ENTER_ALT_SCREEN = `${CSI}?1049h`; +const EXIT_ALT_SCREEN = `${CSI}?1049l`; + +// Cursor movement +const MOVE_CURSOR = (row: number, col: number) => `${CSI}${row};${col}H`; +const CLEAR_SCREEN = `${CSI}2J`; + +// OSC-7 CWD reporting - format is file://hostname/path (path is NOT URL-encoded) +const OSC7_CWD = (path: string) => `${OSC}7;file://localhost${path}${BEL}`; + +describe("HeadlessEmulator", () => { + let emulator: HeadlessEmulator; + + beforeEach(() => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24, scrollback: 1000 }); + }); + + afterEach(() => { + emulator.dispose(); + }); + + describe("basic functionality", () => { + test("should initialize with default modes", () => { + const modes = emulator.getModes(); + expect(modesEqual(modes, DEFAULT_MODES)).toBe(true); + }); + + test("should write text to terminal", async () => { + await emulator.writeSync("Hello, World!\r\n"); + const snapshot = emulator.getSnapshot(); + expect(snapshot.snapshotAnsi).toContain("Hello, World!"); + }); + + test("should track dimensions", () => { + const dims = emulator.getDimensions(); + expect(dims.cols).toBe(80); + expect(dims.rows).toBe(24); + }); + + test("should resize terminal", () => { + emulator.resize(120, 40); + const dims = emulator.getDimensions(); + expect(dims.cols).toBe(120); + expect(dims.rows).toBe(40); + }); + }); + + describe("mode tracking", () => { + test("should track application cursor keys mode", async () => { + expect(emulator.getModes().applicationCursorKeys).toBe(false); + + await emulator.writeSync(ENABLE_APP_CURSOR); + expect(emulator.getModes().applicationCursorKeys).toBe(true); + + await emulator.writeSync(DISABLE_APP_CURSOR); + expect(emulator.getModes().applicationCursorKeys).toBe(false); + }); + + test("should track bracketed paste mode", async () => { + expect(emulator.getModes().bracketedPaste).toBe(false); + + await emulator.writeSync(ENABLE_BRACKETED_PASTE); + expect(emulator.getModes().bracketedPaste).toBe(true); + + await emulator.writeSync(DISABLE_BRACKETED_PASTE); + expect(emulator.getModes().bracketedPaste).toBe(false); + }); + + test("should track mouse SGR mode", async () => { + expect(emulator.getModes().mouseSgr).toBe(false); + + await emulator.writeSync(ENABLE_MOUSE_SGR); + expect(emulator.getModes().mouseSgr).toBe(true); + + await emulator.writeSync(DISABLE_MOUSE_SGR); + expect(emulator.getModes().mouseSgr).toBe(false); + }); + + test("should track mouse normal tracking mode", async () => { + expect(emulator.getModes().mouseTrackingNormal).toBe(false); + + await emulator.writeSync(ENABLE_MOUSE_NORMAL); + expect(emulator.getModes().mouseTrackingNormal).toBe(true); + + await emulator.writeSync(DISABLE_MOUSE_NORMAL); + expect(emulator.getModes().mouseTrackingNormal).toBe(false); + }); + + test("should track focus reporting mode", async () => { + expect(emulator.getModes().focusReporting).toBe(false); + + await emulator.writeSync(ENABLE_FOCUS_REPORTING); + expect(emulator.getModes().focusReporting).toBe(true); + }); + + test("should track cursor visibility", async () => { + expect(emulator.getModes().cursorVisible).toBe(true); // Default is visible + + await emulator.writeSync(HIDE_CURSOR); + expect(emulator.getModes().cursorVisible).toBe(false); + + await emulator.writeSync(SHOW_CURSOR); + expect(emulator.getModes().cursorVisible).toBe(true); + }); + + test("should track alternate screen mode", async () => { + expect(emulator.getModes().alternateScreen).toBe(false); + + await emulator.writeSync(ENTER_ALT_SCREEN); + expect(emulator.getModes().alternateScreen).toBe(true); + + await emulator.writeSync(EXIT_ALT_SCREEN); + expect(emulator.getModes().alternateScreen).toBe(false); + }); + + test("should handle multiple modes in single sequence", async () => { + // Enable both app cursor and bracketed paste in one sequence + await emulator.writeSync(`${CSI}?1;2004h`); + + const modes = emulator.getModes(); + expect(modes.applicationCursorKeys).toBe(true); + expect(modes.bracketedPaste).toBe(true); + }); + }); + + describe("CWD tracking via OSC-7", () => { + test("should parse OSC-7 with BEL terminator", async () => { + expect(emulator.getCwd()).toBeNull(); + + await emulator.writeSync(OSC7_CWD("/Users/test/project")); + expect(emulator.getCwd()).toBe("/Users/test/project"); + }); + + test("should update CWD on directory change", async () => { + await emulator.writeSync(OSC7_CWD("/Users/test")); + expect(emulator.getCwd()).toBe("/Users/test"); + + await emulator.writeSync(OSC7_CWD("/Users/test/subdir")); + expect(emulator.getCwd()).toBe("/Users/test/subdir"); + }); + + test("should handle paths with spaces", async () => { + await emulator.writeSync(OSC7_CWD("/Users/test/my project")); + expect(emulator.getCwd()).toBe("/Users/test/my project"); + }); + }); + + describe("snapshot generation", () => { + test("should generate snapshot with screen content", async () => { + await emulator.writeSync("Line 1\r\nLine 2\r\nLine 3\r\n"); + + const snapshot = emulator.getSnapshot(); + + expect(snapshot.snapshotAnsi).toBeDefined(); + expect(snapshot.snapshotAnsi.length).toBeGreaterThan(0); + expect(snapshot.cols).toBe(80); + expect(snapshot.rows).toBe(24); + }); + + test("should include mode state in snapshot", async () => { + await emulator.writeSync(ENABLE_APP_CURSOR); + await emulator.writeSync(ENABLE_BRACKETED_PASTE); + await emulator.writeSync(ENABLE_MOUSE_SGR); + + const snapshot = emulator.getSnapshot(); + + expect(snapshot.modes.applicationCursorKeys).toBe(true); + expect(snapshot.modes.bracketedPaste).toBe(true); + expect(snapshot.modes.mouseSgr).toBe(true); + }); + + test("should include CWD in snapshot", async () => { + await emulator.writeSync(OSC7_CWD("/home/user/workspace")); + + const snapshot = emulator.getSnapshot(); + + expect(snapshot.cwd).toBe("/home/user/workspace"); + }); + + test("should generate rehydrate sequences for non-default modes", async () => { + await emulator.writeSync(ENABLE_APP_CURSOR); + await emulator.writeSync(ENABLE_BRACKETED_PASTE); + + const snapshot = emulator.getSnapshot(); + + // Rehydrate sequences should contain mode-setting escapes + expect(snapshot.rehydrateSequences).toContain("?1h"); // app cursor + expect(snapshot.rehydrateSequences).toContain("?2004h"); // bracketed paste + }); + + test("should not generate rehydrate sequences for default modes", async () => { + // Don't change any modes - use defaults + await emulator.writeSync("Some text\r\n"); + + const snapshot = emulator.getSnapshot(); + + // Should have empty or minimal rehydrate sequences + expect(snapshot.rehydrateSequences).toBe(""); + }); + }); +}); + +describe("Snapshot Round-Trip", () => { + test("should restore simple text content", async () => { + const source = new HeadlessEmulator({ cols: 80, rows: 24 }); + const target = new HeadlessEmulator({ cols: 80, rows: 24 }); + + try { + // Write content to source + await source.writeSync("Hello, World!\r\n"); + await source.writeSync("This is line 2\r\n"); + await source.writeSync("And line 3\r\n"); + + // Get snapshot and apply to target + const snapshot = source.getSnapshot(); + await target.writeSync(snapshot.rehydrateSequences); + await target.writeSync(snapshot.snapshotAnsi); + + // Verify content matches + const targetSnapshot = target.getSnapshot(); + expect(targetSnapshot.snapshotAnsi).toContain("Hello, World!"); + expect(targetSnapshot.snapshotAnsi).toContain("This is line 2"); + expect(targetSnapshot.snapshotAnsi).toContain("And line 3"); + } finally { + source.dispose(); + target.dispose(); + } + }); + + test("should restore mode state", async () => { + const source = new HeadlessEmulator({ cols: 80, rows: 24 }); + const target = new HeadlessEmulator({ cols: 80, rows: 24 }); + + try { + // Set up modes in source + await source.writeSync(ENABLE_APP_CURSOR); + await source.writeSync(ENABLE_BRACKETED_PASTE); + await source.writeSync(ENABLE_MOUSE_NORMAL); + await source.writeSync(ENABLE_MOUSE_SGR); + + // Get snapshot + const snapshot = source.getSnapshot(); + + // Verify source modes + expect(snapshot.modes.applicationCursorKeys).toBe(true); + expect(snapshot.modes.bracketedPaste).toBe(true); + expect(snapshot.modes.mouseTrackingNormal).toBe(true); + expect(snapshot.modes.mouseSgr).toBe(true); + + // Apply snapshot to target using applySnapshot helper + await applySnapshotAsync(target, snapshot); + + // Verify target modes match + const targetModes = target.getModes(); + expect(targetModes.applicationCursorKeys).toBe(true); + expect(targetModes.bracketedPaste).toBe(true); + expect(targetModes.mouseTrackingNormal).toBe(true); + expect(targetModes.mouseSgr).toBe(true); + } finally { + source.dispose(); + target.dispose(); + } + }); + + test("should restore cursor position and screen state", async () => { + const source = new HeadlessEmulator({ cols: 80, rows: 24 }); + const target = new HeadlessEmulator({ cols: 80, rows: 24 }); + + try { + // Draw a simple screen with cursor at specific position + await source.writeSync(CLEAR_SCREEN); + await source.writeSync(MOVE_CURSOR(1, 1)); + await source.writeSync("Top left"); + await source.writeSync(MOVE_CURSOR(12, 40)); + await source.writeSync("Center"); + await source.writeSync(MOVE_CURSOR(24, 70)); + await source.writeSync("Bottom right"); + + // Get snapshot and apply + const snapshot = source.getSnapshot(); + await applySnapshotAsync(target, snapshot); + + // Verify screen content + const targetSnapshot = target.getSnapshot(); + expect(targetSnapshot.snapshotAnsi).toContain("Top left"); + expect(targetSnapshot.snapshotAnsi).toContain("Center"); + expect(targetSnapshot.snapshotAnsi).toContain("Bottom right"); + } finally { + source.dispose(); + target.dispose(); + } + }); + + test("should handle TUI-like screen with modes enabled", async () => { + const source = new HeadlessEmulator({ cols: 80, rows: 24 }); + const target = new HeadlessEmulator({ cols: 80, rows: 24 }); + + try { + // Simulate a TUI application setup (like vim, htop, etc.) + // Enter alternate screen + await source.writeSync(ENTER_ALT_SCREEN); + // Enable application cursor keys + await source.writeSync(ENABLE_APP_CURSOR); + // Enable bracketed paste + await source.writeSync(ENABLE_BRACKETED_PASTE); + // Enable mouse tracking with SGR encoding + await source.writeSync(ENABLE_MOUSE_NORMAL); + await source.writeSync(ENABLE_MOUSE_SGR); + // Hide cursor + await source.writeSync(HIDE_CURSOR); + // Clear and draw + await source.writeSync(CLEAR_SCREEN); + await source.writeSync(MOVE_CURSOR(1, 1)); + await source.writeSync("=== TUI Application ==="); + await source.writeSync(MOVE_CURSOR(3, 1)); + await source.writeSync("Press q to quit"); + await source.writeSync(MOVE_CURSOR(24, 1)); + await source.writeSync("[Status Bar]"); + + // Get snapshot + const snapshot = source.getSnapshot(); + + // Verify all modes are captured + expect(snapshot.modes.alternateScreen).toBe(true); + expect(snapshot.modes.applicationCursorKeys).toBe(true); + expect(snapshot.modes.bracketedPaste).toBe(true); + expect(snapshot.modes.mouseTrackingNormal).toBe(true); + expect(snapshot.modes.mouseSgr).toBe(true); + expect(snapshot.modes.cursorVisible).toBe(false); + + // Apply to target + await applySnapshotAsync(target, snapshot); + + // Verify target modes + const targetModes = target.getModes(); + expect(targetModes.applicationCursorKeys).toBe(true); + expect(targetModes.bracketedPaste).toBe(true); + expect(targetModes.mouseTrackingNormal).toBe(true); + expect(targetModes.mouseSgr).toBe(true); + expect(targetModes.cursorVisible).toBe(false); + + // Note: alternateScreen mode is handled by the snapshot itself, + // not by rehydrate sequences (since the serialized content already + // represents the correct screen buffer) + + // Verify content + const targetSnapshot = target.getSnapshot(); + expect(targetSnapshot.snapshotAnsi).toContain("TUI Application"); + expect(targetSnapshot.snapshotAnsi).toContain("Press q to quit"); + expect(targetSnapshot.snapshotAnsi).toContain("[Status Bar]"); + } finally { + source.dispose(); + target.dispose(); + } + }); + + test("should preserve scrollback content", async () => { + const source = new HeadlessEmulator({ + cols: 80, + rows: 5, + scrollback: 100, + }); + const target = new HeadlessEmulator({ + cols: 80, + rows: 5, + scrollback: 100, + }); + + try { + // Write many lines to create scrollback + for (let i = 1; i <= 20; i++) { + await source.writeSync(`Line ${i}\r\n`); + } + + const snapshot = source.getSnapshot(); + + // Verify scrollback is captured + expect(snapshot.scrollbackLines).toBeGreaterThan(5); + + // Apply to target + await applySnapshotAsync(target, snapshot); + + // Verify scrollback content is restored + const targetSnapshot = target.getSnapshot(); + expect(targetSnapshot.snapshotAnsi).toContain("Line 1"); + expect(targetSnapshot.snapshotAnsi).toContain("Line 10"); + expect(targetSnapshot.snapshotAnsi).toContain("Line 20"); + } finally { + source.dispose(); + target.dispose(); + } + }); +}); + +describe("Edge Cases", () => { + test("should handle rapid mode toggling", async () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 24 }); + + try { + // Rapidly toggle modes + for (let i = 0; i < 10; i++) { + await emulator.writeSync(ENABLE_APP_CURSOR); + await emulator.writeSync(DISABLE_APP_CURSOR); + await emulator.writeSync(ENABLE_BRACKETED_PASTE); + await emulator.writeSync(DISABLE_BRACKETED_PASTE); + } + + // Should end at default state + const modes = emulator.getModes(); + expect(modes.applicationCursorKeys).toBe(false); + expect(modes.bracketedPaste).toBe(false); + } finally { + emulator.dispose(); + } + }); + + test("should handle interleaved content and mode changes", async () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 24 }); + + try { + await emulator.writeSync("Before modes\r\n"); + await emulator.writeSync(ENABLE_APP_CURSOR); + await emulator.writeSync("After app cursor\r\n"); + await emulator.writeSync(ENABLE_BRACKETED_PASTE); + await emulator.writeSync("After bracketed paste\r\n"); + await emulator.writeSync(OSC7_CWD("/test/path")); + await emulator.writeSync("After CWD\r\n"); + + const snapshot = emulator.getSnapshot(); + + expect(snapshot.modes.applicationCursorKeys).toBe(true); + expect(snapshot.modes.bracketedPaste).toBe(true); + expect(snapshot.cwd).toBe("/test/path"); + expect(snapshot.snapshotAnsi).toContain("Before modes"); + expect(snapshot.snapshotAnsi).toContain("After CWD"); + } finally { + emulator.dispose(); + } + }); + + test("should handle empty terminal", async () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 24 }); + + try { + // Flush to ensure terminal is ready + await emulator.flush(); + const snapshot = emulator.getSnapshot(); + + expect(snapshot.cols).toBe(80); + expect(snapshot.rows).toBe(24); + expect(snapshot.cwd).toBeNull(); + expect(modesEqual(snapshot.modes, DEFAULT_MODES)).toBe(true); + } finally { + emulator.dispose(); + } + }); + + test("should handle resize during session", async () => { + const source = new HeadlessEmulator({ cols: 80, rows: 24 }); + const target = new HeadlessEmulator({ cols: 80, rows: 24 }); + + try { + await source.writeSync("Initial content\r\n"); + source.resize(120, 40); + await source.writeSync("After resize\r\n"); + + const snapshot = source.getSnapshot(); + + expect(snapshot.cols).toBe(120); + expect(snapshot.rows).toBe(40); + + // Resize target to match before applying + target.resize(120, 40); + await applySnapshotAsync(target, snapshot); + + const targetSnapshot = target.getSnapshot(); + expect(targetSnapshot.snapshotAnsi).toContain("Initial content"); + expect(targetSnapshot.snapshotAnsi).toContain("After resize"); + } finally { + source.dispose(); + target.dispose(); + } + }); +}); + +// Helper function to apply snapshot asynchronously +async function applySnapshotAsync( + emulator: HeadlessEmulator, + snapshot: { rehydrateSequences: string; snapshotAnsi: string }, +): Promise { + await emulator.writeSync(snapshot.rehydrateSequences); + await emulator.writeSync(snapshot.snapshotAnsi); +} diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts new file mode 100644 index 00000000000..519e8ce1a4b --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -0,0 +1,947 @@ +/** + * Terminal Host Daemon Client + * + * Client library for the Electron main process to communicate with + * the terminal host daemon. Handles: + * - Daemon lifecycle (spawning if not running) + * - Socket connection and reconnection + * - Request/response framing + * - Event streaming + */ + +import { spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { + existsSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { connect, type Socket } from "node:net"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { app } from "electron"; +import { + type ClearScrollbackRequest, + type CreateOrAttachRequest, + type CreateOrAttachResponse, + type DetachRequest, + type EmptyResponse, + type HelloResponse, + type IpcErrorResponse, + type IpcEvent, + type IpcResponse, + type IpcSuccessResponse, + type KillAllRequest, + type KillRequest, + type ListSessionsResponse, + PROTOCOL_VERSION, + type ResizeRequest, + type ShutdownRequest, + type TerminalDataEvent, + type TerminalErrorEvent, + type TerminalExitEvent, + type WriteRequest, +} from "./types"; + +// ============================================================================= +// Connection State +// ============================================================================= + +enum ConnectionState { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", +} + +// ============================================================================= +// Configuration +// ============================================================================= + +const SUPERSET_DIR_NAME = + process.env.NODE_ENV === "development" ? ".superset-dev" : ".superset"; +const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); + +const SOCKET_PATH = join(SUPERSET_HOME_DIR, "terminal-host.sock"); +const TOKEN_PATH = join(SUPERSET_HOME_DIR, "terminal-host.token"); +const PID_PATH = join(SUPERSET_HOME_DIR, "terminal-host.pid"); +const SPAWN_LOCK_PATH = join(SUPERSET_HOME_DIR, "terminal-host.spawn.lock"); + +// Connection timeouts +const CONNECT_TIMEOUT_MS = 5000; +const SPAWN_WAIT_MS = 2000; +const REQUEST_TIMEOUT_MS = 30000; +const SPAWN_LOCK_TIMEOUT_MS = 10000; // Max time to hold spawn lock + +// Queue limits +const MAX_NOTIFY_QUEUE_BYTES = 2_000_000; // 2MB cap to prevent OOM + +// ============================================================================= +// NDJSON Parser +// ============================================================================= + +class NdjsonParser { + private remainder = ""; + + parse(chunk: string): Array { + const messages: Array = []; + + // Prepend any remainder from previous parse + const data = this.remainder + chunk; + this.remainder = ""; + + let startIndex = 0; + let newlineIndex = data.indexOf("\n"); + + while (newlineIndex !== -1) { + const line = data.slice(startIndex, newlineIndex); + + if (line.trim()) { + try { + messages.push(JSON.parse(line)); + } catch { + console.warn("[TerminalHostClient] Failed to parse NDJSON line"); + } + } + + startIndex = newlineIndex + 1; + newlineIndex = data.indexOf("\n", startIndex); + } + + // Save any remaining data after the last newline + if (startIndex < data.length) { + this.remainder = data.slice(startIndex); + } + + return messages; + } +} + +// ============================================================================= +// Pending Request Tracker +// ============================================================================= + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeoutId: NodeJS.Timeout; +} + +// ============================================================================= +// TerminalHostClient +// ============================================================================= + +export interface TerminalHostClientEvents { + data: (sessionId: string, data: string) => void; + exit: (sessionId: string, exitCode: number, signal?: number) => void; + /** Terminal-specific error (e.g., write queue full - paste dropped) */ + terminalError: (sessionId: string, error: string, code?: string) => void; + connected: () => void; + disconnected: () => void; + error: (error: Error) => void; +} + +/** + * Client for communicating with the terminal host daemon. + * Emits events for terminal data and exit. + */ +export class TerminalHostClient extends EventEmitter { + private socket: Socket | null = null; + private parser = new NdjsonParser(); + private pendingRequests = new Map(); + private requestCounter = 0; + private authenticated = false; + private connectionState = ConnectionState.DISCONNECTED; + private disposed = false; + private notifyQueue: string[] = []; + private notifyQueueBytes = 0; + private notifyDrainArmed = false; + + // =========================================================================== + // Connection Management + // =========================================================================== + + /** + * Ensure we have a connected, authenticated socket. + * Spawns daemon if needed. + */ + async ensureConnected(): Promise { + // Already connected - fast path (no logging to avoid noise on every API call) + if ( + this.connectionState === ConnectionState.CONNECTED && + this.socket && + this.authenticated + ) { + return; + } + + // Another connection in progress - wait with timeout + if (this.connectionState === ConnectionState.CONNECTING) { + console.log( + "[TerminalHostClient] Connection already in progress, waiting...", + ); + return new Promise((resolve, reject) => { + const startTime = Date.now(); + const WAIT_TIMEOUT_MS = 10000; // 10 seconds max wait + + const checkConnection = () => { + if ( + this.connectionState === ConnectionState.CONNECTED && + this.socket && + this.authenticated + ) { + resolve(); + } else if (this.connectionState === ConnectionState.DISCONNECTED) { + reject(new Error("Connection failed while waiting")); + } else if (Date.now() - startTime > WAIT_TIMEOUT_MS) { + reject( + new Error( + "Timeout waiting for connection - daemon may be unresponsive", + ), + ); + } else { + setTimeout(checkConnection, 100); + } + }; + checkConnection(); + }); + } + + this.connectionState = ConnectionState.CONNECTING; + console.log("[TerminalHostClient] Connecting to daemon..."); + + try { + // Try to connect to existing daemon + let connected = await this.tryConnect(); + console.log( + `[TerminalHostClient] Initial connection attempt: ${connected ? "SUCCESS" : "FAILED"}`, + ); + + if (!connected) { + // Spawn daemon and retry + console.log("[TerminalHostClient] Spawning daemon..."); + await this.spawnDaemon(); + connected = await this.tryConnect(); + console.log( + `[TerminalHostClient] Post-spawn connection attempt: ${connected ? "SUCCESS" : "FAILED"}`, + ); + + if (!connected) { + throw new Error("Failed to connect to daemon after spawn"); + } + } + + // Authenticate + console.log("[TerminalHostClient] Authenticating..."); + await this.authenticate(); + console.log("[TerminalHostClient] Authentication successful!"); + + this.connectionState = ConnectionState.CONNECTED; + } catch (error) { + this.connectionState = ConnectionState.DISCONNECTED; + throw error; + } + } + + /** + * Try to connect and authenticate to an existing daemon without spawning. + * Returns true if successfully connected and authenticated, false if no daemon running. + * This is useful for cleanup operations that should only act on existing daemons. + */ + async tryConnectAndAuthenticate(): Promise { + // Already connected and authenticated + if ( + this.connectionState === ConnectionState.CONNECTED && + this.socket && + this.authenticated + ) { + return true; + } + + // Don't interfere with an in-progress connection + if (this.connectionState === ConnectionState.CONNECTING) { + return false; + } + + this.connectionState = ConnectionState.CONNECTING; + + try { + const connected = await this.tryConnect(); + if (!connected) { + this.connectionState = ConnectionState.DISCONNECTED; + return false; + } + + await this.authenticate(); + this.connectionState = ConnectionState.CONNECTED; + return true; + } catch { + this.connectionState = ConnectionState.DISCONNECTED; + return false; + } + } + + /** + * Try to connect to the daemon socket. + * Returns true if connected, false if daemon not running. + */ + private async tryConnect(): Promise { + return new Promise((resolve) => { + if (!existsSync(SOCKET_PATH)) { + resolve(false); + return; + } + + const socket = connect(SOCKET_PATH); + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + socket.destroy(); + resolve(false); + } + }, CONNECT_TIMEOUT_MS); + + socket.on("connect", () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + this.socket = socket; + this.setupSocketHandlers(); + resolve(true); + } + }); + + socket.on("error", () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(false); + } + }); + }); + } + + /** + * Set up socket event handlers + */ + private setupSocketHandlers(): void { + if (!this.socket) return; + + this.socket.setEncoding("utf-8"); + + this.socket.on("data", (data: string) => { + const messages = this.parser.parse(data); + for (const message of messages) { + this.handleMessage(message); + } + }); + + this.socket.on("drain", () => { + this.flushNotifyQueue(); + }); + + this.socket.on("close", () => { + this.handleDisconnect(); + }); + + this.socket.on("error", (error) => { + this.emit("error", error); + this.handleDisconnect(); + }); + } + + /** + * Handle incoming message (response or event) + */ + private handleMessage(message: IpcResponse | IpcEvent): void { + if ("id" in message) { + // Response to a request + const pending = this.pendingRequests.get(message.id); + if (pending) { + this.pendingRequests.delete(message.id); + clearTimeout(pending.timeoutId); + + if (message.ok) { + pending.resolve((message as IpcSuccessResponse).payload); + } else { + const error = (message as IpcErrorResponse).error; + pending.reject(new Error(`${error.code}: ${error.message}`)); + } + } + } else if (message.type === "event") { + // Event from daemon + const event = message as IpcEvent; + const payload = event.payload as + | TerminalDataEvent + | TerminalExitEvent + | TerminalErrorEvent; + + if (payload.type === "data") { + this.emit("data", event.sessionId, (payload as TerminalDataEvent).data); + } else if (payload.type === "exit") { + const exitPayload = payload as TerminalExitEvent; + this.emit( + "exit", + event.sessionId, + exitPayload.exitCode, + exitPayload.signal, + ); + } else if (payload.type === "error") { + const errorPayload = payload as TerminalErrorEvent; + // Emit terminal-specific error so callers can handle it + // This is critical for "Write queue full" - paste was silently dropped before! + this.emit( + "terminalError", + event.sessionId, + errorPayload.error, + errorPayload.code, + ); + } + } + } + + /** + * Handle socket disconnect + */ + private handleDisconnect(): void { + this.socket = null; + this.authenticated = false; + this.connectionState = ConnectionState.DISCONNECTED; + this.notifyQueue = []; + this.notifyQueueBytes = 0; + this.notifyDrainArmed = false; + + // Reject all pending requests + for (const [id, pending] of this.pendingRequests.entries()) { + clearTimeout(pending.timeoutId); + pending.reject(new Error("Connection lost")); + this.pendingRequests.delete(id); + } + + this.emit("disconnected"); + } + + /** + * Authenticate with the daemon + */ + private async authenticate(): Promise { + if (!existsSync(TOKEN_PATH)) { + throw new Error("Auth token not found - daemon may not be running"); + } + + const token = readFileSync(TOKEN_PATH, "utf-8").trim(); + + const response = (await this.sendRequest("hello", { + token, + protocolVersion: PROTOCOL_VERSION, + })) as HelloResponse; + + if (response.protocolVersion !== PROTOCOL_VERSION) { + throw new Error( + `Protocol version mismatch: client=${PROTOCOL_VERSION}, daemon=${response.protocolVersion}`, + ); + } + + this.authenticated = true; + this.emit("connected"); + } + + // =========================================================================== + // Daemon Spawning + // =========================================================================== + + /** + * Check if there's an active daemon listening on the socket. + * Returns true if socket is live and responding. + */ + private isSocketLive(): Promise { + return new Promise((resolve) => { + if (!existsSync(SOCKET_PATH)) { + resolve(false); + return; + } + + const testSocket = connect(SOCKET_PATH); + const timeout = setTimeout(() => { + testSocket.destroy(); + resolve(false); + }, 1000); + + testSocket.on("connect", () => { + clearTimeout(timeout); + testSocket.destroy(); + resolve(true); + }); + + testSocket.on("error", () => { + clearTimeout(timeout); + resolve(false); + }); + }); + } + + /** + * Acquire spawn lock to prevent concurrent daemon spawns. + * Returns true if lock acquired, false if another spawn is in progress. + */ + private acquireSpawnLock(): boolean { + try { + // Ensure superset home directory exists before any file operations + if (!existsSync(SUPERSET_HOME_DIR)) { + mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); + } + + // Check if lock exists and is recent (within timeout) + if (existsSync(SPAWN_LOCK_PATH)) { + const lockContent = readFileSync(SPAWN_LOCK_PATH, "utf-8").trim(); + const lockTime = Number.parseInt(lockContent, 10); + if ( + !Number.isNaN(lockTime) && + Date.now() - lockTime < SPAWN_LOCK_TIMEOUT_MS + ) { + // Lock is held by another process + return false; + } + // Stale lock, remove it + unlinkSync(SPAWN_LOCK_PATH); + } + + // Create lock file with current timestamp + writeFileSync(SPAWN_LOCK_PATH, String(Date.now()), { mode: 0o600 }); + return true; + } catch { + return false; + } + } + + /** + * Release spawn lock + */ + private releaseSpawnLock(): void { + try { + if (existsSync(SPAWN_LOCK_PATH)) { + unlinkSync(SPAWN_LOCK_PATH); + } + } catch { + // Best effort cleanup + } + } + + /** + * Spawn the daemon process if not running + */ + private async spawnDaemon(): Promise { + // Check if socket is live first - this is the authoritative check + // PID file can be stale if daemon crashed and PID was reused by another process + if (existsSync(SOCKET_PATH)) { + const isLive = await this.isSocketLive(); + if (isLive) { + console.log("[TerminalHostClient] Socket is live, daemon is running"); + return; + } + + // Socket exists but not responsive - safe to remove + console.log("[TerminalHostClient] Removing stale socket file"); + try { + unlinkSync(SOCKET_PATH); + } catch { + // Ignore - might not have permission + } + } + + // Also clean up stale PID file if socket was not live + // This handles the case where daemon crashed and PID was reused + if (existsSync(PID_PATH)) { + console.log("[TerminalHostClient] Removing stale PID file"); + try { + unlinkSync(PID_PATH); + } catch { + // Ignore - might not have permission + } + } + + // Acquire spawn lock to prevent concurrent spawns + if (!this.acquireSpawnLock()) { + console.log("[TerminalHostClient] Another spawn in progress, waiting..."); + // Wait for the other spawn to complete + await this.waitForDaemon(); + return; + } + + try { + // Get path to daemon script + const daemonScript = this.getDaemonScriptPath(); + console.log(`[TerminalHostClient] Daemon script path: ${daemonScript}`); + console.log( + `[TerminalHostClient] Script exists: ${existsSync(daemonScript)}`, + ); + + if (!existsSync(daemonScript)) { + throw new Error(`Daemon script not found: ${daemonScript}`); + } + + console.log( + `[TerminalHostClient] Spawning daemon with execPath: ${process.execPath}`, + ); + + // Open log file for daemon output (helps debug daemon-side issues) + const logPath = join(SUPERSET_HOME_DIR, "daemon.log"); + let logFd: number; + try { + logFd = openSync(logPath, "a"); + } catch (error) { + console.warn( + `[TerminalHostClient] Failed to open daemon log file: ${error}`, + ); + // Fall back to ignoring output if we can't open log file + logFd = -1; + } + + // Spawn daemon as detached process + const child = spawn(process.execPath, [daemonScript], { + detached: true, + stdio: logFd >= 0 ? ["ignore", logFd, logFd] : "ignore", + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + NODE_ENV: process.env.NODE_ENV, + }, + }); + + console.log(`[TerminalHostClient] Daemon spawned with PID: ${child.pid}`); + + // Unref to allow parent to exit independently + child.unref(); + + // Wait for daemon to start + console.log("[TerminalHostClient] Waiting for daemon to start..."); + await this.waitForDaemon(); + console.log("[TerminalHostClient] Daemon started successfully"); + } finally { + this.releaseSpawnLock(); + } + } + + /** + * Get path to daemon script + */ + private getDaemonScriptPath(): string { + if (app.isPackaged) { + // Production: script is in app resources + return join(app.getAppPath(), "dist", "main", "terminal-host.js"); + } + + // Development: electron-vite outputs to dist/main/ + const appPath = app.getAppPath(); + return join(appPath, "dist", "main", "terminal-host.js"); + } + + /** + * Wait for daemon to be ready + */ + private async waitForDaemon(): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < SPAWN_WAIT_MS) { + if (existsSync(SOCKET_PATH)) { + // Give it a moment to start listening + await this.sleep(200); + return; + } + await this.sleep(100); + } + + throw new Error("Daemon failed to start in time"); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // =========================================================================== + // Request/Response + // =========================================================================== + + /** + * Send a request to the daemon and wait for response + */ + private sendRequest(type: string, payload: unknown): Promise { + return new Promise((resolve, reject) => { + if (!this.socket) { + reject(new Error("Not connected")); + return; + } + + const id = `req_${++this.requestCounter}`; + + const timeoutId = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Request timeout: ${type}`)); + }, REQUEST_TIMEOUT_MS); + + this.pendingRequests.set(id, { resolve, reject, timeoutId }); + + const message = `${JSON.stringify({ id, type, payload })}\n`; + this.socket.write(message); + }); + } + + /** + * Send a notification (no pending request / no timeout). + * + * Used for high-frequency messages like terminal input, where request/response + * overhead can cause timeouts under load and drop data. The daemon may still + * send a response for compatibility, but this client will ignore it. + * + * Returns false if queue is full (caller should handle). + */ + private sendNotification(type: string, payload: unknown): boolean { + if (!this.socket) return false; + + const id = `notify_${++this.requestCounter}`; + const message = `${JSON.stringify({ id, type, payload })}\n`; + const messageBytes = Buffer.byteLength(message, "utf8"); + + // Check queue limit to prevent OOM under backpressure + if (this.notifyQueueBytes + messageBytes > MAX_NOTIFY_QUEUE_BYTES) { + return false; + } + + // If we're already backpressured, just queue. + if (this.notifyDrainArmed || this.notifyQueue.length > 0) { + this.notifyQueue.push(message); + this.notifyQueueBytes += messageBytes; + return true; + } + + const canWrite = this.socket.write(message); + if (!canWrite) { + // Message is queued internally by the socket; arm drain to flush any + // subsequent notifications we enqueue. + this.notifyDrainArmed = true; + } + return true; + } + + private flushNotifyQueue(): void { + if (!this.socket) return; + if (!this.notifyDrainArmed && this.notifyQueue.length === 0) return; + + this.notifyDrainArmed = false; + + while (this.notifyQueue.length > 0) { + const message = this.notifyQueue.shift(); + if (!message) break; + this.notifyQueueBytes -= Buffer.byteLength(message, "utf8"); + + const canWrite = this.socket.write(message); + if (!canWrite) { + this.notifyDrainArmed = true; + return; + } + } + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Create or attach to a terminal session + */ + async createOrAttach( + request: CreateOrAttachRequest, + ): Promise { + await this.ensureConnected(); + const response = (await this.sendRequest( + "createOrAttach", + request, + )) as CreateOrAttachResponse; + // Version skew: older daemons may not return pid - normalize undefined → null + return { ...response, pid: response.pid ?? null }; + } + + /** + * Write data to a terminal session + */ + async write(request: WriteRequest): Promise { + await this.ensureConnected(); + return (await this.sendRequest("write", request)) as EmptyResponse; + } + + /** + * Write data without waiting for a response (best-effort, backpressured). + * Prevents large pastes from timing out and dropping chunks when the daemon + * is busy processing output. + */ + writeNoAck(request: WriteRequest): void { + void this.ensureConnected() + .then(() => { + const sent = this.sendNotification("write", request); + if (!sent) { + // Queue full - notify the session so it can surface the error to the user + this.emit( + "terminalError", + request.sessionId, + "Write queue full - input dropped", + "QUEUE_FULL", + ); + } + }) + .catch((error) => { + this.emit( + "error", + error instanceof Error ? error : new Error(String(error)), + ); + }); + } + + /** + * Resize a terminal session + */ + async resize(request: ResizeRequest): Promise { + await this.ensureConnected(); + return (await this.sendRequest("resize", request)) as EmptyResponse; + } + + /** + * Detach from a terminal session + */ + async detach(request: DetachRequest): Promise { + await this.ensureConnected(); + return (await this.sendRequest("detach", request)) as EmptyResponse; + } + + /** + * Kill a terminal session + */ + async kill(request: KillRequest): Promise { + await this.ensureConnected(); + return (await this.sendRequest("kill", request)) as EmptyResponse; + } + + /** + * Kill all terminal sessions + */ + async killAll(request: KillAllRequest): Promise { + await this.ensureConnected(); + return (await this.sendRequest("killAll", request)) as EmptyResponse; + } + + /** + * List all sessions + */ + async listSessions(): Promise { + await this.ensureConnected(); + const response = (await this.sendRequest( + "listSessions", + undefined, + )) as ListSessionsResponse; + // Version skew: older daemons may not return pid - normalize undefined → null + return { + sessions: response.sessions.map((s) => ({ ...s, pid: s.pid ?? null })), + }; + } + + /** + * Clear scrollback for a session + */ + async clearScrollback( + request: ClearScrollbackRequest, + ): Promise { + await this.ensureConnected(); + return (await this.sendRequest( + "clearScrollback", + request, + )) as EmptyResponse; + } + + /** + * Shutdown the daemon gracefully. + * After calling this, the client should be disposed and a new daemon + * will be spawned on the next getTerminalHostClient() call. + */ + async shutdown(request: ShutdownRequest = {}): Promise { + await this.ensureConnected(); + const response = (await this.sendRequest( + "shutdown", + request, + )) as EmptyResponse; + // Disconnect after shutdown request is sent + this.disconnect(); + return response; + } + + /** + * Shutdown the daemon if it's currently running, without spawning a new one. + * Returns true if daemon was running and shutdown was sent, false if no daemon was running. + * This is useful for cleanup operations that should only affect existing daemons. + */ + async shutdownIfRunning( + request: ShutdownRequest = {}, + ): Promise<{ wasRunning: boolean }> { + const connected = await this.tryConnectAndAuthenticate(); + if (!connected) { + return { wasRunning: false }; + } + + try { + await this.sendRequest("shutdown", request); + } finally { + this.disconnect(); + } + return { wasRunning: true }; + } + + /** + * Disconnect from daemon (but don't stop it) + */ + disconnect(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + this.authenticated = false; + this.connectionState = ConnectionState.DISCONNECTED; + } + + /** + * Dispose of the client + */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.disconnect(); + this.removeAllListeners(); + } +} + +// ============================================================================= +// Singleton Instance +// ============================================================================= + +let clientInstance: TerminalHostClient | null = null; + +/** + * Get the singleton terminal host client instance + */ +export function getTerminalHostClient(): TerminalHostClient { + if (!clientInstance) { + clientInstance = new TerminalHostClient(); + } + return clientInstance; +} + +/** + * Dispose of the singleton client + */ +export function disposeTerminalHostClient(): void { + if (clientInstance) { + clientInstance.dispose(); + clientInstance = null; + } +} diff --git a/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts b/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts new file mode 100644 index 00000000000..02522ad4f54 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts @@ -0,0 +1,606 @@ +/** + * Headless Terminal Emulator + * + * Wraps @xterm/headless with: + * - Mode tracking (DECSET/DECRST parsing) + * - Snapshot generation via @xterm/addon-serialize + * - Rehydration sequence generation for mode restoration + */ + +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal } from "@xterm/headless"; +import { + DEFAULT_MODES, + type TerminalModes, + type TerminalSnapshot, +} from "./types"; + +// ============================================================================= +// Mode Tracking Constants +// ============================================================================= + +// Escape character +const ESC = "\x1b"; +const BEL = "\x07"; + +const DEBUG_EMULATOR_TIMING = + process.env.SUPERSET_TERMINAL_EMULATOR_DEBUG === "1"; + +/** + * DECSET/DECRST mode numbers we track + */ +const MODE_MAP: Record = { + 1: "applicationCursorKeys", + 6: "originMode", + 7: "autoWrap", + 9: "mouseTrackingX10", + 25: "cursorVisible", + 47: "alternateScreen", // Legacy alternate screen + 1000: "mouseTrackingNormal", + 1001: "mouseTrackingHighlight", + 1002: "mouseTrackingButtonEvent", + 1003: "mouseTrackingAnyEvent", + 1004: "focusReporting", + 1005: "mouseUtf8", + 1006: "mouseSgr", + 1049: "alternateScreen", // Modern alternate screen with save/restore + 2004: "bracketedPaste", +}; + +// ============================================================================= +// Headless Emulator Class +// ============================================================================= + +export interface HeadlessEmulatorOptions { + cols?: number; + rows?: number; + scrollback?: number; +} + +export class HeadlessEmulator { + private terminal: Terminal; + private serializeAddon: SerializeAddon; + private modes: TerminalModes; + private cwd: string | null = null; + private disposed = false; + + // Pending output buffer for query responses + private pendingOutput: string[] = []; + private onDataCallback?: (data: string) => void; + + // Buffer for partial escape sequences that span chunk boundaries + private escapeSequenceBuffer = ""; + + // Maximum buffer size to prevent unbounded growth (safety cap) + private static readonly MAX_ESCAPE_BUFFER_SIZE = 1024; + + constructor(options: HeadlessEmulatorOptions = {}) { + const { cols = 80, rows = 24, scrollback = 10000 } = options; + + this.terminal = new Terminal({ + cols, + rows, + scrollback, + allowProposedApi: true, + }); + + this.serializeAddon = new SerializeAddon(); + this.terminal.loadAddon(this.serializeAddon); + + // Initialize mode state + this.modes = { ...DEFAULT_MODES }; + + // Listen for terminal output (query responses) + this.terminal.onData((data) => { + this.pendingOutput.push(data); + this.onDataCallback?.(data); + }); + } + + /** + * Set callback for terminal-generated output (query responses) + */ + onData(callback: (data: string) => void): void { + this.onDataCallback = callback; + } + + /** + * Get and clear pending output (query responses) + */ + flushPendingOutput(): string[] { + const output = this.pendingOutput; + this.pendingOutput = []; + return output; + } + + /** + * Write data to the terminal emulator (synchronous, non-blocking) + * Data is buffered and will be processed asynchronously. + * Use writeSync() if you need to wait for the write to complete. + */ + write(data: string): void { + if (this.disposed) return; + + if (!DEBUG_EMULATOR_TIMING) { + // Parse escape sequences with chunk-safe buffering + this.parseEscapeSequences(data); + // Write to headless terminal (buffered/async) + this.terminal.write(data); + return; + } + + const parseStart = performance.now(); + this.parseEscapeSequences(data); + const parseTime = performance.now() - parseStart; + + const terminalStart = performance.now(); + this.terminal.write(data); + const terminalTime = performance.now() - terminalStart; + + if (parseTime > 2 || terminalTime > 2) { + console.warn( + `[HeadlessEmulator] write(${data.length}b): parse=${parseTime.toFixed(1)}ms, terminal=${terminalTime.toFixed(1)}ms`, + ); + } + } + + /** + * Write data to the terminal emulator and wait for completion. + * Use this when you need to ensure data is processed before reading state. + */ + async writeSync(data: string): Promise { + if (this.disposed) return; + + // Parse escape sequences with chunk-safe buffering + this.parseEscapeSequences(data); + + // Write to headless terminal and wait for completion + return new Promise((resolve) => { + this.terminal.write(data, () => resolve()); + }); + } + + /** + * Resize the terminal + */ + resize(cols: number, rows: number): void { + if (this.disposed) return; + this.terminal.resize(cols, rows); + } + + /** + * Get current terminal dimensions + */ + getDimensions(): { cols: number; rows: number } { + return { + cols: this.terminal.cols, + rows: this.terminal.rows, + }; + } + + /** + * Get current terminal modes + */ + getModes(): TerminalModes { + return { ...this.modes }; + } + + /** + * Get current working directory (from OSC-7) + */ + getCwd(): string | null { + return this.cwd; + } + + /** + * Set CWD directly (for initial session setup) + */ + setCwd(cwd: string): void { + this.cwd = cwd; + } + + /** + * Get scrollback line count + */ + getScrollbackLines(): number { + return this.terminal.buffer.active.length; + } + + /** + * Flush all pending writes to the terminal. + * Call this before getSnapshot() if you've written data without waiting. + */ + async flush(): Promise { + if (this.disposed) return; + // Write an empty string with callback to ensure all pending writes are processed + return new Promise((resolve) => { + this.terminal.write("", () => resolve()); + }); + } + + /** + * Generate a complete snapshot for session restore. + * Note: Call flush() first if you have pending async writes. + */ + getSnapshot(): TerminalSnapshot { + const snapshotAnsi = this.serializeAddon.serialize({ + scrollback: this.terminal.options.scrollback ?? 10000, + }); + + const rehydrateSequences = this.generateRehydrateSequences(); + + // Build debug diagnostics + const xtermBufferType = this.terminal.buffer.active.type; + const hasAltScreenEntry = snapshotAnsi.includes("\x1b[?1049h"); + + let altBufferDebug: + | { + lines: number; + nonEmptyLines: number; + totalChars: number; + cursorX: number; + cursorY: number; + sampleLines: string[]; + } + | undefined; + + if (this.modes.alternateScreen || xtermBufferType === "alternate") { + const altBuffer = this.terminal.buffer.alternate; + let nonEmptyLines = 0; + let totalChars = 0; + const sampleLines: string[] = []; + + for (let i = 0; i < altBuffer.length; i++) { + const line = altBuffer.getLine(i); + if (line) { + const lineText = line.translateToString(true); + if (lineText.trim().length > 0) { + nonEmptyLines++; + totalChars += lineText.length; + if (sampleLines.length < 3) { + sampleLines.push(lineText.slice(0, 80)); + } + } + } + } + + altBufferDebug = { + lines: altBuffer.length, + nonEmptyLines, + totalChars, + cursorX: altBuffer.cursorX, + cursorY: altBuffer.cursorY, + sampleLines, + }; + } + + return { + snapshotAnsi, + rehydrateSequences, + cwd: this.cwd, + modes: { ...this.modes }, + cols: this.terminal.cols, + rows: this.terminal.rows, + scrollbackLines: this.getScrollbackLines(), + debug: { + xtermBufferType, + hasAltScreenEntry, + altBuffer: altBufferDebug, + normalBufferLines: this.terminal.buffer.normal.length, + }, + }; + } + + /** + * Generate a complete snapshot after flushing pending writes. + * This is the preferred method for getting consistent snapshots. + */ + async getSnapshotAsync(): Promise { + await this.flush(); + return this.getSnapshot(); + } + + /** + * Clear terminal buffer + */ + clear(): void { + if (this.disposed) return; + this.terminal.clear(); + } + + /** + * Reset terminal to default state + */ + reset(): void { + if (this.disposed) return; + this.terminal.reset(); + this.modes = { ...DEFAULT_MODES }; + } + + /** + * Dispose of the terminal + */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.terminal.dispose(); + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Parse escape sequences with chunk-safe buffering. + * PTY output can split sequences across chunks, so we buffer partial sequences. + * + * IMPORTANT: We only buffer sequences we actually track (DECSET/DECRST and OSC-7). + * Other escape sequences (colors, cursor moves, etc.) are NOT buffered to prevent + * memory leaks from unbounded buffer growth. + */ + private parseEscapeSequences(data: string): void { + // Prepend any buffered partial sequence from previous chunk + const fullData = this.escapeSequenceBuffer + data; + this.escapeSequenceBuffer = ""; + + // Parse complete sequences in the data + this.parseModeChanges(fullData); + this.parseOsc7(fullData); + + // Check for incomplete sequences we care about at the end + // We only buffer DECSET/DECRST (ESC[?...) and OSC-7 (ESC]7;...) + const incompleteSequence = this.findIncompleteTrackedSequence(fullData); + + if (incompleteSequence) { + // Cap buffer size to prevent unbounded growth + if ( + incompleteSequence.length <= HeadlessEmulator.MAX_ESCAPE_BUFFER_SIZE + ) { + this.escapeSequenceBuffer = incompleteSequence; + } + // If buffer too large, just discard it (likely malformed or attack) + } + } + + /** + * Find an incomplete DECSET/DECRST or OSC-7 sequence at the end of data. + * Returns the incomplete sequence string, or null if none found. + * + * We ONLY buffer sequences we track: + * - DECSET/DECRST: ESC[?...h or ESC[?...l + * - OSC-7: ESC]7;...BEL or ESC]7;...ESC\ + * + * Other CSI sequences (ESC[31m, ESC[H, etc.) are NOT buffered. + */ + private findIncompleteTrackedSequence(data: string): string | null { + const escEscaped = escapeRegex(ESC); + + // Look for potential incomplete sequences from the end + const lastEscIndex = data.lastIndexOf(ESC); + if (lastEscIndex === -1) return null; + + const afterLastEsc = data.slice(lastEscIndex); + + // Check if this looks like a sequence we track + + // Pattern: ESC[? - start of DECSET/DECRST + if (afterLastEsc.startsWith(`${ESC}[?`)) { + // Check if it's complete (ends with h or l after digits) + const completePattern = new RegExp(`${escEscaped}\\[\\?[0-9;]+[hl]`); + if (completePattern.test(afterLastEsc)) { + // Complete DECSET/DECRST - check if there's another incomplete after + const globalPattern = new RegExp(`${escEscaped}\\[\\?[0-9;]+[hl]`, "g"); + const matches = afterLastEsc.match(globalPattern); + if (matches) { + const lastMatch = matches[matches.length - 1]; + const lastMatchEnd = + afterLastEsc.lastIndexOf(lastMatch) + lastMatch.length; + const remainder = afterLastEsc.slice(lastMatchEnd); + if (remainder.includes(ESC)) { + return this.findIncompleteTrackedSequence(remainder); + } + } + return null; // Complete + } + // Incomplete DECSET/DECRST - buffer it + return afterLastEsc; + } + + // Pattern: ESC]7; - start of OSC-7 + if (afterLastEsc.startsWith(`${ESC}]7;`)) { + // Check if it's complete (ends with BEL or ESC\) + if (afterLastEsc.includes(BEL) || afterLastEsc.includes(`${ESC}\\`)) { + return null; // Complete + } + // Incomplete OSC-7 - buffer it + return afterLastEsc; + } + + // Check for partial starts of tracked sequences + // These could become tracked sequences with more data + if (afterLastEsc === ESC) return afterLastEsc; // Just ESC + if (afterLastEsc === `${ESC}[`) return afterLastEsc; // ESC[ + if (afterLastEsc === `${ESC}]`) return afterLastEsc; // ESC] + if (afterLastEsc === `${ESC}]7`) return afterLastEsc; // ESC]7 + const incompleteDecset = new RegExp(`^${escEscaped}\\[\\?[0-9;]*$`); + if (incompleteDecset.test(afterLastEsc)) return afterLastEsc; // ESC[?123 + + // Not a sequence we track (e.g., ESC[31m, ESC[H) - don't buffer + return null; + } + + /** + * Parse DECSET/DECRST sequences from terminal data + */ + private parseModeChanges(data: string): void { + // Match CSI ? Pm h (DECSET) and CSI ? Pm l (DECRST) + // Examples: ESC[?1h (enable app cursor), ESC[?2004l (disable bracketed paste) + // Also handles multiple modes: ESC[?1;2004h + // Using string-based regex to avoid control character linter errors + const modeRegex = new RegExp( + `${escapeRegex(ESC)}\\[\\?([0-9;]+)([hl])`, + "g", + ); + + for (const match of data.matchAll(modeRegex)) { + const modesStr = match[1]; + const action = match[2]; // 'h' = set (enable), 'l' = reset (disable) + const enable = action === "h"; + + // Split on semicolons for multiple modes + const modeNumbers = modesStr + .split(";") + .map((s) => Number.parseInt(s, 10)); + + for (const modeNum of modeNumbers) { + const modeName = MODE_MAP[modeNum]; + if (modeName) { + // For cursor visibility and auto-wrap, 'h' means true, 'l' means false + // But their defaults are different (cursorVisible=true, autoWrap=true) + this.modes[modeName] = enable; + } + } + } + } + + /** + * Parse OSC-7 sequences for CWD tracking + * Format: ESC]7;file://hostname/path BEL or ESC]7;file://hostname/path ESC\ + * + * The path part starts after the hostname (after file://hostname). + * Hostname can be empty, localhost, or a machine name. + */ + private parseOsc7(data: string): void { + // OSC-7 format: \x1b]7;file://hostname/path\x07 + // We need to extract the /path portion after the hostname + // Hostname ends at the first / after file:// + + // Pattern explanation: + // - ESC ]7;file:// - the OSC-7 prefix + // - [^/]* - the hostname (anything that's not a slash) + // - (/.+?) - capture the path (starts with /, non-greedy) + // - (?:BEL|ESC\\) - terminated by BEL or ST + + // Using string building to avoid control character linter issues + const escEscaped = escapeRegex(ESC); + const belEscaped = escapeRegex(BEL); + + // Match OSC-7 with either terminator + const osc7Pattern = `${escEscaped}\\]7;file://[^/]*(/.+?)(?:${belEscaped}|${escEscaped}\\\\)`; + const osc7Regex = new RegExp(osc7Pattern, "g"); + + for (const match of data.matchAll(osc7Regex)) { + if (match[1]) { + try { + this.cwd = decodeURIComponent(match[1]); + } catch { + // If decoding fails, use the raw path + this.cwd = match[1]; + } + } + } + } + + /** + * Generate escape sequences to restore current mode state + * These sequences should be written to a fresh xterm instance before + * writing the snapshot to ensure input behavior matches. + */ + private generateRehydrateSequences(): string { + const sequences: string[] = []; + + // Helper to add DECSET/DECRST sequence + const addModeSequence = ( + modeNum: number, + enabled: boolean, + defaultEnabled: boolean, + ) => { + // Only add sequence if different from default + if (enabled !== defaultEnabled) { + sequences.push(`${ESC}[?${modeNum}${enabled ? "h" : "l"}`); + } + }; + + // Application cursor keys (mode 1) + addModeSequence(1, this.modes.applicationCursorKeys, false); + + // Origin mode (mode 6) + addModeSequence(6, this.modes.originMode, false); + + // Auto-wrap mode (mode 7) + addModeSequence(7, this.modes.autoWrap, true); + + // Cursor visibility (mode 25) + addModeSequence(25, this.modes.cursorVisible, true); + + // Mouse tracking modes (mutually exclusive typically, but we track all) + addModeSequence(9, this.modes.mouseTrackingX10, false); + addModeSequence(1000, this.modes.mouseTrackingNormal, false); + addModeSequence(1001, this.modes.mouseTrackingHighlight, false); + addModeSequence(1002, this.modes.mouseTrackingButtonEvent, false); + addModeSequence(1003, this.modes.mouseTrackingAnyEvent, false); + + // Mouse encoding modes + addModeSequence(1005, this.modes.mouseUtf8, false); + addModeSequence(1006, this.modes.mouseSgr, false); + + // Focus reporting (mode 1004) + addModeSequence(1004, this.modes.focusReporting, false); + + // Bracketed paste (mode 2004) + addModeSequence(2004, this.modes.bracketedPaste, false); + + // Note: We don't restore alternate screen mode (1049/47) here because + // the serialized snapshot already contains the correct screen buffer. + // Restoring it would cause incorrect behavior. + + return sequences.join(""); + } +} + +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * Escape special regex characters in a string + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Apply a snapshot to a headless emulator (for testing round-trip) + */ +export function applySnapshot( + emulator: HeadlessEmulator, + snapshot: TerminalSnapshot, +): void { + // First, write the rehydrate sequences to restore mode state + emulator.write(snapshot.rehydrateSequences); + + // Then write the serialized screen content + emulator.write(snapshot.snapshotAnsi); +} + +/** + * Compare two mode states for equality + */ +export function modesEqual(a: TerminalModes, b: TerminalModes): boolean { + return ( + a.applicationCursorKeys === b.applicationCursorKeys && + a.bracketedPaste === b.bracketedPaste && + a.mouseTrackingX10 === b.mouseTrackingX10 && + a.mouseTrackingNormal === b.mouseTrackingNormal && + a.mouseTrackingHighlight === b.mouseTrackingHighlight && + a.mouseTrackingButtonEvent === b.mouseTrackingButtonEvent && + a.mouseTrackingAnyEvent === b.mouseTrackingAnyEvent && + a.focusReporting === b.focusReporting && + a.mouseUtf8 === b.mouseUtf8 && + a.mouseSgr === b.mouseSgr && + a.alternateScreen === b.alternateScreen && + a.cursorVisible === b.cursorVisible && + a.originMode === b.originMode && + a.autoWrap === b.autoWrap + ); +} diff --git a/apps/desktop/src/main/lib/terminal-host/types.ts b/apps/desktop/src/main/lib/terminal-host/types.ts new file mode 100644 index 00000000000..a15c394d4e5 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-host/types.ts @@ -0,0 +1,347 @@ +/** + * Terminal Host Daemon Protocol Types + * + * This file defines the IPC protocol between the Electron main process + * and the terminal host daemon. Changes must be additive-only for + * backwards compatibility. + */ + +// Protocol version - increment for breaking changes +export const PROTOCOL_VERSION = 1; + +// ============================================================================= +// Mode Tracking +// ============================================================================= + +/** + * Terminal modes that affect input behavior and must be restored on attach. + * These correspond to DECSET/DECRST (CSI ? Pm h/l) escape sequences. + */ +export interface TerminalModes { + /** DECCKM - Application cursor keys (mode 1) */ + applicationCursorKeys: boolean; + /** Bracketed paste mode (mode 2004) */ + bracketedPaste: boolean; + /** X10 mouse tracking (mode 9) */ + mouseTrackingX10: boolean; + /** Normal mouse tracking - button events (mode 1000) */ + mouseTrackingNormal: boolean; + /** Highlight mouse tracking (mode 1001) */ + mouseTrackingHighlight: boolean; + /** Button-event mouse tracking (mode 1002) */ + mouseTrackingButtonEvent: boolean; + /** Any-event mouse tracking (mode 1003) */ + mouseTrackingAnyEvent: boolean; + /** Focus reporting (mode 1004) */ + focusReporting: boolean; + /** UTF-8 mouse mode (mode 1005) */ + mouseUtf8: boolean; + /** SGR mouse mode (mode 1006) */ + mouseSgr: boolean; + /** Alternate screen buffer (mode 1049 or 47) */ + alternateScreen: boolean; + /** Cursor visibility (mode 25) */ + cursorVisible: boolean; + /** Origin mode (mode 6) */ + originMode: boolean; + /** Auto-wrap mode (mode 7) */ + autoWrap: boolean; +} + +/** + * Default terminal modes (standard terminal state) + */ +export const DEFAULT_MODES: TerminalModes = { + applicationCursorKeys: false, + bracketedPaste: false, + mouseTrackingX10: false, + mouseTrackingNormal: false, + mouseTrackingHighlight: false, + mouseTrackingButtonEvent: false, + mouseTrackingAnyEvent: false, + focusReporting: false, + mouseUtf8: false, + mouseSgr: false, + alternateScreen: false, + cursorVisible: true, + originMode: false, + autoWrap: true, +}; + +// ============================================================================= +// Snapshot Types +// ============================================================================= + +/** + * Snapshot payload returned when attaching to a session. + * Contains everything needed to restore terminal state in the renderer. + */ +export interface TerminalSnapshot { + /** Serialized screen state (ANSI sequences to reproduce screen) */ + snapshotAnsi: string; + /** Control sequences to restore input-affecting modes */ + rehydrateSequences: string; + /** Current working directory (from OSC-7, may be null) */ + cwd: string | null; + /** Current terminal modes */ + modes: TerminalModes; + /** Terminal dimensions */ + cols: number; + rows: number; + /** Scrollback line count */ + scrollbackLines: number; + /** Debug diagnostics for troubleshooting (optional) */ + debug?: { + /** xterm's internal buffer type */ + xtermBufferType: string; + /** Whether serialized output contains alt screen entry */ + hasAltScreenEntry: boolean; + /** Alt buffer stats if in alt screen */ + altBuffer?: { + lines: number; + nonEmptyLines: number; + totalChars: number; + cursorX: number; + cursorY: number; + sampleLines: string[]; + }; + /** Normal buffer line count */ + normalBufferLines: number; + }; +} + +// ============================================================================= +// Session Types +// ============================================================================= + +/** + * Session metadata stored on disk + */ +export interface SessionMeta { + sessionId: string; + workspaceId: string; + paneId: string; + cwd: string; + cols: number; + rows: number; + createdAt: string; + lastAttachedAt: string; + shell: string; +} + +// ============================================================================= +// IPC Protocol Types +// ============================================================================= + +/** + * Hello request - initial handshake with daemon + */ +export interface HelloRequest { + token: string; + protocolVersion: number; +} + +export interface HelloResponse { + protocolVersion: number; + daemonVersion: string; + daemonPid: number; +} + +/** + * Create or attach to a terminal session + */ +export interface CreateOrAttachRequest { + sessionId: string; + cols: number; + rows: number; + cwd?: string; + env?: Record; + shell?: string; + workspaceId: string; + paneId: string; + tabId: string; + workspaceName?: string; + workspacePath?: string; + rootPath?: string; + initialCommands?: string[]; +} + +export interface CreateOrAttachResponse { + isNew: boolean; + snapshot: TerminalSnapshot; + wasRecovered: boolean; + /** PTY process ID for port scanning (null if not yet spawned or exited) */ + pid: number | null; +} + +/** + * Write data to a terminal session + */ +export interface WriteRequest { + sessionId: string; + data: string; +} + +/** + * Resize terminal session + */ +export interface ResizeRequest { + sessionId: string; + cols: number; + rows: number; +} + +/** + * Detach from a terminal session (keep running) + */ +export interface DetachRequest { + sessionId: string; +} + +/** + * Kill a terminal session + */ +export interface KillRequest { + sessionId: string; + deleteHistory?: boolean; +} + +/** + * Kill all terminal sessions + */ +export interface KillAllRequest { + deleteHistory?: boolean; +} + +/** + * List all active sessions + */ +export interface ListSessionsResponse { + sessions: Array<{ + sessionId: string; + workspaceId: string; + paneId: string; + isAlive: boolean; + attachedClients: number; + /** PTY process ID (null if not yet spawned or exited) */ + pid: number | null; + }>; +} + +/** + * Clear scrollback for a session + */ +export interface ClearScrollbackRequest { + sessionId: string; +} + +/** + * Shutdown the daemon gracefully + */ +export interface ShutdownRequest { + /** Optional: Kill all sessions before shutdown (default: false) */ + killSessions?: boolean; +} + +// ============================================================================= +// IPC Message Framing +// ============================================================================= + +/** + * Request message format (client -> daemon) + */ +export interface IpcRequest { + id: string; + type: string; + payload: unknown; +} + +/** + * Success response format (daemon -> client) + */ +export interface IpcSuccessResponse { + id: string; + ok: true; + payload: unknown; +} + +/** + * Error response format (daemon -> client) + */ +export interface IpcErrorResponse { + id: string; + ok: false; + error: { + code: string; + message: string; + }; +} + +export type IpcResponse = IpcSuccessResponse | IpcErrorResponse; + +/** + * Event message format (daemon -> client, unsolicited) + */ +export interface IpcEvent { + type: "event"; + event: string; + sessionId: string; + payload: unknown; +} + +/** + * Terminal data event + */ +export interface TerminalDataEvent { + type: "data"; + data: string; +} + +/** + * Terminal exit event + */ +export interface TerminalExitEvent { + type: "exit"; + exitCode: number; + signal?: number; +} + +/** + * Terminal error event (e.g., write queue full, subprocess error) + */ +export interface TerminalErrorEvent { + type: "error"; + error: string; + /** Error code for programmatic handling */ + code?: "WRITE_QUEUE_FULL" | "SUBPROCESS_ERROR" | "WRITE_FAILED" | "UNKNOWN"; +} + +export type TerminalEvent = + | TerminalDataEvent + | TerminalExitEvent + | TerminalErrorEvent; + +// ============================================================================= +// Request/Response Type Map +// ============================================================================= + +/** Empty response for operations that don't return data */ +export interface EmptyResponse { + success: true; +} + +export type RequestTypeMap = { + hello: { request: HelloRequest; response: HelloResponse }; + createOrAttach: { + request: CreateOrAttachRequest; + response: CreateOrAttachResponse; + }; + write: { request: WriteRequest; response: EmptyResponse }; + resize: { request: ResizeRequest; response: EmptyResponse }; + detach: { request: DetachRequest; response: EmptyResponse }; + kill: { request: KillRequest; response: EmptyResponse }; + killAll: { request: KillAllRequest; response: EmptyResponse }; + listSessions: { request: undefined; response: ListSessionsResponse }; + clearScrollback: { request: ClearScrollbackRequest; response: EmptyResponse }; + shutdown: { request: ShutdownRequest; response: EmptyResponse }; +}; diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts new file mode 100644 index 00000000000..04c3305e041 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -0,0 +1,931 @@ +/** + * Daemon-based Terminal Manager + * + * This version of TerminalManager delegates PTY operations to the + * terminal host daemon for persistence across app restarts. + * + * The daemon owns the PTYs and maintains terminal state. This manager + * maintains the same EventEmitter interface as the original for + * compatibility with existing TRPC router and renderer code. + */ + +import { EventEmitter } from "node:events"; +import { track } from "main/lib/analytics"; +import { + containsClearScrollbackSequence, + extractContentAfterClear, +} from "../terminal-escape-filter"; +import { HistoryReader, HistoryWriter } from "../terminal-history"; +import { + disposeTerminalHostClient, + getTerminalHostClient, + type TerminalHostClient, +} from "../terminal-host/client"; +import { buildTerminalEnv, getDefaultShell } from "./env"; +import { portManager } from "./port-manager"; +import type { CreateSessionParams, SessionResult } from "./types"; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Delay before removing session from local cache after exit event */ +const SESSION_CLEANUP_DELAY_MS = 5000; + +// ============================================================================= +// Types +// ============================================================================= + +interface SessionInfo { + paneId: string; + workspaceId: string; + isAlive: boolean; + lastActive: number; + cwd: string; + /** PTY process ID for port scanning (null if not yet spawned or exited) */ + pid: number | null; + cols: number; + rows: number; + /** Saved viewport scroll position for restoration on reattach */ + viewportY?: number; +} + +// ============================================================================= +// DaemonTerminalManager +// ============================================================================= + +export class DaemonTerminalManager extends EventEmitter { + private client: TerminalHostClient; + private sessions = new Map(); + private pendingSessions = new Map>(); + + /** History writers for persisting scrollback to disk (for reboot recovery) */ + private historyWriters = new Map(); + + /** Buffer for data received before history writer is initialized */ + private pendingHistoryData = new Map(); + + /** Track sessions that are initializing history (to know when to buffer) */ + private historyInitializing = new Set(); + + /** + * Sticky cold restore info - survives multiple createOrAttach calls. + * This ensures React StrictMode double-mounts still see cold restore. + * Cleared when renderer acknowledges via ackColdRestore(). + */ + private coldRestoreInfo = new Map< + string, + { + scrollback: string; + previousCwd: string | undefined; + cols: number; + rows: number; + } + >(); + + constructor() { + super(); + this.client = getTerminalHostClient(); + this.setupClientEventHandlers(); + } + + /** + * Clean up stale sessions from previous app runs. + * Call this on app startup BEFORE renderer restore runs. + * + * Current semantics: terminal persistence = across workspace switches only. + * App restart = fresh start (kill all stale daemon sessions). + */ + async reconcileOnStartup(): Promise { + try { + const response = await this.client.listSessions(); + if (response.sessions.length > 0) { + console.log( + `[DaemonTerminalManager] Cleaning up ${response.sessions.length} stale sessions from previous run`, + ); + await this.client.killAll({}); + } + } catch (error) { + console.warn( + "[DaemonTerminalManager] Failed to reconcile sessions:", + error, + ); + } + } + + /** + * Set up event handlers to forward daemon events to local EventEmitter + */ + private setupClientEventHandlers(): void { + // Forward data events + this.client.on("data", (sessionId: string, data: string) => { + // The sessionId from daemon is the paneId + const paneId = sessionId; + + // Update session state + const session = this.sessions.get(paneId); + if (session) { + session.lastActive = Date.now(); + } + + // Check for port hints in output (triggers process-based scan) + portManager.checkOutputForHint(data, paneId); + + // Write to history file for reboot persistence + this.writeToHistory(paneId, data); + + // Emit to listeners (TRPC router subscription) + this.emit(`data:${paneId}`, data); + }); + + // Forward exit events + this.client.on( + "exit", + (sessionId: string, exitCode: number, signal?: number) => { + const paneId = sessionId; + + // Update session state + const session = this.sessions.get(paneId); + if (session) { + session.isAlive = false; + session.pid = null; // PTY is gone + } + + // Unregister from port manager (clears ports and cancels pending scans) + portManager.unregisterDaemonSession(paneId); + + // Close history writer with exit code (writes endedAt to meta.json) + this.closeHistoryWriter(paneId, exitCode); + + // Emit exit event + this.emit(`exit:${paneId}`, exitCode, signal); + + // Clean up session after delay + setTimeout(() => { + this.sessions.delete(paneId); + }, SESSION_CLEANUP_DELAY_MS); + }, + ); + + // Handle client disconnection - notify all active sessions + this.client.on("disconnected", () => { + console.warn("[DaemonTerminalManager] Disconnected from daemon"); + // Emit disconnect event for all active sessions so terminals can show error UI + for (const [paneId, session] of this.sessions.entries()) { + if (session.isAlive) { + this.emit( + `disconnect:${paneId}`, + "Connection to terminal daemon lost", + ); + } + } + }); + + this.client.on("error", (error: Error) => { + console.error("[DaemonTerminalManager] Client error:", error.message); + // Emit error event for all active sessions + for (const [paneId, session] of this.sessions.entries()) { + if (session.isAlive) { + this.emit(`disconnect:${paneId}`, error.message); + } + } + }); + + // Terminal-specific errors (e.g., subprocess backpressure limits) + this.client.on( + "terminalError", + (sessionId: string, error: string, code?: string) => { + const paneId = sessionId; + console.error( + `[DaemonTerminalManager] Terminal error for ${paneId}: ${code ?? "UNKNOWN"}: ${error}`, + ); + this.emit(`error:${paneId}`, { error, code }); + }, + ); + } + + // =========================================================================== + // History Persistence (for reboot recovery) + // =========================================================================== + + /** + * Initialize a history writer for a session. + * Called after createOrAttach succeeds. + */ + private async initHistoryWriter( + paneId: string, + workspaceId: string, + cwd: string, + cols: number, + rows: number, + initialScrollback?: string, + ): Promise { + // Mark as initializing so data events get buffered + this.historyInitializing.add(paneId); + this.pendingHistoryData.set(paneId, []); + + try { + const writer = new HistoryWriter(workspaceId, paneId, cwd, cols, rows); + await writer.init(initialScrollback); + this.historyWriters.set(paneId, writer); + + // Flush any buffered data + const buffered = this.pendingHistoryData.get(paneId) || []; + for (const data of buffered) { + this.writeToHistory(paneId, data); + } + } catch (error) { + console.error( + `[DaemonTerminalManager] Failed to init history writer for ${paneId}:`, + error, + ); + } finally { + this.historyInitializing.delete(paneId); + this.pendingHistoryData.delete(paneId); + } + } + + /** + * Write data to history file. + * Handles clear scrollback detection and buffering during init. + */ + private writeToHistory(paneId: string, data: string): void { + // If still initializing, buffer the data + if (this.historyInitializing.has(paneId)) { + const buffer = this.pendingHistoryData.get(paneId); + if (buffer) { + buffer.push(data); + } + return; + } + + const writer = this.historyWriters.get(paneId); + if (!writer) { + return; + } + + // Handle clear scrollback (Cmd+K) - reinitialize history + if (containsClearScrollbackSequence(data)) { + const session = this.sessions.get(paneId); + if (session) { + // Close current writer and reinitialize with empty scrollback + writer.close().catch(() => {}); + this.historyWriters.delete(paneId); + + // Create new writer (will only contain content after clear) + const contentAfterClear = extractContentAfterClear(data); + this.initHistoryWriter( + paneId, + session.workspaceId, + session.cwd, + 80, // cols - will be updated on next resize + 24, // rows - will be updated on next resize + contentAfterClear || undefined, + ).catch(() => {}); + } + return; + } + + // Normal write + writer.write(data); + } + + /** + * Close a history writer and write endedAt to meta.json. + */ + private closeHistoryWriter(paneId: string, exitCode?: number): void { + const writer = this.historyWriters.get(paneId); + if (writer) { + writer.close(exitCode).catch((error) => { + console.error( + `[DaemonTerminalManager] Failed to close history writer for ${paneId}:`, + error, + ); + }); + this.historyWriters.delete(paneId); + } + + // Clean up any pending data + this.historyInitializing.delete(paneId); + this.pendingHistoryData.delete(paneId); + } + + /** + * Clean up history files for a session. + */ + private async cleanupHistory( + paneId: string, + workspaceId: string, + ): Promise { + this.closeHistoryWriter(paneId); + + try { + const reader = new HistoryReader(workspaceId, paneId); + await reader.cleanup(); + } catch (error) { + console.error( + `[DaemonTerminalManager] Failed to cleanup history for ${paneId}:`, + error, + ); + } + } + + // =========================================================================== + // Public API (matches original TerminalManager interface) + // =========================================================================== + + async createOrAttach(params: CreateSessionParams): Promise { + const { paneId } = params; + + // Deduplicate concurrent calls + const pending = this.pendingSessions.get(paneId); + if (pending) { + return pending; + } + + const creationPromise = this.doCreateOrAttach(params); + this.pendingSessions.set(paneId, creationPromise); + + try { + return await creationPromise; + } finally { + this.pendingSessions.delete(paneId); + } + } + + private async doCreateOrAttach( + params: CreateSessionParams, + ): Promise { + const { + paneId, + tabId, + workspaceId, + workspaceName, + workspacePath, + rootPath, + cwd, + cols = 80, + rows = 24, + initialCommands, + } = params; + + // FIRST: Check for sticky cold restore info (survives React StrictMode remounts) + // This ensures the second mount still sees the cold restore detected on first mount + const stickyRestore = this.coldRestoreInfo.get(paneId); + if (stickyRestore) { + return { + isNew: false, + scrollback: stickyRestore.scrollback, + wasRecovered: true, + isColdRestore: true, + previousCwd: stickyRestore.previousCwd, + snapshot: { + snapshotAnsi: stickyRestore.scrollback, + rehydrateSequences: "", + cwd: stickyRestore.previousCwd || null, + modes: {}, + cols: stickyRestore.cols, + rows: stickyRestore.rows, + scrollbackLines: 0, + }, + }; + } + + // Check for cold restore: read existing history from disk BEFORE calling daemon + // This detects if there's scrollback from a previous session that ended uncleanly + const historyReader = new HistoryReader(workspaceId, paneId); + const existingHistory = await historyReader.read(); + const hasPreviousSession = + !!existingHistory.metadata && !!existingHistory.scrollback; + const wasUncleanShutdown = + hasPreviousSession && !existingHistory.metadata?.endedAt; + + // Build environment for the terminal + const shell = getDefaultShell(); + const env = buildTerminalEnv({ + shell, + paneId, + tabId, + workspaceId, + workspaceName, + workspacePath, + rootPath, + }); + + console.log("[DaemonTerminalManager] Calling daemon createOrAttach:", { + paneId, + shell, + cwd, + cols, + rows, + }); + + // Call daemon + const response = await this.client.createOrAttach({ + sessionId: paneId, // Use paneId as sessionId for simplicity + paneId, + tabId, + workspaceId, + workspaceName, + workspacePath, + rootPath, + cols, + rows, + cwd, + env, + shell, + initialCommands, + }); + + // Detect cold restore: daemon created new session but we have unclean history + const isColdRestore = response.isNew && wasUncleanShutdown; + + // For cold restore, use the previous session's cwd; otherwise use daemon's cwd + const previousCwd = existingHistory.metadata?.cwd; + const sessionCwd = isColdRestore + ? previousCwd || cwd || "" + : response.snapshot.cwd || cwd || ""; + + // Track session locally + this.sessions.set(paneId, { + paneId, + workspaceId, + isAlive: true, + lastActive: Date.now(), + cwd: sessionCwd, + pid: response.pid, + cols: response.snapshot.cols || cols, + rows: response.snapshot.rows || rows, + }); + + // Register with port manager for process-based port scanning + // PID may be null if PTY not yet spawned (will be polled via listSessions) + portManager.upsertDaemonSession(paneId, workspaceId, response.pid); + + // Initialize history writer for reboot persistence + // For cold restore: start fresh (scrollback is read-only display) + // For recovered session: include existing scrollback + // For new session: start empty + const initialScrollback = response.wasRecovered + ? response.snapshot.snapshotAnsi + : undefined; + + // Guard against invalid dimensions (can happen if terminal not yet sized) + const effectiveCols = response.snapshot.cols || cols; + const effectiveRows = response.snapshot.rows || rows; + + if (effectiveCols >= 1 && effectiveRows >= 1) { + this.initHistoryWriter( + paneId, + workspaceId, + sessionCwd, + effectiveCols, + effectiveRows, + initialScrollback, + ).catch((error) => { + console.error( + `[DaemonTerminalManager] Failed to init history for ${paneId}:`, + error, + ); + }); + } else { + console.warn( + `[DaemonTerminalManager] Skipping history init for ${paneId}: invalid dimensions ${effectiveCols}x${effectiveRows}`, + ); + } + + // Track terminal opened (but not for cold restore - that's a continuation) + if (response.isNew && !isColdRestore) { + track("terminal_opened", { workspace_id: workspaceId, pane_id: paneId }); + } + + // For cold restore, return disk scrollback instead of daemon snapshot + if (isColdRestore) { + // Cap scrollback size for performance (matches non-daemon mode) + const MAX_SCROLLBACK_CHARS = 500_000; + const scrollback = + existingHistory.scrollback.length > MAX_SCROLLBACK_CHARS + ? existingHistory.scrollback.slice(-MAX_SCROLLBACK_CHARS) + : existingHistory.scrollback; + + // Store in sticky map - survives React StrictMode remounts + // Renderer must call ackColdRestore() to clear this + this.coldRestoreInfo.set(paneId, { + scrollback, + previousCwd: previousCwd || undefined, + cols: existingHistory.metadata?.cols || cols, + rows: existingHistory.metadata?.rows || rows, + }); + + return { + isNew: false, // Not truly new - we're restoring + scrollback: scrollback, + wasRecovered: true, + isColdRestore: true, + previousCwd: previousCwd || undefined, + snapshot: { + snapshotAnsi: scrollback, + rehydrateSequences: "", + cwd: previousCwd || null, + modes: {}, + cols: existingHistory.metadata?.cols || cols, + rows: existingHistory.metadata?.rows || rows, + scrollbackLines: 0, + }, + }; + } + + return { + isNew: response.isNew, + // In daemon mode, snapshot.snapshotAnsi is the canonical content source. + // We set scrollback to empty to avoid duplicating the payload over IPC. + // The renderer should prefer snapshot.snapshotAnsi when available. + scrollback: "", + wasRecovered: response.wasRecovered, + viewportY: this.sessions.get(paneId)?.viewportY, + snapshot: { + snapshotAnsi: response.snapshot.snapshotAnsi, + rehydrateSequences: response.snapshot.rehydrateSequences, + cwd: response.snapshot.cwd, + modes: response.snapshot.modes as unknown as Record, + cols: response.snapshot.cols, + rows: response.snapshot.rows, + scrollbackLines: response.snapshot.scrollbackLines, + debug: response.snapshot.debug, + }, + }; + } + + write(params: { paneId: string; data: string }): void { + const { paneId, data } = params; + + const session = this.sessions.get(paneId); + if (!session || !session.isAlive) { + throw new Error(`Terminal session ${paneId} not found or not alive`); + } + + // Fire and forget - daemon will handle the write. + // Use the no-ack fast path to avoid per-chunk request timeouts under load. + this.client.writeNoAck({ sessionId: paneId, data }); + } + + /** + * Acknowledge cold restore - clears the sticky cold restore info. + * Call this after the renderer has displayed the cold restore UI + * and the user has started a new shell. + */ + ackColdRestore(paneId: string): void { + if (this.coldRestoreInfo.has(paneId)) { + this.coldRestoreInfo.delete(paneId); + } + } + + resize(params: { paneId: string; cols: number; rows: number }): void { + const { paneId, cols, rows } = params; + + // Validate geometry + if ( + !Number.isInteger(cols) || + !Number.isInteger(rows) || + cols <= 0 || + rows <= 0 + ) { + console.warn( + `[DaemonTerminalManager] Invalid resize geometry for ${paneId}: cols=${cols}, rows=${rows}`, + ); + return; + } + + const session = this.sessions.get(paneId); + if (!session || !session.isAlive) { + console.warn( + `Cannot resize terminal ${paneId}: session not found or not alive`, + ); + return; + } + + // Fire and forget + this.client.resize({ sessionId: paneId, cols, rows }).catch((error) => { + console.error( + `[DaemonTerminalManager] Resize failed for ${paneId}:`, + error, + ); + }); + + session.lastActive = Date.now(); + } + + signal(params: { paneId: string; signal?: string }): void { + const { paneId, signal = "SIGTERM" } = params; + const session = this.sessions.get(paneId); + + if (!session || !session.isAlive) { + console.warn( + `Cannot signal terminal ${paneId}: session not found or not alive`, + ); + return; + } + + // Daemon doesn't have a signal method, use kill + // For now, just log - we may need to add signal support to daemon + console.warn( + `[DaemonTerminalManager] Signal ${signal} not yet supported for daemon sessions`, + ); + } + + async kill(params: { + paneId: string; + deleteHistory?: boolean; + }): Promise { + const { paneId, deleteHistory = false } = params; + + // Emit exit event BEFORE killing so tRPC subscriptions complete cleanly. + // This prevents WRITE_FAILED errors when the daemon kills the session + // but React components are still mounted with active subscriptions. + // The daemon will also emit an exit event, but duplicate events are + // harmless since emit.complete() has already been called. + const session = this.sessions.get(paneId); + if (session?.isAlive) { + session.isAlive = false; + session.pid = null; + this.emit(`exit:${paneId}`, 0, "SIGTERM"); + } + + // Unregister from port manager + portManager.unregisterDaemonSession(paneId); + + // Close and optionally delete history + if (deleteHistory && session) { + await this.cleanupHistory(paneId, session.workspaceId); + } else { + this.closeHistoryWriter(paneId, 0); + } + + await this.client.kill({ sessionId: paneId, deleteHistory }); + } + + detach(params: { paneId: string; viewportY?: number }): void { + const { paneId, viewportY } = params; + + const session = this.sessions.get(paneId); + if (!session) { + console.warn(`Cannot detach terminal ${paneId}: session not found`); + return; + } + + // Fire and forget + this.client.detach({ sessionId: paneId }).catch((error) => { + console.error( + `[DaemonTerminalManager] Detach failed for ${paneId}:`, + error, + ); + }); + + session.lastActive = Date.now(); + if (viewportY !== undefined) { + session.viewportY = viewportY; + } + } + + async clearScrollback(params: { paneId: string }): Promise { + const { paneId } = params; + + await this.client.clearScrollback({ sessionId: paneId }); + + const session = this.sessions.get(paneId); + if (session) { + session.lastActive = Date.now(); + + // Reinitialize history file (clear the scrollback on disk too) + const writer = this.historyWriters.get(paneId); + if (writer) { + await writer.close().catch(() => {}); + this.historyWriters.delete(paneId); + await this.initHistoryWriter( + paneId, + session.workspaceId, + session.cwd, + 80, + 24, + undefined, + ); + } + } + } + + getSession( + paneId: string, + ): { isAlive: boolean; cwd: string; lastActive: number } | null { + const session = this.sessions.get(paneId); + if (!session) { + return null; + } + + return { + isAlive: session.isAlive, + cwd: session.cwd, + lastActive: session.lastActive, + }; + } + + async killByWorkspaceId( + workspaceId: string, + ): Promise<{ killed: number; failed: number }> { + // Always query daemon for the authoritative list of sessions + // Local sessions map may be incomplete after app restart + const paneIdsToKill = new Set(); + + // Query daemon for all sessions in this workspace + try { + const response = await this.client.listSessions(); + for (const session of response.sessions) { + if (session.workspaceId === workspaceId && session.isAlive) { + paneIdsToKill.add(session.paneId); + } + } + } catch (error) { + console.warn( + "[DaemonTerminalManager] Failed to query daemon for sessions:", + error, + ); + // Fall back to local sessions if daemon query fails + for (const [paneId, session] of this.sessions.entries()) { + if (session.workspaceId === workspaceId) { + paneIdsToKill.add(paneId); + } + } + } + + if (paneIdsToKill.size === 0) { + return { killed: 0, failed: 0 }; + } + + console.log( + `[DaemonTerminalManager] Killing ${paneIdsToKill.size} sessions for workspace ${workspaceId}`, + ); + + let killed = 0; + let failed = 0; + + for (const paneId of paneIdsToKill) { + try { + // Emit exit event BEFORE killing so tRPC subscriptions complete cleanly. + // This prevents WRITE_FAILED error toast floods when deleting workspaces. + const session = this.sessions.get(paneId); + if (session?.isAlive) { + session.isAlive = false; + session.pid = null; + this.emit(`exit:${paneId}`, 0, "SIGTERM"); + } + + // Unregister from port manager + portManager.unregisterDaemonSession(paneId); + + // Clean up history files when deleting workspace + await this.cleanupHistory(paneId, workspaceId); + + await this.client.kill({ sessionId: paneId, deleteHistory: true }); + killed++; + } catch (error) { + console.error( + `[DaemonTerminalManager] Failed to kill session ${paneId}:`, + error, + ); + failed++; + } + } + + if (failed > 0) { + console.warn( + `[DaemonTerminalManager] killByWorkspaceId: killed=${killed}, failed=${failed}`, + ); + } + + return { killed, failed }; + } + + async getSessionCountByWorkspaceId(workspaceId: string): Promise { + // Always query daemon for the authoritative count + // Local sessions map may be incomplete after app restart + try { + const response = await this.client.listSessions(); + return response.sessions.filter( + (s) => s.workspaceId === workspaceId && s.isAlive, + ).length; + } catch (error) { + console.warn( + "[DaemonTerminalManager] Failed to query daemon for session count:", + error, + ); + // Fall back to local sessions if daemon query fails + return Array.from(this.sessions.values()).filter( + (session) => session.workspaceId === workspaceId && session.isAlive, + ).length; + } + } + + /** + * Send a newline to all terminals in a workspace to refresh their prompts. + */ + refreshPromptsForWorkspace(workspaceId: string): void { + for (const [paneId, session] of this.sessions.entries()) { + if (session.workspaceId === workspaceId && session.isAlive) { + this.client.writeNoAck({ sessionId: paneId, data: "\n" }); + } + } + } + + detachAllListeners(): void { + for (const event of this.eventNames()) { + const name = String(event); + if ( + name.startsWith("data:") || + name.startsWith("exit:") || + name.startsWith("disconnect:") || + name.startsWith("error:") + ) { + this.removeAllListeners(event); + } + } + } + + /** + * Cleanup on app quit. + * + * IMPORTANT: In daemon mode, we intentionally do NOT kill sessions. + * The whole point of the daemon is to persist terminals across app restarts. + * We only disconnect from the daemon and clear local state. + * + * We DO close history writers gracefully so meta.json gets endedAt written. + * This allows cold restore detection on next app launch. + */ + async cleanup(): Promise { + // Close all history writers gracefully (writes endedAt to meta.json) + // This is important for cold restore detection - if the app crashes + // or laptop reboots, endedAt won't be written, indicating unclean shutdown. + const closePromises: Promise[] = []; + for (const [paneId, writer] of this.historyWriters.entries()) { + closePromises.push( + writer.close().catch((error) => { + console.error( + `[DaemonTerminalManager] Failed to close history for ${paneId}:`, + error, + ); + }), + ); + } + await Promise.all(closePromises); + this.historyWriters.clear(); + this.historyInitializing.clear(); + this.pendingHistoryData.clear(); + + // Disconnect from daemon but DON'T kill sessions - they should persist + // across app restarts. This is the core feature of daemon mode. + this.sessions.clear(); + this.removeAllListeners(); + disposeTerminalHostClient(); + } + + /** + * Forcefully kill all sessions in the daemon. + * Only use this when you explicitly want to destroy all terminals, + * not during normal app shutdown. + */ + async forceKillAll(): Promise { + // Close all history writers + for (const writer of this.historyWriters.values()) { + await writer.close().catch(() => {}); + } + this.historyWriters.clear(); + this.historyInitializing.clear(); + this.pendingHistoryData.clear(); + + await this.client.killAll({}); + this.sessions.clear(); + } +} + +// ============================================================================= +// Singleton Instance +// ============================================================================= + +let daemonManager: DaemonTerminalManager | null = null; + +export function getDaemonTerminalManager(): DaemonTerminalManager { + if (!daemonManager) { + daemonManager = new DaemonTerminalManager(); + } + return daemonManager; +} + +/** + * Dispose the daemon manager singleton. + * Must be called when the terminal host client is disposed (e.g., daemon restart) + * to ensure the manager gets a fresh client reference on next use. + */ +export function disposeDaemonManager(): void { + if (daemonManager) { + daemonManager.removeAllListeners(); + daemonManager = null; + } +} diff --git a/apps/desktop/src/main/lib/terminal/index.ts b/apps/desktop/src/main/lib/terminal/index.ts index 190aa9dd342..89bf6ba69d0 100644 --- a/apps/desktop/src/main/lib/terminal/index.ts +++ b/apps/desktop/src/main/lib/terminal/index.ts @@ -1,4 +1,17 @@ -export { TerminalManager, terminalManager } from "./manager"; +import { settings } from "@superset/local-db"; +import { localDb } from "main/lib/local-db"; +import { + disposeTerminalHostClient, + getTerminalHostClient, +} from "main/lib/terminal-host/client"; +import { + DaemonTerminalManager, + getDaemonTerminalManager, +} from "./daemon-manager"; +import { TerminalManager, terminalManager } from "./manager"; + +export { TerminalManager, terminalManager }; +export { DaemonTerminalManager, getDaemonTerminalManager }; export type { CreateSessionParams, SessionResult, @@ -6,3 +19,126 @@ export type { TerminalEvent, TerminalExitEvent, } from "./types"; + +// ============================================================================= +// Terminal Manager Selection +// ============================================================================= + +// Cache the daemon mode setting to avoid repeated DB reads +// This is set once at app startup and doesn't change until restart +let cachedDaemonMode: boolean | null = null; + +/** + * Check if daemon mode is enabled. + * Reads from user settings (terminalPersistence) or falls back to env var. + * The value is cached since it requires app restart to take effect. + */ +export function isDaemonModeEnabled(): boolean { + // Return cached value if available + if (cachedDaemonMode !== null) { + return cachedDaemonMode; + } + + // First check environment variable override (for development/testing) + if (process.env.SUPERSET_TERMINAL_DAEMON === "1") { + console.log( + "[TerminalManager] Daemon mode: ENABLED (via SUPERSET_TERMINAL_DAEMON env var)", + ); + cachedDaemonMode = true; + return true; + } + + // Read from user settings + try { + const row = localDb.select().from(settings).get(); + const enabled = row?.terminalPersistence ?? false; + console.log( + `[TerminalManager] Daemon mode: ${enabled ? "ENABLED" : "DISABLED"} (via settings.terminalPersistence)`, + ); + cachedDaemonMode = enabled; + return enabled; + } catch (error) { + console.warn( + "[TerminalManager] Failed to read settings, defaulting to disabled:", + error, + ); + cachedDaemonMode = false; + return false; + } +} + +/** + * Get the active terminal manager based on current settings. + * Returns either the in-process manager or the daemon-based manager. + */ +export function getActiveTerminalManager(): + | TerminalManager + | DaemonTerminalManager { + const daemonEnabled = isDaemonModeEnabled(); + console.log("[getActiveTerminalManager] Daemon mode enabled:", daemonEnabled); + if (daemonEnabled) { + return getDaemonTerminalManager(); + } + return terminalManager; +} + +/** + * Reconcile daemon sessions on app startup. + * Should be called on app startup when daemon mode is ENABLED to clean up + * stale sessions from previous app runs. + * + * Current semantics: terminal persistence = across workspace switches only. + * App restart = fresh start (kill all stale daemon sessions). + */ +export async function reconcileDaemonSessions(): Promise { + if (!isDaemonModeEnabled()) { + // Not in daemon mode, nothing to reconcile + return; + } + + try { + const manager = getDaemonTerminalManager(); + await manager.reconcileOnStartup(); + } catch (error) { + console.warn( + "[TerminalManager] Failed to reconcile daemon sessions:", + error, + ); + } +} + +/** + * Shutdown any orphaned daemon process. + * Should be called on app startup when daemon mode is disabled to clean up + * any daemon left running from a previous session with persistence enabled. + * + * Uses shutdownIfRunning() to avoid spawning a new daemon just to shut it down. + */ +export async function shutdownOrphanedDaemon(): Promise { + if (isDaemonModeEnabled()) { + // Daemon mode is enabled, don't shutdown + return; + } + + try { + const client = getTerminalHostClient(); + // Use shutdownIfRunning to avoid spawning a daemon if none exists + const { wasRunning } = await client.shutdownIfRunning({ + killSessions: true, + }); + if (wasRunning) { + console.log("[TerminalManager] Shutdown orphaned daemon successfully"); + } else { + console.log("[TerminalManager] No orphaned daemon to shutdown"); + } + } catch (error) { + // Unexpected error during shutdown attempt + console.warn( + "[TerminalManager] Error during orphan daemon cleanup:", + error, + ); + } finally { + // Always dispose the client to clean up any partial state + disposeTerminalHostClient(); + } +} diff --git a/apps/desktop/src/main/lib/terminal/manager.test.ts b/apps/desktop/src/main/lib/terminal/manager.test.ts index 29e928e4602..c56b533f783 100644 --- a/apps/desktop/src/main/lib/terminal/manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/manager.test.ts @@ -149,6 +149,9 @@ describe("TerminalManager", () => { data: "ls -la\n", }); + // Wait for PtyWriteQueue async flush (uses setTimeout internally) + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mockPty.write).toHaveBeenCalledWith("ls -la\n"); }); @@ -486,12 +489,18 @@ describe("TerminalManager", () => { workspaceId: "other-workspace", }); - expect(manager.getSessionCountByWorkspaceId("workspace-count")).toBe(2); - expect(manager.getSessionCountByWorkspaceId("other-workspace")).toBe(1); + expect( + await manager.getSessionCountByWorkspaceId("workspace-count"), + ).toBe(2); + expect( + await manager.getSessionCountByWorkspaceId("other-workspace"), + ).toBe(1); }); - it("should return zero for non-existent workspace", () => { - expect(manager.getSessionCountByWorkspaceId("non-existent")).toBe(0); + it("should return zero for non-existent workspace", async () => { + expect(await manager.getSessionCountByWorkspaceId("non-existent")).toBe( + 0, + ); }); it("should not count dead sessions", async () => { @@ -517,7 +526,9 @@ describe("TerminalManager", () => { // Wait for state to update await new Promise((resolve) => setTimeout(resolve, 100)); - expect(manager.getSessionCountByWorkspaceId("workspace-mixed")).toBe(1); + expect( + await manager.getSessionCountByWorkspaceId("workspace-mixed"), + ).toBe(1); }); }); diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index 09c5e367beb..c262e4a4cae 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -111,14 +111,24 @@ export class TerminalManager extends EventEmitter { const { paneId } = params; session.pty.onExit(async ({ exitCode, signal }) => { + const sessionDuration = Date.now() - session.startTime; + console.log("[TerminalManager] Shell exited:", { + paneId, + shell: session.shell, + exitCode, + signal, + sessionDuration, + cwd: session.cwd, + }); + session.isAlive = false; + session.writeQueue.dispose(); // Must capture before flush (flush disposes headless terminal) const existingScrollback = getSerializedScrollback(session); flushSession(session); // Check if shell crashed quickly - try fallback - const sessionDuration = Date.now() - session.startTime; const crashedQuickly = sessionDuration < SHELL_CRASH_THRESHOLD_MS && exitCode !== 0; @@ -165,10 +175,20 @@ export class TerminalManager extends EventEmitter { throw new Error(`Terminal session ${paneId} not found or not alive`); } - session.pty.write(data); + if (!session.writeQueue.write(data)) { + throw new Error(`Terminal ${paneId} write queue full`); + } session.lastActive = Date.now(); } + /** + * Acknowledge cold restore (no-op in non-daemon mode). + * Cold restore only applies to daemon mode where sessions survive app restart. + */ + ackColdRestore(_paneId: string): void { + // No-op in non-daemon mode - cold restore is a daemon-only feature + } + resize(params: { paneId: string; cols: number; rows: number }): void { const { paneId, cols, rows } = params; @@ -317,10 +337,13 @@ export class TerminalManager extends EventEmitter { session: TerminalSession, ): Promise { if (!session.isAlive) { + session.writeQueue.dispose(); this.sessions.delete(paneId); return Promise.resolve(true); } + session.writeQueue.dispose(); + return new Promise((resolve) => { let resolved = false; let sigtermTimeout: ReturnType | undefined; @@ -376,7 +399,7 @@ export class TerminalManager extends EventEmitter { }); } - getSessionCountByWorkspaceId(workspaceId: string): number { + async getSessionCountByWorkspaceId(workspaceId: string): Promise { return Array.from(this.sessions.values()).filter( (session) => session.workspaceId === workspaceId && session.isAlive, ).length; @@ -390,7 +413,7 @@ export class TerminalManager extends EventEmitter { for (const [paneId, session] of this.sessions.entries()) { if (session.workspaceId === workspaceId && session.isAlive) { try { - session.pty.write("\n"); + session.writeQueue.write("\n"); } catch (error) { console.warn( `[TerminalManager] Failed to refresh prompt for pane ${paneId}:`, @@ -404,7 +427,12 @@ export class TerminalManager extends EventEmitter { detachAllListeners(): void { for (const event of this.eventNames()) { const name = String(event); - if (name.startsWith("data:") || name.startsWith("exit:")) { + if ( + name.startsWith("data:") || + name.startsWith("exit:") || + name.startsWith("disconnect:") || + name.startsWith("error:") + ) { this.removeAllListeners(event); } } @@ -434,7 +462,10 @@ export class TerminalManager extends EventEmitter { }); exitPromises.push(exitPromise); + session.writeQueue.dispose(); session.pty.kill(); + } else { + session.writeQueue.dispose(); } } diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index 90d41c47055..d00d920fea8 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -14,9 +14,22 @@ interface RegisteredSession { workspaceId: string; } +/** + * Daemon session registration for port scanning. + * Unlike RegisteredSession, this tracks sessions in the daemon process + * where we only have the PID (not a TerminalSession object). + */ +interface DaemonSession { + workspaceId: string; + /** PTY process ID - null if not yet spawned or exited */ + pid: number | null; +} + class PortManager extends EventEmitter { private ports = new Map(); private sessions = new Map(); + /** Daemon-mode sessions: paneId → { workspaceId, pid } */ + private daemonSessions = new Map(); private scanInterval: ReturnType | null = null; private pendingHintScans = new Map>(); private isScanning = false; @@ -48,6 +61,34 @@ class PortManager extends EventEmitter { } } + /** + * Register or update a daemon-mode terminal session for port scanning. + * Use this when the terminal runs in the daemon process (terminal persistence mode). + * Can be called multiple times to update the PID when it becomes available or changes. + */ + upsertDaemonSession( + paneId: string, + workspaceId: string, + pid: number | null, + ): void { + this.daemonSessions.set(paneId, { workspaceId, pid }); + } + + /** + * Unregister a daemon-mode terminal session and remove its ports + */ + unregisterDaemonSession(paneId: string): void { + this.daemonSessions.delete(paneId); + this.removePortsForPane(paneId); + + // Cancel any pending hint scan for this pane + const pendingTimeout = this.pendingHintScans.get(paneId); + if (pendingTimeout) { + clearTimeout(pendingTimeout); + this.pendingHintScans.delete(paneId); + } + } + /** * Start periodic scanning of all registered sessions */ @@ -80,7 +121,62 @@ class PortManager extends EventEmitter { } /** - * Scan all registered sessions for ports + * Scan a specific pane for ports. + * Works for both regular sessions (with TerminalSession) and daemon sessions (with PID only). + */ + private async scanPane(paneId: string): Promise { + // Check regular sessions first + const registered = this.sessions.get(paneId); + if (registered) { + const { session, workspaceId } = registered; + if (!session.isAlive) return; + + try { + const pid = session.pty.pid; + const pids = await getProcessTree(pid); + if (pids.length === 0) { + // Self-healing: process tree is gone, clear ports + this.removePortsForPane(paneId); + return; + } + + const portInfos = getListeningPortsForPids(pids); + this.updatePortsForPane(paneId, workspaceId, portInfos); + } catch (error) { + console.error(`[PortManager] Error scanning pane ${paneId}:`, error); + } + return; + } + + // Check daemon sessions + const daemonSession = this.daemonSessions.get(paneId); + if (daemonSession) { + const { workspaceId, pid } = daemonSession; + // Skip if PID not yet available (PTY not spawned) + if (pid === null) return; + + try { + const pids = await getProcessTree(pid); + if (pids.length === 0) { + // Self-healing: process tree is gone, clear ports + this.removePortsForPane(paneId); + return; + } + + const portInfos = getListeningPortsForPids(pids); + this.updatePortsForPane(paneId, workspaceId, portInfos); + } catch (error) { + console.error( + `[PortManager] Error scanning daemon pane ${paneId}:`, + error, + ); + } + } + } + + /** + * Scan all registered sessions for ports. + * Includes both regular sessions and daemon sessions. */ private async scanAllSessions(): Promise { if (this.isScanning) return; @@ -91,7 +187,10 @@ class PortManager extends EventEmitter { string, { workspaceId: string; pids: number[] } >(); + // Track panes with empty process trees for self-healing + const emptyTreePanes = new Set(); + // Scan regular sessions for (const [paneId, { session, workspaceId }] of this.sessions) { if (!session.isAlive) continue; @@ -100,19 +199,51 @@ class PortManager extends EventEmitter { const pids = await getProcessTree(pid); if (pids.length > 0) { panePortMap.set(paneId, { workspaceId, pids }); + } else { + // Process tree is gone - mark for self-healing + emptyTreePanes.add(paneId); } } catch { // Session may have exited } } + // Scan daemon sessions + for (const [paneId, { workspaceId, pid }] of this.daemonSessions) { + // Skip if PID not yet available + if (pid === null) continue; + + try { + const pids = await getProcessTree(pid); + if (pids.length > 0) { + panePortMap.set(paneId, { workspaceId, pids }); + } else { + // Process tree is gone - mark for self-healing + emptyTreePanes.add(paneId); + } + } catch { + // Session may have exited + } + } + + // Update ports for panes with active processes for (const [paneId, { workspaceId, pids }] of panePortMap) { const portInfos = await getListeningPortsForPids(pids); this.updatePortsForPane(paneId, workspaceId, portInfos); } + // Self-healing: clear ports for panes with empty process trees + for (const paneId of emptyTreePanes) { + this.removePortsForPane(paneId); + } + + // Cleanup: remove ports for panes that are no longer registered + // (not in sessions AND not in daemonSessions) for (const [key, port] of this.ports) { - if (!this.sessions.has(port.paneId)) { + const isRegistered = + this.sessions.has(port.paneId) || + this.daemonSessions.has(port.paneId); + if (!isRegistered) { this.ports.delete(key); this.emit("port:remove", port); } diff --git a/apps/desktop/src/main/lib/terminal/pty-write-queue.ts b/apps/desktop/src/main/lib/terminal/pty-write-queue.ts new file mode 100644 index 00000000000..e0e4131bb15 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/pty-write-queue.ts @@ -0,0 +1,159 @@ +import type { IPty } from "node-pty"; + +/** + * A write queue for PTY that reduces event loop starvation. + * + * Context: This is used in the non-daemon (in-process) terminal mode. + * For daemon mode, the real backpressure handling (EAGAIN retry with backoff) + * is implemented in pty-subprocess.ts. + * + * Problem: node-pty's write() is synchronous. While the kernel buffer rarely + * fills completely, processing large pastes in a single event loop tick can + * starve other work (IPC handlers, UI updates). + * + * Solution: Queue writes and process them in small chunks, yielding to the + * event loop between chunks via setTimeout. This improves responsiveness + * during large pastes. + * + * Limitations: + * - Does NOT handle true kernel-level backpressure (EAGAIN/EWOULDBLOCK) + * - If node-pty.write() blocks, this cannot prevent it + * - For robust backpressure handling, use daemon mode with subprocess isolation + * + * Features: + * - Chunked writes to reduce event loop starvation + * - Memory-bounded queue to prevent OOM + * - Backpressure signaling when queue is full + * - Graceful handling of PTY closure + */ +export class PtyWriteQueue { + private queue: string[] = []; + private queuedBytes = 0; + private flushing = false; + private disposed = false; + + /** + * Size of each write chunk. Smaller = more responsive but slower throughput. + * 256 bytes keeps individual blocks short (~1-5ms typically). + */ + private readonly CHUNK_SIZE = 256; + + /** + * Delay between chunks in ms. Gives event loop time to process other work. + */ + private readonly CHUNK_DELAY_MS = 1; + + /** + * Maximum bytes allowed in queue. Prevents OOM if PTY stops consuming. + * 1MB is generous - a typical large paste is ~50KB. + */ + private readonly MAX_QUEUE_BYTES = 1_000_000; + + constructor( + private pty: IPty, + private onDrain?: () => void, + ) {} + + /** + * Queue data to be written to the PTY. + * @returns true if queued, false if queue is full (backpressure) + */ + write(data: string): boolean { + if (this.disposed) { + return false; + } + + if (this.queuedBytes + data.length > this.MAX_QUEUE_BYTES) { + console.warn( + `[PtyWriteQueue] Queue full (${this.queuedBytes} bytes), rejecting write of ${data.length} bytes`, + ); + return false; + } + + this.queue.push(data); + this.queuedBytes += data.length; + this.scheduleFlush(); + return true; + } + + /** + * Schedule the flush loop if not already running. + */ + private scheduleFlush(): void { + if (this.flushing || this.disposed) return; + this.flushing = true; + setTimeout(() => this.flush(), 0); + } + + /** + * Process one chunk from the queue and schedule the next. + */ + private flush(): void { + if (this.disposed) { + this.flushing = false; + return; + } + + if (this.queue.length === 0) { + this.flushing = false; + this.onDrain?.(); + return; + } + + // Take a chunk from front of queue + let chunk = this.queue[0]; + if (chunk.length > this.CHUNK_SIZE) { + // Split: take CHUNK_SIZE, leave rest in queue + this.queue[0] = chunk.slice(this.CHUNK_SIZE); + chunk = chunk.slice(0, this.CHUNK_SIZE); + } else { + // Take entire item + this.queue.shift(); + } + + this.queuedBytes -= chunk.length; + + try { + this.pty.write(chunk); + } catch (error) { + // PTY might be closed - clear queue and stop + console.warn("[PtyWriteQueue] Write failed, clearing queue:", error); + this.clear(); + this.flushing = false; + return; + } + + // Yield to event loop with a small delay, allowing other work to run + setTimeout(() => this.flush(), this.CHUNK_DELAY_MS); + } + + /** + * Number of bytes currently queued. + */ + get pending(): number { + return this.queuedBytes; + } + + /** + * Whether there's data waiting to be written. + */ + get hasPending(): boolean { + return this.queuedBytes > 0; + } + + /** + * Clear all pending writes. + */ + clear(): void { + this.queue = []; + this.queuedBytes = 0; + } + + /** + * Stop processing and clear queue. + */ + dispose(): void { + this.disposed = true; + this.clear(); + } +} diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 00929a81e56..97831d80012 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -9,6 +9,7 @@ import { extractContentAfterClear, } from "../terminal-escape-filter"; import { buildTerminalEnv, FALLBACK_SHELL, getDefaultShell } from "./env"; +import { PtyWriteQueue } from "./pty-write-queue"; import type { InternalCreateSessionParams, TerminalSession } from "./types"; const DEFAULT_COLS = 80; @@ -98,6 +99,16 @@ export async function createSession( const terminalCols = cols || DEFAULT_COLS; const terminalRows = rows || DEFAULT_ROWS; + // Debug: Log PTY spawn parameters + console.log("[Terminal Session] Creating session:", { + paneId, + shell, + workingDir, + terminalCols, + terminalRows, + useFallbackShell, + }); + const env = buildTerminalEnv({ shell, paneId, @@ -130,6 +141,8 @@ export async function createSession( onData(paneId, batchedData); }); + const writeQueue = new PtyWriteQueue(ptyProcess); + return { pty: ptyProcess, paneId, @@ -143,6 +156,7 @@ export async function createSession( isAlive: true, wasRecovered, dataBatcher, + writeQueue, shell, startTime: Date.now(), usedFallback: useFallbackShell, @@ -196,7 +210,7 @@ export function setupDataHandler( } if (session.isAlive) { - session.pty.write(initialCommandString); + session.writeQueue.write(initialCommandString); } })(); } diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index f5968d218b9..1a9e7beccce 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -2,6 +2,7 @@ import type { SerializeAddon } from "@xterm/addon-serialize"; import type { Terminal as HeadlessTerminal } from "@xterm/headless"; import type * as pty from "node-pty"; import type { DataBatcher } from "../data-batcher"; +import type { PtyWriteQueue } from "./pty-write-queue"; export interface TerminalSession { pty: pty.IPty; @@ -16,6 +17,8 @@ export interface TerminalSession { isAlive: boolean; wasRecovered: boolean; dataBatcher: DataBatcher; + /** Queued writer to prevent blocking on large writes */ + writeQueue: PtyWriteQueue; shell: string; startTime: number; usedFallback: boolean; @@ -38,10 +41,51 @@ export type TerminalEvent = TerminalDataEvent | TerminalExitEvent; export interface SessionResult { isNew: boolean; + /** + * Initial terminal content (ANSI). + * In daemon mode, this is empty - prefer `snapshot.snapshotAnsi` when available. + * In non-daemon mode, this contains the recovered scrollback content. + */ scrollback: string; wasRecovered: boolean; /** Saved viewport scroll position for restoration on reattach */ viewportY?: number; + /** + * True if this is a cold restore from disk after reboot/crash. + * The daemon didn't have this session, but we found scrollback on disk + * with an unclean shutdown (meta.json has no endedAt). + * UI should show "Session Restored" banner and "Start Shell" action. + */ + isColdRestore?: boolean; + /** + * The cwd from the previous session (for cold restore). + * Use this to start the new shell in the same directory. + */ + previousCwd?: string; + /** Snapshot from daemon (if using daemon mode) */ + snapshot?: { + snapshotAnsi: string; + rehydrateSequences: string; + cwd: string | null; + modes: Record; + cols: number; + rows: number; + scrollbackLines: number; + /** Debug diagnostics for troubleshooting */ + debug?: { + xtermBufferType: string; + hasAltScreenEntry: boolean; + altBuffer?: { + lines: number; + nonEmptyLines: number; + totalChars: number; + cursorX: number; + cursorY: number; + sampleLines: string[]; + }; + normalBufferLines: number; + }; + }; } export interface CreateSessionParams { @@ -55,6 +99,8 @@ export interface CreateSessionParams { cols?: number; rows?: number; initialCommands?: string[]; + /** Skip cold restore detection (used when auto-resuming after cold restore) */ + skipColdRestore?: boolean; } export interface InternalCreateSessionParams extends CreateSessionParams { diff --git a/apps/desktop/src/main/terminal-host/daemon.test.ts b/apps/desktop/src/main/terminal-host/daemon.test.ts new file mode 100644 index 00000000000..69049f10d50 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/daemon.test.ts @@ -0,0 +1,429 @@ +/** + * Terminal Host Daemon Integration Tests + * + * These tests verify the daemon can: + * 1. Start and listen on a Unix socket + * 2. Accept connections and handle NDJSON protocol + * 3. Authenticate clients with token + * 4. Respond to hello requests + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { ChildProcess } from "node:child_process"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { connect, type Socket } from "node:net"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { + type HelloResponse, + type IpcRequest, + type IpcResponse, + PROTOCOL_VERSION, +} from "../lib/terminal-host/types"; + +// Test uses development paths +const SUPERSET_DIR_NAME = ".superset-dev"; +const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); +const SOCKET_PATH = join(SUPERSET_HOME_DIR, "terminal-host.sock"); +const TOKEN_PATH = join(SUPERSET_HOME_DIR, "terminal-host.token"); +const PID_PATH = join(SUPERSET_HOME_DIR, "terminal-host.pid"); + +// Path to the daemon source file +const DAEMON_PATH = resolve(__dirname, "index.ts"); + +// Timeout for daemon operations +const DAEMON_TIMEOUT = 10000; +const CONNECT_TIMEOUT = 5000; + +describe("Terminal Host Daemon", () => { + let daemonProcess: ChildProcess | null = null; + + /** + * Clean up any existing daemon artifacts + */ + function cleanup() { + // Kill any existing daemon + if (existsSync(PID_PATH)) { + try { + const pid = Number.parseInt(readFileSync(PID_PATH, "utf-8").trim(), 10); + if (pid > 0) { + process.kill(pid, "SIGTERM"); + } + } catch { + // Process might not exist + } + } + + // Remove socket file + if (existsSync(SOCKET_PATH)) { + try { + rmSync(SOCKET_PATH); + } catch { + // Ignore + } + } + + // Remove PID file + if (existsSync(PID_PATH)) { + try { + rmSync(PID_PATH); + } catch { + // Ignore + } + } + + // Remove token file (so we get a fresh one) + if (existsSync(TOKEN_PATH)) { + try { + rmSync(TOKEN_PATH); + } catch { + // Ignore + } + } + } + + /** + * Start the daemon process + */ + async function startDaemon(): Promise { + return new Promise((resolve, reject) => { + // Ensure home directory exists + if (!existsSync(SUPERSET_HOME_DIR)) { + mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); + } + + // Start daemon with tsx (bun's typescript runner) + daemonProcess = spawn("bun", ["run", DAEMON_PATH], { + env: { + ...process.env, + NODE_ENV: "development", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let output = ""; + + daemonProcess.stdout?.on("data", (data) => { + output += data.toString(); + // Check if daemon is ready + if (output.includes("Daemon started")) { + resolve(); + } + }); + + daemonProcess.stderr?.on("data", (data) => { + console.error("Daemon stderr:", data.toString()); + }); + + daemonProcess.on("error", (error) => { + reject(new Error(`Failed to start daemon: ${error.message}`)); + }); + + daemonProcess.on("exit", (code, signal) => { + if (code !== 0 && code !== null) { + reject( + new Error(`Daemon exited with code ${code}, signal ${signal}`), + ); + } + }); + + // Timeout if daemon doesn't start + setTimeout(() => { + reject( + new Error( + `Daemon failed to start within ${DAEMON_TIMEOUT}ms. Output: ${output}`, + ), + ); + }, DAEMON_TIMEOUT); + }); + } + + /** + * Stop the daemon process + */ + async function stopDaemon(): Promise { + if (daemonProcess) { + return new Promise((resolve) => { + daemonProcess?.on("exit", () => { + daemonProcess = null; + resolve(); + }); + + daemonProcess?.kill("SIGTERM"); + + // Force kill if it doesn't exit gracefully + setTimeout(() => { + if (daemonProcess) { + daemonProcess.kill("SIGKILL"); + daemonProcess = null; + resolve(); + } + }, 2000); + }); + } + } + + /** + * Connect to the daemon socket + */ + function connectToDaemon(): Promise { + return new Promise((resolve, reject) => { + const socket = connect(SOCKET_PATH); + + socket.on("connect", () => { + resolve(socket); + }); + + socket.on("error", (error) => { + reject(new Error(`Failed to connect to daemon: ${error.message}`)); + }); + + setTimeout(() => { + reject(new Error(`Connection timed out after ${CONNECT_TIMEOUT}ms`)); + }, CONNECT_TIMEOUT); + }); + } + + /** + * Send a request and wait for response + */ + function sendRequest( + socket: Socket, + request: IpcRequest, + ): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + + const onData = (data: Buffer) => { + buffer += data.toString(); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + socket.off("data", onData); + try { + resolve(JSON.parse(line)); + } catch (_error) { + reject(new Error(`Failed to parse response: ${line}`)); + } + } + }; + + socket.on("data", onData); + + socket.write(`${JSON.stringify(request)}\n`); + + setTimeout(() => { + socket.off("data", onData); + reject(new Error("Request timed out")); + }, 5000); + }); + } + + beforeEach(async () => { + cleanup(); + await startDaemon(); + }); + + afterEach(async () => { + await stopDaemon(); + cleanup(); + }); + + describe("hello handshake", () => { + it("should accept valid hello request with correct token", async () => { + const socket = await connectToDaemon(); + + try { + // Read the token that the daemon generated + const token = readFileSync(TOKEN_PATH, "utf-8").trim(); + expect(token).toHaveLength(64); // 32 bytes = 64 hex chars + + // Send hello request + const request: IpcRequest = { + id: "test-1", + type: "hello", + payload: { + token, + protocolVersion: PROTOCOL_VERSION, + }, + }; + + const response = await sendRequest(socket, request); + + expect(response.id).toBe("test-1"); + expect(response.ok).toBe(true); + + if (response.ok) { + const payload = response.payload as HelloResponse; + expect(payload.protocolVersion).toBe(PROTOCOL_VERSION); + expect(payload.daemonVersion).toBe("1.0.0"); + expect(payload.daemonPid).toBeGreaterThan(0); + } + } finally { + socket.destroy(); + } + }); + + it("should reject hello request with invalid token", async () => { + const socket = await connectToDaemon(); + + try { + const request: IpcRequest = { + id: "test-2", + type: "hello", + payload: { + token: "invalid-token", + protocolVersion: PROTOCOL_VERSION, + }, + }; + + const response = await sendRequest(socket, request); + + expect(response.id).toBe("test-2"); + expect(response.ok).toBe(false); + + if (!response.ok) { + expect(response.error.code).toBe("AUTH_FAILED"); + } + } finally { + socket.destroy(); + } + }); + + it("should reject hello request with wrong protocol version", async () => { + const socket = await connectToDaemon(); + + try { + const token = readFileSync(TOKEN_PATH, "utf-8").trim(); + + const request: IpcRequest = { + id: "test-3", + type: "hello", + payload: { + token, + protocolVersion: 999, // Invalid version + }, + }; + + const response = await sendRequest(socket, request); + + expect(response.id).toBe("test-3"); + expect(response.ok).toBe(false); + + if (!response.ok) { + expect(response.error.code).toBe("PROTOCOL_MISMATCH"); + } + } finally { + socket.destroy(); + } + }); + }); + + describe("authentication requirement", () => { + it("should reject requests before authentication", async () => { + const socket = await connectToDaemon(); + + try { + // Try to list sessions without authenticating first + const request: IpcRequest = { + id: "test-4", + type: "listSessions", + payload: undefined, + }; + + const response = await sendRequest(socket, request); + + expect(response.id).toBe("test-4"); + expect(response.ok).toBe(false); + + if (!response.ok) { + expect(response.error.code).toBe("NOT_AUTHENTICATED"); + } + } finally { + socket.destroy(); + } + }); + + it("should allow listSessions after authentication", async () => { + const socket = await connectToDaemon(); + + try { + const token = readFileSync(TOKEN_PATH, "utf-8").trim(); + + // Authenticate first + const helloRequest: IpcRequest = { + id: "test-5a", + type: "hello", + payload: { + token, + protocolVersion: PROTOCOL_VERSION, + }, + }; + + const helloResponse = await sendRequest(socket, helloRequest); + expect(helloResponse.ok).toBe(true); + + // Now list sessions + const listRequest: IpcRequest = { + id: "test-5b", + type: "listSessions", + payload: undefined, + }; + + const listResponse = await sendRequest(socket, listRequest); + + expect(listResponse.id).toBe("test-5b"); + expect(listResponse.ok).toBe(true); + + if (listResponse.ok) { + const payload = listResponse.payload as { sessions: unknown[] }; + expect(payload.sessions).toEqual([]); + } + } finally { + socket.destroy(); + } + }); + }); + + describe("unknown requests", () => { + it("should return error for unknown request type", async () => { + const socket = await connectToDaemon(); + + try { + const token = readFileSync(TOKEN_PATH, "utf-8").trim(); + + // Authenticate first + const helloRequest: IpcRequest = { + id: "test-6a", + type: "hello", + payload: { + token, + protocolVersion: PROTOCOL_VERSION, + }, + }; + + await sendRequest(socket, helloRequest); + + // Send unknown request + const unknownRequest: IpcRequest = { + id: "test-6b", + type: "unknownRequestType", + payload: {}, + }; + + const response = await sendRequest(socket, unknownRequest); + + expect(response.id).toBe("test-6b"); + expect(response.ok).toBe(false); + + if (!response.ok) { + expect(response.error.code).toBe("UNKNOWN_REQUEST"); + } + } finally { + socket.destroy(); + } + }); + }); +}); diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts new file mode 100644 index 00000000000..f4a92d144f0 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -0,0 +1,631 @@ +/** + * Terminal Host Daemon + * + * A persistent background process that owns PTYs and terminal emulator state. + * This allows terminal sessions to survive app restarts and updates. + * + * Run with: ELECTRON_RUN_AS_NODE=1 electron dist/main/terminal-host.js + * + * IPC Protocol: + * - Uses NDJSON (newline-delimited JSON) over Unix domain socket + * - Socket: ~/.superset/terminal-host.sock + * - Auth token: ~/.superset/terminal-host.token + */ + +import { randomBytes } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + type ClearScrollbackRequest, + type CreateOrAttachRequest, + type DetachRequest, + type HelloRequest, + type HelloResponse, + type IpcErrorResponse, + type IpcEvent, + type IpcRequest, + type IpcSuccessResponse, + type KillAllRequest, + type KillRequest, + PROTOCOL_VERSION, + type ResizeRequest, + type ShutdownRequest, + type TerminalErrorEvent, + type WriteRequest, +} from "../lib/terminal-host/types"; +import { TerminalHost } from "./terminal-host"; + +// ============================================================================= +// Configuration +// ============================================================================= + +const DAEMON_VERSION = "1.0.0"; + +// Determine superset directory based on NODE_ENV +const SUPERSET_DIR_NAME = + process.env.NODE_ENV === "development" ? ".superset-dev" : ".superset"; +const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); + +// Socket and token paths +const SOCKET_PATH = join(SUPERSET_HOME_DIR, "terminal-host.sock"); +const TOKEN_PATH = join(SUPERSET_HOME_DIR, "terminal-host.token"); +const PID_PATH = join(SUPERSET_HOME_DIR, "terminal-host.pid"); + +// ============================================================================= +// Logging +// ============================================================================= + +function log( + level: "info" | "warn" | "error", + message: string, + data?: unknown, +) { + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [terminal-host] [${level.toUpperCase()}]`; + if (data !== undefined) { + console.log(`${prefix} ${message}`, data); + } else { + console.log(`${prefix} ${message}`); + } +} + +// ============================================================================= +// Token Management +// ============================================================================= + +let authToken: string; + +function ensureAuthToken(): string { + if (existsSync(TOKEN_PATH)) { + // Read existing token + return readFileSync(TOKEN_PATH, "utf-8").trim(); + } + + // Generate new token (32 bytes = 64 hex chars) + const token = randomBytes(32).toString("hex"); + writeFileSync(TOKEN_PATH, token, { mode: 0o600 }); + log("info", "Generated new auth token"); + return token; +} + +function validateToken(token: string): boolean { + return token === authToken; +} + +// ============================================================================= +// NDJSON Framing +// ============================================================================= + +class NdjsonParser { + private buffer = ""; + + parse(chunk: string): IpcRequest[] { + this.buffer += chunk; + const messages: IpcRequest[] = []; + + let newlineIndex = this.buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = this.buffer.slice(0, newlineIndex); + this.buffer = this.buffer.slice(newlineIndex + 1); + + if (line.trim()) { + try { + messages.push(JSON.parse(line)); + } catch { + // Truncate and redact potentially sensitive data in error logs + const maxLen = 100; + const truncated = + line.length > maxLen + ? `${line.slice(0, maxLen)}... (truncated)` + : line; + // Redact anything that looks like a token or secret + const redacted = truncated.replace( + /["']?(?:token|secret|password|key|auth)["']?\s*[:=]\s*["']?[^"'\s,}]+["']?/gi, + "[REDACTED]", + ); + log("warn", "Failed to parse NDJSON line", { + preview: redacted, + length: line.length, + }); + } + } + + newlineIndex = this.buffer.indexOf("\n"); + } + + return messages; + } +} + +function sendResponse( + socket: Socket, + response: IpcSuccessResponse | IpcErrorResponse, +) { + socket.write(`${JSON.stringify(response)}\n`); +} + +function sendSuccess(socket: Socket, id: string, payload: unknown) { + sendResponse(socket, { id, ok: true, payload }); +} + +function sendError(socket: Socket, id: string, code: string, message: string) { + sendResponse(socket, { id, ok: false, error: { code, message } }); +} + +// ============================================================================= +// Terminal Host Instance +// ============================================================================= + +let terminalHost: TerminalHost; + +// ============================================================================= +// Request Handlers +// ============================================================================= + +type RequestHandler = ( + socket: Socket, + id: string, + payload: unknown, + clientState: ClientState, +) => void; + +interface ClientState { + authenticated: boolean; +} + +const handlers: Record = { + hello: (socket, id, payload, clientState) => { + const request = payload as HelloRequest; + + // Validate protocol version + if (request.protocolVersion !== PROTOCOL_VERSION) { + sendError( + socket, + id, + "PROTOCOL_MISMATCH", + `Protocol version mismatch. Expected ${PROTOCOL_VERSION}, got ${request.protocolVersion}`, + ); + return; + } + + // Validate token + if (!validateToken(request.token)) { + sendError(socket, id, "AUTH_FAILED", "Invalid auth token"); + return; + } + + clientState.authenticated = true; + + const response: HelloResponse = { + protocolVersion: PROTOCOL_VERSION, + daemonVersion: DAEMON_VERSION, + daemonPid: process.pid, + }; + + sendSuccess(socket, id, response); + log("info", "Client authenticated successfully"); + }, + + createOrAttach: async (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const request = payload as CreateOrAttachRequest; + log("info", `Creating/attaching session: ${request.sessionId}`); + + try { + const response = await terminalHost.createOrAttach(socket, request); + sendSuccess(socket, id, response); + + log( + "info", + `Session ${request.sessionId} ${response.isNew ? "created" : "attached"}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + sendError(socket, id, "CREATE_ATTACH_FAILED", message); + log("error", `Failed to create/attach session: ${message}`); + } + }, + + write: (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const request = payload as WriteRequest; + + const isNotify = id.startsWith("notify_"); + + try { + const response = terminalHost.write(request); + // High-frequency write notifications don't need responses; suppress to avoid + // saturating the socket and dropping input under load. + if (!isNotify) { + sendSuccess(socket, id, response); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Write failed"; + + if (isNotify) { + // Emit a session-scoped error event so the main process can surface it. + // (No response is sent for notify writes.) + const event: IpcEvent = { + type: "event", + event: "error", + sessionId: request.sessionId, + payload: { + type: "error", + error: message, + code: "WRITE_FAILED", + } satisfies TerminalErrorEvent, + }; + socket.write(`${JSON.stringify(event)}\n`); + log("warn", `Write failed for ${request.sessionId}`, { + error: message, + }); + return; + } + + sendError(socket, id, "WRITE_FAILED", message); + } + }, + + resize: (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const request = payload as ResizeRequest; + const response = terminalHost.resize(request); + sendSuccess(socket, id, response); + }, + + detach: (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const request = payload as DetachRequest; + const response = terminalHost.detach(socket, request); + sendSuccess(socket, id, response); + }, + + kill: (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const request = payload as KillRequest; + const response = terminalHost.kill(request); + sendSuccess(socket, id, response); + log("info", `Session ${request.sessionId} killed`); + }, + + killAll: (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const request = payload as KillAllRequest; + const response = terminalHost.killAll(request); + sendSuccess(socket, id, response); + log("info", "All sessions killed"); + }, + + listSessions: (socket, id, _payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const response = terminalHost.listSessions(); + sendSuccess(socket, id, response); + }, + + clearScrollback: (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const request = payload as ClearScrollbackRequest; + const response = terminalHost.clearScrollback(request); + sendSuccess(socket, id, response); + }, + + shutdown: (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + + const request = payload as ShutdownRequest; + log("info", "Shutdown requested via IPC", { + killSessions: request.killSessions, + }); + + // Send success response before shutting down + sendSuccess(socket, id, { success: true }); + + // Kill sessions if requested + if (request.killSessions) { + terminalHost.killAll({ deleteHistory: false }); + } + + // Schedule shutdown after a brief delay to allow response to be sent + setTimeout(() => { + stopServer().then(() => process.exit(0)); + }, 100); + }, +}; + +function handleRequest( + socket: Socket, + request: IpcRequest, + clientState: ClientState, +) { + const handler = handlers[request.type]; + + if (!handler) { + sendError( + socket, + request.id, + "UNKNOWN_REQUEST", + `Unknown request type: ${request.type}`, + ); + return; + } + + try { + handler(socket, request.id, request.payload, clientState); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(socket, request.id, "INTERNAL_ERROR", message); + log("error", `Handler error for ${request.type}`, { error: message }); + } +} + +// ============================================================================= +// Socket Server +// ============================================================================= + +let server: Server | null = null; + +function handleConnection(socket: Socket) { + const parser = new NdjsonParser(); + const clientState: ClientState = { authenticated: false }; + const remoteId = `${socket.remoteAddress || "local"}:${Date.now()}`; + + log("info", `Client connected: ${remoteId}`); + + socket.setEncoding("utf-8"); + + socket.on("data", (data: string) => { + const messages = parser.parse(data); + for (const message of messages) { + handleRequest(socket, message, clientState); + } + }); + + const handleDisconnect = () => { + log("info", `Client disconnected: ${remoteId}`); + // Detach this socket from all sessions it was attached to + // This is centralized here to avoid per-session socket listeners + terminalHost.detachFromAllSessions(socket); + }; + + socket.on("close", handleDisconnect); + + socket.on("error", (error) => { + log("error", `Socket error for ${remoteId}`, { error: error.message }); + }); +} + +/** + * Check if there's an active daemon listening on the socket. + * Returns true if socket is live and responding. + */ +function isSocketLive(): Promise { + return new Promise((resolve) => { + if (!existsSync(SOCKET_PATH)) { + resolve(false); + return; + } + + const testSocket = new (require("node:net").Socket)(); + const timeout = setTimeout(() => { + testSocket.destroy(); + resolve(false); + }, 1000); + + testSocket.on("connect", () => { + clearTimeout(timeout); + testSocket.destroy(); + resolve(true); + }); + + testSocket.on("error", () => { + clearTimeout(timeout); + resolve(false); + }); + + testSocket.connect(SOCKET_PATH); + }); +} + +async function startServer(): Promise { + // Ensure superset directory exists with proper permissions + if (!existsSync(SUPERSET_HOME_DIR)) { + mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); + log("info", `Created directory: ${SUPERSET_HOME_DIR}`); + } + + // Ensure directory has correct permissions + try { + chmodSync(SUPERSET_HOME_DIR, 0o700); + } catch { + // May fail if not owner, that's okay + } + + // Check if socket is live before removing it + // This prevents orphaning a running daemon + if (existsSync(SOCKET_PATH)) { + const isLive = await isSocketLive(); + if (isLive) { + log("error", "Another daemon is already running and responsive"); + throw new Error("Another daemon is already running"); + } + + // Socket exists but not responsive - safe to remove + try { + unlinkSync(SOCKET_PATH); + log("info", "Removed stale socket file"); + } catch (error) { + throw new Error(`Failed to remove stale socket: ${error}`); + } + } + + // Clean up stale PID file if socket was removed + if (existsSync(PID_PATH)) { + try { + unlinkSync(PID_PATH); + } catch { + // Ignore - may not have permission + } + } + + // Initialize auth token + authToken = ensureAuthToken(); + + // Initialize terminal host + terminalHost = new TerminalHost(); + + // Create server + const newServer = createServer(handleConnection); + server = newServer; + + // Wrap server.listen in a Promise for async/await + await new Promise((resolve, reject) => { + newServer.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") { + log("error", "Socket already in use - another daemon may be running"); + reject(new Error("Socket already in use")); + } else { + log("error", "Server error", { error: error.message }); + reject(error); + } + }); + + newServer.listen(SOCKET_PATH, () => { + // Set socket permissions (readable/writable by owner only) + try { + chmodSync(SOCKET_PATH, 0o600); + } catch { + // May fail on some systems, that's okay - directory permissions protect us + } + + // Write PID file + writeFileSync(PID_PATH, String(process.pid), { mode: 0o600 }); + + log("info", `Daemon started`); + log("info", `Socket: ${SOCKET_PATH}`); + log("info", `PID: ${process.pid}`); + resolve(); + }); + }); +} + +function stopServer(): Promise { + return new Promise((resolve) => { + // Dispose terminal host (kills all sessions) + if (terminalHost) { + terminalHost.dispose(); + log("info", "Terminal host disposed"); + } + + if (server) { + server.close(() => { + log("info", "Server closed"); + resolve(); + }); + } else { + resolve(); + } + + // Clean up socket and PID files + try { + if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH); + if (existsSync(PID_PATH)) unlinkSync(PID_PATH); + } catch { + // Best effort cleanup + } + }); +} + +// ============================================================================= +// Signal Handling +// ============================================================================= + +function setupSignalHandlers() { + const shutdown = async (signal: string) => { + log("info", `Received ${signal}, shutting down...`); + await stopServer(); + process.exit(0); + }; + + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGHUP", () => shutdown("SIGHUP")); + + // Handle uncaught errors + process.on("uncaughtException", (error) => { + log("error", "Uncaught exception", { + error: error.message, + stack: error.stack, + }); + stopServer().then(() => process.exit(1)); + }); + + process.on("unhandledRejection", (reason) => { + log("error", "Unhandled rejection", { reason }); + stopServer().then(() => process.exit(1)); + }); +} + +// ============================================================================= +// Main +// ============================================================================= + +async function main() { + log("info", "Terminal Host Daemon starting..."); + log("info", `Environment: ${process.env.NODE_ENV || "production"}`); + log("info", `Home directory: ${SUPERSET_HOME_DIR}`); + + setupSignalHandlers(); + + try { + await startServer(); + } catch (error) { + log("error", "Failed to start server", { error }); + process.exit(1); + } +} + +main(); diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts b/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts new file mode 100644 index 00000000000..c4eb0780d1e --- /dev/null +++ b/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts @@ -0,0 +1,128 @@ +export enum PtySubprocessIpcType { + // Daemon -> subprocess commands + Spawn = 1, + Write = 2, + Resize = 3, + Kill = 4, + Dispose = 5, + + // Subprocess -> daemon events + Ready = 101, + Spawned = 102, + Data = 103, + Exit = 104, + Error = 105, +} + +export interface PtySubprocessFrame { + type: PtySubprocessIpcType; + payload: Buffer; +} + +const HEADER_BYTES = 5; +const EMPTY_PAYLOAD = Buffer.alloc(0); + +// Hard cap to avoid OOM if the stream is corrupted. +// PTY data is untrusted input in practice (terminal apps can emit arbitrarily). +const MAX_FRAME_BYTES = 64 * 1024 * 1024; // 64MB + +export function createFrameHeader( + type: PtySubprocessIpcType, + payloadLength: number, +): Buffer { + const header = Buffer.allocUnsafe(HEADER_BYTES); + header.writeUInt8(type, 0); + header.writeUInt32LE(payloadLength, 1); + return header; +} + +export function writeFrame( + writable: NodeJS.WritableStream, + type: PtySubprocessIpcType, + payload?: Buffer, +): boolean { + const payloadBuffer = payload ?? EMPTY_PAYLOAD; + const header = createFrameHeader(type, payloadBuffer.length); + + let canWrite = writable.write(header); + + // Always write payload even if the header write returns false. + // Backpressure is represented by the return value + 'drain' events. + if (payloadBuffer.length > 0) { + canWrite = writable.write(payloadBuffer) && canWrite; + } + + return canWrite; +} + +export class PtySubprocessFrameDecoder { + private header = Buffer.allocUnsafe(HEADER_BYTES); + private headerOffset = 0; + private frameType: PtySubprocessIpcType | null = null; + private payload: Buffer | null = null; + private payloadOffset = 0; + + push(chunk: Buffer): PtySubprocessFrame[] { + const frames: PtySubprocessFrame[] = []; + + let offset = 0; + while (offset < chunk.length) { + if (this.payload === null) { + const headerNeeded = HEADER_BYTES - this.headerOffset; + const available = chunk.length - offset; + const toCopy = Math.min(headerNeeded, available); + + chunk.copy(this.header, this.headerOffset, offset, offset + toCopy); + this.headerOffset += toCopy; + offset += toCopy; + + if (this.headerOffset < HEADER_BYTES) { + continue; + } + + const type = this.header.readUInt8(0) as PtySubprocessIpcType; + const payloadLength = this.header.readUInt32LE(1); + + if (payloadLength > MAX_FRAME_BYTES) { + throw new Error( + `PtySubprocess IPC frame too large: ${payloadLength} bytes`, + ); + } + + this.frameType = type; + this.payload = + payloadLength > 0 ? Buffer.allocUnsafe(payloadLength) : null; + this.payloadOffset = 0; + this.headerOffset = 0; + + if (payloadLength === 0) { + frames.push({ type, payload: EMPTY_PAYLOAD }); + this.frameType = null; + } + } else { + const payloadNeeded = this.payload.length - this.payloadOffset; + const available = chunk.length - offset; + const toCopy = Math.min(payloadNeeded, available); + + chunk.copy(this.payload, this.payloadOffset, offset, offset + toCopy); + this.payloadOffset += toCopy; + offset += toCopy; + + if (this.payloadOffset < this.payload.length) { + continue; + } + + const type = this.frameType ?? PtySubprocessIpcType.Error; + const payload = this.payload; + + this.frameType = null; + this.payload = null; + this.payloadOffset = 0; + + frames.push({ type, payload }); + } + } + + return frames; + } +} diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess.ts b/apps/desktop/src/main/terminal-host/pty-subprocess.ts new file mode 100644 index 00000000000..5ca53290923 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/pty-subprocess.ts @@ -0,0 +1,472 @@ +/** + * PTY Subprocess + * + * This runs as a completely separate process, owning a single PTY. + * Process isolation guarantees that a blocked PTY won't stall the daemon. + * + * Communication via stdin/stdout using a small binary framing protocol + * to avoid JSON escaping overhead on escape-sequence-heavy PTY output. + */ + +import { write as fsWrite } from "node:fs"; +import type { IPty } from "node-pty"; +import * as pty from "node-pty"; +import { + PtySubprocessFrameDecoder, + PtySubprocessIpcType, + writeFrame, +} from "./pty-subprocess-ipc"; + +// ============================================================================= +// Types (kept local to avoid bundling/import surprises) +// ============================================================================= + +interface SpawnPayload { + shell: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + env: Record; +} + +// ============================================================================= +// State +// ============================================================================= + +let ptyProcess: IPty | null = null; +let ptyFd: number | null = null; + +// Write queue for stdin (uses async fs.write on the PTY fd to avoid blocking the event loop) +const writeQueue: Buffer[] = []; +let queuedBytes = 0; +let flushing = false; +let writeBackoffMs = 0; +const MIN_WRITE_BACKOFF_MS = 2; +const MAX_WRITE_BACKOFF_MS = 50; + +let stdinPaused = false; +const INPUT_QUEUE_HIGH_WATERMARK_BYTES = 8 * 1024 * 1024; // 8MB +const INPUT_QUEUE_LOW_WATERMARK_BYTES = 4 * 1024 * 1024; // 4MB +// Hard cap to avoid runaway memory usage if upstream misbehaves. +const INPUT_QUEUE_HARD_LIMIT_BYTES = 64 * 1024 * 1024; // 64MB + +// Output batching - collect PTY output and send periodically. +// CRITICAL: Use array buffering to avoid O(n²) string concatenation. +let outputChunks: string[] = []; +let outputBytesQueued = 0; +let outputFlushScheduled = false; +const OUTPUT_FLUSH_INTERVAL_MS = 32; // ~30 fps max +const MAX_OUTPUT_BATCH_SIZE_BYTES = 128 * 1024; // 128KB max per flush + +// Backpressure - track if stdout is draining +let stdoutDraining = true; +let ptyPaused = false; + +const DEBUG_OUTPUT_BATCHING = process.env.SUPERSET_PTY_SUBPROCESS_DEBUG === "1"; + +// ============================================================================= +// Helpers +// ============================================================================= + +function send(type: PtySubprocessIpcType, payload?: Buffer): void { + stdoutDraining = writeFrame(process.stdout, type, payload); + + // If stdout buffer is full, pause PTY reads (reduces runaway buffering/CPU). + if (!stdoutDraining && ptyProcess && !ptyPaused) { + ptyPaused = true; + ptyProcess.pause(); + } +} + +process.stdout.on("drain", () => { + stdoutDraining = true; + if (ptyPaused && ptyProcess) { + ptyPaused = false; + ptyProcess.resume(); + } +}); + +function sendError(message: string): void { + send(PtySubprocessIpcType.Error, Buffer.from(message, "utf8")); +} + +/** + * Queue PTY output for batched sending. + * Flushes immediately if batch exceeds MAX_OUTPUT_BATCH_SIZE_BYTES. + */ +function queueOutput(data: string): void { + outputChunks.push(data); + outputBytesQueued += Buffer.byteLength(data, "utf8"); + + if (outputBytesQueued >= MAX_OUTPUT_BATCH_SIZE_BYTES) { + outputFlushScheduled = false; + flushOutput(); + return; + } + + if (!outputFlushScheduled) { + outputFlushScheduled = true; + setTimeout(flushOutput, OUTPUT_FLUSH_INTERVAL_MS); + } +} + +function flushOutput(): void { + outputFlushScheduled = false; + if (outputChunks.length === 0) return; + + const data = outputChunks.join(""); + const chunkCount = outputChunks.length; + outputChunks = []; + outputBytesQueued = 0; + + const payload = Buffer.from(data, "utf8"); + + if (DEBUG_OUTPUT_BATCHING) { + console.error( + `[pty-subprocess] Flushing ${payload.length} bytes (${chunkCount} chunks batched)`, + ); + } + + send(PtySubprocessIpcType.Data, payload); +} + +function maybePauseStdin(): void { + if (stdinPaused) return; + if (queuedBytes < INPUT_QUEUE_HIGH_WATERMARK_BYTES) return; + + stdinPaused = true; + process.stdin.pause(); +} + +function maybeResumeStdin(): void { + if (!stdinPaused) return; + if (queuedBytes > INPUT_QUEUE_LOW_WATERMARK_BYTES) return; + + stdinPaused = false; + process.stdin.resume(); +} + +function queueWriteBuffer(buf: Buffer): void { + if (queuedBytes + buf.length > INPUT_QUEUE_HARD_LIMIT_BYTES) { + // This should never happen for normal pastes; avoid OOM if it does. + sendError("Input backlog exceeded hard limit"); + return; + } + + writeQueue.push(buf); + queuedBytes += buf.length; + maybePauseStdin(); + scheduleFlush(); +} + +function scheduleFlush(): void { + if (flushing) return; + flushing = true; + setImmediate(flush); +} + +function flush(): void { + if (!ptyProcess || writeQueue.length === 0) { + flushing = false; + return; + } + + // If we can access the PTY fd, use async fs.write to avoid blocking the JS event loop. + if (typeof ptyFd === "number" && ptyFd > 0) { + const buf = writeQueue[0]; + + fsWrite(ptyFd, buf, 0, buf.length, null, (err, bytesWritten) => { + if (err) { + const code = (err as NodeJS.ErrnoException).code; + // PTY fds are often non-blocking. If the kernel buffer is full, + // writes can fail with EAGAIN/EWOULDBLOCK. This is normal backpressure; + // retry later instead of dropping the paste. + if (code === "EAGAIN" || code === "EWOULDBLOCK") { + writeBackoffMs = + writeBackoffMs === 0 + ? MIN_WRITE_BACKOFF_MS + : Math.min(writeBackoffMs * 2, MAX_WRITE_BACKOFF_MS); + if ( + DEBUG_OUTPUT_BATCHING && + writeBackoffMs === MIN_WRITE_BACKOFF_MS + ) { + console.error("[pty-subprocess] PTY input backpressured (EAGAIN)"); + } + setTimeout(flush, writeBackoffMs); + return; + } + + sendError( + `Write failed: ${err instanceof Error ? err.message : String(err)}`, + ); + writeQueue.length = 0; + queuedBytes = 0; + flushing = false; + return; + } + + const wrote = Math.max(0, bytesWritten ?? 0); + writeBackoffMs = 0; + queuedBytes -= wrote; + + if (wrote >= buf.length) { + writeQueue.shift(); + } else { + writeQueue[0] = buf.subarray(wrote); + } + + maybeResumeStdin(); + + if (writeQueue.length > 0) { + setImmediate(flush); + } else { + flushing = false; + } + }); + return; + } + + // Fallback: node-pty's write() is synchronous and can block. + // This path should rarely be used on macOS, but keep it for safety. + const chunk = writeQueue.shift(); + if (!chunk) { + flushing = false; + return; + } + + queuedBytes -= chunk.length; + maybeResumeStdin(); + + try { + ptyProcess.write(chunk.toString("utf8")); + } catch (error) { + sendError( + `Write failed: ${error instanceof Error ? error.message : String(error)}`, + ); + writeQueue.length = 0; + queuedBytes = 0; + flushing = false; + return; + } + + if (writeQueue.length > 0) { + setImmediate(flush); + return; + } + + flushing = false; +} + +// ============================================================================= +// Message Handlers +// ============================================================================= + +function handleSpawn(payload: Buffer): void { + if (ptyProcess) { + sendError("PTY already spawned"); + return; + } + + let msg: SpawnPayload; + try { + msg = JSON.parse(payload.toString("utf8")) as SpawnPayload; + } catch (error) { + sendError( + `Spawn payload parse failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + // Debug: Log spawn parameters + console.error("[pty-subprocess] Spawning PTY:", { + shell: msg.shell, + args: msg.args, + cwd: msg.cwd, + cols: msg.cols, + rows: msg.rows, + ZDOTDIR: msg.env.ZDOTDIR, + SUPERSET_ORIG_ZDOTDIR: msg.env.SUPERSET_ORIG_ZDOTDIR, + PATH_start: msg.env.PATH?.substring(0, 100), + }); + + try { + ptyProcess = pty.spawn(msg.shell, msg.args, { + name: "xterm-256color", + cols: msg.cols, + rows: msg.rows, + cwd: msg.cwd, + env: msg.env, + }); + + ptyFd = (ptyProcess as unknown as { fd?: number }).fd ?? null; + if (DEBUG_OUTPUT_BATCHING) { + console.error( + `[pty-subprocess] PTY fd ${ptyFd ?? "unknown"} (${typeof ptyFd === "number" ? "async fs.write enabled" : "falling back to pty.write"})`, + ); + } + + ptyProcess.onData((data) => { + queueOutput(data); + }); + + ptyProcess.onExit(({ exitCode, signal }) => { + flushOutput(); + + const exitPayload = Buffer.allocUnsafe(8); + exitPayload.writeInt32LE(exitCode ?? 0, 0); + exitPayload.writeInt32LE(signal ?? 0, 4); + send(PtySubprocessIpcType.Exit, exitPayload); + + ptyProcess = null; + ptyFd = null; + setTimeout(() => { + process.exit(0); + }, 100); + }); + + const pidPayload = Buffer.allocUnsafe(4); + pidPayload.writeUInt32LE(ptyProcess.pid ?? 0, 0); + send(PtySubprocessIpcType.Spawned, pidPayload); + } catch (error) { + sendError( + `Spawn failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function handleWrite(payload: Buffer): void { + if (!ptyProcess) { + sendError("PTY not spawned"); + return; + } + + queueWriteBuffer(payload); +} + +function handleResize(payload: Buffer): void { + if (!ptyProcess) return; + if (payload.length < 8) return; + try { + const cols = payload.readUInt32LE(0); + const rows = payload.readUInt32LE(4); + ptyProcess.resize(cols, rows); + } catch { + // Ignore resize errors + } +} + +function handleKill(payload: Buffer): void { + const signal = payload.length > 0 ? payload.toString("utf8") : "SIGTERM"; + + if (!ptyProcess) { + return; + } + + const pid = ptyProcess.pid; + + // Step 1: Send the requested signal (usually SIGTERM for graceful shutdown) + try { + ptyProcess.kill(signal); + } catch { + // Process may already be dead + } + + // Step 2: Escalate to SIGKILL if still alive after 2 seconds + // node-pty's onExit callback may not fire reliably after pty.kill() + const escalationTimer = setTimeout(() => { + if (!ptyProcess) return; // Already exited via onExit + + try { + ptyProcess.kill("SIGKILL"); + } catch { + // Process may already be dead + } + + // Step 3: Force completion if onExit still hasn't fired after another 1 second + // This ensures the subprocess exits even if node-pty never emits onExit + const forceExitTimer = setTimeout(() => { + if (!ptyProcess) return; // Finally exited via onExit + + console.error( + `[pty-subprocess] Force exit: onExit never fired for pid ${pid}`, + ); + + // Synthesize Exit frame since onExit won't fire + const exitPayload = Buffer.allocUnsafe(8); + exitPayload.writeInt32LE(-1, 0); // Unknown exit code + exitPayload.writeInt32LE(9, 4); // SIGKILL signal number + send(PtySubprocessIpcType.Exit, exitPayload); + + ptyProcess = null; + ptyFd = null; + process.exit(0); + }, 1000); + forceExitTimer.unref(); + }, 2000); + escalationTimer.unref(); +} + +function handleDispose(): void { + flushOutput(); + + writeQueue.length = 0; + queuedBytes = 0; + flushing = false; + outputChunks = []; + outputBytesQueued = 0; + outputFlushScheduled = false; + ptyFd = null; + + if (ptyProcess) { + try { + ptyProcess.kill("SIGKILL"); + } catch { + // Ignore + } + ptyProcess = null; + } + + process.exit(0); +} + +// ============================================================================= +// Main +// ============================================================================= + +const decoder = new PtySubprocessFrameDecoder(); + +process.stdin.on("data", (chunk: Buffer) => { + try { + const frames = decoder.push(chunk); + for (const frame of frames) { + switch (frame.type) { + case PtySubprocessIpcType.Spawn: + handleSpawn(frame.payload); + break; + case PtySubprocessIpcType.Write: + handleWrite(frame.payload); + break; + case PtySubprocessIpcType.Resize: + handleResize(frame.payload); + break; + case PtySubprocessIpcType.Kill: + handleKill(frame.payload); + break; + case PtySubprocessIpcType.Dispose: + handleDispose(); + break; + } + } + } catch (error) { + sendError( + `Failed to parse frame: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}); + +process.stdin.on("end", () => { + handleDispose(); +}); + +send(PtySubprocessIpcType.Ready); diff --git a/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts b/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts new file mode 100644 index 00000000000..4e994c87957 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts @@ -0,0 +1,679 @@ +/** + * Terminal Host Session Lifecycle Integration Tests + * + * Tests the full session lifecycle: + * 1. Create session with PTY + * 2. Write data to terminal + * 3. Receive output events + * 4. Resize terminal + * 5. List sessions + * 6. Kill session + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { ChildProcess } from "node:child_process"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { connect, type Socket } from "node:net"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { + type CreateOrAttachRequest, + type CreateOrAttachResponse, + type IpcEvent, + type IpcRequest, + type IpcResponse, + type ListSessionsResponse, + PROTOCOL_VERSION, + type TerminalDataEvent, +} from "../lib/terminal-host/types"; + +// Test uses development paths +const SUPERSET_DIR_NAME = ".superset-dev"; +const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); +const SOCKET_PATH = join(SUPERSET_HOME_DIR, "terminal-host.sock"); +const TOKEN_PATH = join(SUPERSET_HOME_DIR, "terminal-host.token"); +const PID_PATH = join(SUPERSET_HOME_DIR, "terminal-host.pid"); + +// Path to the daemon source file +const DAEMON_PATH = resolve(__dirname, "index.ts"); + +// Timeouts +const DAEMON_TIMEOUT = 10000; +const CONNECT_TIMEOUT = 5000; + +describe("Terminal Host Session Lifecycle", () => { + let daemonProcess: ChildProcess | null = null; + + /** + * Clean up any existing daemon artifacts + */ + function cleanup() { + if (existsSync(PID_PATH)) { + try { + const pid = Number.parseInt(readFileSync(PID_PATH, "utf-8").trim(), 10); + if (pid > 0) { + process.kill(pid, "SIGTERM"); + } + } catch { + // Process might not exist + } + } + + if (existsSync(SOCKET_PATH)) { + try { + rmSync(SOCKET_PATH); + } catch { + // Ignore + } + } + + if (existsSync(PID_PATH)) { + try { + rmSync(PID_PATH); + } catch { + // Ignore + } + } + + if (existsSync(TOKEN_PATH)) { + try { + rmSync(TOKEN_PATH); + } catch { + // Ignore + } + } + } + + /** + * Start the daemon process + */ + async function startDaemon(): Promise { + return new Promise((resolve, reject) => { + if (!existsSync(SUPERSET_HOME_DIR)) { + mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); + } + + daemonProcess = spawn("bun", ["run", DAEMON_PATH], { + env: { + ...process.env, + NODE_ENV: "development", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let output = ""; + + daemonProcess.stdout?.on("data", (data) => { + output += data.toString(); + if (output.includes("Daemon started")) { + resolve(); + } + }); + + daemonProcess.stderr?.on("data", (data) => { + console.error("Daemon stderr:", data.toString()); + }); + + daemonProcess.on("error", (error) => { + reject(new Error(`Failed to start daemon: ${error.message}`)); + }); + + daemonProcess.on("exit", (code, signal) => { + if (code !== 0 && code !== null) { + reject( + new Error(`Daemon exited with code ${code}, signal ${signal}`), + ); + } + }); + + setTimeout(() => { + reject( + new Error( + `Daemon failed to start within ${DAEMON_TIMEOUT}ms. Output: ${output}`, + ), + ); + }, DAEMON_TIMEOUT); + }); + } + + /** + * Stop the daemon process + */ + async function stopDaemon(): Promise { + if (daemonProcess) { + return new Promise((resolve) => { + daemonProcess?.on("exit", () => { + daemonProcess = null; + resolve(); + }); + + daemonProcess?.kill("SIGTERM"); + + setTimeout(() => { + if (daemonProcess) { + daemonProcess.kill("SIGKILL"); + daemonProcess = null; + resolve(); + } + }, 2000); + }); + } + } + + /** + * Connect to the daemon socket + */ + function connectToDaemon(): Promise { + return new Promise((resolve, reject) => { + const socket = connect(SOCKET_PATH); + + socket.on("connect", () => { + resolve(socket); + }); + + socket.on("error", (error) => { + reject(new Error(`Failed to connect to daemon: ${error.message}`)); + }); + + setTimeout(() => { + reject(new Error(`Connection timed out after ${CONNECT_TIMEOUT}ms`)); + }, CONNECT_TIMEOUT); + }); + } + + /** + * Send a request and wait for response + */ + function sendRequest( + socket: Socket, + request: IpcRequest, + ): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + + const onData = (data: Buffer) => { + buffer += data.toString(); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + socket.off("data", onData); + try { + resolve(JSON.parse(line)); + } catch (_error) { + reject(new Error(`Failed to parse response: ${line}`)); + } + } + }; + + socket.on("data", onData); + socket.write(`${JSON.stringify(request)}\n`); + + setTimeout(() => { + socket.off("data", onData); + reject(new Error("Request timed out")); + }, 5000); + }); + } + + /** + * Wait for a session to be ready (alive and accepting requests) + */ + async function waitForSessionReady( + socket: Socket, + sessionId: string, + timeoutMs = 3000, + ): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + const listRequest: IpcRequest = { + id: `list-${Date.now()}`, + type: "listSessions", + payload: undefined, + }; + const response = await sendRequest(socket, listRequest); + if (response.ok) { + const payload = response.payload as ListSessionsResponse; + const session = payload.sessions.find((s) => s.sessionId === sessionId); + if (session?.isAlive) { + return true; + } + } + await new Promise((r) => setTimeout(r, 100)); + } + return false; + } + + /** + * Authenticate with the daemon + */ + async function authenticate(socket: Socket): Promise { + const token = readFileSync(TOKEN_PATH, "utf-8").trim(); + + const request: IpcRequest = { + id: `auth-${Date.now()}`, + type: "hello", + payload: { + token, + protocolVersion: PROTOCOL_VERSION, + }, + }; + + const response = await sendRequest(socket, request); + if (!response.ok) { + throw new Error(`Authentication failed: ${JSON.stringify(response)}`); + } + } + + /** + * Wait for events from the socket + */ + function waitForEvent( + socket: Socket, + eventType: string, + timeout = 5000, + ): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + + const onData = (data: Buffer) => { + buffer += data.toString(); + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + + try { + const message = JSON.parse(line); + if (message.type === "event" && message.event === eventType) { + socket.off("data", onData); + resolve(message); + return; + } + } catch { + // Ignore parse errors + } + + newlineIndex = buffer.indexOf("\n"); + } + }; + + socket.on("data", onData); + + setTimeout(() => { + socket.off("data", onData); + reject(new Error(`Event '${eventType}' timed out after ${timeout}ms`)); + }, timeout); + }); + } + + beforeEach(async () => { + cleanup(); + await startDaemon(); + }); + + afterEach(async () => { + await stopDaemon(); + cleanup(); + }); + + describe("session creation", () => { + it("should create a new session and return snapshot", async () => { + const socket = await connectToDaemon(); + + try { + await authenticate(socket); + + const createRequest: IpcRequest = { + id: "test-create-1", + type: "createOrAttach", + payload: { + sessionId: "test-session-1", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + + const response = await sendRequest(socket, createRequest); + + expect(response.id).toBe("test-create-1"); + expect(response.ok).toBe(true); + + if (response.ok) { + const payload = response.payload as CreateOrAttachResponse; + expect(payload.isNew).toBe(true); + expect(payload.snapshot).toBeDefined(); + expect(payload.snapshot.cols).toBe(80); + expect(payload.snapshot.rows).toBe(24); + } + } finally { + socket.destroy(); + } + }); + + it("should attach to existing session", async () => { + const socket = await connectToDaemon(); + + try { + await authenticate(socket); + + // Create first session + const createRequest1: IpcRequest = { + id: "test-create-2a", + type: "createOrAttach", + payload: { + sessionId: "test-session-2", + workspaceId: "workspace-1", + paneId: "pane-2", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + + const response1 = await sendRequest(socket, createRequest1); + expect(response1.ok).toBe(true); + if (response1.ok) { + expect((response1.payload as CreateOrAttachResponse).isNew).toBe( + true, + ); + } + + // Wait for the session to be fully ready before attaching + // PTY spawn can be async and session needs to be alive for attach + const isReady = await waitForSessionReady(socket, "test-session-2"); + expect(isReady).toBe(true); + + // Attach to same session + const createRequest2: IpcRequest = { + id: "test-create-2b", + type: "createOrAttach", + payload: { + sessionId: "test-session-2", + workspaceId: "workspace-1", + paneId: "pane-2", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + + const response2 = await sendRequest(socket, createRequest2); + if (!response2.ok) { + // Log error details for debugging + console.error("Attach failed:", JSON.stringify(response2, null, 2)); + } + expect(response2.ok).toBe(true); + if (response2.ok) { + const payload = response2.payload as CreateOrAttachResponse; + expect(payload.isNew).toBe(false); + expect(payload.wasRecovered).toBe(true); + } + } finally { + socket.destroy(); + } + }); + }); + + describe("session operations", () => { + // Note: PTY operations may fail in test environment due to bun/node-pty compatibility + // The daemon infrastructure is tested separately in daemon.test.ts + it.skip("should write data to terminal and receive output", async () => { + const socket = await connectToDaemon(); + + try { + await authenticate(socket); + + // Create session + const createRequest: IpcRequest = { + id: "test-write-1", + type: "createOrAttach", + payload: { + sessionId: "test-session-write", + workspaceId: "workspace-1", + paneId: "pane-write", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + + await sendRequest(socket, createRequest); + + // Wait for shell prompt (data event) + const dataPromise = waitForEvent(socket, "data", 10000); + + // Write a simple echo command + const writeRequest: IpcRequest = { + id: "test-write-2", + type: "write", + payload: { + sessionId: "test-session-write", + data: "echo hello\n", + }, + }; + + const writeResponse = await sendRequest(socket, writeRequest); + if (!writeResponse.ok) { + console.error("Write failed:", writeResponse); + } + expect(writeResponse.ok).toBe(true); + + // Wait for output + const event = await dataPromise; + expect(event.sessionId).toBe("test-session-write"); + expect(event.event).toBe("data"); + + const payload = event.payload as TerminalDataEvent; + expect(payload.type).toBe("data"); + expect(typeof payload.data).toBe("string"); + } finally { + socket.destroy(); + } + }); + + // Note: PTY operations may fail in test environment due to bun/node-pty compatibility + it.skip("should resize terminal", async () => { + const socket = await connectToDaemon(); + + try { + await authenticate(socket); + + // Create session + const createRequest: IpcRequest = { + id: "test-resize-1", + type: "createOrAttach", + payload: { + sessionId: "test-session-resize", + workspaceId: "workspace-1", + paneId: "pane-resize", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + + await sendRequest(socket, createRequest); + + // Resize + const resizeRequest: IpcRequest = { + id: "test-resize-2", + type: "resize", + payload: { + sessionId: "test-session-resize", + cols: 120, + rows: 40, + }, + }; + + const resizeResponse = await sendRequest(socket, resizeRequest); + expect(resizeResponse.ok).toBe(true); + } finally { + socket.destroy(); + } + }); + }); + + describe("session listing", () => { + // Note: PTY operations may fail in test environment due to bun/node-pty compatibility + it.skip("should list all sessions", async () => { + const socket = await connectToDaemon(); + + try { + await authenticate(socket); + + // Create two sessions + for (const id of ["session-list-1", "session-list-2"]) { + const createRequest: IpcRequest = { + id: `create-${id}`, + type: "createOrAttach", + payload: { + sessionId: id, + workspaceId: "workspace-1", + paneId: `pane-${id}`, + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + await sendRequest(socket, createRequest); + } + + // List sessions + const listRequest: IpcRequest = { + id: "test-list", + type: "listSessions", + payload: undefined, + }; + + const listResponse = await sendRequest(socket, listRequest); + expect(listResponse.ok).toBe(true); + + if (listResponse.ok) { + const payload = listResponse.payload as ListSessionsResponse; + expect(payload.sessions.length).toBeGreaterThanOrEqual(2); + + const sessionIds = payload.sessions.map((s) => s.sessionId); + expect(sessionIds).toContain("session-list-1"); + expect(sessionIds).toContain("session-list-2"); + } + } finally { + socket.destroy(); + } + }); + }); + + describe("session termination", () => { + it("should kill a specific session", async () => { + const socket = await connectToDaemon(); + + try { + await authenticate(socket); + + // Create session + const createRequest: IpcRequest = { + id: "test-kill-1", + type: "createOrAttach", + payload: { + sessionId: "test-session-kill", + workspaceId: "workspace-1", + paneId: "pane-kill", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + + await sendRequest(socket, createRequest); + + // Kill session + const killRequest: IpcRequest = { + id: "test-kill-2", + type: "kill", + payload: { + sessionId: "test-session-kill", + }, + }; + + const killResponse = await sendRequest(socket, killRequest); + expect(killResponse.ok).toBe(true); + + // Wait for exit event + const exitEvent = await waitForEvent(socket, "exit", 5000); + expect(exitEvent.sessionId).toBe("test-session-kill"); + } finally { + socket.destroy(); + } + }); + + // Note: PTY operations may fail in test environment due to bun/node-pty compatibility + it.skip("should kill all sessions", async () => { + const socket = await connectToDaemon(); + + try { + await authenticate(socket); + + // Create sessions + for (const id of ["kill-all-1", "kill-all-2"]) { + const createRequest: IpcRequest = { + id: `create-${id}`, + type: "createOrAttach", + payload: { + sessionId: id, + workspaceId: "workspace-1", + paneId: `pane-${id}`, + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + await sendRequest(socket, createRequest); + } + + // Kill all + const killAllRequest: IpcRequest = { + id: "test-killall", + type: "killAll", + payload: {}, + }; + + const killAllResponse = await sendRequest(socket, killAllRequest); + expect(killAllResponse.ok).toBe(true); + + // Wait a bit for exits to propagate + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // List should show no alive sessions + const listRequest: IpcRequest = { + id: "test-list-after-kill", + type: "listSessions", + payload: undefined, + }; + + const listResponse = await sendRequest(socket, listRequest); + expect(listResponse.ok).toBe(true); + + if (listResponse.ok) { + const payload = listResponse.payload as ListSessionsResponse; + const aliveSessions = payload.sessions.filter((s) => s.isAlive); + expect(aliveSessions.length).toBe(0); + } + } finally { + socket.destroy(); + } + }); + }); +}); diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts new file mode 100644 index 00000000000..b4540bcaa94 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -0,0 +1,978 @@ +/** + * Terminal Host Session + * + * A session owns: + * - A PTY subprocess (isolates blocking writes from main daemon) + * - A HeadlessEmulator instance for state tracking + * - A set of attached clients + * - Output capture to disk + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import type { Socket } from "node:net"; +import * as path from "node:path"; +import { HeadlessEmulator } from "../lib/terminal-host/headless-emulator"; +import type { + CreateOrAttachRequest, + IpcEvent, + SessionMeta, + TerminalDataEvent, + TerminalErrorEvent, + TerminalExitEvent, + TerminalSnapshot, +} from "../lib/terminal-host/types"; +import { + createFrameHeader, + PtySubprocessFrameDecoder, + PtySubprocessIpcType, +} from "./pty-subprocess-ipc"; + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Timeout for flushing emulator writes during attach. + * Prevents indefinite hang when continuous output (e.g., tail -f) keeps the queue non-empty. + */ +const ATTACH_FLUSH_TIMEOUT_MS = 500; + +/** + * Maximum bytes allowed in subprocess stdin queue. + * Prevents OOM if subprocess stdin is backpressured (e.g., slow PTY consumer). + * 2MB is generous - typical large paste is ~50KB. + */ +const MAX_SUBPROCESS_STDIN_QUEUE_BYTES = 2_000_000; + +// ============================================================================= +// Types +// ============================================================================= + +export interface SessionOptions { + sessionId: string; + workspaceId: string; + paneId: string; + tabId: string; + cols: number; + rows: number; + cwd: string; + env?: Record; + shell?: string; + workspaceName?: string; + workspacePath?: string; + rootPath?: string; + scrollbackLines?: number; +} + +export interface AttachedClient { + socket: Socket; + attachedAt: number; +} + +// ============================================================================= +// Session Class +// ============================================================================= + +export class Session { + readonly sessionId: string; + readonly workspaceId: string; + readonly paneId: string; + readonly tabId: string; + readonly shell: string; + readonly createdAt: Date; + + private subprocess: ChildProcess | null = null; + private subprocessReady = false; + private emulator: HeadlessEmulator; + private attachedClients: Map = new Map(); + private clientSocketsWaitingForDrain: Set = new Set(); + private subprocessStdoutPaused = false; + private lastAttachedAt: Date; + private exitCode: number | null = null; + private disposed = false; + private terminatingAt: number | null = null; + private subprocessDecoder: PtySubprocessFrameDecoder | null = null; + private subprocessStdinQueue: Buffer[] = []; + private subprocessStdinQueuedBytes = 0; + private subprocessStdinDrainArmed = false; + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: stored for future debugging/logging + private ptyPid: number | null = null; + + // Promise that resolves when PTY is ready to accept writes + private ptyReadyPromise: Promise; + private ptyReadyResolve: (() => void) | null = null; + + private emulatorWriteQueue: string[] = []; + private emulatorWriteQueuedBytes = 0; + private emulatorWriteScheduled = false; + private emulatorFlushWaiters: Array<() => void> = []; + + // Snapshot boundary tracking - allows capturing consistent state with continuous output + private snapshotBoundaryIndex: number | null = null; + private snapshotBoundaryWaiters: Array<() => void> = []; + + // Callbacks + private onSessionExit?: ( + sessionId: string, + exitCode: number, + signal?: number, + ) => void; + + constructor(options: SessionOptions) { + this.sessionId = options.sessionId; + this.workspaceId = options.workspaceId; + this.paneId = options.paneId; + this.tabId = options.tabId; + this.shell = options.shell || this.getDefaultShell(); + this.createdAt = new Date(); + this.lastAttachedAt = new Date(); + + // Initialize PTY ready promise + this.ptyReadyPromise = new Promise((resolve) => { + this.ptyReadyResolve = resolve; + }); + + // Create headless emulator + this.emulator = new HeadlessEmulator({ + cols: options.cols, + rows: options.rows, + scrollback: options.scrollbackLines ?? 10000, + }); + + // Set initial CWD + this.emulator.setCwd(options.cwd); + + // Listen for emulator output (query responses) + this.emulator.onData((data) => { + // If no clients attached, send responses back to PTY + if ( + this.attachedClients.size === 0 && + this.subprocess && + this.subprocessReady + ) { + this.sendWriteToSubprocess(data); + } + }); + } + + /** + * Spawn the PTY process via subprocess + */ + spawn(options: { + cwd: string; + cols: number; + rows: number; + env?: Record; + }): void { + if (this.subprocess) { + throw new Error("PTY already spawned"); + } + + const { cwd, cols, rows, env = {} } = options; + + // Build environment - filter out undefined values and ELECTRON_RUN_AS_NODE + const processEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key === "ELECTRON_RUN_AS_NODE") continue; + if (value !== undefined) { + processEnv[key] = value; + } + } + Object.assign(processEnv, env); + processEnv.TERM = "xterm-256color"; + + // Get shell args + const shellArgs = this.getShellArgs(this.shell); + + // Spawn PTY subprocess + // The subprocess script is bundled alongside terminal-host.js + const subprocessPath = path.join(__dirname, "pty-subprocess.js"); + + // Use electron as node to run the subprocess + const electronPath = process.execPath; + this.subprocess = spawn(electronPath, [subprocessPath], { + stdio: ["pipe", "pipe", "inherit"], // pipe stdin/stdout, inherit stderr + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + }, + }); + + // Read framed messages from subprocess stdout + if (this.subprocess.stdout) { + this.subprocessDecoder = new PtySubprocessFrameDecoder(); + this.subprocess.stdout.on("data", (chunk: Buffer) => { + try { + const frames = this.subprocessDecoder?.push(chunk) ?? []; + for (const frame of frames) { + this.handleSubprocessFrame(frame.type, frame.payload); + } + } catch (error) { + console.error( + `[Session ${this.sessionId}] Failed to parse subprocess frames:`, + error, + ); + } + }); + } + + // Handle subprocess exit + this.subprocess.on("exit", (code) => { + console.log( + `[Session ${this.sessionId}] Subprocess exited with code ${code}`, + ); + this.handleSubprocessExit(code ?? -1); + }); + + this.subprocess.on("error", (error) => { + console.error(`[Session ${this.sessionId}] Subprocess error:`, error); + this.handleSubprocessExit(-1); + }); + + // Debug: Log shell spawn config + console.log(`[Session ${this.sessionId}] Spawn config:`, { + shell: this.shell, + args: shellArgs, + cwd, + cols, + rows, + ZDOTDIR: processEnv.ZDOTDIR, + SUPERSET_ORIG_ZDOTDIR: processEnv.SUPERSET_ORIG_ZDOTDIR, + }); + + // Store pending spawn config + this.pendingSpawn = { + shell: this.shell, + args: shellArgs, + cwd, + cols, + rows, + env: processEnv, + }; + } + + private pendingSpawn: { + shell: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + env: Record; + } | null = null; + + /** + * Handle frames from the PTY subprocess + */ + private handleSubprocessFrame( + type: PtySubprocessIpcType, + payload: Buffer, + ): void { + switch (type) { + case PtySubprocessIpcType.Ready: + this.subprocessReady = true; + if (this.pendingSpawn) { + this.sendSpawnToSubprocess(this.pendingSpawn); + this.pendingSpawn = null; + } + break; + + case PtySubprocessIpcType.Spawned: + this.ptyPid = payload.length >= 4 ? payload.readUInt32LE(0) : null; + // Resolve the ready promise so callers can await PTY readiness + if (this.ptyReadyResolve) { + this.ptyReadyResolve(); + this.ptyReadyResolve = null; + } + break; + + case PtySubprocessIpcType.Data: { + if (payload.length === 0) break; + const data = payload.toString("utf8"); + + this.enqueueEmulatorWrite(data); + + this.broadcastEvent("data", { + type: "data", + data, + } satisfies TerminalDataEvent); + break; + } + + case PtySubprocessIpcType.Exit: { + const exitCode = payload.length >= 4 ? payload.readInt32LE(0) : 0; + const signal = payload.length >= 8 ? payload.readInt32LE(4) : 0; + this.exitCode = exitCode; + + this.broadcastEvent("exit", { + type: "exit", + exitCode, + signal: signal !== 0 ? signal : undefined, + } satisfies TerminalExitEvent); + + this.onSessionExit?.( + this.sessionId, + exitCode, + signal !== 0 ? signal : undefined, + ); + break; + } + + case PtySubprocessIpcType.Error: { + const errorMessage = + payload.length > 0 + ? payload.toString("utf8") + : "Unknown subprocess error"; + + console.error( + `[Session ${this.sessionId}] Subprocess error:`, + errorMessage, + ); + + this.broadcastEvent("error", { + type: "error", + error: errorMessage, + code: errorMessage.includes("Write queue full") + ? "WRITE_QUEUE_FULL" + : "SUBPROCESS_ERROR", + } satisfies TerminalErrorEvent); + break; + } + } + } + + /** + * Handle subprocess exiting + */ + private handleSubprocessExit(exitCode: number): void { + if (this.exitCode === null) { + this.exitCode = exitCode; + + this.broadcastEvent("exit", { + type: "exit", + exitCode, + } satisfies TerminalExitEvent); + + this.onSessionExit?.(this.sessionId, exitCode); + } + + this.subprocess = null; + this.subprocessReady = false; + this.subprocessDecoder = null; + this.subprocessStdinQueue = []; + this.subprocessStdinQueuedBytes = 0; + this.subprocessStdinDrainArmed = false; + this.subprocessStdoutPaused = false; + + this.emulatorWriteQueue = []; + this.emulatorWriteQueuedBytes = 0; + this.emulatorWriteScheduled = false; + this.snapshotBoundaryIndex = null; + const waiters = this.emulatorFlushWaiters; + this.emulatorFlushWaiters = []; + for (const resolve of waiters) resolve(); + const boundaryWaiters = this.snapshotBoundaryWaiters; + this.snapshotBoundaryWaiters = []; + for (const resolve of boundaryWaiters) resolve(); + } + + /** + * Flush queued frames to subprocess stdin, respecting stream backpressure. + */ + private flushSubprocessStdinQueue(): void { + if (!this.subprocess?.stdin || this.disposed) return; + + while (this.subprocessStdinQueue.length > 0) { + const buf = this.subprocessStdinQueue[0]; + const canWrite = this.subprocess.stdin.write(buf); + if (!canWrite) { + if (!this.subprocessStdinDrainArmed) { + this.subprocessStdinDrainArmed = true; + this.subprocess.stdin.once("drain", () => { + this.subprocessStdinDrainArmed = false; + this.flushSubprocessStdinQueue(); + }); + } + return; + } + + this.subprocessStdinQueue.shift(); + this.subprocessStdinQueuedBytes -= buf.length; + } + } + + /** + * Send a frame to the subprocess. + * Returns false if write buffer is full (caller should handle). + */ + private sendFrameToSubprocess( + type: PtySubprocessIpcType, + payload?: Buffer, + ): boolean { + if (!this.subprocess?.stdin || this.disposed) return false; + + const payloadBuffer = payload ?? Buffer.alloc(0); + const frameSize = 5 + payloadBuffer.length; // 5-byte header + payload + + // Check queue limit to prevent OOM under backpressure + if ( + this.subprocessStdinQueuedBytes + frameSize > + MAX_SUBPROCESS_STDIN_QUEUE_BYTES + ) { + console.warn( + `[Session ${this.sessionId}] stdin queue full (${this.subprocessStdinQueuedBytes} bytes), dropping frame`, + ); + this.broadcastEvent("error", { + type: "error", + error: "Write queue full - input dropped", + code: "WRITE_QUEUE_FULL", + } satisfies TerminalErrorEvent); + return false; + } + + const header = createFrameHeader(type, payloadBuffer.length); + + this.subprocessStdinQueue.push(header); + this.subprocessStdinQueuedBytes += header.length; + + if (payloadBuffer.length > 0) { + this.subprocessStdinQueue.push(payloadBuffer); + this.subprocessStdinQueuedBytes += payloadBuffer.length; + } + + const wasBackpressured = this.subprocessStdinDrainArmed; + this.flushSubprocessStdinQueue(); + + if (this.subprocessStdinDrainArmed && !wasBackpressured) { + console.warn( + `[Session ${this.sessionId}] stdin buffer full, write may be delayed`, + ); + } + + return !this.subprocessStdinDrainArmed; + } + + private sendSpawnToSubprocess(payload: { + shell: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + env: Record; + }): boolean { + return this.sendFrameToSubprocess( + PtySubprocessIpcType.Spawn, + Buffer.from(JSON.stringify(payload), "utf8"), + ); + } + + private sendWriteToSubprocess(data: string): boolean { + // Chunk large writes to avoid allocating/queuing massive single frames. + const MAX_CHUNK_CHARS = 8192; + let ok = true; + + for (let offset = 0; offset < data.length; offset += MAX_CHUNK_CHARS) { + const part = data.slice(offset, offset + MAX_CHUNK_CHARS); + ok = + this.sendFrameToSubprocess( + PtySubprocessIpcType.Write, + Buffer.from(part, "utf8"), + ) && ok; + } + + return ok; + } + + private sendResizeToSubprocess(cols: number, rows: number): boolean { + const payload = Buffer.allocUnsafe(8); + payload.writeUInt32LE(cols, 0); + payload.writeUInt32LE(rows, 4); + return this.sendFrameToSubprocess(PtySubprocessIpcType.Resize, payload); + } + + private sendKillToSubprocess(signal?: string): boolean { + const payload = signal ? Buffer.from(signal, "utf8") : undefined; + return this.sendFrameToSubprocess(PtySubprocessIpcType.Kill, payload); + } + + private sendDisposeToSubprocess(): boolean { + return this.sendFrameToSubprocess(PtySubprocessIpcType.Dispose); + } + + private enqueueEmulatorWrite(data: string): void { + this.emulatorWriteQueue.push(data); + this.emulatorWriteQueuedBytes += data.length; + this.scheduleEmulatorWrite(); + } + + private scheduleEmulatorWrite(): void { + if (this.emulatorWriteScheduled || this.disposed) return; + this.emulatorWriteScheduled = true; + setImmediate(() => { + this.processEmulatorWriteQueue(); + }); + } + + private processEmulatorWriteQueue(): void { + if (this.disposed) { + this.emulatorWriteQueue = []; + this.emulatorWriteQueuedBytes = 0; + this.emulatorWriteScheduled = false; + this.snapshotBoundaryIndex = null; + const waiters = this.emulatorFlushWaiters; + this.emulatorFlushWaiters = []; + for (const resolve of waiters) resolve(); + const boundaryWaiters = this.snapshotBoundaryWaiters; + this.snapshotBoundaryWaiters = []; + for (const resolve of boundaryWaiters) resolve(); + return; + } + + const start = performance.now(); + const hasClients = this.attachedClients.size > 0; + const backlogBytes = this.emulatorWriteQueuedBytes; + + // Keep the daemon responsive while still ensuring the emulator catches up eventually. + const baseBudgetMs = hasClients ? 5 : 25; + const budgetMs = + backlogBytes > 1024 * 1024 ? Math.max(baseBudgetMs, 25) : baseBudgetMs; + const MAX_CHUNK_CHARS = 8192; + + while (this.emulatorWriteQueue.length > 0) { + if (performance.now() - start > budgetMs) break; + + let chunk = this.emulatorWriteQueue[0]; + if (chunk.length > MAX_CHUNK_CHARS) { + this.emulatorWriteQueue[0] = chunk.slice(MAX_CHUNK_CHARS); + chunk = chunk.slice(0, MAX_CHUNK_CHARS); + } else { + this.emulatorWriteQueue.shift(); + + // Decrement boundary counter if tracking + if (this.snapshotBoundaryIndex !== null) { + this.snapshotBoundaryIndex--; + } + } + + this.emulatorWriteQueuedBytes -= chunk.length; + this.emulator.write(chunk); + + // Check if we've reached the snapshot boundary (processed all items up to it) + if (this.snapshotBoundaryIndex === 0) { + this.snapshotBoundaryIndex = null; + const boundaryWaiters = this.snapshotBoundaryWaiters; + this.snapshotBoundaryWaiters = []; + for (const resolve of boundaryWaiters) resolve(); + // Continue processing remaining items (arrived after boundary was set) + if (this.emulatorWriteQueue.length > 0) { + setImmediate(() => { + this.processEmulatorWriteQueue(); + }); + return; + } + this.emulatorWriteScheduled = false; + const waiters = this.emulatorFlushWaiters; + this.emulatorFlushWaiters = []; + for (const resolve of waiters) resolve(); + return; + } + } + + if (this.emulatorWriteQueue.length > 0) { + setImmediate(() => { + this.processEmulatorWriteQueue(); + }); + return; + } + + this.emulatorWriteScheduled = false; + + // If we've drained the queue, any pending boundary is also reached + if (this.snapshotBoundaryIndex !== null) { + this.snapshotBoundaryIndex = null; + const boundaryWaiters = this.snapshotBoundaryWaiters; + this.snapshotBoundaryWaiters = []; + for (const resolve of boundaryWaiters) resolve(); + } + + const waiters = this.emulatorFlushWaiters; + this.emulatorFlushWaiters = []; + for (const resolve of waiters) resolve(); + } + + /** + * Flush emulator writes up to current queue position (snapshot boundary). + * Unlike flushEmulatorWrites, this captures a consistent point-in-time state + * even with continuous output - we only wait for data received BEFORE this call. + * + * The key insight: snapshotBoundaryIndex tracks how many items REMAIN that + * need to be processed. Each time we shift an item, we decrement it. + * When it reaches 0, we've processed everything up to the boundary. + */ + private async flushToSnapshotBoundary(timeoutMs: number): Promise { + // Mark the current queue length as how many items we need to process + const itemsToProcess = this.emulatorWriteQueue.length; + + if (itemsToProcess === 0 && !this.emulatorWriteScheduled) { + return true; // Already flushed + } + + // Set the boundary counter - processEmulatorWriteQueue will decrement this + this.snapshotBoundaryIndex = itemsToProcess; + + const boundaryPromise = new Promise((resolve) => { + this.snapshotBoundaryWaiters.push(resolve); + this.scheduleEmulatorWrite(); + }); + + const timeoutPromise = new Promise((resolve) => + setTimeout(resolve, timeoutMs), + ); + + await Promise.race([boundaryPromise, timeoutPromise]); + + // Check if we actually reached the boundary or timed out + const reachedBoundary = this.snapshotBoundaryIndex === null; + + // Clean up if timed out (boundary wasn't reached) + if (!reachedBoundary) { + this.snapshotBoundaryIndex = null; + // Remove our waiter from the list + this.snapshotBoundaryWaiters = []; + } + + return reachedBoundary; + } + + /** + * Check if session is alive (PTY running) + */ + get isAlive(): boolean { + return this.subprocess !== null && this.exitCode === null; + } + + /** + * Get the PTY process ID for port scanning. + * Returns null if PTY not yet spawned or has exited. + */ + get pid(): number | null { + return this.ptyPid; + } + + /** + * Check if session is in the process of terminating. + * A terminating session has received a kill signal but hasn't exited yet. + */ + get isTerminating(): boolean { + return this.terminatingAt !== null; + } + + /** + * Check if session can be attached to. + * A session is attachable if it's alive and not terminating. + * This prevents race conditions where createOrAttach is called + * immediately after kill but before the PTY has actually exited. + */ + get isAttachable(): boolean { + return this.isAlive && !this.isTerminating; + } + + /** + * Wait for PTY to be ready to accept writes. + * Returns immediately if already ready, or waits for Spawned event. + */ + waitForReady(): Promise { + return this.ptyReadyPromise; + } + + /** + * Get number of attached clients + */ + get clientCount(): number { + return this.attachedClients.size; + } + + /** + * Attach a client to this session + */ + async attach(socket: Socket): Promise { + if (this.disposed) { + throw new Error("Session disposed"); + } + + this.attachedClients.set(socket, { + socket, + attachedAt: Date.now(), + }); + this.lastAttachedAt = new Date(); + + // Use snapshot boundary flush for consistent state with continuous output. + // This ensures we capture all data received BEFORE attach was called, + // even if new data continues to arrive during the flush. + const reachedBoundary = await this.flushToSnapshotBoundary( + ATTACH_FLUSH_TIMEOUT_MS, + ); + + if (!reachedBoundary) { + console.warn( + `[Session ${this.sessionId}] Attach flush timeout after ${ATTACH_FLUSH_TIMEOUT_MS}ms`, + ); + } + + return this.emulator.getSnapshotAsync(); + } + + /** + * Detach a client from this session + */ + detach(socket: Socket): void { + this.attachedClients.delete(socket); + this.clientSocketsWaitingForDrain.delete(socket); + this.maybeResumeSubprocessStdout(); + } + + /** + * Write data to PTY (non-blocking - sent to subprocess) + */ + write(data: string): void { + if (!this.subprocess || !this.subprocessReady) { + throw new Error("PTY not spawned"); + } + this.sendWriteToSubprocess(data); + } + + /** + * Resize PTY and emulator + */ + resize(cols: number, rows: number): void { + if (this.subprocess && this.subprocessReady) { + this.sendResizeToSubprocess(cols, rows); + } + this.emulator.resize(cols, rows); + } + + /** + * Clear scrollback buffer + */ + clearScrollback(): void { + this.emulator.clear(); + } + + /** + * Get session snapshot + */ + getSnapshot(): TerminalSnapshot { + return this.emulator.getSnapshot(); + } + + /** + * Get session metadata + */ + getMeta(): SessionMeta { + const dims = this.emulator.getDimensions(); + return { + sessionId: this.sessionId, + workspaceId: this.workspaceId, + paneId: this.paneId, + cwd: this.emulator.getCwd() || "", + cols: dims.cols, + rows: dims.rows, + createdAt: this.createdAt.toISOString(), + lastAttachedAt: this.lastAttachedAt.toISOString(), + shell: this.shell, + }; + } + + /** + * Kill the PTY process. + * Marks the session as terminating immediately (idempotent). + * The actual PTY termination is async - use isTerminating to check state. + */ + kill(signal: string = "SIGTERM"): void { + // Idempotent: if already terminating, don't send another signal + if (this.terminatingAt !== null) { + return; + } + + // Mark as terminating immediately to prevent race conditions + this.terminatingAt = Date.now(); + + if (this.subprocess && this.subprocessReady) { + this.sendKillToSubprocess(signal); + return; + } + + // If the subprocess isn't ready yet, fall back to killing the subprocess itself + // so session termination is reliable (differentiation isn't meaningful pre-spawn). + try { + this.subprocess?.kill(signal as NodeJS.Signals); + } catch { + // Process may already be dead + } + } + + /** + * Dispose of the session + */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + + if (this.subprocess) { + // Capture reference before nullifying - the timeout needs it + const subprocess = this.subprocess; + this.sendDisposeToSubprocess(); + // Force kill after timeout if dispose frame didn't terminate it + const killTimer = setTimeout(() => { + try { + subprocess.kill("SIGKILL"); + } catch { + // Process may already be dead + } + }, 1000); + killTimer.unref(); // Don't keep daemon alive for this timer + this.subprocess = null; + } + this.subprocessReady = false; + this.subprocessDecoder = null; + this.subprocessStdinQueue = []; + this.subprocessStdinQueuedBytes = 0; + this.subprocessStdinDrainArmed = false; + + this.emulatorWriteQueue = []; + this.emulatorWriteQueuedBytes = 0; + this.emulatorWriteScheduled = false; + this.snapshotBoundaryIndex = null; + const waiters = this.emulatorFlushWaiters; + this.emulatorFlushWaiters = []; + for (const resolve of waiters) resolve(); + const boundaryWaiters = this.snapshotBoundaryWaiters; + this.snapshotBoundaryWaiters = []; + for (const resolve of boundaryWaiters) resolve(); + + this.emulator.dispose(); + this.attachedClients.clear(); + this.clientSocketsWaitingForDrain.clear(); + this.subprocessStdoutPaused = false; + } + + /** + * Set exit callback + */ + onExit( + callback: (sessionId: string, exitCode: number, signal?: number) => void, + ): void { + this.onSessionExit = callback; + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Broadcast an event to all attached clients with backpressure awareness. + */ + private broadcastEvent( + eventType: string, + payload: TerminalDataEvent | TerminalExitEvent | TerminalErrorEvent, + ): void { + const event: IpcEvent = { + type: "event", + event: eventType, + sessionId: this.sessionId, + payload, + }; + + const message = `${JSON.stringify(event)}\n`; + + for (const { socket } of this.attachedClients.values()) { + try { + const canWrite = socket.write(message); + if (!canWrite) { + // Socket buffer full - data will be queued but may cause memory pressure + // In production, could track this and pause PTY output temporarily + console.warn( + `[Session ${this.sessionId}] Client socket buffer full, output may be delayed`, + ); + this.handleClientBackpressure(socket); + } + } catch { + this.attachedClients.delete(socket); + this.clientSocketsWaitingForDrain.delete(socket); + } + } + } + + private handleClientBackpressure(socket: Socket): void { + // If the client can’t keep up, pause reading from the subprocess stdout. + // This will backpressure the subprocess stdout pipe, which in turn pauses + // PTY reads inside the subprocess (preventing runaway buffering/CPU). + if (!this.subprocessStdoutPaused && this.subprocess?.stdout) { + this.subprocessStdoutPaused = true; + this.subprocess.stdout.pause(); + } + + if (this.clientSocketsWaitingForDrain.has(socket)) return; + this.clientSocketsWaitingForDrain.add(socket); + + socket.once("drain", () => { + this.clientSocketsWaitingForDrain.delete(socket); + this.maybeResumeSubprocessStdout(); + }); + } + + private maybeResumeSubprocessStdout(): void { + if (this.clientSocketsWaitingForDrain.size > 0) return; + if (!this.subprocessStdoutPaused) return; + if (!this.subprocess?.stdout) return; + + this.subprocessStdoutPaused = false; + this.subprocess.stdout.resume(); + } + + /** + * Get default shell for the platform + */ + private getDefaultShell(): string { + if (process.platform === "win32") { + return process.env.COMSPEC || "cmd.exe"; + } + return process.env.SHELL || "/bin/zsh"; + } + + /** + * Get shell arguments for login shell + */ + private getShellArgs(shell: string): string[] { + const shellName = shell.split("/").pop() || ""; + + if (["zsh", "bash", "sh", "ksh", "fish"].includes(shellName)) { + return ["-l"]; + } + + return []; + } +} + +// ============================================================================= +// Factory Functions +// ============================================================================= + +/** + * Create a new session from request parameters + */ +export function createSession(request: CreateOrAttachRequest): Session { + return new Session({ + sessionId: request.sessionId, + workspaceId: request.workspaceId, + paneId: request.paneId, + tabId: request.tabId, + cols: request.cols, + rows: request.rows, + cwd: request.cwd || process.env.HOME || "/", + env: request.env, + shell: request.shell, + workspaceName: request.workspaceName, + workspacePath: request.workspacePath, + rootPath: request.rootPath, + }); +} diff --git a/apps/desktop/src/main/terminal-host/terminal-host.ts b/apps/desktop/src/main/terminal-host/terminal-host.ts new file mode 100644 index 00000000000..75986144f70 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/terminal-host.ts @@ -0,0 +1,346 @@ +/** + * Terminal Host Manager + * + * Manages all terminal sessions in the daemon. + * Responsible for: + * - Session lifecycle (create, attach, detach, kill) + * - Session lookup and listing + * - Cleanup on shutdown + */ + +import type { Socket } from "node:net"; +import type { + ClearScrollbackRequest, + CreateOrAttachRequest, + CreateOrAttachResponse, + DetachRequest, + EmptyResponse, + KillAllRequest, + KillRequest, + ListSessionsResponse, + ResizeRequest, + WriteRequest, +} from "../lib/terminal-host/types"; +import { createSession, type Session } from "./session"; + +// ============================================================================= +// TerminalHost Class +// ============================================================================= + +/** Timeout for force-disposing sessions that don't exit after kill */ +const KILL_TIMEOUT_MS = 5000; + +export class TerminalHost { + private sessions: Map = new Map(); + private killTimers: Map = new Map(); + + /** + * Create or attach to a terminal session + */ + async createOrAttach( + socket: Socket, + request: CreateOrAttachRequest, + ): Promise { + const { sessionId } = request; + + let session = this.sessions.get(sessionId); + let isNew = false; + + // If session is terminating (kill was called but PTY hasn't exited yet), + // force-dispose it and create a fresh session. This prevents race conditions + // where createOrAttach is called immediately after kill. + if (session?.isTerminating) { + console.log( + `[TerminalHost] Session ${sessionId} is terminating, force-disposing for fresh start`, + ); + session.dispose(); + this.sessions.delete(sessionId); + this.clearKillTimer(sessionId); + session = undefined; + } + + // If session exists but is dead, dispose it and create a new one + if (session && !session.isAlive) { + session.dispose(); + this.sessions.delete(sessionId); + session = undefined; + } + + if (!session) { + // Create new session + session = createSession(request); + + // Set up exit handler + session.onExit((id, exitCode, signal) => { + this.handleSessionExit(id, exitCode, signal); + }); + + // Spawn PTY + session.spawn({ + cwd: request.cwd || process.env.HOME || "/", + cols: request.cols, + rows: request.rows, + env: request.env, + }); + + // Run initial commands if provided (after PTY is ready) + if (request.initialCommands && request.initialCommands.length > 0) { + const initialCommands = request.initialCommands; + // Wait for PTY to be ready, then run commands + session.waitForReady().then(() => { + // Double-check session is still alive after await + if (session?.isAlive) { + try { + const cmdString = `${initialCommands.join(" && ")}\n`; + session.write(cmdString); + } catch (error) { + // Log but don't crash - initialCommands are best-effort + console.error( + `[TerminalHost] Failed to run initial commands for ${sessionId}:`, + error, + ); + } + } + }); + } + + this.sessions.set(sessionId, session); + isNew = true; + } else { + // Attaching to existing live session - resize to requested dimensions + // This ensures the snapshot reflects the client's current terminal size + // Note: Resize can fail if PTY is in a bad state (e.g., EBADF) + // We catch and ignore these errors since the session may still be usable + try { + session.resize(request.cols, request.rows); + } catch { + // Ignore resize failures - session may still be attachable + } + } + + // Attach client to session (async to ensure pending writes are flushed) + const snapshot = await session.attach(socket); + + return { + isNew, + snapshot, + wasRecovered: !isNew && session.isAlive, + pid: session.pid, + }; + } + + /** + * Write data to a terminal session. + * Throws if session is not found or is terminating. + */ + write(request: WriteRequest): EmptyResponse { + const session = this.getActiveSession(request.sessionId); + session.write(request.data); + return { success: true }; + } + + /** + * Resize a terminal session. + * No-op if session is not found or is terminating (prevents race condition errors). + */ + resize(request: ResizeRequest): EmptyResponse { + const session = this.sessions.get(request.sessionId); + // Silently succeed if session doesn't exist or is terminating + // This prevents noisy errors during kill/reconciliation races + if (!session || !session.isAttachable) { + return { success: true }; + } + session.resize(request.cols, request.rows); + return { success: true }; + } + + /** + * Detach a client from a session + */ + detach(socket: Socket, request: DetachRequest): EmptyResponse { + const session = this.sessions.get(request.sessionId); + if (session) { + session.detach(socket); + // Clean up dead sessions when last client detaches + if (!session.isAlive && session.clientCount === 0) { + session.dispose(); + this.sessions.delete(request.sessionId); + } + } + return { success: true }; + } + + /** + * Kill a terminal session. + * The session is marked as terminating immediately (non-attachable). + * A fail-safe timer ensures cleanup even if the PTY never exits. + */ + kill(request: KillRequest): EmptyResponse { + const { sessionId } = request; + const session = this.sessions.get(sessionId); + + if (!session) { + return { success: true }; + } + + session.kill(); + + // Set up fail-safe timer to force-dispose if exit never fires. + // This prevents zombie sessions if the PTY process hangs. + if (!this.killTimers.has(sessionId)) { + const timer = setTimeout(() => { + const s = this.sessions.get(sessionId); + if (s?.isTerminating) { + console.warn( + `[TerminalHost] Force disposing stuck session ${sessionId} after ${KILL_TIMEOUT_MS}ms`, + ); + s.dispose(); + this.sessions.delete(sessionId); + } + this.killTimers.delete(sessionId); + }, KILL_TIMEOUT_MS); + this.killTimers.set(sessionId, timer); + } + + return { success: true }; + } + + /** + * Kill all terminal sessions + */ + killAll(_request: KillAllRequest): EmptyResponse { + for (const session of this.sessions.values()) { + session.kill(); + } + // Sessions will be removed on exit events + return { success: true }; + } + + /** + * List all sessions. + * Note: isAlive reports isAttachable (alive AND not terminating) to prevent + * race conditions where killByWorkspaceId sees a session as alive while + * it's actually in the process of being killed. + */ + listSessions(): ListSessionsResponse { + const sessions = Array.from(this.sessions.values()).map((session) => ({ + sessionId: session.sessionId, + workspaceId: session.workspaceId, + paneId: session.paneId, + isAlive: session.isAttachable, // Use isAttachable to prevent kill/attach races + attachedClients: session.clientCount, + pid: session.pid, + })); + + return { sessions }; + } + + /** + * Clear scrollback for a session. + * Throws if session is not found or is terminating. + */ + clearScrollback(request: ClearScrollbackRequest): EmptyResponse { + const session = this.getActiveSession(request.sessionId); + session.clearScrollback(); + return { success: true }; + } + + /** + * Detach a socket from all sessions it's attached to + * Called when a client connection closes + */ + detachFromAllSessions(socket: Socket): void { + for (const [sessionId, session] of this.sessions.entries()) { + session.detach(socket); + // Clean up dead sessions when last client detaches + if (!session.isAlive && session.clientCount === 0) { + session.dispose(); + this.sessions.delete(sessionId); + } + } + } + + /** + * Clean up all sessions on shutdown + */ + dispose(): void { + // Clear all kill timers + for (const timer of this.killTimers.values()) { + clearTimeout(timer); + } + this.killTimers.clear(); + + // Dispose all sessions + for (const session of this.sessions.values()) { + session.dispose(); + } + this.sessions.clear(); + } + + /** + * Get an active (attachable) session by ID. + * Throws if session doesn't exist or is terminating. + * Use this for mutating operations (write, resize, clearScrollback). + */ + private getActiveSession(sessionId: string): Session { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + if (!session.isAttachable) { + throw new Error(`Session not attachable: ${sessionId}`); + } + return session; + } + + /** + * Handle session exit + */ + private handleSessionExit( + sessionId: string, + _exitCode: number, + _signal?: number, + ): void { + // Clear the kill timer since session exited normally + this.clearKillTimer(sessionId); + + // Keep session around for a bit so clients can see exit status + // Then clean up (reschedule if clients still attached) + this.scheduleSessionCleanup(sessionId); + } + + /** + * Clear the kill timeout for a session + */ + private clearKillTimer(sessionId: string): void { + const timer = this.killTimers.get(sessionId); + if (timer) { + clearTimeout(timer); + this.killTimers.delete(sessionId); + } + } + + /** + * Schedule cleanup of a dead session + * Reschedules if clients are still attached + */ + private scheduleSessionCleanup(sessionId: string): void { + setTimeout(() => { + const session = this.sessions.get(sessionId); + if (!session || session.isAlive) { + // Session was recreated or is alive, nothing to clean up + return; + } + + if (session.clientCount === 0) { + // No clients attached, safe to clean up + session.dispose(); + this.sessions.delete(sessionId); + } else { + // Clients still attached, reschedule cleanup + // They'll see the exit status and can restart + this.scheduleSessionCleanup(sessionId); + } + }, 5000); + } +} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx index b949cc29523..c3f48e4d886 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx @@ -7,6 +7,7 @@ import { PresetsSettings } from "./PresetsSettings"; import { ProjectSettings } from "./ProjectSettings"; import { RingtonesSettings } from "./RingtonesSettings"; import { TeamSettings } from "./TeamSettings"; +import { TerminalSettings } from "./TerminalSettings"; import { WorkspaceSettings } from "./WorkspaceSettings"; interface SettingsContentProps { @@ -25,6 +26,7 @@ export function SettingsContent({ activeSection }: SettingsContentProps) { {activeSection === "keyboard" && } {activeSection === "presets" && } {activeSection === "behavior" && } + {activeSection === "terminal" && } ); } diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx index 883e4a7a834..5e1b03a0c10 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx @@ -4,6 +4,7 @@ import { HiOutlineBell, HiOutlineCog6Tooth, HiOutlineCommandLine, + HiOutlineComputerDesktop, HiOutlinePaintBrush, HiOutlineUser, HiOutlineUserGroup, @@ -55,6 +56,11 @@ const GENERAL_SECTIONS: { label: "Behavior", icon: , }, + { + id: "terminal", + label: "Terminal", + icon: , + }, ]; export function GeneralSettings({ diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx new file mode 100644 index 00000000000..4e4e6cafe78 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx @@ -0,0 +1,80 @@ +import { Label } from "@superset/ui/label"; +import { Switch } from "@superset/ui/switch"; +import { trpc } from "renderer/lib/trpc"; + +export function TerminalSettings() { + const utils = trpc.useUtils(); + const { data: terminalPersistence, isLoading } = + trpc.settings.getTerminalPersistence.useQuery(); + const setTerminalPersistence = + trpc.settings.setTerminalPersistence.useMutation({ + onMutate: async ({ enabled }) => { + // Cancel outgoing fetches + await utils.settings.getTerminalPersistence.cancel(); + // Snapshot previous value + const previous = utils.settings.getTerminalPersistence.getData(); + // Optimistically update + utils.settings.getTerminalPersistence.setData(undefined, enabled); + return { previous }; + }, + onError: (_err, _vars, context) => { + // Rollback on error + if (context?.previous !== undefined) { + utils.settings.getTerminalPersistence.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + // Refetch to ensure sync with server + utils.settings.getTerminalPersistence.invalidate(); + }, + }); + + const handleToggle = (enabled: boolean) => { + setTerminalPersistence.mutate({ enabled }); + }; + + return ( +
+
+

Terminal

+

+ Configure terminal behavior and persistence +

+
+ +
+
+
+ +

+ Keep terminal sessions alive across app restarts and workspace + switches. TUI apps like Claude Code will resume exactly where you + left off. +

+

+ May use more memory with many terminals open. Disable if you + notice performance issues. +

+

+ Requires app restart to take effect. +

+
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index 8e82a2d5289..d2f4aed315f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -16,6 +16,7 @@ interface TabPaneProps { paneId: string; path: MosaicBranch[]; isActive: boolean; + isTabVisible: boolean; tabId: string; workspaceId: string; splitPaneAuto: ( @@ -45,6 +46,7 @@ export function TabPane({ paneId, path, isActive, + isTabVisible, tabId, workspaceId, splitPaneAuto, @@ -123,7 +125,11 @@ export function TabPane({ onMoveToNewTab={onMoveToNewTab} >
- +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 19e8d8e1bf4..49b04da0af7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -21,9 +21,10 @@ import { TabPane } from "./TabPane"; interface TabViewProps { tab: Tab; + isTabVisible: boolean; } -export function TabView({ tab }: TabViewProps) { +export function TabView({ tab, isTabVisible }: TabViewProps) { const updateTabLayout = useTabsStore((s) => s.updateTabLayout); const removePane = useTabsStore((s) => s.removePane); const removeTab = useTabsStore((s) => s.removeTab); @@ -145,6 +146,7 @@ export function TabView({ tab }: TabViewProps) { paneId={paneId} path={path} isActive={isActive} + isTabVisible={isTabVisible} tabId={tab.id} workspaceId={tab.workspaceId} splitPaneAuto={splitPaneAuto} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 027cf9eb4e4..166f3e3c44b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -1,7 +1,7 @@ import { toast } from "@superset/ui/sonner"; import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; -import type { Terminal as XTerm } from "@xterm/xterm"; +import type { IDisposable, Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import debounce from "lodash/debounce"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -20,7 +20,9 @@ import { setupKeyboardHandler, setupPasteHandler, setupResizeHandlers, + type TerminalRendererRef, } from "./helpers"; +import { useTerminalConnection } from "./hooks"; import { parseCwd } from "./parseCwd"; import { ScrollToBottomButton } from "./ScrollToBottomButton"; import { TerminalSearch } from "./TerminalSearch"; @@ -32,6 +34,55 @@ import { smoothScrollToBottom, } from "./utils"; +const FIRST_RENDER_RESTORE_FALLBACK_MS = 250; + +// Module-level map to track pending detach timeouts. +// This survives React StrictMode's unmount/remount cycle, allowing us to +// cancel a pending detach if the component immediately remounts. +const pendingDetaches = new Map(); + +// Module-level map to track cold restore state across StrictMode cycles. +// When cold restore is detected, we store the state here so it survives +// the unmount/remount that StrictMode causes. Without this, the first mount +// detects cold restore and sets state, but StrictMode unmounts and remounts +// with fresh state, losing the cold restore detection. +const coldRestoreState = new Map< + string, + { isRestored: boolean; cwd: string | null; scrollback: string } +>(); + +type CreateOrAttachResult = { + wasRecovered: boolean; + isNew: boolean; + scrollback: string; + viewportY?: number; + // Cold restore fields (for reboot recovery) + isColdRestore?: boolean; + previousCwd?: string; + snapshot?: { + snapshotAnsi: string; + rehydrateSequences: string; + cwd: string | null; + modes: Record; + cols: number; + rows: number; + scrollbackLines: number; + debug?: { + xtermBufferType: string; + hasAltScreenEntry: boolean; + altBuffer?: { + lines: number; + nonEmptyLines: number; + totalChars: number; + cursorX: number; + cursorY: number; + sampleLines: string[]; + }; + normalBufferLines: number; + }; + }; +}; + export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const paneId = tabId; // Use granular selectors to avoid re-renders when other panes change @@ -44,14 +95,17 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const xtermRef = useRef(null); const fitAddonRef = useRef(null); const searchAddonRef = useRef(null); + const rendererRef = useRef(null); const isExitedRef = useRef(false); const pendingEventsRef = useRef([]); const commandBufferRef = useRef(""); - const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const [xtermInstance, setXtermInstance] = useState(null); const [terminalCwd, setTerminalCwd] = useState(null); const [cwdConfirmed, setCwdConfirmed] = useState(false); + // Cold restore state (for reboot recovery) + const [isRestoredMode, setIsRestoredMode] = useState(false); + const [restoredCwd, setRestoredCwd] = useState(null); const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const setTabAutoTitle = useTabsStore((s) => s.setTabAutoTitle); const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); @@ -63,11 +117,42 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const setPaneStatus = useTabsStore((s) => s.setPaneStatus); const terminalTheme = useTerminalTheme(); + // Terminal connection state and mutations (extracted to hook for cleaner code) + const { + connectionError, + setConnectionError, + workspaceCwd, + refs: { + createOrAttach: createOrAttachRef, + write: writeRef, + resize: resizeRef, + detach: detachRef, + clearScrollback: clearScrollbackRef, + }, + } = useTerminalConnection({ workspaceId }); + // Ref for initial theme to avoid recreating terminal on theme change const initialThemeRef = useRef(terminalTheme); const isFocused = focusedPaneId === paneId; + // Gate streaming until initial state restoration is applied to avoid interleaving output. + const isStreamReadyRef = useRef(false); + + // Gate restoration until xterm has rendered at least once (renderer/viewport ready). + const didFirstRenderRef = useRef(false); + const pendingInitialStateRef = useRef(null); + const renderDisposableRef = useRef(null); + const restoreSequenceRef = useRef(0); + + // Track alternate screen mode ourselves (xterm.buffer.active.type is unreliable after HMR/recovery) + // Updated from: snapshot.modes.alternateScreen on restore, escape sequences in stream + const isAlternateScreenRef = useRef(false); + // Track bracketed paste mode so large pastes can preserve a single bracketed-paste envelope. + const isBracketedPasteRef = useRef(false); + // Track mode toggles across chunk boundaries (escape sequences can span stream frames). + const modeScanBufferRef = useRef(""); + // Refs avoid effect re-runs when these values change const isFocusedRef = useRef(isFocused); isFocusedRef.current = isFocused; @@ -79,8 +164,10 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { paneInitialCwdRef.current = paneInitialCwd; clearPaneInitialDataRef.current = clearPaneInitialData; - const { data: workspaceCwd } = - trpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + // Use ref for workspaceCwd to avoid terminal recreation when query loads + // (changing from undefined→string triggers useEffect, causing xterm errors) + const workspaceCwdRef = useRef(workspaceCwd); + workspaceCwdRef.current = workspaceCwd; // Query terminal link behavior setting const { data: terminalLinkBehavior } = @@ -202,23 +289,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const updateCwdRef = useRef(updateCwdFromData); updateCwdRef.current = updateCwdFromData; - const createOrAttachMutation = trpc.terminal.createOrAttach.useMutation(); - const writeMutation = trpc.terminal.write.useMutation(); - const resizeMutation = trpc.terminal.resize.useMutation(); - const detachMutation = trpc.terminal.detach.useMutation(); - const clearScrollbackMutation = trpc.terminal.clearScrollback.useMutation(); - - const createOrAttachRef = useRef(createOrAttachMutation.mutate); - const writeRef = useRef(writeMutation.mutate); - const resizeRef = useRef(resizeMutation.mutate); - const detachRef = useRef(detachMutation.mutate); - const clearScrollbackRef = useRef(clearScrollbackMutation.mutate); - createOrAttachRef.current = createOrAttachMutation.mutate; - writeRef.current = writeMutation.mutate; - resizeRef.current = resizeMutation.mutate; - detachRef.current = detachMutation.mutate; - clearScrollbackRef.current = clearScrollbackMutation.mutate; - const registerClearCallbackRef = useRef( useTerminalCallbacksStore.getState().registerClearCallback, ); @@ -244,19 +314,398 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, 100), ); + const updateModesFromData = useCallback((data: string) => { + // Escape sequences can be split across streamed frames, so scan using a small carry buffer. + const combined = modeScanBufferRef.current + data; + + const enterAltIndex = Math.max( + combined.lastIndexOf("\x1b[?1049h"), + combined.lastIndexOf("\x1b[?47h"), + ); + const exitAltIndex = Math.max( + combined.lastIndexOf("\x1b[?1049l"), + combined.lastIndexOf("\x1b[?47l"), + ); + if (enterAltIndex !== -1 || exitAltIndex !== -1) { + isAlternateScreenRef.current = enterAltIndex > exitAltIndex; + } + + const enableBracketedIndex = combined.lastIndexOf("\x1b[?2004h"); + const disableBracketedIndex = combined.lastIndexOf("\x1b[?2004l"); + if (enableBracketedIndex !== -1 || disableBracketedIndex !== -1) { + isBracketedPasteRef.current = + enableBracketedIndex > disableBracketedIndex; + } + + // Keep a small tail in case the next chunk starts mid-sequence. + modeScanBufferRef.current = combined.slice(-32); + }, []); + + const updateModesFromDataRef = useRef(updateModesFromData); + updateModesFromDataRef.current = updateModesFromData; + + const flushPendingEvents = useCallback(() => { + const xterm = xtermRef.current; + if (!xterm) return; + if (pendingEventsRef.current.length === 0) return; + + const events = pendingEventsRef.current.splice( + 0, + pendingEventsRef.current.length, + ); + + for (const event of events) { + if (event.type === "data") { + updateModesFromDataRef.current(event.data); + xterm.write(event.data); + updateCwdRef.current(event.data); + } else if (event.type === "exit") { + isExitedRef.current = true; + isStreamReadyRef.current = false; + xterm.writeln(`\r\n\r\n[Process exited with code ${event.exitCode}]`); + xterm.writeln("[Press any key to restart]"); + } else if (event.type === "disconnect") { + setConnectionError( + event.reason || "Connection to terminal daemon lost", + ); + } else if (event.type === "error") { + const message = event.code + ? `${event.code}: ${event.error}` + : event.error; + console.warn("[Terminal] stream error:", message); + + toast.error("Terminal error", { + description: message, + }); + + // Don't block interaction for non-fatal issues like a paste drop or a + // transient write failure (we keep the session alive). + if ( + event.code === "WRITE_QUEUE_FULL" || + event.code === "WRITE_FAILED" + ) { + xterm.writeln(`\r\n[Terminal] ${message}`); + } else { + setConnectionError(message); + } + } + } + }, [setConnectionError]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: refs (resizeRef, updateCwdRef, rendererRef) used intentionally to read latest values without recreating callback + const maybeApplyInitialState = useCallback(() => { + if (!didFirstRenderRef.current) return; + const result = pendingInitialStateRef.current; + if (!result) return; + + const xterm = xtermRef.current; + const fitAddon = fitAddonRef.current; + if (!xterm || !fitAddon) return; + + // Clear before applying to prevent double-apply on concurrent triggers. + pendingInitialStateRef.current = null; + const restoreSequence = ++restoreSequenceRef.current; + + try { + // Canonical initial content: prefer snapshot (daemon mode) over scrollback (non-daemon) + // In daemon mode, scrollback is empty to avoid duplicating the payload over IPC. + const initialAnsi = result.snapshot?.snapshotAnsi ?? result.scrollback; + + // Track alternate screen mode from snapshot for our own reference + // (xterm.buffer.active.type is unreliable after HMR/recovery) + isAlternateScreenRef.current = !!result.snapshot?.modes.alternateScreen; + isBracketedPasteRef.current = !!result.snapshot?.modes.bracketedPaste; + modeScanBufferRef.current = ""; + + // Fallback: parse initialAnsi for escape sequences when snapshot.modes is unavailable. + // This handles non-daemon mode and edge cases where daemon didn't track the mode. + if (initialAnsi && result.snapshot?.modes === undefined) { + // Use lastIndexOf to find the final state - handles multiple enter/exit cycles + // (e.g., user opened vim, closed it, opened it again) + const enterAltIndex = Math.max( + initialAnsi.lastIndexOf("\x1b[?1049h"), + initialAnsi.lastIndexOf("\x1b[?47h"), + ); + const exitAltIndex = Math.max( + initialAnsi.lastIndexOf("\x1b[?1049l"), + initialAnsi.lastIndexOf("\x1b[?47l"), + ); + if (enterAltIndex !== -1 || exitAltIndex !== -1) { + isAlternateScreenRef.current = enterAltIndex > exitAltIndex; + } + + // Bracketed paste mode can toggle during a session - use the last seen state. + const bracketEnableIndex = initialAnsi.lastIndexOf("\x1b[?2004h"); + const bracketDisableIndex = initialAnsi.lastIndexOf("\x1b[?2004l"); + if (bracketEnableIndex !== -1 || bracketDisableIndex !== -1) { + isBracketedPasteRef.current = + bracketEnableIndex > bracketDisableIndex; + } + } + + // Apply rehydration sequences to restore other terminal modes + // (app cursor mode, bracketed paste, mouse tracking, etc.) + if (result.snapshot?.rehydrateSequences) { + xterm.write(result.snapshot.rehydrateSequences); + } + + // Resize xterm to match snapshot dimensions before applying content. + // The snapshot's cursor positioning assumes specific cols/rows. + const snapshotCols = result.snapshot?.cols; + const snapshotRows = result.snapshot?.rows; + if ( + snapshotCols && + snapshotRows && + (xterm.cols !== snapshotCols || xterm.rows !== snapshotRows) + ) { + xterm.resize(snapshotCols, snapshotRows); + } + + const isAltScreenReattach = + !result.isNew && result.snapshot?.modes.alternateScreen; + + // For alt-screen (TUI) sessions, the serialized snapshot often renders + // incorrectly because styled spaces and positioning get lost. Instead of + // writing broken snapshot, enter alt-screen and trigger SIGWINCH so the + // TUI redraws itself via the live stream. + // NOTE: This is primarily a fallback path for app restart recovery. + // During normal workspace/tab switching with persistence enabled, + // terminals stay mounted and this code path is not triggered. + if (isAltScreenReattach) { + // Enter alt-screen mode and WAIT for xterm to process it before proceeding. + // xterm.write() is async - if we trigger SIGWINCH before alt-screen is entered, + // the TUI receives SIGWINCH in normal mode, ignores it, then xterm switches + // buffers and we get a white screen. + xterm.write("\x1b[?1049h", () => { + // Apply rehydration sequences for other modes (bracketed paste, etc.) + if (result.snapshot?.rehydrateSequences) { + // Filter out alt-screen sequences since we already entered + const ESC = "\x1b"; + const filteredRehydrate = result.snapshot.rehydrateSequences + .split(`${ESC}[?1049h`) + .join("") + .split(`${ESC}[?47h`) + .join(""); + if (filteredRehydrate) { + xterm.write(filteredRehydrate); + } + } + + // NOW safe to enable streaming and flush pending events + isStreamReadyRef.current = true; + flushPendingEvents(); + + // Fit xterm to container and trigger SIGWINCH + requestAnimationFrame(() => { + if (xtermRef.current !== xterm) return; + + fitAddon.fit(); + const cols = xterm.cols; + const rows = xterm.rows; + + if (cols > 0 && rows > 0) { + // Resize down then up to guarantee SIGWINCH + resizeRef.current({ paneId, cols, rows: rows - 1 }); + setTimeout(() => { + if (xtermRef.current !== xterm) return; + resizeRef.current({ paneId, cols, rows }); + // Force xterm to repaint after SIGWINCH completes + xterm.refresh(0, rows - 1); + }, 100); + } + }); + }); + + // Use snapshot.cwd if available, otherwise parse from content + if (result.snapshot?.cwd) { + updateCwdRef.current(result.snapshot.cwd); + } else { + updateCwdRef.current(initialAnsi); + } + return; // Skip normal snapshot flow + } + + // xterm.write() is asynchronous - escape sequences may not be fully + // processed when the terminal first renders, causing garbled display. + // Force a re-render after write completes to ensure correct display. + // (Symptom: restored terminals show corrupted text until resized) + // Use fitAddon.fit() and (when using WebGL) clear the glyph atlas to force a full repaint. + xterm.write(initialAnsi, () => { + const redraw = () => { + requestAnimationFrame(() => { + try { + if (restoreSequenceRef.current !== restoreSequence) return; + if (xtermRef.current !== xterm) return; + + fitAddon.fit(); + if (xtermRef.current !== xterm) return; + + // Reattached sessions can sometimes render partially until the user resizes the pane. + // WebGL off fully fixes this, which strongly suggests a WebGL texture-atlas repaint bug. + // Clearing the atlas forces xterm-webgl to rebuild glyphs and repaint without a resize nudge. + const cols = xterm.cols; + const rows = xterm.rows; + if (cols <= 0 || rows <= 0) return; + + // Keep PTY dimensions in sync even when FitAddon doesn't change cols/rows. + resizeRef.current({ paneId, cols, rows }); + + if (!result.isNew) { + const renderer = rendererRef.current?.current; + if (renderer?.kind === "webgl") { + // Clear twice: once immediately, and once after fonts settle. + // This reduces restore artifacts (especially for TUIs like opencode) + // and prevents stale glyphs when fonts swap in. + renderer.clearTextureAtlas?.(); + } + } + xterm.refresh(0, rows - 1); + restoreScrollPosition(xterm, result.viewportY); + } catch (error) { + console.warn( + "[Terminal] redraw() failed after restoration:", + error, + ); + } + }); + }; + + // Redraw once immediately, and once again after fonts settle. + redraw(); + void document.fonts?.ready.then(() => { + if (restoreSequenceRef.current !== restoreSequence) return; + if (xtermRef.current !== xterm) return; + redraw(); + }); + + // Enable streaming AFTER xterm has processed the snapshot. + // This prevents live PTY output from interleaving with snapshot replay. + isStreamReadyRef.current = true; + flushPendingEvents(); + }); + // Use snapshot.cwd if available, otherwise parse from content + if (result.snapshot?.cwd) { + updateCwdRef.current(result.snapshot.cwd); + } else { + updateCwdRef.current(initialAnsi); + } + } catch (error) { + console.error("[Terminal] Restoration failed:", error); + } + }, [flushPendingEvents, paneId]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: createOrAttachRef used intentionally to read latest value without recreating callback + const handleRetryConnection = useCallback(() => { + setConnectionError(null); + const xterm = xtermRef.current; + if (!xterm) return; + + isStreamReadyRef.current = false; + pendingInitialStateRef.current = null; + + xterm.clear(); + xterm.writeln("Retrying connection...\r\n"); + + createOrAttachRef.current( + { + paneId, + tabId: parentTabIdRef.current || paneId, + workspaceId, + cols: xterm.cols, + rows: xterm.rows, + }, + { + onSuccess: (result) => { + setConnectionError(null); + pendingInitialStateRef.current = result; + maybeApplyInitialState(); + }, + onError: (error) => { + setConnectionError(error.message || "Connection failed"); + isStreamReadyRef.current = true; + flushPendingEvents(); + }, + }, + ); + }, [ + paneId, + workspaceId, + maybeApplyInitialState, + flushPendingEvents, + setConnectionError, + ]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: refs (createOrAttachRef, resizeRef) used intentionally to read latest values without recreating callback + const handleStartShell = useCallback(() => { + const xterm = xtermRef.current; + const fitAddon = fitAddonRef.current; + if (!xterm || !fitAddon) return; + + // Clear restored mode (both React state and module-level map) + setIsRestoredMode(false); + coldRestoreState.delete(paneId); + + // Acknowledge cold restore to main process (clears sticky state) + trpcClient.terminal.ackColdRestore.mutate({ paneId }).catch(() => { + // Ignore errors - not critical + }); + + // Add visual separator + xterm.write("\r\n\x1b[90m─── New session ───\x1b[0m\r\n\r\n"); + + // Reset state for new session + isStreamReadyRef.current = false; + pendingInitialStateRef.current = null; + isAlternateScreenRef.current = false; + isBracketedPasteRef.current = false; + modeScanBufferRef.current = ""; + + // Create new session with previous cwd + createOrAttachRef.current( + { + paneId, + tabId: parentTabIdRef.current || paneId, + workspaceId, + cols: xterm.cols, + rows: xterm.rows, + cwd: restoredCwd || undefined, + }, + { + onSuccess: (result) => { + pendingInitialStateRef.current = result; + maybeApplyInitialState(); + }, + onError: (error) => { + console.error("[Terminal] Failed to start shell:", error); + setConnectionError(error.message || "Failed to start shell"); + isStreamReadyRef.current = true; + flushPendingEvents(); + }, + }, + ); + }, [ + paneId, + workspaceId, + restoredCwd, + maybeApplyInitialState, + flushPendingEvents, + setConnectionError, + ]); + const handleStreamData = (event: TerminalStreamEvent) => { // Queue events until terminal is ready to prevent data loss - if (!xtermRef.current || !subscriptionEnabled) { + if (!xtermRef.current || !isStreamReadyRef.current) { pendingEventsRef.current.push(event); return; } if (event.type === "data") { + updateModesFromDataRef.current(event.data); xtermRef.current.write(event.data); updateCwdFromData(event.data); } else if (event.type === "exit") { isExitedRef.current = true; - setSubscriptionEnabled(false); + isStreamReadyRef.current = false; xtermRef.current.writeln( `\r\n\r\n[Process exited with code ${event.exitCode}]`, ); @@ -273,6 +722,24 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { ) { setPaneStatus(paneId, "idle"); } + } else if (event.type === "disconnect") { + // Daemon connection lost - show error UI with retry option + setConnectionError(event.reason || "Connection to terminal daemon lost"); + } else if (event.type === "error") { + const message = event.code + ? `${event.code}: ${event.error}` + : event.error; + console.warn("[Terminal] stream error:", message); + + toast.error("Terminal error", { + description: message, + }); + + if (event.code === "WRITE_QUEUE_FULL" || event.code === "WRITE_FAILED") { + xtermRef.current.writeln(`\r\n[Terminal] ${message}`); + } else { + setConnectionError(message); + } } }; @@ -321,26 +788,44 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { [isFocused], ); + // biome-ignore lint/correctness/useExhaustiveDependencies: refs (writeRef, resizeRef, detachRef, clearScrollbackRef, createOrAttachRef) used intentionally to read latest values without resubscribing useEffect(() => { const container = terminalRef.current; if (!container) return; + // Cancel any pending detach from a previous unmount (e.g., React StrictMode's + // simulated unmount/remount cycle). This prevents the detach from corrupting + // the terminal state when we're immediately remounting. + const pendingDetach = pendingDetaches.get(paneId); + if (pendingDetach) { + clearTimeout(pendingDetach); + pendingDetaches.delete(paneId); + } + let isUnmounted = false; const { xterm, fitAddon, + renderer, cleanup: cleanupQuerySuppression, } = createTerminalInstance(container, { - cwd: workspaceCwd, + cwd: workspaceCwdRef.current ?? undefined, initialTheme: initialThemeRef.current, onFileLinkClick: (path, line, column) => handleFileLinkClickRef.current(path, line, column), }); xtermRef.current = xterm; fitAddonRef.current = fitAddon; + rendererRef.current = renderer; isExitedRef.current = false; setXtermInstance(xterm); + isStreamReadyRef.current = false; + didFirstRenderRef.current = false; + pendingInitialStateRef.current = null; + isStreamReadyRef.current = false; + didFirstRenderRef.current = false; + pendingInitialStateRef.current = null; if (isFocusedRef.current) { xterm.focus(); @@ -353,49 +838,37 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { searchAddonRef.current = searchAddon; }); - const flushPendingEvents = () => { - if (pendingEventsRef.current.length === 0) return; - const events = pendingEventsRef.current.splice( - 0, - pendingEventsRef.current.length, - ); - for (const event of events) { - if (event.type === "data") { - xterm.write(event.data); - updateCwdRef.current(event.data); - } else { - isExitedRef.current = true; - setSubscriptionEnabled(false); - xterm.writeln(`\r\n\r\n[Process exited with code ${event.exitCode}]`); - xterm.writeln("[Press any key to restart]"); - - // Clear transient pane status (direct store access since we're in effect) - const currentPane = useTabsStore.getState().panes[paneId]; - if ( - currentPane?.status === "working" || - currentPane?.status === "permission" - ) { - useTabsStore.getState().setPaneStatus(paneId, "idle"); - } - } + // Wait for xterm to render once before applying restoration data. + // This prevents crashes when writing rehydrate escape sequences too early. + renderDisposableRef.current?.dispose(); + let firstRenderFallback: ReturnType | null = null; + + renderDisposableRef.current = xterm.onRender(() => { + if (firstRenderFallback) { + clearTimeout(firstRenderFallback); + firstRenderFallback = null; } - }; + renderDisposableRef.current?.dispose(); + renderDisposableRef.current = null; + didFirstRenderRef.current = true; + maybeApplyInitialState(); + }); - const applyInitialState = (result: { - wasRecovered: boolean; - isNew: boolean; - scrollback: string; - viewportY?: number; - }) => { - xterm.write(result.scrollback, () => { - updateCwdRef.current(result.scrollback); - restoreScrollPosition(xterm, result.viewportY); - }); - }; + // Failure-proofing: if the renderer never emits an initial render (e.g. WebGL hiccup, + // offscreen mount), don't leave the session stuck in "not ready" forever. + firstRenderFallback = setTimeout(() => { + if (isUnmounted) return; + if (didFirstRenderRef.current) return; + didFirstRenderRef.current = true; + maybeApplyInitialState(); + }, FIRST_RENDER_RESTORE_FALLBACK_MS); const restartTerminal = () => { isExitedRef.current = false; - setSubscriptionEnabled(false); + isStreamReadyRef.current = false; + isAlternateScreenRef.current = false; // Reset for new shell + isBracketedPasteRef.current = false; + modeScanBufferRef.current = ""; xterm.clear(); createOrAttachRef.current( { @@ -407,12 +880,14 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, { onSuccess: (result) => { - applyInitialState(result); - setSubscriptionEnabled(true); - flushPendingEvents(); + pendingInitialStateRef.current = result; + maybeApplyInitialState(); }, - onError: () => { - setSubscriptionEnabled(true); + onError: (error) => { + console.error("[Terminal] Failed to restart:", error); + setConnectionError(error.message || "Failed to restart terminal"); + isStreamReadyRef.current = true; + flushPendingEvents(); }, }, ); @@ -432,9 +907,16 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }) => { const { domEvent } = event; if (domEvent.key === "Enter") { - const title = sanitizeForTitle(commandBufferRef.current); - if (title && parentTabIdRef.current) { - debouncedSetTabAutoTitleRef.current(parentTabIdRef.current, title); + // Don't auto-title from keyboard when in alternate screen (TUI apps like vim, codex) + // TUI apps set their own title via escape sequences handled by onTitleChange + // Use our own tracking (isAlternateScreenRef) because xterm.buffer.active.type + // is unreliable after HMR or recovery - the new xterm instance doesn't know + // about escape sequences that were sent before it was created. + if (!isAlternateScreenRef.current) { + const title = sanitizeForTitle(commandBufferRef.current); + if (title && parentTabIdRef.current) { + debouncedSetTabAutoTitleRef.current(parentTabIdRef.current, title); + } } commandBufferRef.current = ""; } else if (domEvent.key === "Backspace") { @@ -486,14 +968,59 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { if (initialCommands || initialCwd) { clearPaneInitialDataRef.current(paneId); } - // Always apply initial state (scrollback) first, then flush pending events - // This ensures we don't lose terminal history when reattaching - applyInitialState(result); - setSubscriptionEnabled(true); - flushPendingEvents(); + + // FIRST: Check if we have stored cold restore state from a previous mount + // (StrictMode causes unmount/remount - check this BEFORE result.isColdRestore + // because the second mount's result won't have isColdRestore=true) + const storedColdRestore = coldRestoreState.get(paneId); + if (storedColdRestore?.isRestored) { + setIsRestoredMode(true); + setRestoredCwd(storedColdRestore.cwd); + + // Write scrollback to terminal as read-only display + if (storedColdRestore.scrollback && xterm) { + xterm.write(storedColdRestore.scrollback); + } + + // Mark first render complete but don't enable streaming + didFirstRenderRef.current = true; + return; + } + + // Handle cold restore (reboot recovery) - first detection + // Store in module-level map to survive StrictMode remount + if (result.isColdRestore) { + const scrollback = + result.snapshot?.snapshotAnsi ?? result.scrollback; + coldRestoreState.set(paneId, { + isRestored: true, + cwd: result.previousCwd || null, + scrollback: scrollback, + }); + setIsRestoredMode(true); + setRestoredCwd(result.previousCwd || null); + + // Write scrollback to terminal as read-only display + if (scrollback && xterm) { + xterm.write(scrollback); + } + + // Mark first render complete but don't enable streaming + // (shell isn't running - user must click Start Shell) + didFirstRenderRef.current = true; + return; + } + + // Defer initial state restoration until xterm has rendered once. + // Streaming is enabled only after restoration is queued into xterm. + pendingInitialStateRef.current = result; + maybeApplyInitialState(); }, - onError: () => { - setSubscriptionEnabled(true); + onError: (error) => { + console.error("[Terminal] Failed to create/attach:", error); + setConnectionError(error.message || "Failed to connect to terminal"); + isStreamReadyRef.current = true; + flushPendingEvents(); }, }, ); @@ -553,10 +1080,15 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { onPaste: (text) => { commandBufferRef.current += text; }, + onWrite: handleWrite, + isBracketedPasteEnabled: () => isBracketedPasteRef.current, }); return () => { isUnmounted = true; + if (firstRenderFallback) { + clearTimeout(firstRenderFallback); + } inputDisposable.dispose(); keyDisposable.dispose(); titleDisposable.dispose(); @@ -569,17 +1101,53 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { unregisterClearCallbackRef.current(paneId); unregisterScrollToBottomCallbackRef.current(paneId); debouncedSetTabAutoTitleRef.current?.cancel?.(); - detachRef.current({ - paneId, - viewportY: getScrollOffsetFromBottom(xterm), - }); - setSubscriptionEnabled(false); - xterm.dispose(); + + // Debounce detach to handle React StrictMode's unmount/remount cycle. + // If the component remounts quickly (as in StrictMode), the new mount will + // cancel this timeout, preventing the detach from corrupting terminal state. + const detachTimeout = setTimeout(() => { + detachRef.current({ + paneId, + viewportY: getScrollOffsetFromBottom(xterm), + }); + pendingDetaches.delete(paneId); + // Clean up cold restore scrollback to prevent memory leak + // (scrollback can be MBs per pane, accumulates if not cleaned) + // Must be inside detachTimeout to survive StrictMode unmount/remount + coldRestoreState.delete(paneId); + }, 50); + pendingDetaches.set(paneId, detachTimeout); + + isStreamReadyRef.current = false; + didFirstRenderRef.current = false; + pendingInitialStateRef.current = null; + isAlternateScreenRef.current = false; + isBracketedPasteRef.current = false; + modeScanBufferRef.current = ""; + renderDisposableRef.current?.dispose(); + renderDisposableRef.current = null; + + // Delay xterm.dispose() to let internal timeouts complete. + // xterm.open() schedules a setTimeout for Viewport.syncScrollArea. + // If we dispose synchronously, that timeout fires after _renderer is + // cleared, causing "Cannot read properties of undefined (reading 'dimensions')". + // Using setTimeout(0) ensures our dispose runs after xterm's internal callback. + setTimeout(() => { + xterm.dispose(); + }, 0); + xtermRef.current = null; searchAddonRef.current = null; + rendererRef.current = null; setXtermInstance(null); }; - }, [paneId, workspaceId, workspaceCwd]); + }, [ + paneId, + workspaceId, + flushPendingEvents, + maybeApplyInitialState, + setConnectionError, + ]); useEffect(() => { const xterm = xtermRef.current; @@ -623,6 +1191,45 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { onClose={() => setIsSearchOpen(false)} /> + {connectionError && ( +
+
+

Connection Error

+

{connectionError}

+
+ +
+ )} + {isRestoredMode && ( +
+
+

+ + Session Restored +

+

+ Your previous terminal output was preserved. Click below to start + a new shell session. +

+ {restoredCwd && ( +

{restoredCwd}

+ )} +
+ +
+ )}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index fbd1b980b19..69db0de1bd1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -61,35 +61,87 @@ export function getDefaultTerminalBg(): string { * Load GPU-accelerated renderer with automatic fallback. * Tries WebGL first, falls back to Canvas if WebGL fails. */ -function loadRenderer(xterm: XTerm): { dispose: () => void } { +export type TerminalRenderer = { + kind: "webgl" | "canvas" | "dom"; + dispose: () => void; + clearTextureAtlas?: () => void; +}; + +type PreferredRenderer = TerminalRenderer["kind"] | "auto"; + +function getPreferredRenderer(): PreferredRenderer { + try { + const stored = localStorage.getItem("terminal-renderer"); + if (stored === "webgl" || stored === "canvas" || stored === "dom") { + return stored; + } + } catch { + // ignore + } + + // Default: avoid xterm-webgl on macOS. We've seen repeated corruption/glitching + // when terminals are hidden/shown or switched between panes. + return navigator.userAgent.includes("Macintosh") ? "canvas" : "webgl"; +} + +function loadRenderer(xterm: XTerm): TerminalRenderer { let renderer: WebglAddon | CanvasAddon | null = null; + let webglAddon: WebglAddon | null = null; + let kind: TerminalRenderer["kind"] = "dom"; + + const preferred = getPreferredRenderer(); + + if (preferred === "dom") { + return { kind: "dom", dispose: () => {}, clearTextureAtlas: undefined }; + } + + const tryLoadCanvas = () => { + try { + renderer = new CanvasAddon(); + xterm.loadAddon(renderer); + kind = "canvas"; + } catch { + // Canvas fallback failed, use default renderer + } + }; + + if (preferred === "canvas") { + tryLoadCanvas(); + return { + kind, + dispose: () => renderer?.dispose(), + clearTextureAtlas: undefined, + }; + } try { - const webglAddon = new WebglAddon(); + webglAddon = new WebglAddon(); webglAddon.onContextLoss(() => { - webglAddon.dispose(); - try { - renderer = new CanvasAddon(); - xterm.loadAddon(renderer); - } catch { - // Canvas fallback failed, use default renderer - } + webglAddon?.dispose(); + webglAddon = null; + tryLoadCanvas(); }); xterm.loadAddon(webglAddon); renderer = webglAddon; + kind = "webgl"; } catch { - try { - renderer = new CanvasAddon(); - xterm.loadAddon(renderer); - } catch { - // Both renderers failed, use default - } + tryLoadCanvas(); } return { + kind, dispose: () => renderer?.dispose(), + clearTextureAtlas: webglAddon + ? () => { + try { + webglAddon?.clearTextureAtlas(); + } catch (error) { + console.warn("[Terminal] WebGL clearTextureAtlas() failed:", error); + } + } + : undefined, }; } @@ -99,12 +151,21 @@ export interface CreateTerminalOptions { onFileLinkClick?: (path: string, line?: number, column?: number) => void; } +/** + * Mutable reference to the terminal renderer. + * Used because the GPU renderer is loaded asynchronously after the terminal is created. + */ +export interface TerminalRendererRef { + current: TerminalRenderer; +} + export function createTerminalInstance( container: HTMLDivElement, options: CreateTerminalOptions = {}, ): { xterm: XTerm; fitAddon: FitAddon; + renderer: TerminalRendererRef; cleanup: () => void; } { const { cwd, initialTheme, onFileLinkClick } = options; @@ -119,17 +180,44 @@ export function createTerminalInstance( const unicode11Addon = new Unicode11Addon(); const imageAddon = new ImageAddon(); + // Track cleanup state to prevent operations on disposed terminal + let isDisposed = false; + let rafId: number | null = null; + + // Use a ref pattern so the renderer can be updated after rAF. + // Start with a no-op DOM renderer - the actual GPU renderer is loaded async. + const rendererRef: TerminalRendererRef = { + current: { + kind: "dom", + dispose: () => {}, + clearTextureAtlas: undefined, + }, + }; + xterm.open(container); + // Load non-renderer addons synchronously - these are safe and needed immediately xterm.loadAddon(fitAddon); - const renderer = loadRenderer(xterm); - xterm.loadAddon(clipboardAddon); xterm.loadAddon(unicode11Addon); xterm.loadAddon(imageAddon); + // Defer GPU renderer loading to next animation frame. + // xterm.open() schedules a setTimeout for Viewport.syncScrollArea which expects + // the renderer to be ready. Loading WebGL/Canvas immediately after open() can + // cause a race condition where the setTimeout fires during addon initialization, + // when _renderer is temporarily undefined (old renderer disposed, new not yet set). + // Deferring to rAF ensures xterm's internal setTimeout completes first with the + // default DOM renderer, then we safely swap to WebGL/Canvas. + rafId = requestAnimationFrame(() => { + rafId = null; + if (isDisposed) return; + rendererRef.current = loadRenderer(xterm); + }); + import("@xterm/addon-ligatures") .then(({ LigaturesAddon }) => { + if (isDisposed) return; try { xterm.loadAddon(new LigaturesAddon()); } catch { @@ -185,9 +273,14 @@ export function createTerminalInstance( return { xterm, fitAddon, + renderer: rendererRef, cleanup: () => { + isDisposed = true; + if (rafId !== null) { + cancelAnimationFrame(rafId); + } cleanupQuerySuppression(); - renderer.dispose(); + rendererRef.current.dispose(); }, }; } @@ -202,6 +295,10 @@ export interface KeyboardHandlerOptions { export interface PasteHandlerOptions { /** Callback when text is pasted, receives the pasted text */ onPaste?: (text: string) => void; + /** Optional direct write callback to bypass xterm's paste burst */ + onWrite?: (data: string) => void; + /** Whether bracketed paste mode is enabled for the current terminal */ + isBracketedPasteEnabled?: () => boolean; } /** @@ -225,6 +322,8 @@ export function setupPasteHandler( const textarea = xterm.textarea; if (!textarea) return () => {}; + let cancelActivePaste: (() => void) | null = null; + const handlePaste = (event: ClipboardEvent) => { const text = event.clipboardData?.getData("text/plain"); if (!text) return; @@ -233,12 +332,100 @@ export function setupPasteHandler( event.stopImmediatePropagation(); options.onPaste?.(text); - xterm.paste(text); + + // Cancel any in-flight chunked paste to avoid overlapping writes. + cancelActivePaste?.(); + cancelActivePaste = null; + + // Chunk large pastes to avoid sending a single massive input burst that can + // overwhelm the PTY pipeline (especially when the app is repainting heavily). + const MAX_SYNC_PASTE_CHARS = 16_384; + + // If no direct write callback is provided, fall back to xterm's paste() + // (it handles newline normalization and bracketed paste mode internally). + if (!options.onWrite) { + const CHUNK_CHARS = 4096; + const CHUNK_DELAY_MS = 5; + + if (text.length <= MAX_SYNC_PASTE_CHARS) { + xterm.paste(text); + return; + } + + let cancelled = false; + let offset = 0; + + const pasteNext = () => { + if (cancelled) return; + + const chunk = text.slice(offset, offset + CHUNK_CHARS); + offset += CHUNK_CHARS; + xterm.paste(chunk); + + if (offset < text.length) { + setTimeout(pasteNext, CHUNK_DELAY_MS); + } + }; + + cancelActivePaste = () => { + cancelled = true; + }; + + pasteNext(); + return; + } + + // Direct write path: replicate xterm's paste normalization, but stream in + // controlled chunks while preserving bracketed-paste semantics. + const preparedText = text.replace(/\r?\n/g, "\r"); + const bracketedPasteEnabled = options.isBracketedPasteEnabled?.() ?? false; + const shouldBracket = bracketedPasteEnabled; + + // For small/medium pastes, preserve the fast path and avoid timers. + if (preparedText.length <= MAX_SYNC_PASTE_CHARS) { + options.onWrite( + shouldBracket ? `\x1b[200~${preparedText}\x1b[201~` : preparedText, + ); + return; + } + + let cancelled = false; + let offset = 0; + const CHUNK_CHARS = 16_384; + const CHUNK_DELAY_MS = 0; + + const pasteNext = () => { + if (cancelled) return; + + const chunk = preparedText.slice(offset, offset + CHUNK_CHARS); + offset += CHUNK_CHARS; + + if (shouldBracket) { + // Wrap each chunk to avoid long-running "open" bracketed paste blocks, + // which some TUIs may defer repainting until the closing sequence arrives. + options.onWrite?.(`\x1b[200~${chunk}\x1b[201~`); + } else { + options.onWrite?.(chunk); + } + + if (offset < preparedText.length) { + setTimeout(pasteNext, CHUNK_DELAY_MS); + return; + } + }; + + cancelActivePaste = () => { + cancelled = true; + }; + + pasteNext(); }; textarea.addEventListener("paste", handlePaste, { capture: true }); return () => { + cancelActivePaste?.(); + cancelActivePaste = null; textarea.removeEventListener("paste", handlePaste, { capture: true }); }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/index.ts new file mode 100644 index 00000000000..dc089375c36 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/index.ts @@ -0,0 +1,2 @@ +export type { UseTerminalConnectionOptions } from "./useTerminalConnection"; +export { useTerminalConnection } from "./useTerminalConnection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts new file mode 100644 index 00000000000..f98bd58276f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts @@ -0,0 +1,67 @@ +import { useRef, useState } from "react"; +import { trpc } from "renderer/lib/trpc"; + +export interface UseTerminalConnectionOptions { + workspaceId: string; +} + +/** + * Hook to manage terminal connection state and mutations. + * + * Encapsulates: + * - tRPC mutations (createOrAttach, write, resize, detach, clearScrollback) + * - Stable refs to mutation functions (to avoid re-renders) + * - Connection error state + * - Workspace CWD query + * + * NOTE: Stream subscription is intentionally NOT included here because it needs + * direct access to xterm refs for event handling. Keep that in the component. + */ +export function useTerminalConnection({ + workspaceId, +}: UseTerminalConnectionOptions) { + const [connectionError, setConnectionError] = useState(null); + + // tRPC mutations + const createOrAttachMutation = trpc.terminal.createOrAttach.useMutation(); + const writeMutation = trpc.terminal.write.useMutation(); + const resizeMutation = trpc.terminal.resize.useMutation(); + const detachMutation = trpc.terminal.detach.useMutation(); + const clearScrollbackMutation = trpc.terminal.clearScrollback.useMutation(); + + // Query for workspace cwd + const { data: workspaceCwd } = + trpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + + // Stable refs to mutation functions - these don't change identity on re-render + const createOrAttachRef = useRef(createOrAttachMutation.mutate); + const writeRef = useRef(writeMutation.mutate); + const resizeRef = useRef(resizeMutation.mutate); + const detachRef = useRef(detachMutation.mutate); + const clearScrollbackRef = useRef(clearScrollbackMutation.mutate); + + // Keep refs up to date + createOrAttachRef.current = createOrAttachMutation.mutate; + writeRef.current = writeMutation.mutate; + resizeRef.current = resizeMutation.mutate; + detachRef.current = detachMutation.mutate; + clearScrollbackRef.current = clearScrollbackMutation.mutate; + + return { + // Connection error state + connectionError, + setConnectionError, + + // Workspace CWD from query + workspaceCwd, + + // Stable refs to mutation functions (use these in effects/callbacks) + refs: { + createOrAttach: createOrAttachRef, + write: writeRef, + resize: resizeRef, + detach: detachRef, + clearScrollback: clearScrollbackRef, + }, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts index 29e41dff6d5..2a99ae85548 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts @@ -1,8 +1,11 @@ export interface TerminalProps { tabId: string; workspaceId: string; + isTabVisible: boolean; } export type TerminalStreamEvent = | { type: "data"; data: string } - | { type: "exit"; exitCode: number }; + | { type: "exit"; exitCode: number; signal?: number } + | { type: "disconnect"; reason: string } + | { type: "error"; error: string; code?: string }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index ae8087438e5..5c6f447c28c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -13,6 +13,8 @@ import { TabView } from "./TabView"; export function TabsContent() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const { data: terminalPersistence } = + trpc.settings.getTerminalPersistence.useQuery(); const activeWorkspaceId = activeWorkspace?.id; const allTabs = useTabsStore((s) => s.tabs); const activeTabIds = useTabsStore((s) => s.activeTabIds); @@ -25,18 +27,100 @@ export function TabsContent() { setIsResizing, } = useSidebarStore(); + const activeTabId = activeWorkspaceId + ? activeTabIds[activeWorkspaceId] + : null; + + // Get all tabs for current workspace (for fallback/empty check) + const currentWorkspaceTabs = useMemo(() => { + if (!activeWorkspaceId) return []; + return allTabs.filter((tab) => tab.workspaceId === activeWorkspaceId); + }, [activeWorkspaceId, allTabs]); + const tabToRender = useMemo(() => { - if (!activeWorkspaceId) return null; - const activeTabId = activeTabIds[activeWorkspaceId]; if (!activeTabId) return null; - return allTabs.find((tab) => tab.id === activeTabId) || null; - }, [activeWorkspaceId, activeTabIds, allTabs]); + }, [activeTabId, allTabs]); + + // When terminal persistence is enabled, keep all terminals mounted across + // workspace/tab switches. This prevents TUI white screen issues by avoiding + // the unmount/remount cycle that requires complex reattach/rehydration logic. + // Uses visibility:hidden (not display:none) to preserve xterm dimensions. + if (terminalPersistence) { + // Show empty view only if current workspace has no tabs + if (currentWorkspaceTabs.length === 0) { + return ( +
+
+ +
+ {isSidebarOpen && ( + + + + )} +
+ ); + } + + return ( +
+
+ {allTabs.map((tab) => { + // A tab is visible only if: + // 1. It belongs to the active workspace AND + // 2. It's the active tab for that workspace + const isVisible = + tab.workspaceId === activeWorkspaceId && tab.id === activeTabId; + + return ( +
+ +
+ ); + })} +
+ {isSidebarOpen && ( + + + + )} +
+ ); + } + // Original behavior when persistence disabled: only render active tab return (
- {tabToRender ? : } + {tabToRender ? ( + + ) : ( + + )}
{isSidebarOpen && ( { + // Guard against null/undefined state from storage + if (!state) { + console.warn( + "[hotkeys] Storage returned null/undefined state, skipping sync", + ); + return; + } const current = useHotkeysStore.getState().hotkeysState; // Use structural comparison that's order-independent const currentStr = JSON.stringify( diff --git a/packages/local-db/drizzle/0011_add_terminal_persistence.sql b/packages/local-db/drizzle/0011_add_terminal_persistence.sql new file mode 100644 index 00000000000..f7a72f4db84 --- /dev/null +++ b/packages/local-db/drizzle/0011_add_terminal_persistence.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `terminal_persistence` integer; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0011_snapshot.json b/packages/local-db/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000000..c54b0aa6314 --- /dev/null +++ b/packages/local-db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1006 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b74ef022-acd9-4140-b9e8-b7c92dd13b16", + "prevId": "3177be28-43bc-4b9b-ba61-763632dee908", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_persistence": { + "name": "terminal_persistence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 298886a93e4..c74f53f0f97 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1768004449114, "tag": "0010_add_workspace_deleting_at", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1767750000000, + "tag": "0011_add_terminal_persistence", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 5a96f81d142..37dfac81b95 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -123,6 +123,16 @@ export const workspaces = sqliteTable( export type InsertWorkspace = typeof workspaces.$inferInsert; export type SelectWorkspace = typeof workspaces.$inferSelect; +/** + * Navigation style for workspace display + */ +export type NavigationStyle = "top-bar" | "sidebar"; + +/** + * Position for group tabs display + */ +export type GroupTabsPosition = "sidebar" | "content-header"; + export const settings = sqliteTable("settings", { id: integer("id").primaryKey().default(1), lastActiveWorkspaceId: text("last_active_workspace_id"), @@ -139,6 +149,9 @@ export const settings = sqliteTable("settings", { terminalLinkBehavior: text( "terminal_link_behavior", ).$type(), + navigationStyle: text("navigation_style").$type(), + groupTabsPosition: text("group_tabs_position").$type(), + terminalPersistence: integer("terminal_persistence", { mode: "boolean" }), }); export type InsertSettings = typeof settings.$inferInsert; From f0479635847e9a9b91a8cfe843e1f2b03d1c932d Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:38:36 +0200 Subject: [PATCH 02/62] fix(desktop): add @xterm/headless dependency and fix type error - Add missing @xterm/headless package to desktop dependencies - Use @ts-expect-error for known xterm addon type mismatch (SerializeAddon types expect @xterm/xterm but works with @xterm/headless) --- apps/desktop/src/main/lib/terminal-host/headless-emulator.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts b/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts index 02522ad4f54..50a93b3c8c5 100644 --- a/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts +++ b/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts @@ -85,6 +85,8 @@ export class HeadlessEmulator { }); this.serializeAddon = new SerializeAddon(); + // @ts-expect-error - SerializeAddon types expect @xterm/xterm Terminal, but works with @xterm/headless at runtime + // This is a known xterm ecosystem type mismatch. See: https://github.com/xtermjs/xterm.js/issues/4775 this.terminal.loadAddon(this.serializeAddon); // Initialize mode state From 527c1d4b72230afbb0a844dbdae6f65d2c504f66 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:44:08 +0200 Subject: [PATCH 03/62] fix(desktop): address CodeRabbit review feedback - Await async handler in terminal-host/index.ts to prevent unhandled promise rejections - Track session cleanup timeouts in daemon-manager.ts and clear on dispose to prevent memory leaks - Move headless-emulator.test.ts to co-locate with implementation (from __tests__/ subfolder) - Fix timeout race conditions in test files by tracking settlement state and clearing timeouts --- ...trip.test.ts => headless-emulator.test.ts} | 0 .../src/main/lib/terminal/daemon-manager.ts | 15 ++++++++++++-- .../src/main/terminal-host/daemon.test.ts | 16 +++++++++++++-- apps/desktop/src/main/terminal-host/index.ts | 12 +++++++---- .../terminal-host/session-lifecycle.test.ts | 20 ++++++++++++++++--- 5 files changed, 52 insertions(+), 11 deletions(-) rename apps/desktop/src/main/lib/terminal-host/{__tests__/headless-roundtrip.test.ts => headless-emulator.test.ts} (100%) diff --git a/apps/desktop/src/main/lib/terminal-host/__tests__/headless-roundtrip.test.ts b/apps/desktop/src/main/lib/terminal-host/headless-emulator.test.ts similarity index 100% rename from apps/desktop/src/main/lib/terminal-host/__tests__/headless-roundtrip.test.ts rename to apps/desktop/src/main/lib/terminal-host/headless-emulator.test.ts diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 04c3305e041..f9c7f70ff76 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -83,6 +83,9 @@ export class DaemonTerminalManager extends EventEmitter { } >(); + /** Track pending cleanup timeouts for cancellation on dispose */ + private cleanupTimeouts = new Map(); + constructor() { super(); this.client = getTerminalHostClient(); @@ -160,10 +163,12 @@ export class DaemonTerminalManager extends EventEmitter { // Emit exit event this.emit(`exit:${paneId}`, exitCode, signal); - // Clean up session after delay - setTimeout(() => { + // Clean up session after delay (track timeout for cancellation on dispose) + const timeoutId = setTimeout(() => { this.sessions.delete(paneId); + this.cleanupTimeouts.delete(paneId); }, SESSION_CLEANUP_DELAY_MS); + this.cleanupTimeouts.set(paneId, timeoutId); }, ); @@ -860,6 +865,12 @@ export class DaemonTerminalManager extends EventEmitter { * This allows cold restore detection on next app launch. */ async cleanup(): Promise { + // Clear pending cleanup timeouts to prevent callbacks after dispose + for (const timeout of this.cleanupTimeouts.values()) { + clearTimeout(timeout); + } + this.cleanupTimeouts.clear(); + // Close all history writers gracefully (writes endedAt to meta.json) // This is important for cold restore detection - if the app crashes // or laptop reboots, endedAt won't be written, indicating unclean shutdown. diff --git a/apps/desktop/src/main/terminal-host/daemon.test.ts b/apps/desktop/src/main/terminal-host/daemon.test.ts index 69049f10d50..eff21b3e19c 100644 --- a/apps/desktop/src/main/terminal-host/daemon.test.ts +++ b/apps/desktop/src/main/terminal-host/daemon.test.ts @@ -104,11 +104,16 @@ describe("Terminal Host Daemon", () => { }); let output = ""; + let settled = false; + let timeoutId: ReturnType; daemonProcess.stdout?.on("data", (data) => { output += data.toString(); // Check if daemon is ready if (output.includes("Daemon started")) { + if (settled) return; + settled = true; + clearTimeout(timeoutId); resolve(); } }); @@ -118,11 +123,16 @@ describe("Terminal Host Daemon", () => { }); daemonProcess.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); reject(new Error(`Failed to start daemon: ${error.message}`)); }); daemonProcess.on("exit", (code, signal) => { - if (code !== 0 && code !== null) { + if (!settled && code !== 0 && code !== null) { + settled = true; + clearTimeout(timeoutId); reject( new Error(`Daemon exited with code ${code}, signal ${signal}`), ); @@ -130,7 +140,9 @@ describe("Terminal Host Daemon", () => { }); // Timeout if daemon doesn't start - setTimeout(() => { + timeoutId = setTimeout(() => { + if (settled) return; + settled = true; reject( new Error( `Daemon failed to start within ${DAEMON_TIMEOUT}ms. Output: ${output}`, diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index f4a92d144f0..15a26f54407 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -376,11 +376,11 @@ const handlers: Record = { }, }; -function handleRequest( +async function handleRequest( socket: Socket, request: IpcRequest, clientState: ClientState, -) { +): Promise { const handler = handlers[request.type]; if (!handler) { @@ -394,7 +394,7 @@ function handleRequest( } try { - handler(socket, request.id, request.payload, clientState); + await handler(socket, request.id, request.payload, clientState); } catch (error) { const message = error instanceof Error ? error.message : String(error); sendError(socket, request.id, "INTERNAL_ERROR", message); @@ -420,7 +420,11 @@ function handleConnection(socket: Socket) { socket.on("data", (data: string) => { const messages = parser.parse(data); for (const message of messages) { - handleRequest(socket, message, clientState); + handleRequest(socket, message, clientState).catch((error) => { + log("error", "Unhandled request error", { + error: error instanceof Error ? error.message : String(error), + }); + }); } }); diff --git a/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts b/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts index 4e994c87957..43320de19c3 100644 --- a/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts +++ b/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts @@ -104,10 +104,15 @@ describe("Terminal Host Session Lifecycle", () => { }); let output = ""; + let settled = false; + let timeoutId: ReturnType; daemonProcess.stdout?.on("data", (data) => { output += data.toString(); if (output.includes("Daemon started")) { + if (settled) return; + settled = true; + clearTimeout(timeoutId); resolve(); } }); @@ -117,18 +122,25 @@ describe("Terminal Host Session Lifecycle", () => { }); daemonProcess.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); reject(new Error(`Failed to start daemon: ${error.message}`)); }); daemonProcess.on("exit", (code, signal) => { - if (code !== 0 && code !== null) { + if (!settled && code !== 0 && code !== null) { + settled = true; + clearTimeout(timeoutId); reject( new Error(`Daemon exited with code ${code}, signal ${signal}`), ); } }); - setTimeout(() => { + timeoutId = setTimeout(() => { + if (settled) return; + settled = true; reject( new Error( `Daemon failed to start within ${DAEMON_TIMEOUT}ms. Output: ${output}`, @@ -192,6 +204,7 @@ describe("Terminal Host Session Lifecycle", () => { ): Promise { return new Promise((resolve, reject) => { let buffer = ""; + let timeoutId: ReturnType; const onData = (data: Buffer) => { buffer += data.toString(); @@ -200,6 +213,7 @@ describe("Terminal Host Session Lifecycle", () => { const line = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); socket.off("data", onData); + clearTimeout(timeoutId); try { resolve(JSON.parse(line)); } catch (_error) { @@ -211,7 +225,7 @@ describe("Terminal Host Session Lifecycle", () => { socket.on("data", onData); socket.write(`${JSON.stringify(request)}\n`); - setTimeout(() => { + timeoutId = setTimeout(() => { socket.off("data", onData); reject(new Error("Request timed out")); }, 5000); From 52767f045292ecf0cb75e6692b360da38435d032 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:49:14 +0200 Subject: [PATCH 04/62] fix(desktop): fix CI errors after test file move - Update relative imports in headless-emulator.test.ts after moving from __tests__/ - Externalize @xterm/* packages in Vite config (incorrect package exports for bundlers) --- apps/desktop/electron.vite.config.ts | 1 + .../src/main/lib/terminal-host/headless-emulator.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 94fb417115f..9623f9c804d 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -73,6 +73,7 @@ export default defineConfig({ "better-sqlite3", "node-pty", /^@sentry\/electron/, + /^@xterm\//, // xterm packages have incorrect exports for bundlers ], }, }, diff --git a/apps/desktop/src/main/lib/terminal-host/headless-emulator.test.ts b/apps/desktop/src/main/lib/terminal-host/headless-emulator.test.ts index c04e6a023f1..8db9e7ebfa2 100644 --- a/apps/desktop/src/main/lib/terminal-host/headless-emulator.test.ts +++ b/apps/desktop/src/main/lib/terminal-host/headless-emulator.test.ts @@ -13,8 +13,8 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { HeadlessEmulator, modesEqual } from "../headless-emulator"; -import { DEFAULT_MODES } from "../types"; +import { HeadlessEmulator, modesEqual } from "./headless-emulator"; +import { DEFAULT_MODES } from "./types"; // Escape sequences for testing const ESC = "\x1b"; From fc3fcd8184f369370cbbe96c2616b985a411cfc0 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:55:08 +0200 Subject: [PATCH 05/62] refactor(desktop): centralize DEFAULT_TERMINAL_PERSISTENCE constant Extract the terminal persistence default value (false) into a shared constant to avoid duplicating magic values across the codebase. --- apps/desktop/src/lib/trpc/routers/settings/index.ts | 4 ++-- apps/desktop/src/main/lib/terminal/index.ts | 7 ++++--- .../main/components/SettingsView/TerminalSettings.tsx | 3 ++- apps/desktop/src/shared/constants.ts | 1 + 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index cc9e847daa6..80d658013b2 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -7,6 +7,7 @@ import { localDb } from "main/lib/local-db"; import { DEFAULT_CONFIRM_ON_QUIT, DEFAULT_TERMINAL_LINK_BEHAVIOR, + DEFAULT_TERMINAL_PERSISTENCE, } from "shared/constants"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; import { z } from "zod"; @@ -240,8 +241,7 @@ export const createSettingsRouter = () => { getTerminalPersistence: publicProcedure.query(() => { const row = getSettings(); - // Default to false (terminal persistence disabled by default) - return row.terminalPersistence ?? false; + return row.terminalPersistence ?? DEFAULT_TERMINAL_PERSISTENCE; }), setTerminalPersistence: publicProcedure diff --git a/apps/desktop/src/main/lib/terminal/index.ts b/apps/desktop/src/main/lib/terminal/index.ts index 89bf6ba69d0..4e27e06cde8 100644 --- a/apps/desktop/src/main/lib/terminal/index.ts +++ b/apps/desktop/src/main/lib/terminal/index.ts @@ -4,6 +4,7 @@ import { disposeTerminalHostClient, getTerminalHostClient, } from "main/lib/terminal-host/client"; +import { DEFAULT_TERMINAL_PERSISTENCE } from "shared/constants"; import { DaemonTerminalManager, getDaemonTerminalManager, @@ -51,7 +52,7 @@ export function isDaemonModeEnabled(): boolean { // Read from user settings try { const row = localDb.select().from(settings).get(); - const enabled = row?.terminalPersistence ?? false; + const enabled = row?.terminalPersistence ?? DEFAULT_TERMINAL_PERSISTENCE; console.log( `[TerminalManager] Daemon mode: ${enabled ? "ENABLED" : "DISABLED"} (via settings.terminalPersistence)`, ); @@ -62,8 +63,8 @@ export function isDaemonModeEnabled(): boolean { "[TerminalManager] Failed to read settings, defaulting to disabled:", error, ); - cachedDaemonMode = false; - return false; + cachedDaemonMode = DEFAULT_TERMINAL_PERSISTENCE; + return DEFAULT_TERMINAL_PERSISTENCE; } } diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx index 4e4e6cafe78..6c74a11a7d0 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx @@ -1,6 +1,7 @@ import { Label } from "@superset/ui/label"; import { Switch } from "@superset/ui/switch"; import { trpc } from "renderer/lib/trpc"; +import { DEFAULT_TERMINAL_PERSISTENCE } from "shared/constants"; export function TerminalSettings() { const utils = trpc.useUtils(); @@ -69,7 +70,7 @@ export function TerminalSettings() {
diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index fe0b04b5786..1881aec86b1 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -48,3 +48,4 @@ export const NOTIFICATION_EVENTS = { // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; export const DEFAULT_TERMINAL_LINK_BEHAVIOR = "external-editor" as const; +export const DEFAULT_TERMINAL_PERSISTENCE = false; From 794e80c6224431834a126c403ca671757bc83167 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 14:12:45 +0200 Subject: [PATCH 06/62] refactor(desktop): address PR review comments - Add SUPERSET_TERMINAL_DEBUG env var for conditional debug logging - Wrap verbose console.log statements in debug checks (terminal router and terminal host client) - Make sendRequest generic to eliminate type casts in public API - Use switch statement for event payload narrowing - Remove unused imports (IpcSuccessResponse, IpcErrorResponse) --- .../src/lib/trpc/routers/terminal/terminal.ts | 7 - .../src/main/lib/terminal-host/client.ts | 195 +++++++++++------- 2 files changed, 117 insertions(+), 85 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 7ed70708ed1..c98698f498f 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -15,7 +15,6 @@ import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveCwd } from "./utils"; const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; -let createOrAttachCallCounter = 0; /** * Terminal router using TerminalManager with node-pty @@ -56,8 +55,6 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { - const callId = ++createOrAttachCallCounter; - const startedAt = Date.now(); const { paneId, tabId, @@ -121,11 +118,9 @@ export const createTerminalRouter = () => { if (DEBUG_TERMINAL) { console.log("[Terminal Router] createOrAttach result:", { - callId, paneId, isNew: result.isNew, wasRecovered: result.wasRecovered, - durationMs: Date.now() - startedAt, }); } @@ -144,9 +139,7 @@ export const createTerminalRouter = () => { } catch (error) { if (DEBUG_TERMINAL) { console.warn("[Terminal Router] createOrAttach failed:", { - callId, paneId, - durationMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error), }); } diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 519e8ce1a4b..77f4f46363b 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -30,10 +30,8 @@ import { type DetachRequest, type EmptyResponse, type HelloResponse, - type IpcErrorResponse, type IpcEvent, type IpcResponse, - type IpcSuccessResponse, type KillAllRequest, type KillRequest, type ListSessionsResponse, @@ -60,6 +58,8 @@ enum ConnectionState { // Configuration // ============================================================================= +const DEBUG_CLIENT = process.env.SUPERSET_TERMINAL_DEBUG === "1"; + const SUPERSET_DIR_NAME = process.env.NODE_ENV === "development" ? ".superset-dev" : ".superset"; const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); @@ -179,9 +179,11 @@ export class TerminalHostClient extends EventEmitter { // Another connection in progress - wait with timeout if (this.connectionState === ConnectionState.CONNECTING) { - console.log( - "[TerminalHostClient] Connection already in progress, waiting...", - ); + if (DEBUG_CLIENT) { + console.log( + "[TerminalHostClient] Connection already in progress, waiting...", + ); + } return new Promise((resolve, reject) => { const startTime = Date.now(); const WAIT_TIMEOUT_MS = 10000; // 10 seconds max wait @@ -210,23 +212,31 @@ export class TerminalHostClient extends EventEmitter { } this.connectionState = ConnectionState.CONNECTING; - console.log("[TerminalHostClient] Connecting to daemon..."); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Connecting to daemon..."); + } try { // Try to connect to existing daemon let connected = await this.tryConnect(); - console.log( - `[TerminalHostClient] Initial connection attempt: ${connected ? "SUCCESS" : "FAILED"}`, - ); + if (DEBUG_CLIENT) { + console.log( + `[TerminalHostClient] Initial connection attempt: ${connected ? "SUCCESS" : "FAILED"}`, + ); + } if (!connected) { // Spawn daemon and retry - console.log("[TerminalHostClient] Spawning daemon..."); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Spawning daemon..."); + } await this.spawnDaemon(); connected = await this.tryConnect(); - console.log( - `[TerminalHostClient] Post-spawn connection attempt: ${connected ? "SUCCESS" : "FAILED"}`, - ); + if (DEBUG_CLIENT) { + console.log( + `[TerminalHostClient] Post-spawn connection attempt: ${connected ? "SUCCESS" : "FAILED"}`, + ); + } if (!connected) { throw new Error("Failed to connect to daemon after spawn"); @@ -234,9 +244,13 @@ export class TerminalHostClient extends EventEmitter { } // Authenticate - console.log("[TerminalHostClient] Authenticating..."); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Authenticating..."); + } await this.authenticate(); - console.log("[TerminalHostClient] Authentication successful!"); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Authentication successful!"); + } this.connectionState = ConnectionState.CONNECTED; } catch (error) { @@ -358,6 +372,7 @@ export class TerminalHostClient extends EventEmitter { * Handle incoming message (response or event) */ private handleMessage(message: IpcResponse | IpcEvent): void { + // Type guard: responses have 'id' field, events have 'type: event' if ("id" in message) { // Response to a request const pending = this.pendingRequests.get(message.id); @@ -366,40 +381,43 @@ export class TerminalHostClient extends EventEmitter { clearTimeout(pending.timeoutId); if (message.ok) { - pending.resolve((message as IpcSuccessResponse).payload); + pending.resolve(message.payload); } else { - const error = (message as IpcErrorResponse).error; - pending.reject(new Error(`${error.code}: ${error.message}`)); + pending.reject( + new Error(`${message.error.code}: ${message.error.message}`), + ); } } } else if (message.type === "event") { - // Event from daemon - const event = message as IpcEvent; - const payload = event.payload as + // Event from daemon - narrow payload based on type field + const { sessionId, payload } = message; + const eventPayload = payload as | TerminalDataEvent | TerminalExitEvent | TerminalErrorEvent; - if (payload.type === "data") { - this.emit("data", event.sessionId, (payload as TerminalDataEvent).data); - } else if (payload.type === "exit") { - const exitPayload = payload as TerminalExitEvent; - this.emit( - "exit", - event.sessionId, - exitPayload.exitCode, - exitPayload.signal, - ); - } else if (payload.type === "error") { - const errorPayload = payload as TerminalErrorEvent; - // Emit terminal-specific error so callers can handle it - // This is critical for "Write queue full" - paste was silently dropped before! - this.emit( - "terminalError", - event.sessionId, - errorPayload.error, - errorPayload.code, - ); + switch (eventPayload.type) { + case "data": + this.emit("data", sessionId, eventPayload.data); + break; + case "exit": + this.emit( + "exit", + sessionId, + eventPayload.exitCode, + eventPayload.signal, + ); + break; + case "error": + // Emit terminal-specific error so callers can handle it + // This is critical for "Write queue full" - paste was silently dropped before! + this.emit( + "terminalError", + sessionId, + eventPayload.error, + eventPayload.code, + ); + break; } } } @@ -435,10 +453,10 @@ export class TerminalHostClient extends EventEmitter { const token = readFileSync(TOKEN_PATH, "utf-8").trim(); - const response = (await this.sendRequest("hello", { + const response = await this.sendRequest("hello", { token, protocolVersion: PROTOCOL_VERSION, - })) as HelloResponse; + }); if (response.protocolVersion !== PROTOCOL_VERSION) { throw new Error( @@ -540,12 +558,16 @@ export class TerminalHostClient extends EventEmitter { if (existsSync(SOCKET_PATH)) { const isLive = await this.isSocketLive(); if (isLive) { - console.log("[TerminalHostClient] Socket is live, daemon is running"); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Socket is live, daemon is running"); + } return; } // Socket exists but not responsive - safe to remove - console.log("[TerminalHostClient] Removing stale socket file"); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Removing stale socket file"); + } try { unlinkSync(SOCKET_PATH); } catch { @@ -556,7 +578,9 @@ export class TerminalHostClient extends EventEmitter { // Also clean up stale PID file if socket was not live // This handles the case where daemon crashed and PID was reused if (existsSync(PID_PATH)) { - console.log("[TerminalHostClient] Removing stale PID file"); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Removing stale PID file"); + } try { unlinkSync(PID_PATH); } catch { @@ -566,7 +590,11 @@ export class TerminalHostClient extends EventEmitter { // Acquire spawn lock to prevent concurrent spawns if (!this.acquireSpawnLock()) { - console.log("[TerminalHostClient] Another spawn in progress, waiting..."); + if (DEBUG_CLIENT) { + console.log( + "[TerminalHostClient] Another spawn in progress, waiting...", + ); + } // Wait for the other spawn to complete await this.waitForDaemon(); return; @@ -575,18 +603,22 @@ export class TerminalHostClient extends EventEmitter { try { // Get path to daemon script const daemonScript = this.getDaemonScriptPath(); - console.log(`[TerminalHostClient] Daemon script path: ${daemonScript}`); - console.log( - `[TerminalHostClient] Script exists: ${existsSync(daemonScript)}`, - ); + if (DEBUG_CLIENT) { + console.log(`[TerminalHostClient] Daemon script path: ${daemonScript}`); + console.log( + `[TerminalHostClient] Script exists: ${existsSync(daemonScript)}`, + ); + } if (!existsSync(daemonScript)) { throw new Error(`Daemon script not found: ${daemonScript}`); } - console.log( - `[TerminalHostClient] Spawning daemon with execPath: ${process.execPath}`, - ); + if (DEBUG_CLIENT) { + console.log( + `[TerminalHostClient] Spawning daemon with execPath: ${process.execPath}`, + ); + } // Open log file for daemon output (helps debug daemon-side issues) const logPath = join(SUPERSET_HOME_DIR, "daemon.log"); @@ -612,15 +644,23 @@ export class TerminalHostClient extends EventEmitter { }, }); - console.log(`[TerminalHostClient] Daemon spawned with PID: ${child.pid}`); + if (DEBUG_CLIENT) { + console.log( + `[TerminalHostClient] Daemon spawned with PID: ${child.pid}`, + ); + } // Unref to allow parent to exit independently child.unref(); // Wait for daemon to start - console.log("[TerminalHostClient] Waiting for daemon to start..."); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Waiting for daemon to start..."); + } await this.waitForDaemon(); - console.log("[TerminalHostClient] Daemon started successfully"); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Daemon started successfully"); + } } finally { this.releaseSpawnLock(); } @@ -669,8 +709,8 @@ export class TerminalHostClient extends EventEmitter { /** * Send a request to the daemon and wait for response */ - private sendRequest(type: string, payload: unknown): Promise { - return new Promise((resolve, reject) => { + private sendRequest(type: string, payload: unknown): Promise { + return new Promise((resolve, reject) => { if (!this.socket) { reject(new Error("Not connected")); return; @@ -683,7 +723,12 @@ export class TerminalHostClient extends EventEmitter { reject(new Error(`Request timeout: ${type}`)); }, REQUEST_TIMEOUT_MS); - this.pendingRequests.set(id, { resolve, reject, timeoutId }); + // Cast resolve to unknown handler - safe because response type matches T + this.pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timeoutId, + }); const message = `${JSON.stringify({ id, type, payload })}\n`; this.socket.write(message); @@ -757,10 +802,10 @@ export class TerminalHostClient extends EventEmitter { request: CreateOrAttachRequest, ): Promise { await this.ensureConnected(); - const response = (await this.sendRequest( + const response = await this.sendRequest( "createOrAttach", request, - )) as CreateOrAttachResponse; + ); // Version skew: older daemons may not return pid - normalize undefined → null return { ...response, pid: response.pid ?? null }; } @@ -770,7 +815,7 @@ export class TerminalHostClient extends EventEmitter { */ async write(request: WriteRequest): Promise { await this.ensureConnected(); - return (await this.sendRequest("write", request)) as EmptyResponse; + return this.sendRequest("write", request); } /** @@ -805,7 +850,7 @@ export class TerminalHostClient extends EventEmitter { */ async resize(request: ResizeRequest): Promise { await this.ensureConnected(); - return (await this.sendRequest("resize", request)) as EmptyResponse; + return this.sendRequest("resize", request); } /** @@ -813,7 +858,7 @@ export class TerminalHostClient extends EventEmitter { */ async detach(request: DetachRequest): Promise { await this.ensureConnected(); - return (await this.sendRequest("detach", request)) as EmptyResponse; + return this.sendRequest("detach", request); } /** @@ -821,7 +866,7 @@ export class TerminalHostClient extends EventEmitter { */ async kill(request: KillRequest): Promise { await this.ensureConnected(); - return (await this.sendRequest("kill", request)) as EmptyResponse; + return this.sendRequest("kill", request); } /** @@ -829,7 +874,7 @@ export class TerminalHostClient extends EventEmitter { */ async killAll(request: KillAllRequest): Promise { await this.ensureConnected(); - return (await this.sendRequest("killAll", request)) as EmptyResponse; + return this.sendRequest("killAll", request); } /** @@ -837,10 +882,10 @@ export class TerminalHostClient extends EventEmitter { */ async listSessions(): Promise { await this.ensureConnected(); - const response = (await this.sendRequest( + const response = await this.sendRequest( "listSessions", undefined, - )) as ListSessionsResponse; + ); // Version skew: older daemons may not return pid - normalize undefined → null return { sessions: response.sessions.map((s) => ({ ...s, pid: s.pid ?? null })), @@ -854,10 +899,7 @@ export class TerminalHostClient extends EventEmitter { request: ClearScrollbackRequest, ): Promise { await this.ensureConnected(); - return (await this.sendRequest( - "clearScrollback", - request, - )) as EmptyResponse; + return this.sendRequest("clearScrollback", request); } /** @@ -867,10 +909,7 @@ export class TerminalHostClient extends EventEmitter { */ async shutdown(request: ShutdownRequest = {}): Promise { await this.ensureConnected(); - const response = (await this.sendRequest( - "shutdown", - request, - )) as EmptyResponse; + const response = await this.sendRequest("shutdown", request); // Disconnect after shutdown request is sent this.disconnect(); return response; @@ -890,7 +929,7 @@ export class TerminalHostClient extends EventEmitter { } try { - await this.sendRequest("shutdown", request); + await this.sendRequest("shutdown", request); } finally { this.disconnect(); } From 234b2d72ae9311d5ed6b253c22f1923be8f3d994 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 14:34:51 +0200 Subject: [PATCH 07/62] fix(desktop): align @xterm/headless version with @xterm/xterm Downgrade @xterm/headless from ^6.0.0 to ^5.5.0 to match @xterm/xterm version. Mismatched major versions can cause runtime failures. Also removes now-unnecessary @ts-expect-error directive since matching versions resolve the type compatibility issue. --- apps/desktop/src/main/lib/terminal-host/headless-emulator.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts b/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts index 50a93b3c8c5..02522ad4f54 100644 --- a/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts +++ b/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts @@ -85,8 +85,6 @@ export class HeadlessEmulator { }); this.serializeAddon = new SerializeAddon(); - // @ts-expect-error - SerializeAddon types expect @xterm/xterm Terminal, but works with @xterm/headless at runtime - // This is a known xterm ecosystem type mismatch. See: https://github.com/xtermjs/xterm.js/issues/4775 this.terminal.loadAddon(this.serializeAddon); // Initialize mode state From af855ff466bb4477c1d4da460df5f39f85a6fe41 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 15:43:12 +0200 Subject: [PATCH 08/62] fix(desktop): address code review feedback for terminal persistence P0 Fixes: - Use getActiveTerminalManager() instead of direct terminalManager import in workspaces.ts, projects.ts, and main/windows/main.ts - Add missing await for async getSessionCountByWorkspaceId() call - Update reconcileOnStartup() to preserve sessions for true app restart persistence, only killing orphaned sessions (deleted workspaces) P1 Fixes: - Call shutdownOrphanedDaemon() on startup when persistence is disabled - Fix error code mismatch: QUEUE_FULL -> WRITE_QUEUE_FULL in client.ts P2 Fixes: - Only keep terminal-containing tabs mounted when persistence enabled, non-terminal tabs use normal unmount behavior to save memory Additional Fixes (from runtime testing): - Fix resize race condition: forward resize to daemon regardless of local session cache state (handles startup race) - Add safety checks in initHistoryWriter: validate scrollback is string and cap at 512KB to prevent RangeError: Invalid array length --- .../src/lib/trpc/routers/projects/projects.ts | 4 +- .../src/main/lib/terminal-host/client.ts | 2 +- .../src/main/lib/terminal/daemon-manager.ts | 96 ++++++++++++++----- apps/desktop/src/main/windows/main.ts | 4 +- .../ContentView/TabsContent/index.tsx | 37 +++++-- 5 files changed, 108 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 33225e71129..dcd9941728a 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -12,7 +12,7 @@ import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; -import { terminalManager } from "main/lib/terminal"; +import { getActiveTerminalManager } from "main/lib/terminal"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; import simpleGit from "simple-git"; import { z } from "zod"; @@ -659,7 +659,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { let totalFailed = 0; for (const workspace of projectWorkspaces) { - const terminalResult = await terminalManager.killByWorkspaceId( + const terminalResult = await getActiveTerminalManager().killByWorkspaceId( workspace.id, ); totalFailed += terminalResult.failed; diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 77f4f46363b..2e9b6b7570f 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -833,7 +833,7 @@ export class TerminalHostClient extends EventEmitter { "terminalError", request.sessionId, "Write queue full - input dropped", - "QUEUE_FULL", + "WRITE_QUEUE_FULL", ); } }) diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index f9c7f70ff76..25b292b7698 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -10,7 +10,9 @@ */ import { EventEmitter } from "node:events"; +import { workspaces } from "@superset/local-db"; import { track } from "main/lib/analytics"; +import { localDb } from "main/lib/local-db"; import { containsClearScrollbackSequence, extractContentAfterClear, @@ -93,20 +95,47 @@ export class DaemonTerminalManager extends EventEmitter { } /** - * Clean up stale sessions from previous app runs. - * Call this on app startup BEFORE renderer restore runs. - * - * Current semantics: terminal persistence = across workspace switches only. - * App restart = fresh start (kill all stale daemon sessions). + * Reconcile daemon sessions on app startup. + * Sessions are preserved for reattachment when renderer restores panes. + * Orphaned sessions (workspaces deleted while app was closed) are cleaned up. */ async reconcileOnStartup(): Promise { try { const response = await this.client.listSessions(); - if (response.sessions.length > 0) { + if (response.sessions.length === 0) { + return; + } + + console.log( + `[DaemonTerminalManager] Found ${response.sessions.length} sessions from previous run`, + ); + + // Get valid workspace IDs from database + const validWorkspaceIds = new Set( + localDb + .select({ id: workspaces.id }) + .from(workspaces) + .all() + .map((w) => w.id), + ); + + // Kill sessions for deleted workspaces, keep others for reattach + let orphanedCount = 0; + for (const session of response.sessions) { + if (!validWorkspaceIds.has(session.workspaceId)) { + console.log( + `[DaemonTerminalManager] Killing orphaned session ${session.sessionId} (workspace deleted)`, + ); + await this.client.kill({ sessionId: session.sessionId }); + orphanedCount++; + } + } + + const preservedCount = response.sessions.length - orphanedCount; + if (preservedCount > 0) { console.log( - `[DaemonTerminalManager] Cleaning up ${response.sessions.length} stale sessions from previous run`, + `[DaemonTerminalManager] Preserving ${preservedCount} sessions for reattach`, ); - await this.client.killAll({}); } } catch (error) { console.warn( @@ -229,9 +258,28 @@ export class DaemonTerminalManager extends EventEmitter { this.historyInitializing.add(paneId); this.pendingHistoryData.set(paneId, []); + // Safety check: validate and cap initialScrollback to prevent RangeError + // Large snapshots can cause Buffer.from() to fail with "Invalid array length" + const MAX_SCROLLBACK_BYTES = 512 * 1024; // 512KB + let safeScrollback = initialScrollback; + if (initialScrollback !== undefined) { + if (typeof initialScrollback !== "string") { + console.warn( + `[DaemonTerminalManager] initialScrollback for ${paneId} is not a string, ignoring`, + ); + safeScrollback = undefined; + } else if (initialScrollback.length > MAX_SCROLLBACK_BYTES) { + console.warn( + `[DaemonTerminalManager] initialScrollback for ${paneId} too large (${initialScrollback.length} bytes), truncating to ${MAX_SCROLLBACK_BYTES}`, + ); + // Keep the most recent content (end of scrollback) + safeScrollback = initialScrollback.slice(-MAX_SCROLLBACK_BYTES); + } + } + try { const writer = new HistoryWriter(workspaceId, paneId, cwd, cols, rows); - await writer.init(initialScrollback); + await writer.init(safeScrollback); this.historyWriters.set(paneId, writer); // Flush any buffered data @@ -601,23 +649,25 @@ export class DaemonTerminalManager extends EventEmitter { return; } - const session = this.sessions.get(paneId); - if (!session || !session.isAlive) { - console.warn( - `Cannot resize terminal ${paneId}: session not found or not alive`, - ); - return; - } - - // Fire and forget + // Fire and forget to daemon - don't require local session cache + // This handles startup race where renderer sends resize before createOrAttach completes + // Daemon will silently ignore resize for non-existent sessions this.client.resize({ sessionId: paneId, cols, rows }).catch((error) => { - console.error( - `[DaemonTerminalManager] Resize failed for ${paneId}:`, - error, - ); + // Only log if it's not a "session not found" error (expected during startup) + const errorMsg = error instanceof Error ? error.message : String(error); + if (!errorMsg.includes("not found")) { + console.error( + `[DaemonTerminalManager] Resize failed for ${paneId}:`, + error, + ); + } }); - session.lastActive = Date.now(); + // Update local session if we have it + const session = this.sessions.get(paneId); + if (session) { + session.lastActive = Date.now(); + } } signal(params: { paneId: string; signal?: string }): void { diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index dc40dfeebeb..fc061313b16 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -17,7 +17,7 @@ import { notificationsApp, notificationsEmitter, } from "../lib/notifications/server"; -import { terminalManager } from "../lib/terminal"; +import { getActiveTerminalManager } from "../lib/terminal"; import { getInitialWindowBounds, loadWindowState, @@ -193,7 +193,7 @@ export async function MainWindow() { server.close(); notificationsEmitter.removeAllListeners(); // Remove terminal listeners to prevent duplicates when window reopens on macOS - terminalManager.detachAllListeners(); + getActiveTerminalManager().detachAllListeners(); // Detach window from IPC handler (handler stays alive for window reopen) ipcHandler?.detachWindow(window); // Clear current window reference diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 5c6f447c28c..5cfe705b199 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -6,11 +6,22 @@ import { MIN_SIDEBAR_WIDTH, } from "renderer/stores/sidebar-state"; import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Pane, Tab } from "renderer/stores/tabs/types"; +import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; import { ResizablePanel } from "../../../ResizablePanel"; import { Sidebar } from "../../Sidebar"; import { EmptyTabView } from "./EmptyTabView"; import { TabView } from "./TabView"; +/** + * Check if a tab contains at least one terminal pane. + * Used to determine which tabs need to stay mounted for persistence. + */ +function hasTerminalPane(tab: Tab, panes: Record): boolean { + const paneIds = extractPaneIdsFromLayout(tab.layout); + return paneIds.some((paneId) => panes[paneId]?.type === "terminal"); +} + export function TabsContent() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: terminalPersistence } = @@ -42,9 +53,10 @@ export function TabsContent() { return allTabs.find((tab) => tab.id === activeTabId) || null; }, [activeTabId, allTabs]); - // When terminal persistence is enabled, keep all terminals mounted across - // workspace/tab switches. This prevents TUI white screen issues by avoiding - // the unmount/remount cycle that requires complex reattach/rehydration logic. + // When terminal persistence is enabled, keep terminal-containing tabs mounted + // across workspace/tab switches. This prevents TUI white screen issues by + // avoiding the unmount/remount cycle that requires complex reattach/rehydration. + // Non-terminal tabs use normal unmount behavior to save memory. // Uses visibility:hidden (not display:none) to preserve xterm dimensions. if (terminalPersistence) { // Show empty view only if current workspace has no tabs @@ -71,13 +83,18 @@ export function TabsContent() { ); } + // Partition tabs: terminal tabs stay mounted, non-terminal tabs unmount when inactive + const terminalTabs = allTabs.filter((tab) => hasTerminalPane(tab, panes)); + const activeNonTerminalTab = + tabToRender && !hasTerminalPane(tabToRender, panes) + ? tabToRender + : null; + return (
- {allTabs.map((tab) => { - // A tab is visible only if: - // 1. It belongs to the active workspace AND - // 2. It's the active tab for that workspace + {/* Terminal tabs: keep mounted with visibility toggle */} + {terminalTabs.map((tab) => { const isVisible = tab.workspaceId === activeWorkspaceId && tab.id === activeTabId; @@ -94,6 +111,12 @@ export function TabsContent() {
); })} + {/* Active non-terminal tab: render normally (unmounts when switching) */} + {activeNonTerminalTab && ( +
+ +
+ )}
{isSidebarOpen && ( Date: Tue, 6 Jan 2026 15:52:42 +0200 Subject: [PATCH 09/62] fix(desktop): guard pane updates against deleted panes When async processes like Claude Code still hold pane references after the terminal is closed, calling setNeedsAttention (or similar) would create an undefined entry in the panes record. This caused GroupStrip to crash when iterating Object.values(panes). Now all pane update functions check if the pane exists first and return early (no-op) if it doesn't, preventing undefined entries. --- apps/desktop/src/renderer/stores/tabs/store.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index e7c43b6e786..e7d617fb0ac 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -616,6 +616,19 @@ export const useTabsStore = create()( }); }, + setNeedsAttention: (paneId, needsAttention) => { + set((state) => { + // Guard: no-op for unknown panes to avoid corrupting panes map + if (!state.panes[paneId]) return state; + return { + panes: { + ...state.panes, + [paneId]: { ...state.panes[paneId], needsAttention }, + }, + }; + }); + }, + clearWorkspaceAttentionStatus: (workspaceId) => { const state = get(); const workspaceTabs = state.tabs.filter( From 8e567aa98583b31a80891f93657e2bb0071ed15f Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 20:42:03 +0200 Subject: [PATCH 10/62] fix(desktop): split terminal host control/stream sockets Prevents createOrAttach timeouts by removing head-of-line blocking when terminal output backpressures. Adds protocol v2 hello with clientId/role, pairs control+stream sockets, and adds a backpressure isolation integration test. --- .../src/main/lib/terminal-host/client.ts | 515 ++++++++++++++---- .../src/main/lib/terminal-host/types.ts | 6 +- .../src/main/terminal-host/daemon.test.ts | 16 +- apps/desktop/src/main/terminal-host/index.ts | 153 +++++- .../terminal-host/session-lifecycle.test.ts | 172 ++++-- 5 files changed, 705 insertions(+), 157 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 2e9b6b7570f..6109bce9fb4 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -10,6 +10,7 @@ */ import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { existsSync, @@ -148,16 +149,21 @@ export interface TerminalHostClientEvents { * Emits events for terminal data and exit. */ export class TerminalHostClient extends EventEmitter { - private socket: Socket | null = null; - private parser = new NdjsonParser(); + private controlSocket: Socket | null = null; + private streamSocket: Socket | null = null; + private controlParser = new NdjsonParser(); + private streamParser = new NdjsonParser(); private pendingRequests = new Map(); private requestCounter = 0; - private authenticated = false; + private controlAuthenticated = false; + private streamAuthenticated = false; private connectionState = ConnectionState.DISCONNECTED; private disposed = false; private notifyQueue: string[] = []; private notifyQueueBytes = 0; private notifyDrainArmed = false; + private disconnectArmed = false; + private clientId = randomUUID(); // =========================================================================== // Connection Management @@ -171,8 +177,10 @@ export class TerminalHostClient extends EventEmitter { // Already connected - fast path (no logging to avoid noise on every API call) if ( this.connectionState === ConnectionState.CONNECTED && - this.socket && - this.authenticated + this.controlSocket && + this.streamSocket && + this.controlAuthenticated && + this.streamAuthenticated ) { return; } @@ -191,8 +199,10 @@ export class TerminalHostClient extends EventEmitter { const checkConnection = () => { if ( this.connectionState === ConnectionState.CONNECTED && - this.socket && - this.authenticated + this.controlSocket && + this.streamSocket && + this.controlAuthenticated && + this.streamAuthenticated ) { resolve(); } else if (this.connectionState === ConnectionState.DISCONNECTED) { @@ -212,49 +222,19 @@ export class TerminalHostClient extends EventEmitter { } this.connectionState = ConnectionState.CONNECTING; + this.disconnectArmed = false; if (DEBUG_CLIENT) { console.log("[TerminalHostClient] Connecting to daemon..."); } try { - // Try to connect to existing daemon - let connected = await this.tryConnect(); - if (DEBUG_CLIENT) { - console.log( - `[TerminalHostClient] Initial connection attempt: ${connected ? "SUCCESS" : "FAILED"}`, - ); - } - - if (!connected) { - // Spawn daemon and retry - if (DEBUG_CLIENT) { - console.log("[TerminalHostClient] Spawning daemon..."); - } - await this.spawnDaemon(); - connected = await this.tryConnect(); - if (DEBUG_CLIENT) { - console.log( - `[TerminalHostClient] Post-spawn connection attempt: ${connected ? "SUCCESS" : "FAILED"}`, - ); - } - - if (!connected) { - throw new Error("Failed to connect to daemon after spawn"); - } - } - - // Authenticate - if (DEBUG_CLIENT) { - console.log("[TerminalHostClient] Authenticating..."); - } - await this.authenticate(); - if (DEBUG_CLIENT) { - console.log("[TerminalHostClient] Authentication successful!"); - } - + await this.connectAndAuthenticate(); this.connectionState = ConnectionState.CONNECTED; + this.disconnectArmed = false; + this.emit("connected"); } catch (error) { - this.connectionState = ConnectionState.DISCONNECTED; + // Reset without emitting disconnected (connection never became usable) + this.resetConnectionState({ emitDisconnected: false }); throw error; } } @@ -265,14 +245,8 @@ export class TerminalHostClient extends EventEmitter { * This is useful for cleanup operations that should only act on existing daemons. */ async tryConnectAndAuthenticate(): Promise { - // Already connected and authenticated - if ( - this.connectionState === ConnectionState.CONNECTED && - this.socket && - this.authenticated - ) { - return true; - } + // Already connected and authenticated (control socket is sufficient here) + if (this.controlSocket && this.controlAuthenticated) return true; // Don't interfere with an in-progress connection if (this.connectionState === ConnectionState.CONNECTING) { @@ -282,26 +256,73 @@ export class TerminalHostClient extends EventEmitter { this.connectionState = ConnectionState.CONNECTING; try { - const connected = await this.tryConnect(); + const connected = await this.tryConnectControl(); if (!connected) { - this.connectionState = ConnectionState.DISCONNECTED; + this.resetConnectionState({ emitDisconnected: false }); return false; } - await this.authenticate(); - this.connectionState = ConnectionState.CONNECTED; + const token = this.readAuthToken(); + await this.authenticateControl({ token }); + this.connectionState = ConnectionState.CONNECTED; // control-only return true; - } catch { - this.connectionState = ConnectionState.DISCONNECTED; + } catch (_error) { + this.resetConnectionState({ emitDisconnected: false }); return false; } } /** - * Try to connect to the daemon socket. - * Returns true if connected, false if daemon not running. + * Connect and authenticate both control + stream sockets. + * Handles protocol mismatch by shutting down a legacy daemon and retrying once. */ - private async tryConnect(): Promise { + private async connectAndAuthenticate(): Promise { + const token = this.readAuthToken(); + + for (let attempt = 0; attempt < 2; attempt++) { + // Control socket (RPC) + let controlConnected = await this.tryConnectControl(); + if (!controlConnected) { + await this.spawnDaemon(); + controlConnected = await this.tryConnectControl(); + if (!controlConnected) { + throw new Error("Failed to connect control socket after spawn"); + } + } + + try { + await this.authenticateControl({ token }); + } catch (error) { + if (attempt === 0 && this.isProtocolMismatchError(error)) { + if (DEBUG_CLIENT) { + console.log( + "[TerminalHostClient] Protocol mismatch detected, shutting down legacy daemon...", + ); + } + this.resetConnectionState({ emitDisconnected: false }); + await this.shutdownLegacyDaemon(); + await this.waitForDaemonShutdown(); + await this.spawnDaemon(); + continue; + } + throw error; + } + + // Stream socket (events) + const streamConnected = await this.tryConnectStream(); + if (!streamConnected) { + throw new Error("Failed to connect stream socket"); + } + + await this.authenticateStream({ token }); + this.setupStreamSocketHandlers(); + return; + } + + throw new Error("Failed to connect after protocol upgrade"); + } + + private async tryConnectControl(): Promise { return new Promise((resolve) => { if (!existsSync(SOCKET_PATH)) { resolve(false); @@ -323,8 +344,8 @@ export class TerminalHostClient extends EventEmitter { if (!resolved) { resolved = true; clearTimeout(timeout); - this.socket = socket; - this.setupSocketHandlers(); + this.controlSocket = socket; + this.setupControlSocketHandlers(); resolve(true); } }); @@ -339,35 +360,89 @@ export class TerminalHostClient extends EventEmitter { }); } - /** - * Set up socket event handlers - */ - private setupSocketHandlers(): void { - if (!this.socket) return; + private async tryConnectStream(): Promise { + return new Promise((resolve) => { + if (!existsSync(SOCKET_PATH)) { + resolve(false); + return; + } + + const socket = connect(SOCKET_PATH); + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + socket.destroy(); + resolve(false); + } + }, CONNECT_TIMEOUT_MS); + + socket.on("connect", () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + socket.setEncoding("utf-8"); + socket.on("close", () => this.handleDisconnect()); + socket.on("error", (error) => { + this.emit( + "error", + error instanceof Error ? error : new Error(String(error)), + ); + this.handleDisconnect(); + }); + this.streamSocket = socket; + resolve(true); + } + }); + + socket.on("error", () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(false); + } + }); + }); + } - this.socket.setEncoding("utf-8"); + private setupControlSocketHandlers(): void { + if (!this.controlSocket) return; - this.socket.on("data", (data: string) => { - const messages = this.parser.parse(data); + this.controlSocket.setEncoding("utf-8"); + + this.controlSocket.on("data", (data: string) => { + const messages = this.controlParser.parse(data); for (const message of messages) { this.handleMessage(message); } }); - this.socket.on("drain", () => { + this.controlSocket.on("drain", () => { this.flushNotifyQueue(); }); - this.socket.on("close", () => { + this.controlSocket.on("close", () => { this.handleDisconnect(); }); - this.socket.on("error", (error) => { + this.controlSocket.on("error", (error) => { this.emit("error", error); this.handleDisconnect(); }); } + private setupStreamSocketHandlers(): void { + if (!this.streamSocket) return; + + this.streamSocket.on("data", (data: string) => { + const messages = this.streamParser.parse(data); + for (const message of messages) { + this.handleMessage(message); + } + }); + } + /** * Handle incoming message (response or event) */ @@ -426,13 +501,45 @@ export class TerminalHostClient extends EventEmitter { * Handle socket disconnect */ private handleDisconnect(): void { - this.socket = null; - this.authenticated = false; + if (this.disconnectArmed) return; + this.disconnectArmed = true; + this.resetConnectionState({ emitDisconnected: true }); + } + + /** + * Reset all connection state and optionally emit `disconnected`. + */ + private resetConnectionState({ + emitDisconnected, + }: { + emitDisconnected: boolean; + }): void { + // Destroy sockets (best-effort; close handlers may also fire) + try { + this.controlSocket?.destroy(); + } catch { + // Ignore + } + try { + this.streamSocket?.destroy(); + } catch { + // Ignore + } + + this.controlSocket = null; + this.streamSocket = null; + + this.controlAuthenticated = false; + this.streamAuthenticated = false; this.connectionState = ConnectionState.DISCONNECTED; + this.notifyQueue = []; this.notifyQueueBytes = 0; this.notifyDrainArmed = false; + this.controlParser = new NdjsonParser(); + this.streamParser = new NdjsonParser(); + // Reject all pending requests for (const [id, pending] of this.pendingRequests.entries()) { clearTimeout(pending.timeoutId); @@ -440,22 +547,59 @@ export class TerminalHostClient extends EventEmitter { this.pendingRequests.delete(id); } - this.emit("disconnected"); + if (emitDisconnected) { + this.emit("disconnected"); + } } - /** - * Authenticate with the daemon - */ - private async authenticate(): Promise { + private readAuthToken(): string { if (!existsSync(TOKEN_PATH)) { throw new Error("Auth token not found - daemon may not be running"); } - const token = readFileSync(TOKEN_PATH, "utf-8").trim(); + return readFileSync(TOKEN_PATH, "utf-8").trim(); + } + private isProtocolMismatchError(error: unknown): boolean { + return ( + error instanceof Error && error.message.startsWith("PROTOCOL_MISMATCH:") + ); + } + + private async authenticateControl({ + token, + }: { + token: string; + }): Promise { const response = await this.sendRequest("hello", { token, protocolVersion: PROTOCOL_VERSION, + clientId: this.clientId, + role: "control", + }); + + if (response.protocolVersion !== PROTOCOL_VERSION) { + throw new Error( + `Protocol version mismatch: client=${PROTOCOL_VERSION}, daemon=${response.protocolVersion}`, + ); + } + + this.controlAuthenticated = true; + } + + private async authenticateStream({ + token, + }: { + token: string; + }): Promise { + const response = await this.sendRequestOnStream({ + type: "hello", + payload: { + token, + protocolVersion: PROTOCOL_VERSION, + clientId: this.clientId, + role: "stream", + }, }); if (response.protocolVersion !== PROTOCOL_VERSION) { @@ -464,8 +608,169 @@ export class TerminalHostClient extends EventEmitter { ); } - this.authenticated = true; - this.emit("connected"); + this.streamAuthenticated = true; + } + + private async sendRequestOnStream({ + type, + payload, + }: { + type: string; + payload: unknown; + }): Promise { + return new Promise((resolve, reject) => { + if (!this.streamSocket) { + reject(new Error("Stream socket not connected")); + return; + } + + const id = `stream_req_${++this.requestCounter}`; + let buffer = ""; + + const timeoutId = setTimeout(() => { + this.streamSocket?.off("data", onData); + reject(new Error(`Request timeout: ${type}`)); + }, REQUEST_TIMEOUT_MS); + + const onData = (data: string) => { + buffer += data; + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) return; + + const line = buffer.slice(0, newlineIndex); + this.streamSocket?.off("data", onData); + clearTimeout(timeoutId); + + try { + const message = JSON.parse(line) as IpcResponse; + if (!("id" in message) || message.id !== id) { + reject(new Error("Unexpected stream response")); + return; + } + if (message.ok) { + resolve(message.payload as T); + } else { + reject( + new Error(`${message.error.code}: ${message.error.message}`), + ); + } + } catch { + reject(new Error("Failed to parse stream response")); + } + }; + + this.streamSocket.on("data", onData); + + const message = `${JSON.stringify({ id, type, payload })}\n`; + this.streamSocket.write(message); + }); + } + + private async shutdownLegacyDaemon({ + killSessions = true, + }: { + killSessions?: boolean; + } = {}): Promise { + if (!existsSync(SOCKET_PATH)) return; + + const token = this.readAuthToken(); + + await new Promise((resolve, reject) => { + const socket = connect(SOCKET_PATH); + let settled = false; + + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + socket.destroy(); + reject(new Error("Legacy daemon connect timeout")); + }, CONNECT_TIMEOUT_MS); + + socket.on("connect", () => { + if (settled) return; + clearTimeout(timeoutId); + socket.setEncoding("utf-8"); + + const sendAndWait = (request: { + id: string; + type: string; + payload: unknown; + }): Promise => + new Promise((res, rej) => { + let buffer = ""; + const onData = (data: string) => { + buffer += data; + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) return; + socket.off("data", onData); + try { + res(JSON.parse(buffer.slice(0, newlineIndex)) as IpcResponse); + } catch { + rej(new Error("Failed to parse legacy response")); + } + }; + socket.on("data", onData); + socket.write(`${JSON.stringify(request)}\n`); + }); + + (async () => { + try { + const helloId = `legacy_hello_${Date.now()}`; + const hello = await sendAndWait({ + id: helloId, + type: "hello", + payload: { + token, + protocolVersion: 1, + clientId: this.clientId, + role: "control", + }, + }); + if (!hello.ok) { + throw new Error( + `Legacy hello failed: ${hello.error.code}: ${hello.error.message}`, + ); + } + + const shutdownId = `legacy_shutdown_${Date.now()}`; + await sendAndWait({ + id: shutdownId, + type: "shutdown", + payload: { killSessions }, + }); + + settled = true; + socket.destroy(); + resolve(); + } catch (error) { + settled = true; + socket.destroy(); + reject(error instanceof Error ? error : new Error(String(error))); + } + })().catch(() => { + // Errors handled above + }); + }); + + socket.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + reject(error); + }); + }); + } + + private async waitForDaemonShutdown(): Promise { + const startTime = Date.now(); + const timeoutMs = 2000; + + while (Date.now() - startTime < timeoutMs) { + if (!existsSync(SOCKET_PATH)) return; + const live = await this.isSocketLive(); + if (!live) return; + await this.sleep(100); + } } // =========================================================================== @@ -711,7 +1016,7 @@ export class TerminalHostClient extends EventEmitter { */ private sendRequest(type: string, payload: unknown): Promise { return new Promise((resolve, reject) => { - if (!this.socket) { + if (!this.controlSocket) { reject(new Error("Not connected")); return; } @@ -731,7 +1036,7 @@ export class TerminalHostClient extends EventEmitter { }); const message = `${JSON.stringify({ id, type, payload })}\n`; - this.socket.write(message); + this.controlSocket.write(message); }); } @@ -745,7 +1050,7 @@ export class TerminalHostClient extends EventEmitter { * Returns false if queue is full (caller should handle). */ private sendNotification(type: string, payload: unknown): boolean { - if (!this.socket) return false; + if (!this.controlSocket) return false; const id = `notify_${++this.requestCounter}`; const message = `${JSON.stringify({ id, type, payload })}\n`; @@ -763,7 +1068,7 @@ export class TerminalHostClient extends EventEmitter { return true; } - const canWrite = this.socket.write(message); + const canWrite = this.controlSocket.write(message); if (!canWrite) { // Message is queued internally by the socket; arm drain to flush any // subsequent notifications we enqueue. @@ -773,7 +1078,7 @@ export class TerminalHostClient extends EventEmitter { } private flushNotifyQueue(): void { - if (!this.socket) return; + if (!this.controlSocket) return; if (!this.notifyDrainArmed && this.notifyQueue.length === 0) return; this.notifyDrainArmed = false; @@ -783,7 +1088,7 @@ export class TerminalHostClient extends EventEmitter { if (!message) break; this.notifyQueueBytes -= Buffer.byteLength(message, "utf8"); - const canWrite = this.socket.write(message); + const canWrite = this.controlSocket.write(message); if (!canWrite) { this.notifyDrainArmed = true; return; @@ -923,29 +1228,39 @@ export class TerminalHostClient extends EventEmitter { async shutdownIfRunning( request: ShutdownRequest = {}, ): Promise<{ wasRunning: boolean }> { - const connected = await this.tryConnectAndAuthenticate(); - if (!connected) { - return { wasRunning: false }; - } + // Avoid spawning a daemon if none exists. + const connected = await this.tryConnectControl(); + if (!connected) return { wasRunning: false }; try { + const token = this.readAuthToken(); + try { + await this.authenticateControl({ token }); + } catch (error) { + if (this.isProtocolMismatchError(error)) { + this.resetConnectionState({ emitDisconnected: false }); + await this.shutdownLegacyDaemon({ + killSessions: request.killSessions ?? false, + }); + return { wasRunning: true }; + } + throw error; + } + await this.sendRequest("shutdown", request); + return { wasRunning: true }; } finally { this.disconnect(); } - return { wasRunning: true }; } /** * Disconnect from daemon (but don't stop it) */ disconnect(): void { - if (this.socket) { - this.socket.destroy(); - this.socket = null; - } - this.authenticated = false; - this.connectionState = ConnectionState.DISCONNECTED; + // Explicit disconnect should not emit a disconnected event (caller controls UX) + this.disconnectArmed = true; + this.resetConnectionState({ emitDisconnected: false }); } /** diff --git a/apps/desktop/src/main/lib/terminal-host/types.ts b/apps/desktop/src/main/lib/terminal-host/types.ts index a15c394d4e5..bcca5d75a29 100644 --- a/apps/desktop/src/main/lib/terminal-host/types.ts +++ b/apps/desktop/src/main/lib/terminal-host/types.ts @@ -7,7 +7,7 @@ */ // Protocol version - increment for breaking changes -export const PROTOCOL_VERSION = 1; +export const PROTOCOL_VERSION = 2; // ============================================================================= // Mode Tracking @@ -139,6 +139,10 @@ export interface SessionMeta { export interface HelloRequest { token: string; protocolVersion: number; + /** Stable ID shared between a client’s control + stream sockets */ + clientId: string; + /** Socket role: control carries RPC; stream carries events */ + role: "control" | "stream"; } export interface HelloResponse { diff --git a/apps/desktop/src/main/terminal-host/daemon.test.ts b/apps/desktop/src/main/terminal-host/daemon.test.ts index eff21b3e19c..edafcee28ec 100644 --- a/apps/desktop/src/main/terminal-host/daemon.test.ts +++ b/apps/desktop/src/main/terminal-host/daemon.test.ts @@ -8,7 +8,7 @@ * 4. Respond to hello requests */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { ChildProcess } from "node:child_process"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; @@ -233,12 +233,12 @@ describe("Terminal Host Daemon", () => { }); } - beforeEach(async () => { + beforeAll(async () => { cleanup(); await startDaemon(); }); - afterEach(async () => { + afterAll(async () => { await stopDaemon(); cleanup(); }); @@ -259,6 +259,8 @@ describe("Terminal Host Daemon", () => { payload: { token, protocolVersion: PROTOCOL_VERSION, + clientId: "test-client", + role: "control", }, }; @@ -288,6 +290,8 @@ describe("Terminal Host Daemon", () => { payload: { token: "invalid-token", protocolVersion: PROTOCOL_VERSION, + clientId: "test-client", + role: "control", }, }; @@ -316,6 +320,8 @@ describe("Terminal Host Daemon", () => { payload: { token, protocolVersion: 999, // Invalid version + clientId: "test-client", + role: "control", }, }; @@ -371,6 +377,8 @@ describe("Terminal Host Daemon", () => { payload: { token, protocolVersion: PROTOCOL_VERSION, + clientId: "test-client", + role: "control", }, }; @@ -413,6 +421,8 @@ describe("Terminal Host Daemon", () => { payload: { token, protocolVersion: PROTOCOL_VERSION, + clientId: "test-client", + role: "control", }, }; diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index 15a26f54407..97191f6cb17 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -180,6 +180,27 @@ type RequestHandler = ( interface ClientState { authenticated: boolean; + clientId?: string; + role?: "control" | "stream"; +} + +interface ClientSockets { + control?: Socket; + stream?: Socket; +} + +const clientsById = new Map(); + +function isValidRole(role: unknown): role is "control" | "stream" { + return role === "control" || role === "stream"; +} + +function getStreamSocketForClient( + clientState: ClientState, +): Socket | undefined { + const clientId = clientState.clientId; + if (!clientId) return undefined; + return clientsById.get(clientId)?.stream; } const handlers: Record = { @@ -203,7 +224,38 @@ const handlers: Record = { return; } + // Validate v2 fields + if (typeof request.clientId !== "string" || request.clientId.length === 0) { + sendError(socket, id, "INVALID_HELLO", "Missing clientId"); + return; + } + if (!isValidRole(request.role)) { + sendError(socket, id, "INVALID_HELLO", "Invalid role"); + return; + } + clientState.authenticated = true; + clientState.clientId = request.clientId; + clientState.role = request.role; + + // Register the socket under the clientId/role. Replace any existing socket for + // the same role to avoid ghost connections that can re-introduce backpressure. + const existing = clientsById.get(request.clientId) ?? {}; + const previousSocket = + request.role === "control" ? existing.control : existing.stream; + if (previousSocket && previousSocket !== socket) { + try { + terminalHost.detachFromAllSessions(previousSocket); + previousSocket.destroy(); + } catch { + // Best effort cleanup + } + } + const updated: ClientSockets = + request.role === "control" + ? { ...existing, control: socket } + : { ...existing, stream: socket }; + clientsById.set(request.clientId, updated); const response: HelloResponse = { protocolVersion: PROTOCOL_VERSION, @@ -212,7 +264,10 @@ const handlers: Record = { }; sendSuccess(socket, id, response); - log("info", "Client authenticated successfully"); + log("info", "Client authenticated successfully", { + clientId: request.clientId, + role: request.role, + }); }, createOrAttach: async (socket, id, payload, clientState) => { @@ -220,12 +275,27 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "createOrAttach requires control"); + return; + } const request = payload as CreateOrAttachRequest; log("info", `Creating/attaching session: ${request.sessionId}`); try { - const response = await terminalHost.createOrAttach(socket, request); + const streamSocket = getStreamSocketForClient(clientState); + if (!streamSocket) { + sendError( + socket, + id, + "STREAM_NOT_CONNECTED", + "Stream socket not connected", + ); + return; + } + + const response = await terminalHost.createOrAttach(streamSocket, request); sendSuccess(socket, id, response); log( @@ -244,6 +314,10 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "write requires control"); + return; + } const request = payload as WriteRequest; @@ -262,6 +336,14 @@ const handlers: Record = { if (isNotify) { // Emit a session-scoped error event so the main process can surface it. // (No response is sent for notify writes.) + const streamSocket = getStreamSocketForClient(clientState); + if (!streamSocket) { + log("warn", "Notify write failed but no stream socket registered", { + sessionId: request.sessionId, + error: message, + }); + return; + } const event: IpcEvent = { type: "event", event: "error", @@ -272,7 +354,7 @@ const handlers: Record = { code: "WRITE_FAILED", } satisfies TerminalErrorEvent, }; - socket.write(`${JSON.stringify(event)}\n`); + streamSocket.write(`${JSON.stringify(event)}\n`); log("warn", `Write failed for ${request.sessionId}`, { error: message, }); @@ -288,6 +370,10 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "resize requires control"); + return; + } const request = payload as ResizeRequest; const response = terminalHost.resize(request); @@ -299,9 +385,23 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "detach requires control"); + return; + } const request = payload as DetachRequest; - const response = terminalHost.detach(socket, request); + const streamSocket = getStreamSocketForClient(clientState); + if (!streamSocket) { + sendError( + socket, + id, + "STREAM_NOT_CONNECTED", + "Stream socket not connected", + ); + return; + } + const response = terminalHost.detach(streamSocket, request); sendSuccess(socket, id, response); }, @@ -310,6 +410,10 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "kill requires control"); + return; + } const request = payload as KillRequest; const response = terminalHost.kill(request); @@ -322,6 +426,10 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "killAll requires control"); + return; + } const request = payload as KillAllRequest; const response = terminalHost.killAll(request); @@ -334,6 +442,10 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "listSessions requires control"); + return; + } const response = terminalHost.listSessions(); sendSuccess(socket, id, response); @@ -344,6 +456,10 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "clearScrollback requires control"); + return; + } const request = payload as ClearScrollbackRequest; const response = terminalHost.clearScrollback(request); @@ -355,6 +471,10 @@ const handlers: Record = { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); return; } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "shutdown requires control"); + return; + } const request = payload as ShutdownRequest; log("info", "Shutdown requested via IPC", { @@ -433,6 +553,31 @@ function handleConnection(socket: Socket) { // Detach this socket from all sessions it was attached to // This is centralized here to avoid per-session socket listeners terminalHost.detachFromAllSessions(socket); + + // Remove from client map if this was a registered control/stream socket. + const { clientId, role } = clientState; + if (clientId && role) { + const entry = clientsById.get(clientId); + if (entry) { + const matches = + role === "control" + ? entry.control === socket + : entry.stream === socket; + if (matches) { + const next: ClientSockets = { ...entry }; + if (role === "control") { + delete next.control; + } else { + delete next.stream; + } + if (!next.control && !next.stream) { + clientsById.delete(clientId); + } else { + clientsById.set(clientId, next); + } + } + } + } }; socket.on("close", handleDisconnect); diff --git a/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts b/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts index 43320de19c3..19e456cb5e4 100644 --- a/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts +++ b/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts @@ -10,7 +10,7 @@ * 6. Kill session */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { ChildProcess } from "node:child_process"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; @@ -263,7 +263,15 @@ describe("Terminal Host Session Lifecycle", () => { /** * Authenticate with the daemon */ - async function authenticate(socket: Socket): Promise { + async function authenticate({ + socket, + clientId, + role, + }: { + socket: Socket; + clientId: string; + role: "control" | "stream"; + }): Promise { const token = readFileSync(TOKEN_PATH, "utf-8").trim(); const request: IpcRequest = { @@ -272,6 +280,8 @@ describe("Terminal Host Session Lifecycle", () => { payload: { token, protocolVersion: PROTOCOL_VERSION, + clientId, + role, }, }; @@ -281,6 +291,19 @@ describe("Terminal Host Session Lifecycle", () => { } } + async function connectClient(): Promise<{ + control: Socket; + stream: Socket; + clientId: string; + }> { + const control = await connectToDaemon(); + const stream = await connectToDaemon(); + const clientId = `test-client-${Date.now()}-${Math.random().toString(16).slice(2)}`; + await authenticate({ socket: control, clientId, role: "control" }); + await authenticate({ socket: stream, clientId, role: "stream" }); + return { control, stream, clientId }; + } + /** * Wait for events from the socket */ @@ -323,23 +346,21 @@ describe("Terminal Host Session Lifecycle", () => { }); } - beforeEach(async () => { + beforeAll(async () => { cleanup(); await startDaemon(); }); - afterEach(async () => { + afterAll(async () => { await stopDaemon(); cleanup(); }); describe("session creation", () => { it("should create a new session and return snapshot", async () => { - const socket = await connectToDaemon(); + const { control, stream } = await connectClient(); try { - await authenticate(socket); - const createRequest: IpcRequest = { id: "test-create-1", type: "createOrAttach", @@ -354,7 +375,7 @@ describe("Terminal Host Session Lifecycle", () => { } satisfies CreateOrAttachRequest, }; - const response = await sendRequest(socket, createRequest); + const response = await sendRequest(control, createRequest); expect(response.id).toBe("test-create-1"); expect(response.ok).toBe(true); @@ -367,16 +388,15 @@ describe("Terminal Host Session Lifecycle", () => { expect(payload.snapshot.rows).toBe(24); } } finally { - socket.destroy(); + control.destroy(); + stream.destroy(); } }); it("should attach to existing session", async () => { - const socket = await connectToDaemon(); + const { control, stream } = await connectClient(); try { - await authenticate(socket); - // Create first session const createRequest1: IpcRequest = { id: "test-create-2a", @@ -392,7 +412,7 @@ describe("Terminal Host Session Lifecycle", () => { } satisfies CreateOrAttachRequest, }; - const response1 = await sendRequest(socket, createRequest1); + const response1 = await sendRequest(control, createRequest1); expect(response1.ok).toBe(true); if (response1.ok) { expect((response1.payload as CreateOrAttachResponse).isNew).toBe( @@ -402,7 +422,7 @@ describe("Terminal Host Session Lifecycle", () => { // Wait for the session to be fully ready before attaching // PTY spawn can be async and session needs to be alive for attach - const isReady = await waitForSessionReady(socket, "test-session-2"); + const isReady = await waitForSessionReady(control, "test-session-2"); expect(isReady).toBe(true); // Attach to same session @@ -420,7 +440,7 @@ describe("Terminal Host Session Lifecycle", () => { } satisfies CreateOrAttachRequest, }; - const response2 = await sendRequest(socket, createRequest2); + const response2 = await sendRequest(control, createRequest2); if (!response2.ok) { // Log error details for debugging console.error("Attach failed:", JSON.stringify(response2, null, 2)); @@ -432,7 +452,66 @@ describe("Terminal Host Session Lifecycle", () => { expect(payload.wasRecovered).toBe(true); } } finally { - socket.destroy(); + control.destroy(); + stream.destroy(); + } + }); + }); + + describe("backpressure isolation", () => { + it("should not delay createOrAttach when stream socket is backpressured", async () => { + const { control, stream } = await connectClient(); + + try { + // Stop consuming the stream to simulate a slow/unresponsive client. + stream.pause(); + + // Force the daemon to write a *large* event to the stream socket without relying on PTY output. + // We do this by sending a notify write to a non-existent session with an intentionally huge ID, + // which triggers an error event written to the stream socket. + const hugeSessionId = `bp-missing-${"x".repeat(50_000)}`; + control.write( + `${JSON.stringify({ + id: "notify_bp_1", + type: "write", + payload: { sessionId: hugeSessionId, data: "x" }, + })}\n`, + ); + + // Give the daemon a moment to enqueue the error event and hit backpressure. + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Create/attach should still complete quickly because it returns over the control socket. + const startTime = Date.now(); + const createRequest2: IpcRequest = { + id: "bp-create-2", + type: "createOrAttach", + payload: { + sessionId: "bp-session-2", + workspaceId: "workspace-1", + paneId: "bp-pane-2", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: process.env.HOME, + } satisfies CreateOrAttachRequest, + }; + + const createResponse2 = await sendRequest(control, createRequest2); + const elapsedMs = Date.now() - startTime; + + expect(createResponse2.ok).toBe(true); + expect(elapsedMs).toBeLessThan(3000); + + // Cleanup (best-effort) + await sendRequest(control, { + id: "bp-kill-2", + type: "kill", + payload: { sessionId: "bp-session-2" }, + }); + } finally { + control.destroy(); + stream.destroy(); } }); }); @@ -441,11 +520,9 @@ describe("Terminal Host Session Lifecycle", () => { // Note: PTY operations may fail in test environment due to bun/node-pty compatibility // The daemon infrastructure is tested separately in daemon.test.ts it.skip("should write data to terminal and receive output", async () => { - const socket = await connectToDaemon(); + const { control, stream } = await connectClient(); try { - await authenticate(socket); - // Create session const createRequest: IpcRequest = { id: "test-write-1", @@ -461,10 +538,10 @@ describe("Terminal Host Session Lifecycle", () => { } satisfies CreateOrAttachRequest, }; - await sendRequest(socket, createRequest); + await sendRequest(control, createRequest); // Wait for shell prompt (data event) - const dataPromise = waitForEvent(socket, "data", 10000); + const dataPromise = waitForEvent(stream, "data", 10000); // Write a simple echo command const writeRequest: IpcRequest = { @@ -476,7 +553,7 @@ describe("Terminal Host Session Lifecycle", () => { }, }; - const writeResponse = await sendRequest(socket, writeRequest); + const writeResponse = await sendRequest(control, writeRequest); if (!writeResponse.ok) { console.error("Write failed:", writeResponse); } @@ -491,17 +568,16 @@ describe("Terminal Host Session Lifecycle", () => { expect(payload.type).toBe("data"); expect(typeof payload.data).toBe("string"); } finally { - socket.destroy(); + control.destroy(); + stream.destroy(); } }); // Note: PTY operations may fail in test environment due to bun/node-pty compatibility it.skip("should resize terminal", async () => { - const socket = await connectToDaemon(); + const { control, stream } = await connectClient(); try { - await authenticate(socket); - // Create session const createRequest: IpcRequest = { id: "test-resize-1", @@ -517,7 +593,7 @@ describe("Terminal Host Session Lifecycle", () => { } satisfies CreateOrAttachRequest, }; - await sendRequest(socket, createRequest); + await sendRequest(control, createRequest); // Resize const resizeRequest: IpcRequest = { @@ -530,10 +606,11 @@ describe("Terminal Host Session Lifecycle", () => { }, }; - const resizeResponse = await sendRequest(socket, resizeRequest); + const resizeResponse = await sendRequest(control, resizeRequest); expect(resizeResponse.ok).toBe(true); } finally { - socket.destroy(); + control.destroy(); + stream.destroy(); } }); }); @@ -541,11 +618,9 @@ describe("Terminal Host Session Lifecycle", () => { describe("session listing", () => { // Note: PTY operations may fail in test environment due to bun/node-pty compatibility it.skip("should list all sessions", async () => { - const socket = await connectToDaemon(); + const { control, stream } = await connectClient(); try { - await authenticate(socket); - // Create two sessions for (const id of ["session-list-1", "session-list-2"]) { const createRequest: IpcRequest = { @@ -561,7 +636,7 @@ describe("Terminal Host Session Lifecycle", () => { cwd: process.env.HOME, } satisfies CreateOrAttachRequest, }; - await sendRequest(socket, createRequest); + await sendRequest(control, createRequest); } // List sessions @@ -571,7 +646,7 @@ describe("Terminal Host Session Lifecycle", () => { payload: undefined, }; - const listResponse = await sendRequest(socket, listRequest); + const listResponse = await sendRequest(control, listRequest); expect(listResponse.ok).toBe(true); if (listResponse.ok) { @@ -583,18 +658,17 @@ describe("Terminal Host Session Lifecycle", () => { expect(sessionIds).toContain("session-list-2"); } } finally { - socket.destroy(); + control.destroy(); + stream.destroy(); } }); }); describe("session termination", () => { it("should kill a specific session", async () => { - const socket = await connectToDaemon(); + const { control, stream } = await connectClient(); try { - await authenticate(socket); - // Create session const createRequest: IpcRequest = { id: "test-kill-1", @@ -610,7 +684,7 @@ describe("Terminal Host Session Lifecycle", () => { } satisfies CreateOrAttachRequest, }; - await sendRequest(socket, createRequest); + await sendRequest(control, createRequest); // Kill session const killRequest: IpcRequest = { @@ -621,24 +695,23 @@ describe("Terminal Host Session Lifecycle", () => { }, }; - const killResponse = await sendRequest(socket, killRequest); + const killResponse = await sendRequest(control, killRequest); expect(killResponse.ok).toBe(true); // Wait for exit event - const exitEvent = await waitForEvent(socket, "exit", 5000); + const exitEvent = await waitForEvent(stream, "exit", 5000); expect(exitEvent.sessionId).toBe("test-session-kill"); } finally { - socket.destroy(); + control.destroy(); + stream.destroy(); } }); // Note: PTY operations may fail in test environment due to bun/node-pty compatibility it.skip("should kill all sessions", async () => { - const socket = await connectToDaemon(); + const { control, stream } = await connectClient(); try { - await authenticate(socket); - // Create sessions for (const id of ["kill-all-1", "kill-all-2"]) { const createRequest: IpcRequest = { @@ -654,7 +727,7 @@ describe("Terminal Host Session Lifecycle", () => { cwd: process.env.HOME, } satisfies CreateOrAttachRequest, }; - await sendRequest(socket, createRequest); + await sendRequest(control, createRequest); } // Kill all @@ -664,7 +737,7 @@ describe("Terminal Host Session Lifecycle", () => { payload: {}, }; - const killAllResponse = await sendRequest(socket, killAllRequest); + const killAllResponse = await sendRequest(control, killAllRequest); expect(killAllResponse.ok).toBe(true); // Wait a bit for exits to propagate @@ -677,7 +750,7 @@ describe("Terminal Host Session Lifecycle", () => { payload: undefined, }; - const listResponse = await sendRequest(socket, listRequest); + const listResponse = await sendRequest(control, listRequest); expect(listResponse.ok).toBe(true); if (listResponse.ok) { @@ -686,7 +759,8 @@ describe("Terminal Host Session Lifecycle", () => { expect(aliveSessions.length).toBe(0); } } finally { - socket.destroy(); + control.destroy(); + stream.destroy(); } }); }); From d77a7ef240ecd8639ec2cb83710825832c3aef63 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 20:42:19 +0200 Subject: [PATCH 11/62] chore(desktop): biome format + archive execplan Formats a few files to satisfy biome format checks and moves the terminal host dual-socket ExecPlan to apps/desktop/plans/done/. --- ...00-terminal-host-control-stream-sockets.md | 331 ++++++++++++++++++ .../src/lib/trpc/routers/projects/projects.ts | 5 +- .../ContentView/TabsContent/index.tsx | 4 +- 3 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/plans/done/20260106-1800-terminal-host-control-stream-sockets.md diff --git a/apps/desktop/plans/done/20260106-1800-terminal-host-control-stream-sockets.md b/apps/desktop/plans/done/20260106-1800-terminal-host-control-stream-sockets.md new file mode 100644 index 00000000000..e8b4de04b54 --- /dev/null +++ b/apps/desktop/plans/done/20260106-1800-terminal-host-control-stream-sockets.md @@ -0,0 +1,331 @@ +# Terminal Host Freeze Fix: Split Control vs Stream Sockets + + +## Purpose + +Stop terminal creation from freezing the desktop app (macOS spinner) and eliminate the `Request timeout: createOrAttach` error by removing head‑of‑line blocking between high‑volume terminal output and low‑volume RPC requests. + +After this change, opening a new terminal should remain responsive even if another terminal is spamming output (for example running `yes`), and `createOrAttach` should not be delayed by socket backpressure from terminal data streaming. + + +## Context + +The desktop app uses a “terminal host daemon” (a persistent background Node process) that owns PTYs and a headless xterm emulator. The Electron main process talks to the daemon over a Unix domain socket using NDJSON (newline‑delimited JSON). + +Today, a single socket carries two very different traffic patterns: + +1. High‑frequency, high‑volume terminal output events (daemon → main). +2. Low‑frequency request/response RPC (main → daemon → main), including `createOrAttach`. + +When any attached terminal produces output faster than the Electron client can consume, the daemon’s writes back up (`socket.write()` returns false). Because the same socket is also used for RPC responses, the `createOrAttach` response can be delayed behind queued output, which presents as: + +1. A multi‑second UI stall while the renderer waits for the `createOrAttach` tRPC mutation to resolve. +2. Eventually, `Request timeout: createOrAttach` thrown by the main‑process daemon client after 30s. + +This is classic head‑of‑line blocking caused by multiplexing dissimilar traffic onto one ordered byte stream. + +### Why Protocol Negotiation Matters + +In practice, users can end up with a stale daemon from an older app version still running (for example after an update, a crash, or having multiple installed versions). That means “daemon and client ship together” is not sufficient on its own: a new client must be able to detect and replace an old daemon cleanly, otherwise the socket path remains occupied and the new daemon cannot start. + +This plan therefore includes explicit handling for protocol mismatches and partial connection failures. + +Relevant code locations (for orientation): + +- Daemon client timeout: `apps/desktop/src/main/lib/terminal-host/client.ts` +- Daemon server + handlers: `apps/desktop/src/main/terminal-host/index.ts` +- Session output backpressure log: `apps/desktop/src/main/terminal-host/session.ts` +- Main process terminal manager that calls daemon: `apps/desktop/src/main/lib/terminal/daemon-manager.ts` + + +## Scope + +In scope: + +- Introduce a protocol v2 that uses two separate Unix socket connections: + - A “control socket” for request/response RPC (hello, createOrAttach, write, resize, detach, kill, listSessions, etc.). + - A “stream socket” dedicated to daemon → main event streaming (terminal data/exit/error). +- Ensure `createOrAttach` attaches the stream socket to the session (so events flow) while returning the snapshot over the control socket (so the response is never blocked by streaming backpressure). +- Keep the renderer API and UI behavior unchanged (still uses existing tRPC mutations/subscriptions). +- Add tests that reproduce the backpressure scenario and prove `createOrAttach` is not delayed by a congested stream socket. +- Define upgrade / mismatch semantics for when the client connects to a daemon running an older protocol. + +Out of scope (can be future work): + +- Refactoring history persistence, locale detection, or port scanning (these are performance improvements but not the root cause). +- Changing renderer rendering/coalescing of terminal output. + + +## Assumptions + +- The daemon and Electron main process are shipped together, so a protocol version bump is acceptable. +- The daemon already supports multiple client connections; we will formalize per‑connection “role” and a shared client identifier. +- The renderer cannot import Node.js modules (desktop rule); all socket work remains in `apps/desktop/src/main`. + + +## Open Questions + +(None. Resolved during implementation; see Decision Log.) + + +## Decision Log + +- Decision: Handle protocol mismatch by performing an authenticated v1 `shutdown` against the legacy daemon, then spawning a v2 daemon and retrying once. + Rationale: In practice users can have a stale daemon from an older app version still running and occupying the socket path. Shutting it down avoids versioned socket paths, prevents multiple daemons from contending, and gives a deterministic recovery path for upgrades. + Date/Author: 2026-01-06 / codex + +- Decision: Use a shared `clientId` to pair a client’s control + stream sockets, generated once per `TerminalHostClient` instance. + Rationale: The daemon needs a stable key to associate the two sockets. A per-client UUID is simple, avoids cross-client interference, and requires no renderer changes. + Date/Author: 2026-01-06 / codex + +- Decision: Keep `writeNoAck` (terminal input) on the control socket and keep the change unflagged; protocol version is the rollout gate. + Rationale: Input is low-volume and latency sensitive and already has a best-effort path. A feature flag would add complexity for a daemon/client pair shipped together; protocol mismatch recovery + revert is the escape hatch. + Date/Author: 2026-01-06 / codex + + +## Plan Of Work + +### Milestone 1: Define protocol v2 (types + invariants) + +Goal: Make the protocol express “this connection is control” vs “this connection is stream” and reliably pair the two connections that belong to the same app instance. + +Changes: + +- Update `apps/desktop/src/main/lib/terminal-host/types.ts`: + - Bump `PROTOCOL_VERSION` from 1 to 2. + - Extend `HelloRequest` to include: + - `clientId: string` (generated once by the Electron main process and reused for both sockets). + - `role: "control" | "stream"`. + - Document the v2 invariants in comments: + - Events must only be delivered on stream sockets. + - RPC responses must only be delivered on control sockets. + - `createOrAttach` must attach the stream socket before the snapshot boundary is chosen, so the snapshot excludes post‑attach output (avoids duplicates). + - Define the explicit mismatch surface area the client will rely on: + - Daemon returns `PROTOCOL_MISMATCH` with a message that includes both expected and received versions (already true today). + +Acceptance (type level): + + bun run typecheck + + +### Milestone 2: Daemon server supports dual sockets + +Goal: The daemon can accept two sockets per clientId, authenticate them, and use the stream socket for session attachment/event broadcast. + +Changes: + +- Update `apps/desktop/src/main/terminal-host/index.ts`: + - Track client connections in a map keyed by `clientId`: + - control socket (optional) + - stream socket (optional) + - authenticated flag(s) per socket + - In `hello` handler: + - Validate token and protocol version. + - Record `clientId` and `role` on the socket’s clientState. + - Store socket in the `clientsById` map for lookup. + - Keep explicit version mismatch behavior: + - If `protocolVersion !== 2`, respond with `PROTOCOL_MISMATCH` and do not register the client/socket. + - Update `createOrAttach` handler: + - Require role = control. + - Look up the stream socket for the same clientId; if missing, return a clear error (e.g. `STREAM_NOT_CONNECTED`). + - Pass the stream socket into terminal session attach logic so the stream socket becomes an attached client. + - Return the snapshot payload on the control socket response. + - Update `detach` handler: + - Require role = control. + - Detach the stream socket (not the control socket) from the session. + - On socket close: + - Remove the socket from the client map. + - Keep existing `detachFromAllSessions(socket)` behavior, which will now primarily apply to stream sockets. + +Acceptance (manual log sanity): + + SUPERSET_TERMINAL_DEBUG=1 ELECTRON_RUN_AS_NODE=1 bun run desktop:dev + +Expected: daemon logs show two authenticated connections per app instance (control + stream), and session data events are only written to the stream socket. + + +### Milestone 3: Electron main daemon client uses two connections + +Goal: The Electron main process establishes and maintains both sockets, sends RPC over control, and receives events over stream. + +Changes: + +Milestone 3 is the riskiest change; implement it in three sub‑milestones inside `apps/desktop/src/main/lib/terminal-host/client.ts` to keep it reviewable and testable. + +#### Milestone 3.1: Establish two authenticated connections with shared clientId + +Goal: `ensureConnected()` only returns when both sockets are connected and authenticated, and it tears down both sockets on any partial failure. + +Changes: + +- Generate a stable `clientId` when the singleton is created. +- Maintain `controlSocket` and `streamSocket` with independent connect timeouts. +- Add a unified “connected” state that means: both sockets exist, both are authenticated. +- On failure in either connect/auth step: + - Close/destroy both sockets. + - Reset state to disconnected. + +#### Milestone 3.2: Route responses vs events to the correct socket + +Goal: Remove accidental multiplexing in the client: control socket is only for request/response; stream socket is only for events. + +Changes: + +- Control socket parser only feeds `pendingRequests`. +- Stream socket parser only emits `data` / `exit` / `terminalError`. + +#### Milestone 3.3: Define mismatch + restart behavior and mid-session failure semantics + +Goal: Prevent “stale daemon blocks new client” and define what happens when a socket or daemon dies. + +Changes: + +- Protocol mismatch handling (upgrade path): + - If control `hello` fails with `PROTOCOL_MISMATCH`, attempt a v1 shutdown sequence: + - Connect and authenticate using `protocolVersion = 1` (legacy hello shape) on a temporary socket. + - Send `shutdown`. + - Disconnect, then spawn the v2 daemon and retry the v2 connect/auth flow. + - If the v1 shutdown attempt fails, surface a clear error and instruct the user to restart (this should be rare). +- Daemon restart / socket death: + - Treat either socket closing as “daemon connection lost” and tear down both sockets (simplest semantics). + - Emit the existing `disconnected` event so `DaemonTerminalManager` can show the existing error UI. + - Recovery happens the same way as today: the next `createOrAttach` / user “Retry Connection” will call `ensureConnected()` and re-establish both sockets. + +Acceptance (unit/integration): + + bun test apps/desktop/src/main/lib/terminal-host + + +### Milestone 4: Prove the fix under backpressure (tests) + +Goal: Add an automated test that fails on the current single‑socket design and passes with the split sockets, demonstrating that `createOrAttach` is not blocked by stream backpressure. + +Test design: + +- Start a daemon instance in test mode. +- Connect a stream socket and deliberately stop reading from it (`socket.pause()` or never attach a data handler) to create backpressure. +- Create a session that produces lots of output (write a large payload or run a command that floods output). +- In parallel, call `createOrAttach` for a new pane/session over the control socket. +- Assert that: + - The `createOrAttach` response arrives within a small bound (for example < 500ms locally). + - No `Request timeout: createOrAttach` occurs. + +Implementation location: + +- Prefer extending `apps/desktop/src/main/terminal-host/daemon.test.ts` or adding a new test file next to it, keeping the test focused on the socket protocol and not on renderer UI. + +Acceptance: + + bun test apps/desktop/src/main/terminal-host/daemon.test.ts + + +### Milestone 5: Rollout safety + observability + +Goal: Make it safe to ship and easy to diagnose. + +Changes: + +- Prefer not adding a feature flag unless release process requires it (flags add complexity and a second behavior to maintain). The protocol mismatch handling is the primary safety mechanism; rollback is via reverting the change and shipping a patch release. +- Improve debug logging (only when `SUPERSET_TERMINAL_DEBUG=1`): + - Log connection roles and clientId. + - Log when `createOrAttach` is rejected due to missing stream socket. + +Acceptance (manual): + +1. Reproduce “`yes` in one terminal then open another” and confirm no UI stall. +2. Kill the daemon process while the app is open, then use the terminal UI “Retry Connection” and confirm both sockets reconnect and output resumes. +3. (If a feature flag is added despite the preference above) confirm toggling the flag selects the expected code path. + + +## Validation + +Run these from repo root: + + bun run typecheck + bun run lint + bun test + +Manual reproduction (macOS): + +1. Open a terminal and run: + + yes + +2. Immediately open a new terminal tab/pane. + +Expected after fix: + +- The new terminal should open without a 5–10s spinner. +- No “Connection Error / Request timeout: createOrAttach” overlay appears. +- Daemon logs may still show stream backpressure warnings, but they should not block new `createOrAttach` responses. + + +## Idempotence And Rollback + +Idempotence: + +- Connecting both sockets should be safe to retry; repeated hello calls should either be rejected cleanly or treated as re‑auth without leaking session attachments. +- If a stream socket disconnects, existing sessions must remain alive; only streaming to that client stops. + +Rollback: + +- Feature flag fallback to the legacy single‑socket mode (if we decide to include it). +- If we do not include a flag, rollback is via reverting the protocol v2 commit(s) and shipping a patch release. + + +## Risks And Mitigations + +Risk: Missing or mismatched pairing between control and stream sockets (wrong clientId). + +Mitigation: Enforce a required `clientId` for protocol v2 and reject `createOrAttach` if no stream socket is registered for that clientId. + +Risk: Duplicate or missing terminal output around initial attach. + +Mitigation: Attach the stream socket and set the snapshot boundary in the same synchronous turn of `createOrAttach`, mirroring the existing single‑socket attach boundary logic. + +Risk: Multi‑window behavior. + +Mitigation: Treat each Electron main process instance (or window, if applicable) as a separate clientId; keep daemon mapping per clientId. + + +## Progress + +- [x] (2026-01-06) Answer Open Questions and fill Decision Log. +- [x] (2026-01-06) Implement protocol v2 types. +- [x] (2026-01-06) Update daemon server for dual sockets. +- [x] (2026-01-06) Update main-process daemon client for dual sockets. +- [x] (2026-01-06) Add backpressure regression test. +- [x] (2026-01-06) Run validation commands and document manual reproduction. +- [x] (2026-01-06) Write Outcomes & Retrospective. + + +## Surprises And Discoveries + +- Biome’s formatter surfaced pre-existing formatting drift in a few files; fixing formatting was required to get `bun run lint` passing. +- Spawning/tearing down a daemon per test was flaky due to shared socket path timing; switching to `beforeAll`/`afterAll` for daemon-backed integration tests stabilized them. + + +## Outcomes And Retrospective + +Protocol v2 now uses two sockets per client instance: a low-volume control socket for request/response RPC and a high-volume stream socket for terminal output/exit/error events. This removes head-of-line blocking so `createOrAttach` responses are not delayed by backpressured terminal output. + +Implementation highlights: + +- The daemon pairs sockets by `clientId` and enforces `role` for each connection. `createOrAttach` returns the snapshot over the control socket but attaches the session to the stream socket. +- The main-process client establishes/authenticates both sockets and tears down both on any partial failure. On `PROTOCOL_MISMATCH`, it shuts down a legacy v1 daemon and respawns v2 once. +- Added an integration test that pauses the stream socket (simulated backpressure) and asserts `createOrAttach` latency stays bounded. + +Validation: + + bun run typecheck + bun run lint + bun test apps/desktop/src/main/terminal-host/daemon.test.ts + bun test apps/desktop/src/main/terminal-host/session-lifecycle.test.ts + +Manual reproduction (recommended): + + # In one terminal tab in the app: + yes + # Then open a new terminal tab; it should still attach quickly and not time out. + +Plan revision note: Updated this ExecPlan after implementation to record decisions, progress, validation, and outcomes. diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index dcd9941728a..f83c4d9693e 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -659,9 +659,8 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { let totalFailed = 0; for (const workspace of projectWorkspaces) { - const terminalResult = await getActiveTerminalManager().killByWorkspaceId( - workspace.id, - ); + const terminalResult = + await getActiveTerminalManager().killByWorkspaceId(workspace.id); totalFailed += terminalResult.failed; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 5cfe705b199..e6a48aacb60 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -86,9 +86,7 @@ export function TabsContent() { // Partition tabs: terminal tabs stay mounted, non-terminal tabs unmount when inactive const terminalTabs = allTabs.filter((tab) => hasTerminalPane(tab, panes)); const activeNonTerminalTab = - tabToRender && !hasTerminalPane(tabToRender, panes) - ? tabToRender - : null; + tabToRender && !hasTerminalPane(tabToRender, panes) ? tabToRender : null; return (
From 9ea48018457115b29f9a2f1a400795564505c455 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 21:06:39 +0200 Subject: [PATCH 12/62] fix(desktop): spawn daemon when token missing Read terminal-host auth token after ensuring a daemon exists; if token is missing with a live socket, restart the daemon to re-create a coherent socket+token pair. --- .../src/main/lib/terminal-host/client.ts | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 6109bce9fb4..6d89312a5af 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -277,8 +277,6 @@ export class TerminalHostClient extends EventEmitter { * Handles protocol mismatch by shutting down a legacy daemon and retrying once. */ private async connectAndAuthenticate(): Promise { - const token = this.readAuthToken(); - for (let attempt = 0; attempt < 2; attempt++) { // Control socket (RPC) let controlConnected = await this.tryConnectControl(); @@ -290,6 +288,29 @@ export class TerminalHostClient extends EventEmitter { } } + // Token is created by the daemon at startup, so we must read it after we’ve + // ensured a daemon exists (fresh installs / cleaned ~/.superset). + let token: string; + try { + token = this.readAuthToken(); + } catch (error) { + // If a socket exists but the token is missing, we can’t authenticate; force + // a daemon restart to re-create a coherent socket+token pair. + if (attempt === 0) { + if (DEBUG_CLIENT) { + console.log( + "[TerminalHostClient] Auth token missing, restarting daemon...", + ); + } + this.resetConnectionState({ emitDisconnected: false }); + this.killDaemonFromPidFile(); + await this.waitForDaemonShutdown(); + await this.spawnDaemon(); + continue; + } + throw error; + } + try { await this.authenticateControl({ token }); } catch (error) { @@ -322,6 +343,24 @@ export class TerminalHostClient extends EventEmitter { throw new Error("Failed to connect after protocol upgrade"); } + private killDaemonFromPidFile(): void { + if (!existsSync(PID_PATH)) return; + + try { + const raw = readFileSync(PID_PATH, "utf-8").trim(); + const pid = Number.parseInt(raw, 10); + if (!Number.isNaN(pid)) { + try { + process.kill(pid, "SIGTERM"); + } catch { + // Best-effort; PID may be stale or process already exited. + } + } + } catch { + // Best-effort. + } + } + private async tryConnectControl(): Promise { return new Promise((resolve) => { if (!existsSync(SOCKET_PATH)) { From 842726483018a6690845b6b5888d5cf75d83f5dd Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 21:47:30 +0200 Subject: [PATCH 13/62] fix(desktop): harden terminal persistence data perms Ensure ~/.superset* is created/repair-chmodded to 0700 and history/log files are written with 0600 where applicable. Also closes the daemon.log fd in the parent after spawning the daemon. --- apps/desktop/src/main/lib/app-environment.ts | 20 ++++++++ apps/desktop/src/main/lib/local-db/index.ts | 18 ++++--- .../src/main/lib/terminal-host/client.ts | 47 +++++++++++++++---- 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/lib/app-environment.ts b/apps/desktop/src/main/lib/app-environment.ts index 5881e38d02c..b69f39bc8a8 100644 --- a/apps/desktop/src/main/lib/app-environment.ts +++ b/apps/desktop/src/main/lib/app-environment.ts @@ -1,9 +1,29 @@ +import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { SUPERSET_DIR_NAME } from "shared/constants"; export const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); +export const SUPERSET_HOME_DIR_MODE = 0o700; +export const SUPERSET_SENSITIVE_FILE_MODE = 0o600; + +export function ensureSupersetHomeDirExists(): void { + if (!existsSync(SUPERSET_HOME_DIR)) { + mkdirSync(SUPERSET_HOME_DIR, { + recursive: true, + mode: SUPERSET_HOME_DIR_MODE, + }); + } + + // Best-effort repair if the directory already existed with weak permissions. + try { + chmodSync(SUPERSET_HOME_DIR, SUPERSET_HOME_DIR_MODE); + } catch { + // Ignore - may fail if not owner / filesystem restrictions. + } +} + // For lowdb - use our own path instead of app.getPath("userData") export const APP_STATE_PATH = join(SUPERSET_HOME_DIR, "app-state.json"); diff --git a/apps/desktop/src/main/lib/local-db/index.ts b/apps/desktop/src/main/lib/local-db/index.ts index a0c79c2219f..6393da8e45e 100644 --- a/apps/desktop/src/main/lib/local-db/index.ts +++ b/apps/desktop/src/main/lib/local-db/index.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from "node:fs"; +import { chmodSync, existsSync } from "node:fs"; import { join } from "node:path"; import * as schema from "@superset/local-db"; @@ -7,14 +7,15 @@ import { drizzle } from "drizzle-orm/better-sqlite3"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { app } from "electron"; import { env } from "../../env.main"; -import { SUPERSET_HOME_DIR } from "../app-environment"; +import { + ensureSupersetHomeDirExists, + SUPERSET_HOME_DIR, + SUPERSET_SENSITIVE_FILE_MODE, +} from "../app-environment"; const DB_PATH = join(SUPERSET_HOME_DIR, "local.db"); -function ensureAppHomeDirExists() { - mkdirSync(SUPERSET_HOME_DIR, { recursive: true }); -} -ensureAppHomeDirExists(); +ensureSupersetHomeDirExists(); /** * Gets the migrations directory path. @@ -73,6 +74,11 @@ function getMigrationsDirectory(): string { const migrationsFolder = getMigrationsDirectory(); const sqlite = new Database(DB_PATH); +try { + chmodSync(DB_PATH, SUPERSET_SENSITIVE_FILE_MODE); +} catch { + // Best-effort; directory permissions should still protect the DB. +} sqlite.pragma("journal_mode = WAL"); sqlite.pragma("foreign_keys = OFF"); diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 6d89312a5af..36b07f288f8 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -13,6 +13,8 @@ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { + chmodSync, + closeSync, existsSync, mkdirSync, openSync, @@ -856,6 +858,11 @@ export class TerminalHostClient extends EventEmitter { if (!existsSync(SUPERSET_HOME_DIR)) { mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); } + try { + chmodSync(SUPERSET_HOME_DIR, 0o700); + } catch { + // Best-effort. + } // Check if lock exists and is recent (within timeout) if (existsSync(SPAWN_LOCK_PATH)) { @@ -968,7 +975,12 @@ export class TerminalHostClient extends EventEmitter { const logPath = join(SUPERSET_HOME_DIR, "daemon.log"); let logFd: number; try { - logFd = openSync(logPath, "a"); + logFd = openSync(logPath, "a", 0o600); + try { + chmodSync(logPath, 0o600); + } catch { + // Best-effort. + } } catch (error) { console.warn( `[TerminalHostClient] Failed to open daemon log file: ${error}`, @@ -978,15 +990,30 @@ export class TerminalHostClient extends EventEmitter { } // Spawn daemon as detached process - const child = spawn(process.execPath, [daemonScript], { - detached: true, - stdio: logFd >= 0 ? ["ignore", logFd, logFd] : "ignore", - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: "1", - NODE_ENV: process.env.NODE_ENV, - }, - }); + let child: ReturnType | null = null; + try { + child = spawn(process.execPath, [daemonScript], { + detached: true, + stdio: logFd >= 0 ? ["ignore", logFd, logFd] : "ignore", + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + NODE_ENV: process.env.NODE_ENV, + }, + }); + } finally { + if (logFd >= 0) { + try { + closeSync(logFd); + } catch { + // Best-effort. + } + } + } + + if (!child) { + throw new Error("Failed to spawn daemon"); + } if (DEBUG_CLIENT) { console.log( From 07cae93de5ed81495d670c238b0d2802e583e074 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 21:47:54 +0200 Subject: [PATCH 14/62] fix(desktop): address terminal persistence review feedback - Keep terminal tabs mounted even when switching to empty workspaces - Gate noisy logs behind SUPERSET_TERMINAL_DEBUG - Use TRPCError for settings validation - Fix history reinit sizing + avoid silent catch blocks - Gate PTY subprocess spawn logging --- .../src/lib/trpc/routers/settings/index.ts | 11 ++- .../src/main/lib/terminal/daemon-manager.ts | 86 +++++++++++++------ apps/desktop/src/main/lib/terminal/index.ts | 13 ++- apps/desktop/src/main/lib/terminal/session.ts | 12 ++- apps/desktop/src/main/terminal-host/index.ts | 6 +- .../src/main/terminal-host/pty-subprocess.ts | 24 +++--- .../TabsContent/Terminal/Terminal.tsx | 7 +- .../ContentView/TabsContent/index.tsx | 48 ++++------- .../desktop/src/renderer/stores/tabs/store.ts | 2 +- 9 files changed, 127 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 80d658013b2..0f3218b0f22 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -3,6 +3,7 @@ import { TERMINAL_LINK_BEHAVIORS, type TerminalPreset, } from "@superset/local-db"; +import { TRPCError } from "@trpc/server"; import { localDb } from "main/lib/local-db"; import { DEFAULT_CONFIRM_ON_QUIT, @@ -82,7 +83,10 @@ export const createSettingsRouter = () => { const preset = presets.find((p) => p.id === input.id); if (!preset) { - throw new Error(`Preset ${input.id} not found`); + throw new TRPCError({ + code: "NOT_FOUND", + message: `Terminal preset ${input.id} not found`, + }); } if (input.patch.name !== undefined) preset.name = input.patch.name; @@ -183,7 +187,10 @@ export const createSettingsRouter = () => { .input(z.object({ ringtoneId: z.string() })) .mutation(({ input }) => { if (!VALID_RINGTONE_IDS.includes(input.ringtoneId)) { - throw new Error(`Invalid ringtone ID: ${input.ringtoneId}`); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid ringtone ID: ${input.ringtoneId}`, + }); } localDb diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 25b292b7698..5e677664c9a 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -33,6 +33,7 @@ import type { CreateSessionParams, SessionResult } from "./types"; /** Delay before removing session from local cache after exit event */ const SESSION_CLEANUP_DELAY_MS = 5000; +const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; // ============================================================================= // Types @@ -322,7 +323,12 @@ export class DaemonTerminalManager extends EventEmitter { const session = this.sessions.get(paneId); if (session) { // Close current writer and reinitialize with empty scrollback - writer.close().catch(() => {}); + writer.close().catch((error) => { + console.warn( + `[DaemonTerminalManager] Failed to close history writer for ${paneId}:`, + error, + ); + }); this.historyWriters.delete(paneId); // Create new writer (will only contain content after clear) @@ -331,10 +337,15 @@ export class DaemonTerminalManager extends EventEmitter { paneId, session.workspaceId, session.cwd, - 80, // cols - will be updated on next resize - 24, // rows - will be updated on next resize + session.cols, + session.rows, contentAfterClear || undefined, - ).catch(() => {}); + ).catch((error) => { + console.warn( + `[DaemonTerminalManager] Failed to reinitialize history writer for ${paneId}:`, + error, + ); + }); } return; } @@ -465,13 +476,15 @@ export class DaemonTerminalManager extends EventEmitter { rootPath, }); - console.log("[DaemonTerminalManager] Calling daemon createOrAttach:", { - paneId, - shell, - cwd, - cols, - rows, - }); + if (DEBUG_TERMINAL) { + console.log("[DaemonTerminalManager] Calling daemon createOrAttach:", { + paneId, + shell, + cwd, + cols, + rows, + }); + } // Call daemon const response = await this.client.createOrAttach({ @@ -499,6 +512,10 @@ export class DaemonTerminalManager extends EventEmitter { ? previousCwd || cwd || "" : response.snapshot.cwd || cwd || ""; + // Guard against invalid dimensions (can happen if terminal not yet sized) + const effectiveCols = response.snapshot.cols || cols; + const effectiveRows = response.snapshot.rows || rows; + // Track session locally this.sessions.set(paneId, { paneId, @@ -507,8 +524,8 @@ export class DaemonTerminalManager extends EventEmitter { lastActive: Date.now(), cwd: sessionCwd, pid: response.pid, - cols: response.snapshot.cols || cols, - rows: response.snapshot.rows || rows, + cols: effectiveCols, + rows: effectiveRows, }); // Register with port manager for process-based port scanning @@ -523,10 +540,6 @@ export class DaemonTerminalManager extends EventEmitter { ? response.snapshot.snapshotAnsi : undefined; - // Guard against invalid dimensions (can happen if terminal not yet sized) - const effectiveCols = response.snapshot.cols || cols; - const effectiveRows = response.snapshot.rows || rows; - if (effectiveCols >= 1 && effectiveRows >= 1) { this.initHistoryWriter( paneId, @@ -667,6 +680,8 @@ export class DaemonTerminalManager extends EventEmitter { const session = this.sessions.get(paneId); if (session) { session.lastActive = Date.now(); + session.cols = cols; + session.rows = rows; } } @@ -754,16 +769,28 @@ export class DaemonTerminalManager extends EventEmitter { // Reinitialize history file (clear the scrollback on disk too) const writer = this.historyWriters.get(paneId); if (writer) { - await writer.close().catch(() => {}); + await writer.close().catch((error) => { + console.warn( + `[DaemonTerminalManager] Failed to close history writer for ${paneId}:`, + error, + ); + }); this.historyWriters.delete(paneId); - await this.initHistoryWriter( - paneId, - session.workspaceId, - session.cwd, - 80, - 24, - undefined, - ); + try { + await this.initHistoryWriter( + paneId, + session.workspaceId, + session.cwd, + session.cols, + session.rows, + undefined, + ); + } catch (error) { + console.warn( + `[DaemonTerminalManager] Failed to reinitialize history writer for ${paneId}:`, + error, + ); + } } } } @@ -955,7 +982,12 @@ export class DaemonTerminalManager extends EventEmitter { async forceKillAll(): Promise { // Close all history writers for (const writer of this.historyWriters.values()) { - await writer.close().catch(() => {}); + await writer.close().catch((error) => { + console.warn( + "[DaemonTerminalManager] Failed to close history writer during forceKillAll:", + error, + ); + }); } this.historyWriters.clear(); this.historyInitializing.clear(); diff --git a/apps/desktop/src/main/lib/terminal/index.ts b/apps/desktop/src/main/lib/terminal/index.ts index 4e27e06cde8..258de1a837b 100644 --- a/apps/desktop/src/main/lib/terminal/index.ts +++ b/apps/desktop/src/main/lib/terminal/index.ts @@ -28,6 +28,7 @@ export type { // Cache the daemon mode setting to avoid repeated DB reads // This is set once at app startup and doesn't change until restart let cachedDaemonMode: boolean | null = null; +const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; /** * Check if daemon mode is enabled. @@ -76,7 +77,12 @@ export function getActiveTerminalManager(): | TerminalManager | DaemonTerminalManager { const daemonEnabled = isDaemonModeEnabled(); - console.log("[getActiveTerminalManager] Daemon mode enabled:", daemonEnabled); + if (DEBUG_TERMINAL) { + console.log( + "[getActiveTerminalManager] Daemon mode enabled:", + daemonEnabled, + ); + } if (daemonEnabled) { return getDaemonTerminalManager(); } @@ -88,8 +94,9 @@ export function getActiveTerminalManager(): * Should be called on app startup when daemon mode is ENABLED to clean up * stale sessions from previous app runs. * - * Current semantics: terminal persistence = across workspace switches only. - * App restart = fresh start (kill all stale daemon sessions). + * Current semantics: terminal persistence survives app restarts. + * Reconciliation removes sessions that no longer map to existing workspaces and + * restores state for sessions that can be retained. */ export async function reconcileDaemonSessions(): Promise { if (!isDaemonModeEnabled()) { diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 97831d80012..e4d67218da2 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -205,7 +205,17 @@ export function setupDataHandler( setTimeout(resolve, AGENT_HOOKS_TIMEOUT_MS), ); await Promise.race([beforeInitialCommands, timeout]).catch( - () => {}, + (error) => { + console.warn( + "[terminal/session] Initial command preconditions failed:", + { + paneId: session.paneId, + workspaceId: session.workspaceId, + error: + error instanceof Error ? error.message : String(error), + }, + ); + }, ); } diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index 97191f6cb17..0c05fb2af9c 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -21,7 +21,7 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { createServer, type Server, type Socket } from "node:net"; +import { createServer, type Server, Socket } from "node:net"; import { homedir } from "node:os"; import { join } from "node:path"; import { @@ -176,7 +176,7 @@ type RequestHandler = ( id: string, payload: unknown, clientState: ClientState, -) => void; +) => void | Promise; interface ClientState { authenticated: boolean; @@ -598,7 +598,7 @@ function isSocketLive(): Promise { return; } - const testSocket = new (require("node:net").Socket)(); + const testSocket = new Socket(); const timeout = setTimeout(() => { testSocket.destroy(); resolve(false); diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess.ts b/apps/desktop/src/main/terminal-host/pty-subprocess.ts index 5ca53290923..8b508262e04 100644 --- a/apps/desktop/src/main/terminal-host/pty-subprocess.ts +++ b/apps/desktop/src/main/terminal-host/pty-subprocess.ts @@ -278,17 +278,19 @@ function handleSpawn(payload: Buffer): void { return; } - // Debug: Log spawn parameters - console.error("[pty-subprocess] Spawning PTY:", { - shell: msg.shell, - args: msg.args, - cwd: msg.cwd, - cols: msg.cols, - rows: msg.rows, - ZDOTDIR: msg.env.ZDOTDIR, - SUPERSET_ORIG_ZDOTDIR: msg.env.SUPERSET_ORIG_ZDOTDIR, - PATH_start: msg.env.PATH?.substring(0, 100), - }); + if (DEBUG_OUTPUT_BATCHING) { + // Debug: Log spawn parameters + console.error("[pty-subprocess] Spawning PTY:", { + shell: msg.shell, + args: msg.args, + cwd: msg.cwd, + cols: msg.cols, + rows: msg.rows, + ZDOTDIR: msg.env.ZDOTDIR, + SUPERSET_ORIG_ZDOTDIR: msg.env.SUPERSET_ORIG_ZDOTDIR, + PATH_start: msg.env.PATH?.substring(0, 100), + }); + } try { ptyProcess = pty.spawn(msg.shell, msg.args, { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 166f3e3c44b..3a4598298b9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -646,8 +646,11 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { coldRestoreState.delete(paneId); // Acknowledge cold restore to main process (clears sticky state) - trpcClient.terminal.ackColdRestore.mutate({ paneId }).catch(() => { - // Ignore errors - not critical + trpcClient.terminal.ackColdRestore.mutate({ paneId }).catch((error) => { + console.warn("[Terminal] Failed to acknowledge cold restore:", { + paneId, + error: error instanceof Error ? error.message : String(error), + }); }); // Add visual separator diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index e6a48aacb60..d5fee1cc096 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -38,15 +38,17 @@ export function TabsContent() { setIsResizing, } = useSidebarStore(); - const activeTabId = activeWorkspaceId - ? activeTabIds[activeWorkspaceId] - : null; + const activeTabId = useMemo(() => { + if (!activeWorkspaceId) return null; - // Get all tabs for current workspace (for fallback/empty check) - const currentWorkspaceTabs = useMemo(() => { - if (!activeWorkspaceId) return []; - return allTabs.filter((tab) => tab.workspaceId === activeWorkspaceId); - }, [activeWorkspaceId, allTabs]); + // Prefer the store's active tab, but fall back to the first tab to avoid a + // blank render when activeTabIds isn't hydrated yet. + return ( + activeTabIds[activeWorkspaceId] ?? + allTabs.find((tab) => tab.workspaceId === activeWorkspaceId)?.id ?? + null + ); + }, [activeWorkspaceId, activeTabIds, allTabs]); const tabToRender = useMemo(() => { if (!activeTabId) return null; @@ -59,30 +61,6 @@ export function TabsContent() { // Non-terminal tabs use normal unmount behavior to save memory. // Uses visibility:hidden (not display:none) to preserve xterm dimensions. if (terminalPersistence) { - // Show empty view only if current workspace has no tabs - if (currentWorkspaceTabs.length === 0) { - return ( -
-
- -
- {isSidebarOpen && ( - - - - )} -
- ); - } - // Partition tabs: terminal tabs stay mounted, non-terminal tabs unmount when inactive const terminalTabs = allTabs.filter((tab) => hasTerminalPane(tab, panes)); const activeNonTerminalTab = @@ -115,6 +93,12 @@ export function TabsContent() {
)} + {/* Fallback: show empty view without unmounting terminal tabs */} + {!activeNonTerminalTab && !tabToRender && ( +
+ +
+ )} {isSidebarOpen && ( Date: Wed, 7 Jan 2026 10:53:46 +0200 Subject: [PATCH 15/62] fix(desktop): bundle @xterm packages for terminal-host daemon The daemon runs as standalone Node.js outside app.asar and needs @xterm/headless and @xterm/addon-serialize bundled to function. --- apps/desktop/electron.vite.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 9623f9c804d..94fb417115f 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -73,7 +73,6 @@ export default defineConfig({ "better-sqlite3", "node-pty", /^@sentry\/electron/, - /^@xterm\//, // xterm packages have incorrect exports for bundlers ], }, }, From 4fc7d26ee8877b8493502abca15cd4cbbb165645 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 7 Jan 2026 12:14:42 +0200 Subject: [PATCH 16/62] wip: dx hardening plan --- ...-1107-terminal-persistence-dx-hardening.md | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md diff --git a/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md b/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md new file mode 100644 index 00000000000..94633404d3e --- /dev/null +++ b/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md @@ -0,0 +1,336 @@ +# Terminal Persistence DX Hardening (No Startup Freeze, Background Activity, Bounded Resources) + + +## Purpose + +When “Terminal persistence” is enabled, Superset should never freeze or spin at startup, even if the user has accumulated dozens of terminal panes over time. The user should be able to switch between recent terminal tabs with near‑instant feedback, and they should still get clear signals (badges and optional notifications) when background terminals produce output or exit, without keeping every terminal renderer and stream active. + +This work matters because today a user can get their desktop app into a broken state where a large restored terminal set causes 99% CPU usage and an infinite macOS spinner. The goal is to make persistence robust by default and to make failure modes recoverable from within the UI (no manual edits to `~/.superset/app-state.json`). + + +## Context + +Superset Desktop (Electron) renders terminals in the renderer process using xterm.js. For persistence across app restarts, the Electron main process can delegate terminal ownership to a detached “terminal host daemon” (a Node process) that owns PTYs and maintains a headless xterm emulator for each session. The renderer talks to the main process via tRPC, and the main process talks to the daemon via a Unix domain socket using NDJSON messages. + +On this branch, the daemon protocol was recently changed to split “control” (RPC) and “stream” (terminal output) sockets (see `apps/desktop/plans/done/20260106-1800-terminal-host-control-stream-sockets.md`). That fix addresses head‑of‑line blocking when one terminal spams output, but it does not address a different failure mode: restoring many sessions at once can still saturate CPU and freeze the UI. + +The observed freeze happens because the renderer mounts far more terminal UIs than the user can see, and each mounted terminal immediately calls `terminal.createOrAttach`, which in daemon mode can cause disk I/O, snapshot generation, and (when sessions are missing) new PTY spawns. When this happens tens of times concurrently, startup becomes unresponsive. + + +## Definitions (Plain Language) + +A “workspace” is a worktree-backed project environment shown in the left sidebar. A “tab” is a group within a workspace (the top “GroupStrip”). A “pane” is a tile within a tab’s Mosaic layout; a terminal pane is one pane type. In this codebase, a pane has a stable ID and the terminal session is keyed by that pane ID. + +“Daemon mode” means terminal persistence is enabled; terminal sessions live in the detached daemon process and survive app restarts. “Attach” means connecting the app’s event stream to an existing daemon session. “Spawn” means starting a new PTY/shell process for a session. + +An “activity signal” is a low‑volume event meaning “this background terminal has new output or exited” without delivering full terminal output. + +“Cold restore” means: the daemon does not have a session (for example after reboot), but we have on-disk scrollback from a prior run that did not shut down cleanly. The UI should show the saved scrollback and let the user explicitly start a new shell. + + +## Repo Orientation (Where Things Live) + +Renderer (browser environment, no Node imports): + + apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx + apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx + apps/desktop/src/renderer/stores/tabs/store.ts + apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts + +Main process (Node/Electron environment): + + apps/desktop/src/main/index.ts + apps/desktop/src/main/lib/terminal/index.ts + apps/desktop/src/main/lib/terminal/daemon-manager.ts + apps/desktop/src/main/lib/terminal-host/client.ts + +Daemon: + + apps/desktop/src/main/terminal-host/index.ts + apps/desktop/src/main/terminal-host/terminal-host.ts + apps/desktop/src/main/terminal-host/session.ts + +Persisted UI state: + + apps/desktop/src/main/lib/app-state/index.ts + apps/desktop/src/lib/trpc/routers/ui-state/index.ts + + +## Problem Statement (What Breaks Today) + +When terminal persistence is enabled, the renderer currently keeps every tab that contains a terminal mounted (even if hidden). This is implemented in `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx` by rendering all “terminal tabs” and toggling visibility. Each terminal pane mounts a `Terminal` component, and each `Terminal` immediately calls `trpc.terminal.createOrAttach` and enables a stream subscription (`trpc.terminal.stream.useSubscription`). + +If a user has accumulated many terminal panes in persisted state (for example `tabsState.panes` contains ~90 terminal panes), startup mounts and attaches all of them. In daemon mode, each attach also does disk work for cold-restore detection (`HistoryReader.read`) and can cause new PTY spawns if the daemon is missing sessions. The combined fan‑out can saturate CPU, fill logs, and freeze the UI. + + +## Goals + +This work should deliver these user-visible outcomes: + +1. App startup remains responsive with 50–100 persisted terminal panes; the UI shows quickly and does not beachball. +2. Switching to a recently used terminal tab feels “instant enough” (target: the user sees a correct terminal view within ~200ms in the common case). +3. Background terminals still surface activity (badge on the tab and workspace; optional system notification on exit or when the user opts in). +4. The daemon cannot be driven into unbounded resource usage by accident. There are clear limits, and the UI provides a way to manage sessions and recover from overload. +5. Cold restore does not spawn a new shell until the user explicitly starts one. + + +## Non-Goals + +This plan does not attempt to replace xterm.js, node-pty, or rewrite the persistence architecture. It also does not attempt to perfectly summarize background output; it only needs a reliable “something happened” signal plus exit/error. + + +## Assumptions + +Terminal persistence is a user setting that requires an app restart to take effect (`apps/desktop/src/main/lib/terminal/index.ts`). The renderer and main process can therefore treat “daemon mode enabled” as stable for the lifetime of a run. + +The daemon and client are shipped together, but we must handle stale daemons because the daemon is detached and can outlive an app update. Any incompatible protocol changes must include an upgrade path that cleanly shuts down old daemons. + + +## Open Questions + +These questions must be answered (or explicitly decided) before implementation is finalized: + +1. Should background activity signals be enabled by default when terminal persistence is enabled, or should it be a separate setting? +2. What is the “warm” cache size for keeping a small number of terminal tabs fully mounted/streaming (suggestion: 2–3), and should it be configurable? +3. What is the default daemon resource policy: warn-only, or automatic cleanup (idle timeout, max sessions) enabled by default? +4. What should the product promise be for cold restore: always show saved scrollback first and require explicit “Start Shell”, or auto-start a shell in some cases? +5. What is the acceptable worst-case reattach latency, and do we treat alt-screen (TUI) sessions differently in UX (for example always show a short “Resuming…” overlay)? + + +## Decision Log (To Be Filled As Questions Are Resolved) + +1. Decision for Open Question 1: TBD. +2. Decision for Open Question 2: TBD. +3. Decision for Open Question 3: TBD. +4. Decision for Open Question 4: TBD. +5. Decision for Open Question 5: TBD. + + +## Plan of Work + +### Milestone 0: Baseline Reproduction and Instrumentation Spike + +This milestone makes the failure mode easy to reproduce and makes improvements measurable. At completion, a developer can reproduce “mass restore” locally and can observe how many sessions are being attached/spawned and how long attaches take. + +Work: + +Create a small, dev-only reproduction procedure that does not require manual JSON edits. The simplest acceptable version is a documented set of UI steps to create many terminal panes and a “Reset terminal state” developer command that clears app-state and terminal history for quick iteration. If a UI or CLI seeding tool already exists, use it instead of inventing a new one. + +Add minimal timing logs/metrics around `createOrAttach` calls in main and daemon mode. Prefer existing `track(...)` (analytics) or prefixed console logging. The key metrics are counts and durations, not full output. + +Acceptance: + + bun run typecheck + bun test + +Manual verification: with terminal persistence enabled, create ~30 terminals, restart the app, and confirm logs show the number of `createOrAttach` calls and typical durations. + + +### Milestone 1: Stop Startup Fan-Out by Changing Renderer Mount Policy + +This milestone removes the direct cause of “restore everything on startup”. At completion, terminal persistence no longer implies “mount all terminal tabs”. Instead, only the active tab is mounted, plus a small “warm” set of most-recently-used terminal tabs to keep common switching fast. + +Work: + +Update `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx` so that when terminal persistence is enabled it does not render every terminal-containing tab. Compute a bounded warm set using `tabHistoryStacks` from the tabs store. Always include the active tab. Then include up to N previously active terminal tabs for the active workspace. Render only that warm set, still using `visibility: hidden` to preserve xterm dimensions for warm tabs. + +Do not change non-terminal tab behavior: non-terminal tabs should continue to mount only when active. + +Acceptance: + + bun run typecheck + bun run lint + bun test + +Manual verification: create a workspace with many terminal tabs and restart the app. Observe that only the active tab (and warm set) trigger `createOrAttach` and that the app becomes interactive quickly. + + +### Milestone 2: Add Safety Nets (Concurrency Limits and Spawn Limits) + +This milestone ensures that even if the renderer or a future regression triggers many attaches/spawns, the system degrades gracefully instead of freezing. At completion, the main process limits concurrent attaches and the daemon limits concurrent spawns of new PTY sessions. + +Work: + +In `apps/desktop/src/main/lib/terminal/daemon-manager.ts`, add a small concurrency limiter around the expensive path of `createOrAttach`. The limiter should prioritize the focused pane (when known) and should not block the UI thread. Prefer a small custom semaphore implementation over adding new dependencies. + +In `apps/desktop/src/main/terminal-host/terminal-host.ts`, add a spawn limiter that only applies when creating a brand new session (the “spawn PTY” path). Attaching to an existing session should remain fast and should not be queued behind spawns. + +Acceptance: + + bun run typecheck + bun test apps/desktop/src/main/terminal-host + +Manual verification: create 10 new terminals quickly and confirm sessions are created progressively without UI lockups. + + +### Milestone 3: Background Activity Signals and Badges for Hidden Terminals + +This milestone restores a key piece of DX that “mount everything” previously provided: knowing when something happens in a background terminal. At completion, the app shows an activity badge for background terminals without subscribing to their full output stream. + +Work: + +Extend the daemon IPC to support “activity-only” subscriptions that do not stream full terminal output. Implement this by introducing a separate client set inside `apps/desktop/src/main/terminal-host/session.ts` so that “data” frames are not written to activity subscribers (avoiding backpressure and CPU churn). Activity events must be throttled and coalesced (for example at most one “activity” event per session every 250–500ms while output continues). + +Ensure that exit and error are delivered to activity subscribers as high-signal events. + +Expose a single renderer-level subscription for activity signals. This should be one subscription for the whole app, not one per pane. Implement it in the terminal tRPC router (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`) as a subscription that streams `{ paneId, workspaceId, type, ts }` events. Then add a small renderer hook/component mounted once (for example alongside `useAgentHookListener`) that listens for these events and sets `pane.needsAttention = true` when the pane is not currently focused. The existing UI already renders attention indicators in the tab strip and workspace list via `needsAttention`. + +Acceptance: + + bun run typecheck + bun test apps/desktop/src/main/lib/terminal-host + +Manual verification: run a command that produces output in a background terminal. Confirm the tab shows an attention indicator and that switching to the tab clears it. + + +### Milestone 4: Progressive Attach for Heavy Active Tabs (Split-Aware) + +This milestone addresses the remaining fan-out case: a single active tab may contain many panes (splits). At completion, opening a heavy tab remains responsive and terminals attach progressively, prioritizing visible and focused panes. + +Work: + +Introduce a small “attach scheduler” in the renderer. Each `Terminal` registers a request to attach; the scheduler permits only K concurrent attaches. The focused pane is highest priority. Other visible panes in the active tab attach next. Non-visible panes (not in the active tab’s Mosaic layout) must not attach. + +The scheduler must treat multi-way splits correctly: all panes in a 2–4 way split should be considered visible and should attach quickly; the concurrency cap is a safety net, not an excuse to starve visible panes. + +Acceptance: + + bun run typecheck + bun test + +Manual verification: create a tab with a 4-way terminal split and confirm all 4 panes attach. Then create an artificially heavy layout (10+ panes) and confirm the UI remains responsive while panes progressively connect. + + +### Milestone 5: Cold Restore Semantics and Disk I/O Optimization + +This milestone fixes two related issues: unnecessary disk reads for normal attaches, and cold restore spawning shells before the user opts in. At completion, disk reads only occur when needed, and cold restore shows scrollback without starting a new PTY until the user clicks “Start Shell”. + +Work: + +Change main/daemon interactions so that “attach to existing session” is a fast path that does not touch disk. Only when the daemon does not have a session should the main process consider cold restore. If cold restore is present, return the saved scrollback and do not create a daemon session yet. + +If the daemon protocol needs a “attach-only” operation (fail if session doesn’t exist), add it. Ensure protocol upgrade logic in `apps/desktop/src/main/lib/terminal-host/client.ts` can shut down older daemons cleanly. + +Update `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` cold restore UX only as needed to match the new semantics. The “Start Shell” action should explicitly create a new session and should set `skipColdRestore` to avoid re-triggering the cold restore branch. + +Acceptance: + + bun run typecheck + bun test apps/desktop/src/main/lib/terminal + +Manual verification: simulate a reboot/crash by ensuring the daemon is not running but on-disk scrollback exists. Confirm the UI shows restored content without spawning a new PTY until the user starts a shell. + + +### Milestone 6: Daemon Resource Policy and User-Facing Recovery Tools + +This milestone bounds the daemon’s memory and process usage and gives the user in-product recovery options. At completion, the user can see how many sessions exist, can kill idle sessions, and can clear terminal state without editing files. + +Work: + +Add a daemon-side policy for sessions with no attached UI clients. Track per-session timestamps (last attached, last output, last input). Implement an idle timeout and/or a maximum session cap, consistent with product decisions from Open Questions 2 and 3. Prefer conservative defaults and clear UI warnings over aggressive auto-eviction. + +Add tRPC endpoints to list daemon sessions and to kill sessions (single and all). Expose them in the settings UI (`apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx`) as a “Manage sessions” surface with clear confirmations. + +Acceptance: + + bun run typecheck + bun run lint + bun test + +Manual verification: create many sessions, open the management UI, and kill idle sessions. Confirm the daemon process count decreases and the app remains stable. + + +### Milestone 7: Performance Validation and Regression Coverage + +This milestone ensures the fixes stick. At completion, we have repeatable validation steps and automated tests for the most important invariants. + +Work: + +Add unit/integration tests around the daemon protocol additions (activity subscription, attach-only, spawn limiting). Add a renderer-level test if the repo’s test setup supports it; otherwise document a deterministic manual verification checklist that a reviewer can run in under five minutes. + +Acceptance: + + bun run typecheck + bun run lint + bun test + + +### Milestone 8: PR Description Alignment and Closeout + +This milestone ensures the PR description accurately reflects the shipped behavior and any changes made during implementation. At completion, a reviewer can read the PR description and understand exactly what the change does, what risks remain, and how it was validated. + +Work: + +Update the PR description to include: + + - A concise “what changed” summary tied to observable behavior (startup no longer restores everything, background activity badges, etc.). + - The user-facing UX changes and any settings/flags involved (defaults and restart requirements). + - The key technical changes (renderer mount policy, attach/spawn limits, activity channel, cold restore semantics, daemon resource policy). + - Known risks and mitigations (reattach latency, noisy activity signals, resource limits). + - Exact validation steps run (commands and any manual scenarios). + +Ensure the description matches the final implementation details and file paths in this plan. If scope changed during implementation, update this ExecPlan to match before updating the PR description. + +Acceptance: + +Manual verification: the PR description is up to date and reviewers can follow its validation steps to reproduce expected behavior. + + +## Validation (What to Run and What “Good” Looks Like) + +Always run: + + bun run typecheck + bun run lint + bun test + +Key manual scenarios: + +1. Mass restore: create many terminal tabs/panes, restart app, confirm UI becomes interactive quickly and does not spawn dozens of shells at once. +2. Background activity: run a long build in one terminal, switch away, confirm the tab shows attention on output/exit, and the indicator clears on view. +3. Heavy tab: open a tab with many panes; confirm the UI remains responsive and panes connect progressively. +4. Cold restore: simulate daemon absence + existing history; confirm no shell starts until user clicks “Start Shell”. + + +## Idempotence and Safety + +All changes should be safe to run repeatedly. Any cleanup tooling must require explicit user confirmation before deleting history or killing all sessions. Any daemon cleanup policy must avoid killing active sessions with attached clients and must be conservative by default. + +Avoid importing Node.js modules in renderer code. Any new renderer components must remain browser-safe. + + +## Rollout Strategy + +Gate the new behaviors behind the existing “Terminal persistence” setting. If additional settings are introduced (for example background activity signals or auto-cleanup), default them conservatively and document them in the Terminal settings UI. + +Ensure protocol changes include a robust upgrade path for stale daemons that may remain running across app updates. + + +## Risks and Mitigations + +The main DX risk is perceived latency when switching to a terminal that is not warm. Mitigate this by keeping a small warm set mounted, showing a fast “Resuming…” state when attaching, and ensuring attach is a fast path that avoids unnecessary disk I/O. + +Another risk is that background activity signals become noisy for chatty terminals. Mitigate this by throttling, coalescing, and allowing the user to disable or narrow notifications to exit/error only. + + +## Progress + +- [ ] Milestone 0: Baseline reproduction and instrumentation exists and is documented +- [ ] Milestone 1: Renderer mount policy limits terminal tab mounts to active + warm set +- [ ] Milestone 2: Main attach concurrency and daemon spawn concurrency limits added +- [ ] Milestone 3: Background activity signals implemented and UI badges wired +- [ ] Milestone 4: Progressive attach scheduler for heavy tabs implemented +- [ ] Milestone 5: Cold restore semantics fixed and disk I/O optimized +- [ ] Milestone 6: Daemon resource policy and session management UI shipped +- [ ] Milestone 7: Performance validation and regression coverage added +- [ ] Milestone 8: PR description updated and aligned + + +## Outcomes and Retrospective (Fill In After Implementation) + +TBD. + + +## Surprises and Discoveries (Fill In During Implementation) + +TBD. From 17029f3811400fa96341671161275a9e99df75d7 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 7 Jan 2026 14:59:50 +0200 Subject: [PATCH 17/62] fix(desktop): remove obsolete setNeedsAttention after rebase PR #588 replaced needsAttention with PaneStatus (idle/working/permission/review). Remove the obsolete setNeedsAttention method and update plan doc references. --- ...260107-1107-terminal-persistence-dx-hardening.md | 2 +- apps/desktop/src/renderer/stores/tabs/store.ts | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md b/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md index 94633404d3e..be6566c499e 100644 --- a/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md +++ b/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md @@ -173,7 +173,7 @@ Extend the daemon IPC to support “activity-only” subscriptions that do not s Ensure that exit and error are delivered to activity subscribers as high-signal events. -Expose a single renderer-level subscription for activity signals. This should be one subscription for the whole app, not one per pane. Implement it in the terminal tRPC router (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`) as a subscription that streams `{ paneId, workspaceId, type, ts }` events. Then add a small renderer hook/component mounted once (for example alongside `useAgentHookListener`) that listens for these events and sets `pane.needsAttention = true` when the pane is not currently focused. The existing UI already renders attention indicators in the tab strip and workspace list via `needsAttention`. +Expose a single renderer-level subscription for activity signals. This should be one subscription for the whole app, not one per pane. Implement it in the terminal tRPC router (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`) as a subscription that streams `{ paneId, workspaceId, type, ts }` events. Then add a small renderer hook/component mounted once (for example alongside `useAgentHookListener`) that listens for these events and calls `setPaneStatus(paneId, "review")` when the pane is not currently focused. The existing UI already renders status indicators in the tab strip and workspace list via `pane.status`. Acceptance: diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 109355bd1c4..815b80a1991 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -616,19 +616,6 @@ export const useTabsStore = create()( }); }, - setNeedsAttention: (paneId, needsAttention) => { - set((state) => { - // Guard: no-op for unknown panes to avoid corrupting panes map - if (!state.panes[paneId]) return state; - return { - panes: { - ...state.panes, - [paneId]: { ...state.panes[paneId], needsAttention }, - }, - }; - }); - }, - clearWorkspaceAttentionStatus: (workspaceId) => { const state = get(); const workspaceTabs = state.tabs.filter( From 965239d78e02094a29bcf54733f7012feee6baed Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 7 Jan 2026 18:04:25 +0200 Subject: [PATCH 18/62] docs(desktop): update terminal persistence exec plan --- ...-1107-terminal-persistence-dx-hardening.md | 117 +++++++++++++----- 1 file changed, 84 insertions(+), 33 deletions(-) diff --git a/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md b/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md index be6566c499e..1e5849b5601 100644 --- a/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md +++ b/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md @@ -1,9 +1,9 @@ -# Terminal Persistence DX Hardening (No Startup Freeze, Background Activity, Bounded Resources) +# Terminal Persistence DX Hardening (No Startup Freeze, Smooth Switching, Bounded Resources) ## Purpose -When “Terminal persistence” is enabled, Superset should never freeze or spin at startup, even if the user has accumulated dozens of terminal panes over time. The user should be able to switch between recent terminal tabs with near‑instant feedback, and they should still get clear signals (badges and optional notifications) when background terminals produce output or exit, without keeping every terminal renderer and stream active. +When “Terminal persistence” is enabled, Superset should never freeze or spin at startup, even if the user has accumulated dozens of terminal panes over time. The user should be able to switch between recent terminal tabs with near‑instant feedback, without keeping every terminal renderer and stream active. This work matters because today a user can get their desktop app into a broken state where a large restored terminal set causes 99% CPU usage and an infinite macOS spinner. The goal is to make persistence robust by default and to make failure modes recoverable from within the UI (no manual edits to `~/.superset/app-state.json`). @@ -23,7 +23,21 @@ A “workspace” is a worktree-backed project environment shown in the left sid “Daemon mode” means terminal persistence is enabled; terminal sessions live in the detached daemon process and survive app restarts. “Attach” means connecting the app’s event stream to an existing daemon session. “Spawn” means starting a new PTY/shell process for a session. -An “activity signal” is a low‑volume event meaning “this background terminal has new output or exited” without delivering full terminal output. +“tRPC” is the typed RPC layer used for renderer ↔ main-process calls in this repo. In the renderer, calls live under `apps/desktop/src/renderer/lib/trpc`. In the main process, handlers live under `apps/desktop/src/lib/trpc/routers/*`. + +“NDJSON” means newline-delimited JSON (each message is a JSON object followed by `\n`). The main process and the daemon use NDJSON over Unix domain sockets for control messages and terminal event streaming. + +“PTY” (pseudo-terminal) is the OS-backed terminal device used to run shells. In daemon mode, the daemon spawns PTYs (via node-pty) and streams their output. + +“TUI” (text user interface) means full-screen terminal apps like `vim`, `htop`, or Codex/Claude Code UIs. These often use the “alternate screen” buffer (“alt-screen”), which is why unmount/remount must restore terminal state carefully. + +“Pane status” is a persisted per-pane UI indicator used to surface agent lifecycle state across tabs/workspaces. It lives on `pane.status` and currently supports `idle`, `working`, `permission`, and `review`. The tab strip (`GroupStrip`) and workspace list aggregate pane statuses using shared priority logic in `apps/desktop/src/shared/tabs-types.ts` and render dots via `apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx`. + +Separately, Superset Desktop can show macOS desktop notifications for agent lifecycle events (for example “Agent Complete” or “Input Needed”). Those notifications are triggered in the main process (`apps/desktop/src/main/windows/main.ts`) from the same agent lifecycle event stream, and they are not driven by general terminal output. + +An “LRU warm set” is a small, bounded cache of most-recently-used terminal tabs that remain mounted to keep common tab switches fast. It is explicitly not persisted, so it cannot cause startup fan-out after restart. + +A “session lifecycle signal” (in scope for this PR) is a low‑volume event such as “this terminal session exited” that exists only to keep existing UI state correct (for example clearing stuck agent lifecycle statuses) when terminal panes are not mounted. “Cold restore” means: the daemon does not have a session (for example after reboot), but we have on-disk scrollback from a prior run that did not shut down cleanly. The UI should show the saved scrollback and let the user explicitly start a new shell. @@ -36,6 +50,7 @@ Renderer (browser environment, no Node imports): apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx apps/desktop/src/renderer/stores/tabs/store.ts apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts + apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx Main process (Node/Electron environment): @@ -54,11 +69,12 @@ Persisted UI state: apps/desktop/src/main/lib/app-state/index.ts apps/desktop/src/lib/trpc/routers/ui-state/index.ts + apps/desktop/src/shared/tabs-types.ts ## Problem Statement (What Breaks Today) -When terminal persistence is enabled, the renderer currently keeps every tab that contains a terminal mounted (even if hidden). This is implemented in `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx` by rendering all “terminal tabs” and toggling visibility. Each terminal pane mounts a `Terminal` component, and each `Terminal` immediately calls `trpc.terminal.createOrAttach` and enables a stream subscription (`trpc.terminal.stream.useSubscription`). +When terminal persistence is enabled, the renderer currently keeps every tab that contains a terminal mounted (even if hidden), across all workspaces. This is implemented in `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx` by rendering all “terminal tabs” and toggling visibility. Each terminal pane mounts a `Terminal` component, and each `Terminal` immediately calls `trpc.terminal.createOrAttach` and enables a stream subscription (`trpc.terminal.stream.useSubscription`). If a user has accumulated many terminal panes in persisted state (for example `tabsState.panes` contains ~90 terminal panes), startup mounts and attaches all of them. In daemon mode, each attach also does disk work for cold-restore detection (`HistoryReader.read`) and can cause new PTY spawns if the daemon is missing sessions. The combined fan‑out can saturate CPU, fill logs, and freeze the UI. @@ -69,14 +85,16 @@ This work should deliver these user-visible outcomes: 1. App startup remains responsive with 50–100 persisted terminal panes; the UI shows quickly and does not beachball. 2. Switching to a recently used terminal tab feels “instant enough” (target: the user sees a correct terminal view within ~200ms in the common case). -3. Background terminals still surface activity (badge on the tab and workspace; optional system notification on exit or when the user opts in). +3. Terminal persistence remains “real” (processes keep running in the daemon) even if their UI is not mounted, and the UI does not regress in correctness (for example, agent lifecycle `pane.status` does not get stuck forever because a terminal exited while hidden). 4. The daemon cannot be driven into unbounded resource usage by accident. There are clear limits, and the UI provides a way to manage sessions and recover from overload. 5. Cold restore does not spawn a new shell until the user explicitly starts one. ## Non-Goals -This plan does not attempt to replace xterm.js, node-pty, or rewrite the persistence architecture. It also does not attempt to perfectly summarize background output; it only needs a reliable “something happened” signal plus exit/error. +This plan does not attempt to replace xterm.js, node-pty, or rewrite the persistence architecture. It does not introduce any new user-facing “background terminal output” indicators or notifications; it only preserves correctness via low-volume session lifecycle signals (exit/error) needed to keep existing agent lifecycle `pane.status` state accurate. + +This plan explicitly does not attempt to provide “notify me when an arbitrary command finishes” for normal terminal commands like `pnpm test`. Implementing that requires prompt-level hooks or explicit wrappers, and will be tracked as a separate DX follow-up PR. ## Assumptions @@ -90,20 +108,17 @@ The daemon and client are shipped together, but we must handle stale daemons bec These questions must be answered (or explicitly decided) before implementation is finalized: -1. Should background activity signals be enabled by default when terminal persistence is enabled, or should it be a separate setting? -2. What is the “warm” cache size for keeping a small number of terminal tabs fully mounted/streaming (suggestion: 2–3), and should it be configurable? -3. What is the default daemon resource policy: warn-only, or automatic cleanup (idle timeout, max sessions) enabled by default? -4. What should the product promise be for cold restore: always show saved scrollback first and require explicit “Start Shell”, or auto-start a shell in some cases? -5. What is the acceptable worst-case reattach latency, and do we treat alt-screen (TUI) sessions differently in UX (for example always show a short “Resuming…” overlay)? +None (all previously open questions have been decided for this PR scope). ## Decision Log (To Be Filled As Questions Are Resolved) -1. Decision for Open Question 1: TBD. -2. Decision for Open Question 2: TBD. -3. Decision for Open Question 3: TBD. -4. Decision for Open Question 4: TBD. -5. Decision for Open Question 5: TBD. +1. Decision (Background indicators scope): This PR will not introduce any new background terminal activity indicators beyond the existing agent lifecycle `pane.status` updates driven by `apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts`. Any additional background terminal output indicators or “command finished” semantics will be explored in follow-up PRs based on user/team feedback. +2. Decision (Warm set size): Use a global per-run LRU warm set of 8 terminal tabs (across all workspaces), not persisted and not configurable in the first iteration. This matches the “I jump between up to ~8 workspaces” power-user workflow while keeping the resource cost bounded. Tradeoff: higher warm size increases steady-state renderer memory and can increase CPU under very chatty background terminals; Milestone 0 includes a concrete measurement step and we will reduce the default if the measured cost is too high. +3. Decision (Daemon resource policy): Warn-only by default + user-facing recovery tools. Do not enable automatic idle eviction / LRU eviction by default in this PR, because it can kill long-running user processes unexpectedly. If we add eviction later, it should be opt-in and clearly explained in settings. +4. Decision (Cold restore promise): Always show saved scrollback first and require an explicit user action (“Start Shell”) to spawn a new PTY after cold restore. Never auto-spawn on attach. +5. Decision (Reattach latency UX): Keep warm tabs effectively instant. For cold attaches, show a fast “Resuming…” UI state while attaching/snapshotting so the user never sees a blank terminal, and progressively attach panes in heavy tabs. +6. Deferred question (follow-up PR): If we ever add background terminal output indicators, decide whether to reuse `pane.status="review"` or introduce a separate “unread output” indicator. This is intentionally out of scope for this PR. ## Plan of Work @@ -125,6 +140,8 @@ Acceptance: Manual verification: with terminal persistence enabled, create ~30 terminals, restart the app, and confirm logs show the number of `createOrAttach` calls and typical durations. +Manual verification (warm sizing): after the app is running, visit several terminal tabs across multiple workspaces until the warm set is full, then observe CPU and memory in Activity Monitor. This establishes whether the chosen warm set size is acceptable on typical developer machines. + ### Milestone 1: Stop Startup Fan-Out by Changing Renderer Mount Policy @@ -132,7 +149,16 @@ This milestone removes the direct cause of “restore everything on startup”. Work: -Update `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx` so that when terminal persistence is enabled it does not render every terminal-containing tab. Compute a bounded warm set using `tabHistoryStacks` from the tabs store. Always include the active tab. Then include up to N previously active terminal tabs for the active workspace. Render only that warm set, still using `visibility: hidden` to preserve xterm dimensions for warm tabs. +Update `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx` so that when terminal persistence is enabled it does not render every terminal-containing tab across all workspaces. Instead, render a bounded “warm” set so switching between recently used terminal tabs stays smooth without mounting everything. + +Implement the warm set as a global per-run LRU list (not persisted), so it improves common navigation during a session but does not re-introduce startup fan-out after restart. The warm set should be capped by a small constant (recommended default: 8). + +Concrete behavior: + + - Always render the active tab for the active workspace (current behavior). + - Additionally, render the most recently visited terminal tabs across all workspaces, up to a total of N mounted terminal tabs including the active tab (N = warm set size). + - Use `visibility: hidden` (not `display: none`) for warm-but-not-active tabs to preserve xterm sizing and avoid resize bugs. + - When a tab leaves the warm set, it should unmount, which triggers normal `Terminal` detach behavior (the daemon session continues running). Do not change non-terminal tab behavior: non-terminal tabs should continue to mount only when active. @@ -163,24 +189,30 @@ Acceptance: Manual verification: create 10 new terminals quickly and confirm sessions are created progressively without UI lockups. -### Milestone 3: Background Activity Signals and Badges for Hidden Terminals +### Milestone 3: Session Lifecycle Signals for Hidden Terminals (Correctness Only) -This milestone restores a key piece of DX that “mount everything” previously provided: knowing when something happens in a background terminal. At completion, the app shows an activity badge for background terminals without subscribing to their full output stream. +This milestone prevents a subtle correctness regression once we stop mounting/attaching everything. Today, `Terminal.tsx` clears stuck agent lifecycle statuses on terminal exit (for example, if the user interrupts an agent and the hook doesn’t fire, exit clears `pane.status` from `working`/`permission` back to `idle`). If a terminal pane is not mounted, the renderer will not receive per-pane stream exit events, so those statuses can remain stuck indefinitely. + +At completion, the app receives low-volume session lifecycle events (exit and error) even for daemon sessions that are not currently attached to a renderer terminal stream, and uses those events only to keep `pane.status` correct. This milestone does not attempt to signal “command finished” for arbitrary commands, and it does not stream background output. Work: -Extend the daemon IPC to support “activity-only” subscriptions that do not stream full terminal output. Implement this by introducing a separate client set inside `apps/desktop/src/main/terminal-host/session.ts` so that “data” frames are not written to activity subscribers (avoiding backpressure and CPU churn). Activity events must be throttled and coalesced (for example at most one “activity” event per session every 250–500ms while output continues). +Extend the daemon IPC to broadcast session lifecycle events (at minimum: exit; optionally: terminalError) to a global subscriber set that is not per-session attach. The key invariant is: session exit is observable even when no UI client is attached to the session’s stream. + +Route those lifecycle events to the renderer in a single subscription (one per app), not one per pane. Prefer reusing the existing notifications subscription plumbing (`notificationsEmitter` + `trpc.notifications.subscribe`) to avoid introducing a parallel event system, but ensure these events do not trigger macOS notifications (agent lifecycle notifications remain unchanged). + +In the renderer, add a small listener mounted once (near `useAgentHookListener`) that receives terminal exit events and applies only the following rule: -Ensure that exit and error are delivered to activity subscribers as high-signal events. + If the affected `pane.status` is `working` or `permission`, set it to `idle`. -Expose a single renderer-level subscription for activity signals. This should be one subscription for the whole app, not one per pane. Implement it in the terminal tRPC router (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`) as a subscription that streams `{ paneId, workspaceId, type, ts }` events. Then add a small renderer hook/component mounted once (for example alongside `useAgentHookListener`) that listens for these events and calls `setPaneStatus(paneId, "review")` when the pane is not currently focused. The existing UI already renders status indicators in the tab strip and workspace list via `pane.status`. +It must never modify `review` (completed agent work should remain visible), and it must never set new non-idle statuses. Acceptance: bun run typecheck bun test apps/desktop/src/main/lib/terminal-host -Manual verification: run a command that produces output in a background terminal. Confirm the tab shows an attention indicator and that switching to the tab clears it. +Manual verification: start an agent so a pane is `working`, then force the underlying process to exit without a Stop hook (for example via a kill/crash scenario) while that pane is not mounted. Confirm the pane status does not remain stuck as `working`/`permission`. ### Milestone 4: Progressive Attach for Heavy Active Tabs (Split-Aware) @@ -227,7 +259,15 @@ This milestone bounds the daemon’s memory and process usage and gives the user Work: -Add a daemon-side policy for sessions with no attached UI clients. Track per-session timestamps (last attached, last output, last input). Implement an idle timeout and/or a maximum session cap, consistent with product decisions from Open Questions 2 and 3. Prefer conservative defaults and clear UI warnings over aggressive auto-eviction. +Add a daemon-side session inventory (list sessions + basic metadata like createdAt/lastAttachedAt/attachedClients) and expose it via tRPC so the renderer can display “how many sessions exist”. This PR should prefer manual recovery tools and warnings over automatic eviction. + +Implement user-facing recovery actions: + + - “Kill all sessions” (explicit confirmation) + - “Kill sessions for this workspace” (optional, if low-risk to implement) + - “Clear terminal history” (explicit confirmation) + +Do not enable automatic idle eviction by default in this PR. If we add idle timeouts / LRU eviction later, it should be a follow-up decision with clear UX. Add tRPC endpoints to list daemon sessions and to kill sessions (single and all). Expose them in the settings UI (`apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx`) as a “Manage sessions” surface with clear confirmations. @@ -246,7 +286,7 @@ This milestone ensures the fixes stick. At completion, we have repeatable valida Work: -Add unit/integration tests around the daemon protocol additions (activity subscription, attach-only, spawn limiting). Add a renderer-level test if the repo’s test setup supports it; otherwise document a deterministic manual verification checklist that a reviewer can run in under five minutes. +Add unit/integration tests around the daemon protocol additions (session lifecycle signals if introduced, attach-only, spawn limiting). Add a renderer-level test if the repo’s test setup supports it; otherwise document a deterministic manual verification checklist that a reviewer can run in under five minutes. Acceptance: @@ -263,10 +303,10 @@ Work: Update the PR description to include: - - A concise “what changed” summary tied to observable behavior (startup no longer restores everything, background activity badges, etc.). + - A concise “what changed” summary tied to observable behavior (startup no longer restores everything; warm set keeps up to 8 recent terminal tabs mounted per run for smooth switching; etc.). - The user-facing UX changes and any settings/flags involved (defaults and restart requirements). - - The key technical changes (renderer mount policy, attach/spawn limits, activity channel, cold restore semantics, daemon resource policy). - - Known risks and mitigations (reattach latency, noisy activity signals, resource limits). + - The key technical changes (renderer mount policy, attach/spawn limits, session lifecycle signals for correctness, cold restore semantics, daemon recovery tools). + - Known risks and mitigations (reattach latency, resource limits). - Exact validation steps run (commands and any manual scenarios). Ensure the description matches the final implementation details and file paths in this plan. If scope changed during implementation, update this ExecPlan to match before updating the PR description. @@ -287,9 +327,10 @@ Always run: Key manual scenarios: 1. Mass restore: create many terminal tabs/panes, restart app, confirm UI becomes interactive quickly and does not spawn dozens of shells at once. -2. Background activity: run a long build in one terminal, switch away, confirm the tab shows attention on output/exit, and the indicator clears on view. +2. Smooth switching: open several terminal tabs across multiple workspaces, switch between them repeatedly, confirm warm tabs switch without a noticeable attach delay. 3. Heavy tab: open a tab with many panes; confirm the UI remains responsive and panes connect progressively. -4. Cold restore: simulate daemon absence + existing history; confirm no shell starts until user clicks “Start Shell”. +4. Agent status correctness: put a pane into `working`/`permission`, then force the underlying process to exit while the pane is not mounted; confirm status does not remain stuck. +5. Cold restore: simulate daemon absence + existing history; confirm no shell starts until user clicks “Start Shell”. ## Idempotence and Safety @@ -301,7 +342,7 @@ Avoid importing Node.js modules in renderer code. Any new renderer components mu ## Rollout Strategy -Gate the new behaviors behind the existing “Terminal persistence” setting. If additional settings are introduced (for example background activity signals or auto-cleanup), default them conservatively and document them in the Terminal settings UI. +Gate the new behaviors behind the existing “Terminal persistence” setting. If additional settings are introduced later (for example optional auto-cleanup, or future background output attention indicators), default them conservatively and document them in the Terminal settings UI. Ensure protocol changes include a robust upgrade path for stale daemons that may remain running across app updates. @@ -310,7 +351,17 @@ Ensure protocol changes include a robust upgrade path for stale daemons that may The main DX risk is perceived latency when switching to a terminal that is not warm. Mitigate this by keeping a small warm set mounted, showing a fast “Resuming…” state when attaching, and ensuring attach is a fast path that avoids unnecessary disk I/O. -Another risk is that background activity signals become noisy for chatty terminals. Mitigate this by throttling, coalescing, and allowing the user to disable or narrow notifications to exit/error only. +Warm set sizing is a tradeoff between “instant tab switches” and steady-state renderer resources. Each mounted terminal pane keeps a live xterm.js instance with large scrollback (`scrollback: 10000` in `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts`), plus a canvas/WebGL renderer and an active stream subscription. Idle warm terminals should cost little CPU, but memory scales with the number of mounted terminal panes and how much scrollback/output they have accumulated. As a rough order-of-magnitude estimate, a single mounted terminal pane can be on the order of ~10–30MB of renderer memory once it has meaningful scrollback; a warm set of 8 single-pane tabs is therefore plausibly ~80–240MB of renderer memory. Milestone 0 must validate this with real measurements (Activity Monitor + our attach/mount counters), and we should reduce the warm set size (or add a secondary pane-count cap) if the observed cost is too high. + +If we choose to add background output attention indicators in a later PR, another risk is that they become noisy for chatty terminals. Mitigate this by throttling/coalescing, and making them opt-in and easy to disable. + + +## DX Follow-Ups (Separate PRs) + +These are explicitly out of scope for this PR, but are natural next steps: + +1. Command completion notifications for arbitrary non-agent commands (for example “notify when `pnpm test` finishes”). This likely requires prompt-level hooks or an explicit “run with notify” wrapper so we can detect “command finished” without parsing output. +2. Background output attention indicators (beyond correctness exit/error signals), such as “output after silence” badges for non-agent commands. This should not reuse `pane.status="review"` unless product agrees that “review” can mean “terminal has unread output”. ## Progress @@ -318,7 +369,7 @@ Another risk is that background activity signals become noisy for chatty termina - [ ] Milestone 0: Baseline reproduction and instrumentation exists and is documented - [ ] Milestone 1: Renderer mount policy limits terminal tab mounts to active + warm set - [ ] Milestone 2: Main attach concurrency and daemon spawn concurrency limits added -- [ ] Milestone 3: Background activity signals implemented and UI badges wired +- [ ] Milestone 3: Hidden terminal lifecycle signals keep pane.status correct - [ ] Milestone 4: Progressive attach scheduler for heavy tabs implemented - [ ] Milestone 5: Cold restore semantics fixed and disk I/O optimized - [ ] Milestone 6: Daemon resource policy and session management UI shipped From 2dd48acbe4af9ff8c62282fa2b871a6566a43d25 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Thu, 8 Jan 2026 10:24:23 +0200 Subject: [PATCH 19/62] fix(desktop): harden terminal persistence DX --- ...-1107-terminal-persistence-dx-hardening.md | 6 + .../src/lib/trpc/routers/notifications.ts | 23 +- .../src/lib/trpc/routers/terminal/terminal.ts | 13 +- apps/desktop/src/main/lib/menu.ts | 18 +- .../src/main/lib/terminal-host/types.ts | 5 + .../src/main/lib/terminal/daemon-manager.ts | 528 ++++++++++++------ .../src/main/lib/terminal/dev-reset.ts | 45 ++ apps/desktop/src/main/lib/terminal/manager.ts | 4 +- apps/desktop/src/main/terminal-host/index.ts | 44 +- .../desktop/src/main/terminal-host/session.ts | 8 +- .../src/main/terminal-host/terminal-host.ts | 143 ++++- apps/desktop/src/main/windows/main.ts | 14 + .../SettingsView/TerminalSettings.tsx | 438 +++++++++++++++ .../ContentView/TabsContent/TabView/index.tsx | 1 + .../TabsContent/Terminal/Terminal.tsx | 152 ++--- .../TabsContent/Terminal/attach-scheduler.ts | 75 +++ .../ContentView/TabsContent/index.tsx | 60 +- .../stores/tabs/useAgentHookListener.ts | 13 +- apps/desktop/src/shared/constants.ts | 1 + 19 files changed, 1318 insertions(+), 273 deletions(-) create mode 100644 apps/desktop/src/main/lib/terminal/dev-reset.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts diff --git a/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md b/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md index 1e5849b5601..6813b94aaa8 100644 --- a/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md +++ b/apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md @@ -16,6 +16,8 @@ On this branch, the daemon protocol was recently changed to split “control” The observed freeze happens because the renderer mounts far more terminal UIs than the user can see, and each mounted terminal immediately calls `terminal.createOrAttach`, which in daemon mode can cause disk I/O, snapshot generation, and (when sessions are missing) new PTY spawns. When this happens tens of times concurrently, startup becomes unresponsive. +Mount policy is therefore the primary lever for fixing the startup freeze. However, mount policy alone is not a complete robustness strategy: a single tab can still contain many terminal panes (splits), cold restore can otherwise spawn many shells quickly when sessions are missing, and future regressions could reintroduce large attach/spawn fan-out. This plan pairs the mount-policy fix with safety nets (concurrency limits, progressive attach) and clearer cold-restore semantics. + ## Definitions (Plain Language) @@ -153,6 +155,8 @@ Update `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentV Implement the warm set as a global per-run LRU list (not persisted), so it improves common navigation during a session but does not re-introduce startup fan-out after restart. The warm set should be capped by a small constant (recommended default: 8). +Note: This milestone addresses the bulk of the freeze by preventing “mount everything”. Milestones 2 and 4 are still required to prevent overload when a user opens a very heavy split tab or rapidly opens many new terminals. + Concrete behavior: - Always render the active tab for the active workspace (current behavior). @@ -261,6 +265,8 @@ Work: Add a daemon-side session inventory (list sessions + basic metadata like createdAt/lastAttachedAt/attachedClients) and expose it via tRPC so the renderer can display “how many sessions exist”. This PR should prefer manual recovery tools and warnings over automatic eviction. +Clarification: to distinguish “a terminal is still running in the daemon” vs “only old scrollback exists on disk”, use daemon session existence (`listSessions` / “does this sessionId exist”) as the source of truth. A PTY PID is useful metadata to display for debugging and to support port-scanning, but it must not be treated as a stable identity for a session (PIDs change/reuse and do not survive daemon restarts). + Implement user-facing recovery actions: - “Kill all sessions” (explicit confirmation) diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index f90d264b6f6..44624e0645a 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -7,12 +7,21 @@ import { import { NOTIFICATION_EVENTS } from "shared/constants"; import { publicProcedure, router } from ".."; +type TerminalExitNotification = NotificationIds & { + exitCode: number; + signal?: number; +}; + type NotificationEvent = | { type: typeof NOTIFICATION_EVENTS.AGENT_LIFECYCLE; data?: AgentLifecycleEvent; } - | { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds }; + | { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds } + | { + type: typeof NOTIFICATION_EVENTS.TERMINAL_EXIT; + data?: TerminalExitNotification; + }; export const createNotificationsRouter = () => { return router({ @@ -26,11 +35,19 @@ export const createNotificationsRouter = () => { emit.next({ type: NOTIFICATION_EVENTS.FOCUS_TAB, data }); }; + const onTerminalExit = (data: TerminalExitNotification) => { + emit.next({ type: NOTIFICATION_EVENTS.TERMINAL_EXIT, data }); + }; + notificationsEmitter.on( NOTIFICATION_EVENTS.AGENT_LIFECYCLE, onLifecycle, ); notificationsEmitter.on(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); + notificationsEmitter.on( + NOTIFICATION_EVENTS.TERMINAL_EXIT, + onTerminalExit, + ); return () => { notificationsEmitter.off( @@ -38,6 +55,10 @@ export const createNotificationsRouter = () => { onLifecycle, ); notificationsEmitter.off(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); + notificationsEmitter.off( + NOTIFICATION_EVENTS.TERMINAL_EXIT, + onTerminalExit, + ); }; }); }), diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index c98698f498f..908addb60c3 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -8,6 +8,7 @@ import { DaemonTerminalManager, getActiveTerminalManager, } from "main/lib/terminal"; +import { getTerminalHistoryRootDir } from "main/lib/terminal-history"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; @@ -15,6 +16,7 @@ import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveCwd } from "./utils"; const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; +let createOrAttachCallCounter = 0; /** * Terminal router using TerminalManager with node-pty @@ -55,6 +57,8 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { + const callId = ++createOrAttachCallCounter; + const startedAt = Date.now(); const { paneId, tabId, @@ -118,9 +122,11 @@ export const createTerminalRouter = () => { if (DEBUG_TERMINAL) { console.log("[Terminal Router] createOrAttach result:", { + callId, paneId, isNew: result.isNew, wasRecovered: result.wasRecovered, + durationMs: Date.now() - startedAt, }); } @@ -139,7 +145,9 @@ export const createTerminalRouter = () => { } catch (error) { if (DEBUG_TERMINAL) { console.warn("[Terminal Router] createOrAttach failed:", { + callId, paneId, + durationMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error), }); } @@ -288,8 +296,9 @@ export const createTerminalRouter = () => { }), clearTerminalHistory: publicProcedure.mutation(async () => { - // Note: Disk-based terminal history was removed. This is now a no-op - // for non-daemon mode. In daemon mode, it resets the history persistence. + const historyRoot = getTerminalHistoryRootDir(); + await fs.rm(historyRoot, { recursive: true, force: true }); + if (terminalManager instanceof DaemonTerminalManager) { await terminalManager.resetHistoryPersistence(); } diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index 3f08aac4455..332fd7b4753 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,8 +1,9 @@ import { COMPANY } from "@superset/shared/constants"; -import { app, Menu, shell } from "electron"; +import { app, BrowserWindow, Menu, shell } from "electron"; import { env } from "main/env.main"; import { appState } from "main/lib/app-state"; import { hotkeysEmitter } from "main/lib/hotkeys-events"; +import { resetTerminalStateDev } from "main/lib/terminal/dev-reset"; import { getCurrentPlatform, getEffectiveHotkey, @@ -113,6 +114,21 @@ export function createApplicationMenu() { template.push({ label: "Dev", submenu: [ + { + label: "Reset Terminal State", + click: () => { + resetTerminalStateDev() + .then(() => { + for (const window of BrowserWindow.getAllWindows()) { + window.reload(); + } + }) + .catch((error) => { + console.error("[menu] Failed to reset terminal state:", error); + }); + }, + }, + { type: "separator" }, { label: "Simulate Update Downloading", click: () => simulateDownloading(), diff --git a/apps/desktop/src/main/lib/terminal-host/types.ts b/apps/desktop/src/main/lib/terminal-host/types.ts index bcca5d75a29..32d9b8d2523 100644 --- a/apps/desktop/src/main/lib/terminal-host/types.ts +++ b/apps/desktop/src/main/lib/terminal-host/types.ts @@ -229,6 +229,11 @@ export interface ListSessionsResponse { attachedClients: number; /** PTY process ID (null if not yet spawned or exited) */ pid: number | null; + /** ISO timestamp */ + createdAt?: string; + /** ISO timestamp */ + lastAttachedAt?: string; + shell?: string; }>; } diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 5e677664c9a..1a8452aefe4 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -12,6 +12,7 @@ import { EventEmitter } from "node:events"; import { workspaces } from "@superset/local-db"; import { track } from "main/lib/analytics"; +import { appState } from "main/lib/app-state"; import { localDb } from "main/lib/local-db"; import { containsClearScrollbackSequence, @@ -23,6 +24,7 @@ import { getTerminalHostClient, type TerminalHostClient, } from "../terminal-host/client"; +import type { ListSessionsResponse } from "../terminal-host/types"; import { buildTerminalEnv, getDefaultShell } from "./env"; import { portManager } from "./port-manager"; import type { CreateSessionParams, SessionResult } from "./types"; @@ -34,6 +36,7 @@ import type { CreateSessionParams, SessionResult } from "./types"; /** Delay before removing session from local cache after exit event */ const SESSION_CLEANUP_DELAY_MS = 5000; const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; +const CREATE_OR_ATTACH_CONCURRENCY = 3; // ============================================================================= // Types @@ -61,6 +64,11 @@ export class DaemonTerminalManager extends EventEmitter { private client: TerminalHostClient; private sessions = new Map(); private pendingSessions = new Map>(); + private createOrAttachLimiter = new PrioritySemaphore( + CREATE_OR_ATTACH_CONCURRENCY, + ); + private daemonAliveSessionIds = new Set(); + private daemonSessionIdsHydrated = false; /** History writers for persisting scrollback to disk (for reboot recovery) */ private historyWriters = new Map(); @@ -104,6 +112,8 @@ export class DaemonTerminalManager extends EventEmitter { try { const response = await this.client.listSessions(); if (response.sessions.length === 0) { + this.daemonAliveSessionIds.clear(); + this.daemonSessionIdsHydrated = true; return; } @@ -132,6 +142,18 @@ export class DaemonTerminalManager extends EventEmitter { } } + // Cache the daemon session inventory so createOrAttach can fast-path + // existing sessions without touching disk (cold restore check only + // applies when the daemon does not have a session). + const preservedSessions = response.sessions.filter( + (session) => + validWorkspaceIds.has(session.workspaceId) && session.isAlive, + ); + this.daemonAliveSessionIds = new Set( + preservedSessions.map((session) => session.sessionId), + ); + this.daemonSessionIdsHydrated = true; + const preservedCount = response.sessions.length - orphanedCount; if (preservedCount > 0) { console.log( @@ -146,6 +168,23 @@ export class DaemonTerminalManager extends EventEmitter { } } + private async ensureDaemonSessionIdsHydrated(): Promise { + if (this.daemonSessionIdsHydrated) return; + + try { + const response = await this.client.listSessions(); + this.daemonAliveSessionIds = new Set( + response.sessions.filter((s) => s.isAlive).map((s) => s.sessionId), + ); + this.daemonSessionIdsHydrated = true; + } catch (error) { + console.warn( + "[DaemonTerminalManager] Failed to list daemon sessions:", + error, + ); + } + } + /** * Set up event handlers to forward daemon events to local EventEmitter */ @@ -176,6 +215,7 @@ export class DaemonTerminalManager extends EventEmitter { "exit", (sessionId: string, exitCode: number, signal?: number) => { const paneId = sessionId; + this.daemonAliveSessionIds.delete(paneId); // Update session state const session = this.sessions.get(paneId); @@ -192,6 +232,7 @@ export class DaemonTerminalManager extends EventEmitter { // Emit exit event this.emit(`exit:${paneId}`, exitCode, signal); + this.emit("terminalExit", { paneId, exitCode, signal }); // Clean up session after delay (track timeout for cancellation on dispose) const timeoutId = setTimeout(() => { @@ -205,6 +246,8 @@ export class DaemonTerminalManager extends EventEmitter { // Handle client disconnection - notify all active sessions this.client.on("disconnected", () => { console.warn("[DaemonTerminalManager] Disconnected from daemon"); + this.daemonAliveSessionIds.clear(); + this.daemonSessionIdsHydrated = false; // Emit disconnect event for all active sessions so terminals can show error UI for (const [paneId, session] of this.sessions.entries()) { if (session.isAlive) { @@ -218,6 +261,8 @@ export class DaemonTerminalManager extends EventEmitter { this.client.on("error", (error: Error) => { console.error("[DaemonTerminalManager] Client error:", error.message); + this.daemonAliveSessionIds.clear(); + this.daemonSessionIdsHydrated = false; // Emit error event for all active sessions for (const [paneId, session] of this.sessions.entries()) { if (session.isAlive) { @@ -417,9 +462,21 @@ export class DaemonTerminalManager extends EventEmitter { } } + async listDaemonSessions(): Promise { + const response = await this.client.listSessions(); + this.daemonAliveSessionIds = new Set( + response.sessions.filter((s) => s.isAlive).map((s) => s.sessionId), + ); + this.daemonSessionIdsHydrated = true; + return response; + } + private async doCreateOrAttach( params: CreateSessionParams, ): Promise { + const releaseCreateOrAttach = await this.createOrAttachLimiter.acquire( + this.getCreateOrAttachPriority(params), + ); const { paneId, tabId, @@ -431,175 +488,187 @@ export class DaemonTerminalManager extends EventEmitter { cols = 80, rows = 24, initialCommands, + skipColdRestore, } = params; - // FIRST: Check for sticky cold restore info (survives React StrictMode remounts) - // This ensures the second mount still sees the cold restore detected on first mount - const stickyRestore = this.coldRestoreInfo.get(paneId); - if (stickyRestore) { - return { - isNew: false, - scrollback: stickyRestore.scrollback, - wasRecovered: true, - isColdRestore: true, - previousCwd: stickyRestore.previousCwd, - snapshot: { - snapshotAnsi: stickyRestore.scrollback, - rehydrateSequences: "", - cwd: stickyRestore.previousCwd || null, - modes: {}, - cols: stickyRestore.cols, - rows: stickyRestore.rows, - scrollbackLines: 0, - }, - }; - } + const MAX_SCROLLBACK_CHARS = 500_000; - // Check for cold restore: read existing history from disk BEFORE calling daemon - // This detects if there's scrollback from a previous session that ended uncleanly - const historyReader = new HistoryReader(workspaceId, paneId); - const existingHistory = await historyReader.read(); - const hasPreviousSession = - !!existingHistory.metadata && !!existingHistory.scrollback; - const wasUncleanShutdown = - hasPreviousSession && !existingHistory.metadata?.endedAt; - - // Build environment for the terminal - const shell = getDefaultShell(); - const env = buildTerminalEnv({ - shell, - paneId, - tabId, - workspaceId, - workspaceName, - workspacePath, - rootPath, - }); + try { + // Sticky cold restore info (survives React StrictMode remounts). + // The renderer must call ackColdRestore() to clear this. + if (!skipColdRestore) { + const stickyRestore = this.coldRestoreInfo.get(paneId); + if (stickyRestore) { + return { + isNew: false, + scrollback: stickyRestore.scrollback, + wasRecovered: true, + isColdRestore: true, + previousCwd: stickyRestore.previousCwd, + snapshot: { + snapshotAnsi: stickyRestore.scrollback, + rehydrateSequences: "", + cwd: stickyRestore.previousCwd || null, + modes: {}, + cols: stickyRestore.cols, + rows: stickyRestore.rows, + scrollbackLines: 0, + }, + }; + } + } - if (DEBUG_TERMINAL) { - console.log("[DaemonTerminalManager] Calling daemon createOrAttach:", { - paneId, - shell, - cwd, - cols, - rows, - }); - } + if (skipColdRestore) { + this.coldRestoreInfo.delete(paneId); + } - // Call daemon - const response = await this.client.createOrAttach({ - sessionId: paneId, // Use paneId as sessionId for simplicity - paneId, - tabId, - workspaceId, - workspaceName, - workspacePath, - rootPath, - cols, - rows, - cwd, - env, - shell, - initialCommands, - }); + // Attach fast-path: if the daemon already has the session, don't touch disk. + await this.ensureDaemonSessionIdsHydrated(); + const daemonHasSession = this.daemonAliveSessionIds.has(paneId); + + // Cold restore: only applies when the daemon does not have the session. + if (!daemonHasSession && !skipColdRestore) { + const historyReader = new HistoryReader(workspaceId, paneId); + const metadata = await historyReader.readMetadata(); + const wasUncleanShutdown = !!metadata && !metadata.endedAt; + + if (wasUncleanShutdown) { + const rawScrollback = await historyReader.readScrollback(); + const scrollback = + rawScrollback.length > MAX_SCROLLBACK_CHARS + ? rawScrollback.slice(-MAX_SCROLLBACK_CHARS) + : rawScrollback; + + // Store sticky info so StrictMode remounts still show cold restore. + this.coldRestoreInfo.set(paneId, { + scrollback, + previousCwd: metadata.cwd, + cols: metadata.cols || cols, + rows: metadata.rows || rows, + }); + + return { + isNew: false, + scrollback, + wasRecovered: true, + isColdRestore: true, + previousCwd: metadata.cwd, + snapshot: { + snapshotAnsi: scrollback, + rehydrateSequences: "", + cwd: metadata.cwd, + modes: {}, + cols: metadata.cols || cols, + rows: metadata.rows || rows, + scrollbackLines: 0, + }, + }; + } + } - // Detect cold restore: daemon created new session but we have unclean history - const isColdRestore = response.isNew && wasUncleanShutdown; + // If the user explicitly starts a new shell after cold restore, clear the + // on-disk history so we don't re-trigger cold restore on the next attach. + if (!daemonHasSession && skipColdRestore) { + await this.cleanupHistory(paneId, workspaceId); + } - // For cold restore, use the previous session's cwd; otherwise use daemon's cwd - const previousCwd = existingHistory.metadata?.cwd; - const sessionCwd = isColdRestore - ? previousCwd || cwd || "" - : response.snapshot.cwd || cwd || ""; + // Build environment for the terminal. + const shell = getDefaultShell(); + const env = buildTerminalEnv({ + shell, + paneId, + tabId, + workspaceId, + workspaceName, + workspacePath, + rootPath, + }); - // Guard against invalid dimensions (can happen if terminal not yet sized) - const effectiveCols = response.snapshot.cols || cols; - const effectiveRows = response.snapshot.rows || rows; + if (DEBUG_TERMINAL) { + console.log("[DaemonTerminalManager] Calling daemon createOrAttach:", { + paneId, + shell, + cwd, + cols, + rows, + }); + } - // Track session locally - this.sessions.set(paneId, { - paneId, - workspaceId, - isAlive: true, - lastActive: Date.now(), - cwd: sessionCwd, - pid: response.pid, - cols: effectiveCols, - rows: effectiveRows, - }); + // Create or attach via daemon. + const response = await this.client.createOrAttach({ + sessionId: paneId, // Use paneId as sessionId for simplicity + paneId, + tabId, + workspaceId, + workspaceName, + workspacePath, + rootPath, + cols, + rows, + cwd, + env, + shell, + initialCommands, + }); + + this.daemonAliveSessionIds.add(paneId); - // Register with port manager for process-based port scanning - // PID may be null if PTY not yet spawned (will be polled via listSessions) - portManager.upsertDaemonSession(paneId, workspaceId, response.pid); + const sessionCwd = response.snapshot.cwd || cwd || ""; - // Initialize history writer for reboot persistence - // For cold restore: start fresh (scrollback is read-only display) - // For recovered session: include existing scrollback - // For new session: start empty - const initialScrollback = response.wasRecovered - ? response.snapshot.snapshotAnsi - : undefined; + // Guard against invalid dimensions (can happen if terminal not yet sized). + const effectiveCols = response.snapshot.cols || cols; + const effectiveRows = response.snapshot.rows || rows; - if (effectiveCols >= 1 && effectiveRows >= 1) { - this.initHistoryWriter( + // Track session locally. + this.sessions.set(paneId, { paneId, workspaceId, - sessionCwd, - effectiveCols, - effectiveRows, - initialScrollback, - ).catch((error) => { - console.error( - `[DaemonTerminalManager] Failed to init history for ${paneId}:`, - error, - ); + isAlive: true, + lastActive: Date.now(), + cwd: sessionCwd, + pid: response.pid, + cols: effectiveCols, + rows: effectiveRows, }); - } else { - console.warn( - `[DaemonTerminalManager] Skipping history init for ${paneId}: invalid dimensions ${effectiveCols}x${effectiveRows}`, - ); - } - // Track terminal opened (but not for cold restore - that's a continuation) - if (response.isNew && !isColdRestore) { - track("terminal_opened", { workspace_id: workspaceId, pane_id: paneId }); - } + // Register with port manager for process-based port scanning. + // PID may be null if PTY not yet spawned or has exited. + portManager.upsertDaemonSession(paneId, workspaceId, response.pid); - // For cold restore, return disk scrollback instead of daemon snapshot - if (isColdRestore) { - // Cap scrollback size for performance (matches non-daemon mode) - const MAX_SCROLLBACK_CHARS = 500_000; - const scrollback = - existingHistory.scrollback.length > MAX_SCROLLBACK_CHARS - ? existingHistory.scrollback.slice(-MAX_SCROLLBACK_CHARS) - : existingHistory.scrollback; - - // Store in sticky map - survives React StrictMode remounts - // Renderer must call ackColdRestore() to clear this - this.coldRestoreInfo.set(paneId, { - scrollback, - previousCwd: previousCwd || undefined, - cols: existingHistory.metadata?.cols || cols, - rows: existingHistory.metadata?.rows || rows, - }); + // Initialize history writer for reboot persistence. + const snapshotAnsi = response.snapshot.snapshotAnsi || ""; + const initialScrollback = + snapshotAnsi.length > MAX_SCROLLBACK_CHARS + ? snapshotAnsi.slice(-MAX_SCROLLBACK_CHARS) + : snapshotAnsi; - return { - isNew: false, // Not truly new - we're restoring - scrollback: scrollback, - wasRecovered: true, - isColdRestore: true, - previousCwd: previousCwd || undefined, - snapshot: { - snapshotAnsi: scrollback, - rehydrateSequences: "", - cwd: previousCwd || null, - modes: {}, - cols: existingHistory.metadata?.cols || cols, - rows: existingHistory.metadata?.rows || rows, - scrollbackLines: 0, - }, - }; - } + if (effectiveCols >= 1 && effectiveRows >= 1) { + this.initHistoryWriter( + paneId, + workspaceId, + sessionCwd, + effectiveCols, + effectiveRows, + initialScrollback, + ).catch((error) => { + console.error( + `[DaemonTerminalManager] Failed to init history for ${paneId}:`, + error, + ); + }); + } else { + console.warn( + `[DaemonTerminalManager] Skipping history init for ${paneId}: invalid dimensions ${effectiveCols}x${effectiveRows}`, + ); + } + + // Track terminal opened (only on actual daemon session creation). + if (response.isNew) { + track("terminal_opened", { + workspace_id: workspaceId, + pane_id: paneId, + }); + } return { isNew: response.isNew, @@ -612,14 +681,34 @@ export class DaemonTerminalManager extends EventEmitter { snapshot: { snapshotAnsi: response.snapshot.snapshotAnsi, rehydrateSequences: response.snapshot.rehydrateSequences, - cwd: response.snapshot.cwd, - modes: response.snapshot.modes as unknown as Record, - cols: response.snapshot.cols, - rows: response.snapshot.rows, - scrollbackLines: response.snapshot.scrollbackLines, - debug: response.snapshot.debug, - }, - }; + cwd: response.snapshot.cwd, + modes: response.snapshot.modes as unknown as Record, + cols: response.snapshot.cols, + rows: response.snapshot.rows, + scrollbackLines: response.snapshot.scrollbackLines, + debug: response.snapshot.debug, + }, + }; + } finally { + releaseCreateOrAttach(); + } + } + private getCreateOrAttachPriority(params: CreateSessionParams): number { + try { + const tabsState = appState.data?.tabsState; + const activeTabId = tabsState?.activeTabIds?.[params.workspaceId]; + const focusedPaneId = + activeTabId && tabsState?.focusedPaneIds?.[activeTabId]; + + const isActiveFocusedPane = + activeTabId === params.tabId && focusedPaneId === params.paneId; + + return isActiveFocusedPane ? 0 : 1; + } catch { + // If appState isn't initialized yet or is otherwise unavailable, treat all + // requests as the same priority. + return 1; + } } write(params: { paneId: string; data: string }): void { @@ -708,6 +797,7 @@ export class DaemonTerminalManager extends EventEmitter { deleteHistory?: boolean; }): Promise { const { paneId, deleteHistory = false } = params; + this.daemonAliveSessionIds.delete(paneId); // Emit exit event BEFORE killing so tRPC subscriptions complete cleanly. // This prevents WRITE_FAILED errors when the daemon kills the session @@ -719,6 +809,7 @@ export class DaemonTerminalManager extends EventEmitter { session.isAlive = false; session.pid = null; this.emit(`exit:${paneId}`, 0, "SIGTERM"); + this.emit("terminalExit", { paneId, exitCode: 0, signal: undefined }); } // Unregister from port manager @@ -738,12 +829,9 @@ export class DaemonTerminalManager extends EventEmitter { const { paneId, viewportY } = params; const session = this.sessions.get(paneId); - if (!session) { - console.warn(`Cannot detach terminal ${paneId}: session not found`); - return; - } - // Fire and forget + // Fire and forget. Daemon detach is idempotent and succeeds even if the + // session doesn't exist (prevents noisy races during startup/cold restore). this.client.detach({ sessionId: paneId }).catch((error) => { console.error( `[DaemonTerminalManager] Detach failed for ${paneId}:`, @@ -751,9 +839,11 @@ export class DaemonTerminalManager extends EventEmitter { ); }); - session.lastActive = Date.now(); - if (viewportY !== undefined) { - session.viewportY = viewportY; + if (session) { + session.lastActive = Date.now(); + if (viewportY !== undefined) { + session.viewportY = viewportY; + } } } @@ -795,6 +885,45 @@ export class DaemonTerminalManager extends EventEmitter { } } + async resetHistoryPersistence(): Promise { + const closePromises: Promise[] = []; + for (const [paneId, writer] of this.historyWriters.entries()) { + closePromises.push( + writer.close().catch((error) => { + console.warn( + `[DaemonTerminalManager] Failed to close history for ${paneId}:`, + error, + ); + }), + ); + } + await Promise.all(closePromises); + this.historyWriters.clear(); + this.historyInitializing.clear(); + this.pendingHistoryData.clear(); + + const initPromises: Promise[] = []; + for (const [paneId, session] of this.sessions.entries()) { + if (!session.isAlive) continue; + initPromises.push( + this.initHistoryWriter( + paneId, + session.workspaceId, + session.cwd, + session.cols, + session.rows, + undefined, + ).catch((error) => { + console.warn( + `[DaemonTerminalManager] Failed to reinitialize history for ${paneId}:`, + error, + ); + }), + ); + } + await Promise.all(initPromises); + } + getSession( paneId: string, ): { isAlive: boolean; cwd: string; lastActive: number } | null { @@ -858,6 +987,7 @@ export class DaemonTerminalManager extends EventEmitter { session.isAlive = false; session.pid = null; this.emit(`exit:${paneId}`, 0, "SIGTERM"); + this.emit("terminalExit", { paneId, exitCode: 0, signal: undefined }); } // Unregister from port manager @@ -924,7 +1054,8 @@ export class DaemonTerminalManager extends EventEmitter { name.startsWith("data:") || name.startsWith("exit:") || name.startsWith("disconnect:") || - name.startsWith("error:") + name.startsWith("error:") || + name === "terminalExit" ) { this.removeAllListeners(event); } @@ -970,6 +1101,9 @@ export class DaemonTerminalManager extends EventEmitter { // Disconnect from daemon but DON'T kill sessions - they should persist // across app restarts. This is the core feature of daemon mode. this.sessions.clear(); + this.daemonAliveSessionIds.clear(); + this.daemonSessionIdsHydrated = false; + this.coldRestoreInfo.clear(); this.removeAllListeners(); disposeTerminalHostClient(); } @@ -980,6 +1114,17 @@ export class DaemonTerminalManager extends EventEmitter { * not during normal app shutdown. */ async forceKillAll(): Promise { + const response = await this.client.listSessions().catch(() => ({ + sessions: [], + })); + const sessionIds = response.sessions.map((s) => s.sessionId); + + // Clear pending cleanup timeouts to prevent callbacks after force kill. + for (const timeout of this.cleanupTimeouts.values()) { + clearTimeout(timeout); + } + this.cleanupTimeouts.clear(); + // Close all history writers for (const writer of this.historyWriters.values()) { await writer.close().catch((error) => { @@ -994,10 +1139,57 @@ export class DaemonTerminalManager extends EventEmitter { this.pendingHistoryData.clear(); await this.client.killAll({}); + for (const paneId of sessionIds) { + portManager.unregisterDaemonSession(paneId); + } + this.daemonAliveSessionIds.clear(); + this.daemonSessionIdsHydrated = true; + this.coldRestoreInfo.clear(); this.sessions.clear(); } } +// ============================================================================= +// PrioritySemaphore (small custom concurrency limiter) +// ============================================================================= + +class PrioritySemaphore { + private inUse = 0; + private queue: Array<{ + priority: number; + resolve: (release: () => void) => void; + }> = []; + + constructor(private max: number) {} + + acquire(priority: number): Promise<() => void> { + if (this.inUse < this.max) { + this.inUse++; + return Promise.resolve(() => this.release()); + } + + return new Promise<() => void>((resolve) => { + this.queue.push({ priority, resolve }); + // Small N; simple sort is fine and keeps the implementation obvious. + this.queue.sort((a, b) => a.priority - b.priority); + }); + } + + private release(): void { + this.inUse = Math.max(0, this.inUse - 1); + this.pump(); + } + + private pump(): void { + while (this.inUse < this.max && this.queue.length > 0) { + const next = this.queue.shift(); + if (!next) return; + this.inUse++; + next.resolve(() => this.release()); + } + } +} + // ============================================================================= // Singleton Instance // ============================================================================= diff --git a/apps/desktop/src/main/lib/terminal/dev-reset.ts b/apps/desktop/src/main/lib/terminal/dev-reset.ts new file mode 100644 index 00000000000..335b956ab09 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/dev-reset.ts @@ -0,0 +1,45 @@ +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { SUPERSET_HOME_DIR } from "main/lib/app-environment"; +import { appState } from "main/lib/app-state"; +import { defaultAppState } from "main/lib/app-state/schemas"; +import { + disposeTerminalHostClient, + getTerminalHostClient, +} from "main/lib/terminal-host/client"; + +const TERMINAL_STATE_PATHS = [ + "terminal-history", + "terminal-host.sock", + "terminal-host.token", + "terminal-host.pid", + "terminal-host.spawn.lock", + "daemon.log", +] as const; + +export async function resetTerminalStateDev(): Promise { + console.log("[dev/reset-terminal-state] Resetting terminal state…"); + + try { + const client = getTerminalHostClient(); + await client.shutdownIfRunning({ killSessions: true }); + } catch (error) { + console.warn( + "[dev/reset-terminal-state] Failed to shutdown daemon (best-effort):", + error, + ); + } finally { + disposeTerminalHostClient(); + } + + for (const relativePath of TERMINAL_STATE_PATHS) { + const fullPath = join(SUPERSET_HOME_DIR, relativePath); + await rm(fullPath, { recursive: true, force: true }).catch(() => {}); + } + + // Clear tabs/panes so we don't immediately try to restore a large terminal set. + appState.data.tabsState = defaultAppState.tabsState; + await appState.write(); + + console.log("[dev/reset-terminal-state] Done."); +} diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index c262e4a4cae..1ac9959d1b4 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -158,6 +158,7 @@ export class TerminalManager extends EventEmitter { portManager.unregisterSession(paneId); this.emit(`exit:${paneId}`, exitCode, signal); + this.emit("terminalExit", { paneId, exitCode, signal }); // Clean up session after delay const timeout = setTimeout(() => { @@ -431,7 +432,8 @@ export class TerminalManager extends EventEmitter { name.startsWith("data:") || name.startsWith("exit:") || name.startsWith("disconnect:") || - name.startsWith("error:") + name.startsWith("error:") || + name === "terminalExit" ) { this.removeAllListeners(event); } diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index 0c05fb2af9c..490159699a8 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -40,6 +40,7 @@ import { type ResizeRequest, type ShutdownRequest, type TerminalErrorEvent, + type TerminalExitEvent, type WriteRequest, } from "../lib/terminal-host/types"; import { TerminalHost } from "./terminal-host"; @@ -195,6 +196,32 @@ function isValidRole(role: unknown): role is "control" | "stream" { return role === "control" || role === "stream"; } +function broadcastEventToAllStreamSockets(event: IpcEvent): void { + const message = `${JSON.stringify(event)}\n`; + + for (const [clientId, sockets] of clientsById.entries()) { + const streamSocket = sockets.stream; + if (!streamSocket) continue; + + try { + streamSocket.write(message); + } catch { + // Best-effort cleanup of broken sockets. + try { + streamSocket.destroy(); + } catch { + // ignore + } + const { control } = sockets; + if (control) { + clientsById.set(clientId, { control }); + } else { + clientsById.delete(clientId); + } + } + } +} + function getStreamSocketForClient( clientState: ClientState, ): Socket | undefined { @@ -664,7 +691,22 @@ async function startServer(): Promise { authToken = ensureAuthToken(); // Initialize terminal host - terminalHost = new TerminalHost(); + terminalHost = new TerminalHost({ + onUnattachedExit: ({ sessionId, exitCode, signal }) => { + const event: IpcEvent = { + type: "event", + event: "exit", + sessionId, + payload: { + type: "exit", + exitCode, + signal, + } satisfies TerminalExitEvent, + }; + + broadcastEventToAllStreamSockets(event); + }, + }); // Create server const newServer = createServer(handleConnection); diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index b4540bcaa94..f62244373e1 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -95,7 +95,6 @@ export class Session { private subprocessStdinQueue: Buffer[] = []; private subprocessStdinQueuedBytes = 0; private subprocessStdinDrainArmed = false; - // biome-ignore lint/correctness/noUnusedPrivateClassMembers: stored for future debugging/logging private ptyPid: number | null = null; // Promise that resolves when PTY is ready to accept writes @@ -355,6 +354,13 @@ export class Session { this.onSessionExit?.(this.sessionId, exitCode); } + // Ensure waiters don't hang forever if the subprocess exits before sending Spawned. + // Callers must still check isAlive before writing. + if (this.ptyReadyResolve) { + this.ptyReadyResolve(); + this.ptyReadyResolve = null; + } + this.subprocess = null; this.subprocessReady = false; this.subprocessDecoder = null; diff --git a/apps/desktop/src/main/terminal-host/terminal-host.ts b/apps/desktop/src/main/terminal-host/terminal-host.ts index 75986144f70..ff8f42eb928 100644 --- a/apps/desktop/src/main/terminal-host/terminal-host.ts +++ b/apps/desktop/src/main/terminal-host/terminal-host.ts @@ -29,10 +29,51 @@ import { createSession, type Session } from "./session"; /** Timeout for force-disposing sessions that don't exit after kill */ const KILL_TIMEOUT_MS = 5000; +const MAX_CONCURRENT_SPAWNS = 3; +const SPAWN_READY_TIMEOUT_MS = 5000; + +function promiseWithTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`Timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + promise + .then((value) => { + clearTimeout(timeoutId); + resolve(value); + }) + .catch((error) => { + clearTimeout(timeoutId); + reject(error); + }); + }); +} export class TerminalHost { private sessions: Map = new Map(); private killTimers: Map = new Map(); + private spawnLimiter = new Semaphore(MAX_CONCURRENT_SPAWNS); + private onUnattachedExit?: (event: { + sessionId: string; + exitCode: number; + signal?: number; + }) => void; + + constructor({ + onUnattachedExit, + }: { + onUnattachedExit?: (event: { + sessionId: string; + exitCode: number; + signal?: number; + }) => void; + } = {}) { + this.onUnattachedExit = onUnattachedExit; + } /** * Create or attach to a terminal session @@ -67,21 +108,37 @@ export class TerminalHost { } if (!session) { + const releaseSpawn = await this.spawnLimiter.acquire(); + // Create new session - session = createSession(request); + try { + session = createSession(request); - // Set up exit handler - session.onExit((id, exitCode, signal) => { - this.handleSessionExit(id, exitCode, signal); - }); + // Set up exit handler + session.onExit((id, exitCode, signal) => { + this.handleSessionExit(id, exitCode, signal); + }); - // Spawn PTY - session.spawn({ - cwd: request.cwd || process.env.HOME || "/", - cols: request.cols, - rows: request.rows, - env: request.env, - }); + // Spawn PTY + session.spawn({ + cwd: request.cwd || process.env.HOME || "/", + cols: request.cols, + rows: request.rows, + env: request.env, + }); + + // Hold the spawn permit until the PTY is ready (or times out). This spreads + // out large bursts of session creation and avoids CPU spikes from dozens + // of shells starting simultaneously. + void promiseWithTimeout(session.waitForReady(), SPAWN_READY_TIMEOUT_MS) + .catch(() => {}) + .finally(() => { + releaseSpawn(); + }); + } catch (error) { + releaseSpawn(); + throw error; + } // Run initial commands if provided (after PTY is ready) if (request.initialCommands && request.initialCommands.length > 0) { @@ -223,14 +280,20 @@ export class TerminalHost { * it's actually in the process of being killed. */ listSessions(): ListSessionsResponse { - const sessions = Array.from(this.sessions.values()).map((session) => ({ - sessionId: session.sessionId, - workspaceId: session.workspaceId, - paneId: session.paneId, - isAlive: session.isAttachable, // Use isAttachable to prevent kill/attach races - attachedClients: session.clientCount, - pid: session.pid, - })); + const sessions = Array.from(this.sessions.values()).map((session) => { + const meta = session.getMeta(); + return { + sessionId: session.sessionId, + workspaceId: session.workspaceId, + paneId: session.paneId, + isAlive: session.isAttachable, // Use isAttachable to prevent kill/attach races + attachedClients: session.clientCount, + pid: session.pid, + createdAt: meta.createdAt, + lastAttachedAt: meta.lastAttachedAt, + shell: meta.shell, + }; + }); return { sessions }; } @@ -298,12 +361,20 @@ export class TerminalHost { */ private handleSessionExit( sessionId: string, - _exitCode: number, - _signal?: number, + exitCode: number, + signal?: number, ): void { // Clear the kill timer since session exited normally this.clearKillTimer(sessionId); + // If no clients are attached, the session won't have anywhere to deliver the + // exit event. Emit a low-volume lifecycle signal so the main process can keep + // UI state correct even when a terminal isn't actively streamed. + const session = this.sessions.get(sessionId); + if (session?.clientCount === 0) { + this.onUnattachedExit?.({ sessionId, exitCode, signal }); + } + // Keep session around for a bit so clients can see exit status // Then clean up (reschedule if clients still attached) this.scheduleSessionCleanup(sessionId); @@ -344,3 +415,31 @@ export class TerminalHost { }, 5000); } } + +class Semaphore { + private inUse = 0; + private queue: Array<(release: () => void) => void> = []; + + constructor(private max: number) {} + + acquire(): Promise<() => void> { + if (this.inUse < this.max) { + this.inUse++; + return Promise.resolve(() => this.release()); + } + + return new Promise<() => void>((resolve) => { + this.queue.push(resolve); + }); + } + + private release(): void { + this.inUse = Math.max(0, this.inUse - 1); + + const next = this.queue.shift(); + if (next) { + this.inUse++; + next(() => this.release()); + } + } +} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index fc061313b16..ac4ff0494d6 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -170,6 +170,20 @@ export async function MainWindow() { }, ); + // Forward low-volume terminal lifecycle events to the renderer via the existing + // notifications subscription. This is used only for correctness (e.g. clearing + // stuck agent lifecycle statuses when terminal panes aren't mounted). + getActiveTerminalManager().on( + "terminalExit", + (event: { paneId: string; exitCode: number; signal?: number }) => { + notificationsEmitter.emit(NOTIFICATION_EVENTS.TERMINAL_EXIT, { + paneId: event.paneId, + exitCode: event.exitCode, + signal: event.signal, + }); + }, + ); + window.webContents.on("did-finish-load", async () => { // Restore maximized state if it was saved if (initialBounds.isMaximized) { diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx index 6c74a11a7d0..9d2341baac7 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/TerminalSettings.tsx @@ -1,5 +1,16 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; import { Switch } from "@superset/ui/switch"; +import { useMemo, useState } from "react"; import { trpc } from "renderer/lib/trpc"; import { DEFAULT_TERMINAL_PERSISTENCE } from "shared/constants"; @@ -7,6 +18,36 @@ export function TerminalSettings() { const utils = trpc.useUtils(); const { data: terminalPersistence, isLoading } = trpc.settings.getTerminalPersistence.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + + const { data: daemonSessions } = trpc.terminal.listDaemonSessions.useQuery(); + const daemonModeEnabled = daemonSessions?.daemonModeEnabled ?? false; + const sessions = daemonSessions?.sessions ?? []; + const sessionsSorted = useMemo(() => { + return [...sessions].sort((a, b) => { + // Attached sessions first, then newest attach time. + if (a.attachedClients !== b.attachedClients) { + return b.attachedClients - a.attachedClients; + } + const aTime = a.lastAttachedAt ? Date.parse(a.lastAttachedAt) : 0; + const bTime = b.lastAttachedAt ? Date.parse(b.lastAttachedAt) : 0; + return bTime - aTime; + }); + }, [sessions]); + const activeWorkspaceSessionCount = useMemo(() => { + if (!activeWorkspace?.id) return 0; + return sessions.filter((s) => s.workspaceId === activeWorkspace.id).length; + }, [sessions, activeWorkspace?.id]); + + const [confirmKillAllOpen, setConfirmKillAllOpen] = useState(false); + const [confirmKillWorkspaceOpen, setConfirmKillWorkspaceOpen] = + useState(false); + const [confirmClearHistoryOpen, setConfirmClearHistoryOpen] = useState(false); + const [showSessionList, setShowSessionList] = useState(false); + const [pendingKillSession, setPendingKillSession] = useState<{ + sessionId: string; + workspaceId: string; + } | null>(null); const setTerminalPersistence = trpc.settings.setTerminalPersistence.useMutation({ onMutate: async ({ enabled }) => { @@ -37,6 +78,78 @@ export function TerminalSettings() { setTerminalPersistence.mutate({ enabled }); }; + const killAllDaemonSessions = trpc.terminal.killAllDaemonSessions.useMutation( + { + onSuccess: (result) => { + if (result.daemonModeEnabled) { + toast.success("Killed all terminal sessions", { + description: `${result.killedCount} sessions terminated`, + }); + } else { + toast.error("Terminal persistence is not active", { + description: "Restart the app after enabling terminal persistence.", + }); + } + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to kill sessions", { + description: error.message, + }); + }, + }, + ); + + const killDaemonSessionsForWorkspace = + trpc.terminal.killDaemonSessionsForWorkspace.useMutation({ + onSuccess: (result) => { + if (result.daemonModeEnabled) { + toast.success("Killed workspace terminal sessions", { + description: `${result.killedCount} sessions terminated`, + }); + } else { + toast.error("Terminal persistence is not active", { + description: "Restart the app after enabling terminal persistence.", + }); + } + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to kill sessions", { + description: error.message, + }); + }, + }); + + const clearTerminalHistory = trpc.terminal.clearTerminalHistory.useMutation({ + onSuccess: () => { + toast.success("Cleared terminal history"); + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to clear terminal history", { + description: error.message, + }); + }, + }); + + const killDaemonSession = trpc.terminal.kill.useMutation({ + onSuccess: () => { + toast.success("Killed terminal session"); + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to kill session", { + description: error.message, + }); + }, + }); + + const formatTimestamp = (value?: string) => { + if (!value) return "—"; + return value.replace("T", " ").replace(/\.\d+Z$/, "Z"); + }; + return (
@@ -75,7 +188,332 @@ export function TerminalSettings() { disabled={isLoading || setTerminalPersistence.isPending} />
+ +
+
+
+ + +
+ {daemonModeEnabled ? ( + <> +

+ Daemon sessions running: {sessions.length} +

+ {sessions.length >= 20 && ( +

+ Large numbers of persistent terminals can increase + CPU/memory usage. Consider killing old sessions if you + notice slowdowns. +

+ )} + + ) : ( +

+ Enable terminal persistence and restart the app to manage daemon + sessions. +

+ )} +
+ +
+ + + + +
+ + {daemonModeEnabled && showSessionList && sessions.length > 0 && ( +
+
+ + + + + + + + + + + + + {sessionsSorted.map((session) => ( + + + + + + + + + ))} + +
+ Workspace + + Session + + Clients + PID + Last attached + + Action +
+ {session.workspaceId} + + {session.sessionId} + + {session.attachedClients} + + {session.pid ?? "—"} + + {formatTimestamp(session.lastAttachedAt)} + + +
+
+
+ )} +
+ + + + + + Kill all terminal sessions? + + +
+ + This will terminate all persistent terminal processes (builds, + tests, agents, etc.). + + + You can’t undo this action. Terminal panes will show “Process + exited” and can be restarted. + +
+
+
+ + + + +
+
+ + + + + + Kill active workspace terminal sessions? + + +
+ + This will terminate terminal processes for the currently + active workspace. + + + You can’t undo this action. Terminal panes will show “Process + exited” and can be restarted. + +
+
+
+ + + + +
+
+ + + + + + Clear terminal history? + + +
+ + This deletes the saved scrollback used for reboot/crash + recovery. + + + Running terminal processes continue, but older output may no + longer be available after restarting the app. + +
+
+
+ + + + +
+
+ + { + if (!open) setPendingKillSession(null); + }} + > + + + + Kill terminal session? + + +
+ + This will terminate the session and its underlying process. + + {pendingKillSession && ( + + {pendingKillSession.workspaceId} /{" "} + {pendingKillSession.sessionId} + + )} +
+
+
+ + + + +
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 49b04da0af7..ed54260745f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -163,6 +163,7 @@ export function TabView({ tab, isTabVisible }: TabViewProps) { [ tabPanes, focusedPaneId, + isTabVisible, tab.id, tab.workspaceId, worktreePath, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 3a4598298b9..236451bf173 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -11,6 +11,7 @@ import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; import { useTerminalTheme } from "renderer/stores/theme"; +import { scheduleTerminalAttach } from "./attach-scheduler"; import { sanitizeForTitle } from "./commandBuffer"; import { createTerminalInstance, @@ -83,7 +84,11 @@ type CreateOrAttachResult = { }; }; -export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { +export const Terminal = ({ + tabId, + workspaceId, + isTabVisible, +}: TerminalProps) => { const paneId = tabId; // Use granular selectors to avoid re-renders when other panes change const pane = useTabsStore((s) => s.panes[paneId]); @@ -672,6 +677,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { cols: xterm.cols, rows: xterm.rows, cwd: restoredCwd || undefined, + skipColdRestore: true, }, { onSuccess: (result) => { @@ -955,78 +961,89 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const initialCommands = paneInitialCommandsRef.current; const initialCwd = paneInitialCwdRef.current; - createOrAttachRef.current( - { - paneId, - tabId: parentTabIdRef.current || paneId, - workspaceId, - cols: xterm.cols, - rows: xterm.rows, - initialCommands, - cwd: initialCwd, - }, - { - onSuccess: (result) => { - // Clear after successful creation to prevent re-running on future reattach - if (initialCommands || initialCwd) { - clearPaneInitialDataRef.current(paneId); - } - - // FIRST: Check if we have stored cold restore state from a previous mount - // (StrictMode causes unmount/remount - check this BEFORE result.isColdRestore - // because the second mount's result won't have isColdRestore=true) - const storedColdRestore = coldRestoreState.get(paneId); - if (storedColdRestore?.isRestored) { - setIsRestoredMode(true); - setRestoredCwd(storedColdRestore.cwd); - - // Write scrollback to terminal as read-only display - if (storedColdRestore.scrollback && xterm) { - xterm.write(storedColdRestore.scrollback); - } + const cancelInitialAttach = scheduleTerminalAttach({ + paneId, + priority: isTabVisible ? (isFocusedRef.current ? 0 : 1) : 2, + run: (done) => { + createOrAttachRef.current( + { + paneId, + tabId: parentTabIdRef.current || paneId, + workspaceId, + cols: xterm.cols, + rows: xterm.rows, + initialCommands, + cwd: initialCwd, + }, + { + onSuccess: (result) => { + // Clear after successful creation to prevent re-running on future reattach + if (initialCommands || initialCwd) { + clearPaneInitialDataRef.current(paneId); + } - // Mark first render complete but don't enable streaming - didFirstRenderRef.current = true; - return; - } + // FIRST: Check if we have stored cold restore state from a previous mount + // (StrictMode causes unmount/remount - check this BEFORE result.isColdRestore + // because the second mount's result won't have isColdRestore=true) + const storedColdRestore = coldRestoreState.get(paneId); + if (storedColdRestore?.isRestored) { + setIsRestoredMode(true); + setRestoredCwd(storedColdRestore.cwd); + + // Write scrollback to terminal as read-only display + if (storedColdRestore.scrollback && xterm) { + xterm.write(storedColdRestore.scrollback); + } - // Handle cold restore (reboot recovery) - first detection - // Store in module-level map to survive StrictMode remount - if (result.isColdRestore) { - const scrollback = - result.snapshot?.snapshotAnsi ?? result.scrollback; - coldRestoreState.set(paneId, { - isRestored: true, - cwd: result.previousCwd || null, - scrollback: scrollback, - }); - setIsRestoredMode(true); - setRestoredCwd(result.previousCwd || null); + // Mark first render complete but don't enable streaming + didFirstRenderRef.current = true; + return; + } - // Write scrollback to terminal as read-only display - if (scrollback && xterm) { - xterm.write(scrollback); - } + // Handle cold restore (reboot recovery) - first detection + // Store in module-level map to survive StrictMode remount + if (result.isColdRestore) { + const scrollback = + result.snapshot?.snapshotAnsi ?? result.scrollback; + coldRestoreState.set(paneId, { + isRestored: true, + cwd: result.previousCwd || null, + scrollback, + }); + setIsRestoredMode(true); + setRestoredCwd(result.previousCwd || null); + + // Write scrollback to terminal as read-only display + if (scrollback && xterm) { + xterm.write(scrollback); + } - // Mark first render complete but don't enable streaming - // (shell isn't running - user must click Start Shell) - didFirstRenderRef.current = true; - return; - } + // Mark first render complete but don't enable streaming + // (shell isn't running - user must click Start Shell) + didFirstRenderRef.current = true; + return; + } - // Defer initial state restoration until xterm has rendered once. - // Streaming is enabled only after restoration is queued into xterm. - pendingInitialStateRef.current = result; - maybeApplyInitialState(); - }, - onError: (error) => { - console.error("[Terminal] Failed to create/attach:", error); - setConnectionError(error.message || "Failed to connect to terminal"); - isStreamReadyRef.current = true; - flushPendingEvents(); - }, + // Defer initial state restoration until xterm has rendered once. + // Streaming is enabled only after restoration is queued into xterm. + pendingInitialStateRef.current = result; + maybeApplyInitialState(); + }, + onError: (error) => { + console.error("[Terminal] Failed to create/attach:", error); + setConnectionError( + error.message || "Failed to connect to terminal", + ); + isStreamReadyRef.current = true; + flushPendingEvents(); + }, + onSettled: () => { + done(); + }, + }, + ); }, - ); + }); const inputDisposable = xterm.onData(handleTerminalInput); const keyDisposable = xterm.onKey(handleKeyPress); @@ -1088,6 +1105,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }); return () => { + cancelInitialAttach(); isUnmounted = true; if (firstRenderFallback) { clearTimeout(firstRenderFallback); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts new file mode 100644 index 00000000000..aa5e34af0cf --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts @@ -0,0 +1,75 @@ +type AttachTask = { + paneId: string; + priority: number; + enqueuedAt: number; + canceled: boolean; + run: (done: () => void) => void; +}; + +const MAX_CONCURRENT_ATTACHES = 3; + +let inFlight = 0; +const queue: AttachTask[] = []; +const pendingByPaneId = new Map(); + +function pump(): void { + while (inFlight < MAX_CONCURRENT_ATTACHES && queue.length > 0) { + // Pick highest priority (lowest number), FIFO within same priority. + queue.sort( + (a, b) => a.priority - b.priority || a.enqueuedAt - b.enqueuedAt, + ); + const task = queue.shift(); + if (!task) return; + if (task.canceled) continue; + + // If a newer task replaced this paneId, skip this stale one. + const current = pendingByPaneId.get(task.paneId); + if (current !== task) continue; + + inFlight++; + task.run(() => { + // Only clear if this task is still the current one for the paneId. + if (pendingByPaneId.get(task.paneId) === task) { + pendingByPaneId.delete(task.paneId); + } + inFlight = Math.max(0, inFlight - 1); + pump(); + }); + } +} + +export function scheduleTerminalAttach({ + paneId, + priority, + run, +}: { + paneId: string; + priority: number; + run: (done: () => void) => void; +}): () => void { + // Replace any existing pending task for this paneId. + const existing = pendingByPaneId.get(paneId); + if (existing) { + existing.canceled = true; + pendingByPaneId.delete(paneId); + } + + const task: AttachTask = { + paneId, + priority, + enqueuedAt: Date.now(), + canceled: false, + run, + }; + + pendingByPaneId.set(paneId, task); + queue.push(task); + pump(); + + return () => { + task.canceled = true; + if (pendingByPaneId.get(paneId) === task) { + pendingByPaneId.delete(paneId); + } + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index d5fee1cc096..42f5f73df0a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { trpc } from "renderer/lib/trpc"; import { useSidebarStore } from "renderer/stores"; import { @@ -13,6 +13,8 @@ import { Sidebar } from "../../Sidebar"; import { EmptyTabView } from "./EmptyTabView"; import { TabView } from "./TabView"; +const WARM_TERMINAL_TAB_LIMIT = 8; + /** * Check if a tab contains at least one terminal pane. * Used to determine which tabs need to stay mounted for persistence. @@ -29,6 +31,7 @@ export function TabsContent() { const activeWorkspaceId = activeWorkspace?.id; const allTabs = useTabsStore((s) => s.tabs); const activeTabIds = useTabsStore((s) => s.activeTabIds); + const panes = useTabsStore((s) => s.panes); const { isSidebarOpen, @@ -55,22 +58,63 @@ export function TabsContent() { return allTabs.find((tab) => tab.id === activeTabId) || null; }, [activeTabId, allTabs]); - // When terminal persistence is enabled, keep terminal-containing tabs mounted - // across workspace/tab switches. This prevents TUI white screen issues by - // avoiding the unmount/remount cycle that requires complex reattach/rehydration. + const activeTabHasTerminal = useMemo(() => { + if (!tabToRender) return false; + return hasTerminalPane(tabToRender, panes); + }, [tabToRender, panes]); + + // Per-run warm set of terminal tab IDs (MRU order). Not persisted. + const [warmTerminalTabIds, setWarmTerminalTabIds] = useState([]); + + // Track terminal tab visits to keep a bounded set mounted for smooth switching. + useEffect(() => { + if (!terminalPersistence) return; + if (!activeTabId) return; + if (!activeTabHasTerminal) return; + + setWarmTerminalTabIds((prev) => { + const next = [activeTabId, ...prev.filter((id) => id !== activeTabId)]; + return next.slice(0, WARM_TERMINAL_TAB_LIMIT); + }); + }, [terminalPersistence, activeTabId, activeTabHasTerminal]); + + // When terminal persistence is enabled, keep a bounded set of terminal tabs + // mounted across workspace/tab switches. This prevents TUI white screen issues + // for recently used terminals by avoiding the unmount/remount cycle that + // requires complex reattach/rehydration, while avoiding startup fan-out. // Non-terminal tabs use normal unmount behavior to save memory. // Uses visibility:hidden (not display:none) to preserve xterm dimensions. if (terminalPersistence) { - // Partition tabs: terminal tabs stay mounted, non-terminal tabs unmount when inactive + // Partition tabs: a bounded set of terminal tabs stay mounted, non-terminal tabs unmount when inactive. const terminalTabs = allTabs.filter((tab) => hasTerminalPane(tab, panes)); + const terminalTabsById = new Map(terminalTabs.map((tab) => [tab.id, tab])); + + const warmIdsFiltered = warmTerminalTabIds.filter((id) => + terminalTabsById.has(id), + ); + + // Ensure active terminal tab is included in the mounted set even before the + // warm-set effect runs (first render after tab switch). + const terminalTabIdsToRender = (() => { + const ids = [...warmIdsFiltered]; + if (activeTabHasTerminal && activeTabId && !ids.includes(activeTabId)) { + ids.unshift(activeTabId); + } + return ids.slice(0, WARM_TERMINAL_TAB_LIMIT); + })(); + + const terminalTabsToRender = terminalTabIdsToRender + .map((id) => terminalTabsById.get(id)) + .filter((tab): tab is Tab => !!tab); + const activeNonTerminalTab = - tabToRender && !hasTerminalPane(tabToRender, panes) ? tabToRender : null; + tabToRender && !activeTabHasTerminal ? tabToRender : null; return (
{/* Terminal tabs: keep mounted with visibility toggle */} - {terminalTabs.map((tab) => { + {terminalTabsToRender.map((tab) => { const isVisible = tab.workspaceId === activeWorkspaceId && tab.id === activeTabId; @@ -90,7 +134,7 @@ export function TabsContent() { {/* Active non-terminal tab: render normally (unmounts when switching) */} {activeNonTerminalTab && (
- +
)} {/* Fallback: show empty view without unmounting terminal tabs */} diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 19e44b17ad2..4e52382bfb8 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -15,7 +15,7 @@ import { resolveNotificationTarget } from "./utils/resolve-notification-target"; * - Start → "working" (amber pulsing indicator) * - Stop → "review" (green static) if pane not active, "idle" if active * - PermissionRequest → "permission" (red pulsing indicator) - * - Terminal Exit → "idle" (handled in Terminal.tsx, clears stuck indicators) + * - Terminal Exit → "idle" (handled in Terminal.tsx when mounted; also forwarded via notifications for unmounted panes) * * KNOWN LIMITATIONS (External - Claude Code / OpenCode hook systems): * @@ -96,6 +96,17 @@ export function useAgentHookListener() { state.setPaneStatus(paneId, "review"); } } + } else if (event.type === NOTIFICATION_EVENTS.TERMINAL_EXIT) { + // Correctness-only: clear transient status if the underlying process exited + // while the terminal pane was not mounted (no per-pane stream subscription). + if (!paneId) return; + const currentPane = state.panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + state.setPaneStatus(paneId, "idle"); + } } else if (event.type === NOTIFICATION_EVENTS.FOCUS_TAB) { const appState = useAppStore.getState(); if (appState.currentView !== "workspace") { diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 1881aec86b1..f45da7c4c49 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -43,6 +43,7 @@ export const CONFIG_TEMPLATE = `{ export const NOTIFICATION_EVENTS = { AGENT_LIFECYCLE: "agent-lifecycle", FOCUS_TAB: "focus-tab", + TERMINAL_EXIT: "terminal-exit", } as const; // Default user preference values From d357bc48312fed2f3de8cc2b18646c8305acd277 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Thu, 8 Jan 2026 11:12:32 +0200 Subject: [PATCH 20/62] fix(desktop): prevent history init buffer loop --- .../src/main/lib/terminal/daemon-manager.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 1a8452aefe4..3a6928421d0 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -323,21 +323,25 @@ export class DaemonTerminalManager extends EventEmitter { } } - try { - const writer = new HistoryWriter(workspaceId, paneId, cwd, cols, rows); - await writer.init(safeScrollback); - this.historyWriters.set(paneId, writer); - - // Flush any buffered data - const buffered = this.pendingHistoryData.get(paneId) || []; - for (const data of buffered) { - this.writeToHistory(paneId, data); - } - } catch (error) { - console.error( - `[DaemonTerminalManager] Failed to init history writer for ${paneId}:`, - error, - ); + try { + const writer = new HistoryWriter(workspaceId, paneId, cwd, cols, rows); + await writer.init(safeScrollback); + this.historyWriters.set(paneId, writer); + + // Flush any buffered data. Important: mark init as complete BEFORE replaying, + // otherwise writeToHistory() would re-buffer into the same array we're + // iterating (infinite loop / RangeError: Invalid array length). + const buffered = this.pendingHistoryData.get(paneId) || []; + this.historyInitializing.delete(paneId); + this.pendingHistoryData.delete(paneId); + for (const data of buffered) { + writer.write(data); + } + } catch (error) { + console.error( + `[DaemonTerminalManager] Failed to init history writer for ${paneId}:`, + error, + ); } finally { this.historyInitializing.delete(paneId); this.pendingHistoryData.delete(paneId); From eaf480597b3467ca6c0cd7c9690f65e89e821c8f Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Thu, 8 Jan 2026 11:33:48 +0200 Subject: [PATCH 21/62] fix(desktop): enable terminal stream when snapshot empty --- .../TabsContent/Terminal/Terminal.tsx | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 236451bf173..bd987903cf1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -448,12 +448,6 @@ export const Terminal = ({ } } - // Apply rehydration sequences to restore other terminal modes - // (app cursor mode, bracketed paste, mouse tracking, etc.) - if (result.snapshot?.rehydrateSequences) { - xterm.write(result.snapshot.rehydrateSequences); - } - // Resize xterm to match snapshot dimensions before applying content. // The snapshot's cursor positioning assumes specific cols/rows. const snapshotCols = result.snapshot?.cols; @@ -530,12 +524,9 @@ export const Terminal = ({ return; // Skip normal snapshot flow } - // xterm.write() is asynchronous - escape sequences may not be fully - // processed when the terminal first renders, causing garbled display. - // Force a re-render after write completes to ensure correct display. - // (Symptom: restored terminals show corrupted text until resized) - // Use fitAddon.fit() and (when using WebGL) clear the glyph atlas to force a full repaint. - xterm.write(initialAnsi, () => { + const rehydrateSequences = result.snapshot?.rehydrateSequences ?? ""; + + const finalizeRestore = () => { const redraw = () => { requestAnimationFrame(() => { try { @@ -583,11 +574,36 @@ export const Terminal = ({ redraw(); }); - // Enable streaming AFTER xterm has processed the snapshot. + // Enable streaming AFTER xterm has processed the restoration writes. // This prevents live PTY output from interleaving with snapshot replay. isStreamReadyRef.current = true; flushPendingEvents(); - }); + }; + + const writeSnapshot = () => { + // xterm's WriteBuffer skips empty string chunks and never calls the callback. + // If there's no snapshot content, enable streaming immediately. + if (!initialAnsi) { + finalizeRestore(); + return; + } + + // xterm.write() is asynchronous - escape sequences may not be fully + // processed when the terminal first renders, causing garbled display. + // Force a re-render after write completes to ensure correct display. + // (Symptom: restored terminals show corrupted text until resized) + // Use fitAddon.fit() and (when using WebGL) clear the glyph atlas to force a full repaint. + xterm.write(initialAnsi, finalizeRestore); + }; + + // Apply rehydration sequences to restore other terminal modes + // (app cursor mode, bracketed paste, mouse tracking, etc.) before replaying snapshot. + if (rehydrateSequences) { + xterm.write(rehydrateSequences, writeSnapshot); + } else { + writeSnapshot(); + } + // Use snapshot.cwd if available, otherwise parse from content if (result.snapshot?.cwd) { updateCwdRef.current(result.snapshot.cwd); From 8add9e0fcfade279044474053d85480ce18f50de Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Thu, 8 Jan 2026 13:41:11 +0200 Subject: [PATCH 22/62] fix(desktop): prevent scheduler deadlock on React StrictMode unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React StrictMode simulates mount → unmount → mount cycles. The terminal attach scheduler was deadlocking because: 1. First mount starts a task (inFlight++) 2. Unmount cancels the task but it's still executing 3. Second mount queues a new task 4. tRPC callbacks for unmounted components don't fire reliably 5. done() never gets called → inFlight stays stuck at MAX_CONCURRENT Fix: Track running tasks per paneId and immediately decrement inFlight when canceling a running task. Also add optional debug logging (enable via localStorage.setItem('SUPERSET_TERMINAL_DEBUG', '1')). --- .../src/lib/trpc/routers/terminal/terminal.ts | 2 +- .../src/main/lib/terminal-host/client.ts | 12 ++ .../TabsContent/Terminal/Terminal.tsx | 59 +++++++++ .../TabsContent/Terminal/attach-scheduler.ts | 115 +++++++++++++++++- 4 files changed, 184 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 908addb60c3..0ba11690132 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -408,7 +408,7 @@ export const createTerminalRouter = () => { if (DEBUG_TERMINAL && !firstDataReceived) { firstDataReceived = true; console.log( - `[Terminal Stream] First data for ${paneId}: ${data.length} bytes`, + `[Terminal Stream] First data event for ${paneId}: ${data.length} bytes`, ); } emit.next({ type: "data", data }); diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 36b07f288f8..b4dd000a557 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -167,6 +167,18 @@ export class TerminalHostClient extends EventEmitter { private disconnectArmed = false; private clientId = randomUUID(); + constructor() { + super(); + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Initialized with paths:", { + SUPERSET_DIR_NAME, + SUPERSET_HOME_DIR, + SOCKET_PATH, + NODE_ENV: process.env.NODE_ENV, + }); + } + } + // =========================================================================== // Connection Management // =========================================================================== diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index bd987903cf1..0301c9bdf7d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -37,6 +37,12 @@ import { const FIRST_RENDER_RESTORE_FALLBACK_MS = 250; +// Debug logging for terminal lifecycle (enable via localStorage) +// Run in DevTools console: localStorage.setItem('SUPERSET_TERMINAL_DEBUG', '1') +const DEBUG_TERMINAL = + typeof localStorage !== "undefined" && + localStorage.getItem("SUPERSET_TERMINAL_DEBUG") === "1"; + // Module-level map to track pending detach timeouts. // This survives React StrictMode's unmount/remount cycle, allowing us to // cancel a pending detach if the component immediately remounts. @@ -492,6 +498,11 @@ export const Terminal = ({ // NOW safe to enable streaming and flush pending events isStreamReadyRef.current = true; + if (DEBUG_TERMINAL) { + console.log( + `[Terminal] isStreamReady=true (altScreen): ${paneId}, pendingEvents=${pendingEventsRef.current.length}`, + ); + } flushPendingEvents(); // Fit xterm to container and trigger SIGWINCH @@ -577,6 +588,11 @@ export const Terminal = ({ // Enable streaming AFTER xterm has processed the restoration writes. // This prevents live PTY output from interleaving with snapshot replay. isStreamReadyRef.current = true; + if (DEBUG_TERMINAL) { + console.log( + `[Terminal] isStreamReady=true (finalizeRestore): ${paneId}, pendingEvents=${pendingEventsRef.current.length}`, + ); + } flushPendingEvents(); }; @@ -717,14 +733,28 @@ export const Terminal = ({ setConnectionError, ]); + // Track first data event for debugging + const firstStreamDataReceivedRef = useRef(false); + const handleStreamData = (event: TerminalStreamEvent) => { // Queue events until terminal is ready to prevent data loss if (!xtermRef.current || !isStreamReadyRef.current) { + if (DEBUG_TERMINAL && event.type === "data") { + console.log( + `[Terminal] Queuing event (not ready): ${paneId}, type=${event.type}, bytes=${event.data.length}, isStreamReady=${isStreamReadyRef.current}`, + ); + } pendingEventsRef.current.push(event); return; } if (event.type === "data") { + if (DEBUG_TERMINAL && !firstStreamDataReceivedRef.current) { + firstStreamDataReceivedRef.current = true; + console.log( + `[Terminal] First stream data received: ${paneId}, ${event.data.length} bytes`, + ); + } updateModesFromDataRef.current(event.data); xtermRef.current.write(event.data); updateCwdFromData(event.data); @@ -818,6 +848,10 @@ export const Terminal = ({ const container = terminalRef.current; if (!container) return; + if (DEBUG_TERMINAL) { + console.log(`[Terminal] Mount: ${paneId}`); + } + // Cancel any pending detach from a previous unmount (e.g., React StrictMode's // simulated unmount/remount cycle). This prevents the detach from corrupting // the terminal state when we're immediately remounting. @@ -981,6 +1015,10 @@ export const Terminal = ({ paneId, priority: isTabVisible ? (isFocusedRef.current ? 0 : 1) : 2, run: (done) => { + if (DEBUG_TERMINAL) { + console.log(`[Terminal] createOrAttach start: ${paneId}`); + } + const createOrAttachStartTime = Date.now(); createOrAttachRef.current( { paneId, @@ -993,6 +1031,17 @@ export const Terminal = ({ }, { onSuccess: (result) => { + if (DEBUG_TERMINAL) { + console.log( + `[Terminal] createOrAttach success: ${paneId} (${Date.now() - createOrAttachStartTime}ms)`, + { + isNew: result.isNew, + wasRecovered: result.wasRecovered, + isColdRestore: result.isColdRestore, + snapshotBytes: result.snapshot?.snapshotAnsi?.length ?? 0, + }, + ); + } // Clear after successful creation to prevent re-running on future reattach if (initialCommands || initialCwd) { clearPaneInitialDataRef.current(paneId); @@ -1046,6 +1095,12 @@ export const Terminal = ({ maybeApplyInitialState(); }, onError: (error) => { + if (DEBUG_TERMINAL) { + console.log( + `[Terminal] createOrAttach error: ${paneId}`, + error.message, + ); + } console.error("[Terminal] Failed to create/attach:", error); setConnectionError( error.message || "Failed to connect to terminal", @@ -1121,8 +1176,12 @@ export const Terminal = ({ }); return () => { + if (DEBUG_TERMINAL) { + console.log(`[Terminal] Unmount: ${paneId}`); + } cancelInitialAttach(); isUnmounted = true; + firstStreamDataReceivedRef.current = false; if (firstRenderFallback) { clearTimeout(firstRenderFallback); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts index aa5e34af0cf..520077c3957 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts @@ -8,10 +8,21 @@ type AttachTask = { const MAX_CONCURRENT_ATTACHES = 3; +// Debug logging (enable via localStorage.setItem('SUPERSET_TERMINAL_DEBUG', '1')) +const DEBUG_SCHEDULER = + typeof localStorage !== "undefined" && + localStorage.getItem("SUPERSET_TERMINAL_DEBUG") === "1"; + let inFlight = 0; const queue: AttachTask[] = []; const pendingByPaneId = new Map(); +// Track running tasks per paneId to prevent StrictMode double-runs exhausting concurrency +const runningByPaneId = new Map(); + +// Tasks waiting for a running task to complete (stored separately to avoid infinite loop) +const waitingByPaneId = new Map(); + function pump(): void { while (inFlight < MAX_CONCURRENT_ATTACHES && queue.length > 0) { // Pick highest priority (lowest number), FIFO within same priority. @@ -20,22 +31,91 @@ function pump(): void { ); const task = queue.shift(); if (!task) return; - if (task.canceled) continue; + if (task.canceled) { + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Skipping canceled task: ${task.paneId}`, + ); + } + continue; + } // If a newer task replaced this paneId, skip this stale one. const current = pendingByPaneId.get(task.paneId); - if (current !== task) continue; + if (current !== task) { + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Skipping replaced task: ${task.paneId}`, + ); + } + continue; + } + + // If there's already a running task for this paneId (from a previous mount + // that was canceled but still executing), wait for it to finish before + // starting a new one. This prevents StrictMode double-mounts from + // exhausting the concurrency limit. + const running = runningByPaneId.get(task.paneId); + if (running && running !== task) { + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Waiting for previous task to finish: ${task.paneId}, inFlight=${inFlight}`, + ); + } + // Store in waiting map (NOT back in queue to avoid infinite loop). + // Will be re-queued when the running task completes. + waitingByPaneId.set(task.paneId, task); + continue; + } inFlight++; + runningByPaneId.set(task.paneId, task); + + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Starting task: ${task.paneId}, inFlight=${inFlight}, queueLength=${queue.length}`, + ); + } + task.run(() => { - // Only clear if this task is still the current one for the paneId. + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Task done: ${task.paneId}, inFlight=${inFlight - 1}`, + ); + } + + // Clear running tracker + if (runningByPaneId.get(task.paneId) === task) { + runningByPaneId.delete(task.paneId); + } + + // Only clear pending if this task is still the current one for the paneId. if (pendingByPaneId.get(task.paneId) === task) { pendingByPaneId.delete(task.paneId); } + + // Re-queue any task that was waiting for this one to complete + const waiting = waitingByPaneId.get(task.paneId); + if (waiting && !waiting.canceled) { + waitingByPaneId.delete(task.paneId); + queue.push(waiting); + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Re-queued waiting task: ${task.paneId}`, + ); + } + } + inFlight = Math.max(0, inFlight - 1); pump(); }); } + + if (DEBUG_SCHEDULER && queue.length > 0) { + console.log( + `[AttachScheduler] pump() exited with ${queue.length} tasks waiting, inFlight=${inFlight}`, + ); + } } export function scheduleTerminalAttach({ @@ -47,11 +127,20 @@ export function scheduleTerminalAttach({ priority: number; run: (done: () => void) => void; }): () => void { + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Schedule: ${paneId}, priority=${priority}, inFlight=${inFlight}, queueLength=${queue.length}`, + ); + } + // Replace any existing pending task for this paneId. const existing = pendingByPaneId.get(paneId); if (existing) { existing.canceled = true; pendingByPaneId.delete(paneId); + if (DEBUG_SCHEDULER) { + console.log(`[AttachScheduler] Canceled existing pending task: ${paneId}`); + } } const task: AttachTask = { @@ -71,5 +160,25 @@ export function scheduleTerminalAttach({ if (pendingByPaneId.get(paneId) === task) { pendingByPaneId.delete(paneId); } + if (waitingByPaneId.get(paneId) === task) { + waitingByPaneId.delete(paneId); + } + + // If this task is currently running, we need to decrement inFlight now + // because the tRPC callbacks for unmounted components may not fire, + // meaning done() would never be called and inFlight would stay stuck. + if (runningByPaneId.get(paneId) === task) { + runningByPaneId.delete(paneId); + inFlight = Math.max(0, inFlight - 1); + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Cancel running task: ${paneId}, inFlight=${inFlight}`, + ); + } + // Pump to start any waiting tasks now that we have capacity + pump(); + } else if (DEBUG_SCHEDULER) { + console.log(`[AttachScheduler] Cancel called: ${paneId}`); + } }; } From e1139027b453c86ecb8ecbdac51a767c5b6095a6 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Thu, 8 Jan 2026 20:18:45 +0200 Subject: [PATCH 23/62] fix(desktop): address PR review blocking issues - P0: Fix attach-scheduler race condition where inFlight counter could be double-decremented when cancel() and done() both fire for the same task. Added `released` flag to ensure idempotent completion. - P1: Fix sendRequestOnStream NDJSON parsing bug that dropped messages arriving in the same TCP read as the hello response. Now feeds remainder data to streamParser after parsing first response. - P1: Fix maybeApplyInitialState catch block that would wedge terminal on restoration error. Now fail-open by setting isStreamReady and flushing pending events even on error. - P2: Fix coldRestoreState memory leak by cleaning up on unmount. Previously scrollback (potentially MBs per pane) was only cleared on "Start Shell" click, not on component unmount. --- .../src/main/lib/terminal-host/client.ts | 11 ++++++++++ .../TabsContent/Terminal/Terminal.tsx | 8 +++++++ .../TabsContent/Terminal/attach-scheduler.ts | 21 ++++++++++++++++--- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index b4dd000a557..6a4cb25def7 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -691,6 +691,7 @@ export class TerminalHostClient extends EventEmitter { if (newlineIndex === -1) return; const line = buffer.slice(0, newlineIndex); + const remainder = buffer.slice(newlineIndex + 1); this.streamSocket?.off("data", onData); clearTimeout(timeoutId); @@ -700,6 +701,16 @@ export class TerminalHostClient extends EventEmitter { reject(new Error("Unexpected stream response")); return; } + + // Feed any remainder data to the streamParser so events + // arriving in the same TCP read as the response aren't lost + if (remainder) { + const messages = this.streamParser.parse(remainder); + for (const msg of messages) { + this.handleMessage(msg); + } + } + if (message.ok) { resolve(message.payload as T); } else { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 0301c9bdf7d..5760febbfe7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -628,6 +628,10 @@ export const Terminal = ({ } } catch (error) { console.error("[Terminal] Restoration failed:", error); + // Fail-open: even on error, mark stream ready and flush pending events + // to prevent terminal from wedging with unbounded event queue + isStreamReadyRef.current = true; + flushPendingEvents(); } }, [flushPendingEvents, paneId]); @@ -1223,6 +1227,10 @@ export const Terminal = ({ renderDisposableRef.current?.dispose(); renderDisposableRef.current = null; + // Clean up cold restore scrollback to prevent memory leak + // (scrollback can be MBs per pane, accumulates if not cleaned) + coldRestoreState.delete(paneId); + // Delay xterm.dispose() to let internal timeouts complete. // xterm.open() schedules a setTimeout for Viewport.syncScrollArea. // If we dispose synchronously, that timeout fires after _renderer is diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts index 520077c3957..fb50d52f9e8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts @@ -3,6 +3,8 @@ type AttachTask = { priority: number; enqueuedAt: number; canceled: boolean; + /** Whether this task has released its inFlight slot (idempotent completion) */ + released: boolean; run: (done: () => void) => void; }; @@ -78,9 +80,16 @@ function pump(): void { } task.run(() => { + // Idempotent completion: only release inFlight slot once + // This prevents double-decrement when cancel() was called while task was running + const shouldRelease = !task.released; + if (shouldRelease) { + task.released = true; + } + if (DEBUG_SCHEDULER) { console.log( - `[AttachScheduler] Task done: ${task.paneId}, inFlight=${inFlight - 1}`, + `[AttachScheduler] Task done: ${task.paneId}, inFlight=${shouldRelease ? inFlight - 1 : inFlight}, alreadyReleased=${!shouldRelease}`, ); } @@ -106,7 +115,10 @@ function pump(): void { } } - inFlight = Math.max(0, inFlight - 1); + // Only decrement inFlight if we're the one releasing + if (shouldRelease) { + inFlight = Math.max(0, inFlight - 1); + } pump(); }); } @@ -148,6 +160,7 @@ export function scheduleTerminalAttach({ priority, enqueuedAt: Date.now(), canceled: false, + released: false, run, }; @@ -167,7 +180,9 @@ export function scheduleTerminalAttach({ // If this task is currently running, we need to decrement inFlight now // because the tRPC callbacks for unmounted components may not fire, // meaning done() would never be called and inFlight would stay stuck. - if (runningByPaneId.get(paneId) === task) { + // Use idempotent release to prevent double-decrement if done() also fires. + if (runningByPaneId.get(paneId) === task && !task.released) { + task.released = true; runningByPaneId.delete(paneId); inFlight = Math.max(0, inFlight - 1); if (DEBUG_SCHEDULER) { From 1e2fc0bd7a7a1ff4df6d3acc8cf7445190a60665 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Thu, 8 Jan 2026 20:36:16 +0200 Subject: [PATCH 24/62] fix(desktop): address oracle feedback on PR fixes - Move coldRestoreState cleanup into detachTimeout to preserve StrictMode unmount/remount semantics. The module-level Map is specifically designed to survive quick remounts, so deleting immediately on unmount was wrong. - Harden attach-scheduler cancel path: when a running task is canceled, re-queue any waiting task for the same paneId. This mirrors the done() behavior and protects against the "done never fires" scenario. --- .../ContentView/TabsContent/Terminal/Terminal.tsx | 4 ---- .../TabsContent/Terminal/attach-scheduler.ts | 12 ++++++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 5760febbfe7..fb2d9b89403 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -1227,10 +1227,6 @@ export const Terminal = ({ renderDisposableRef.current?.dispose(); renderDisposableRef.current = null; - // Clean up cold restore scrollback to prevent memory leak - // (scrollback can be MBs per pane, accumulates if not cleaned) - coldRestoreState.delete(paneId); - // Delay xterm.dispose() to let internal timeouts complete. // xterm.open() schedules a setTimeout for Viewport.syncScrollArea. // If we dispose synchronously, that timeout fires after _renderer is diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts index fb50d52f9e8..412e53c97bc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts @@ -190,6 +190,18 @@ export function scheduleTerminalAttach({ `[AttachScheduler] Cancel running task: ${paneId}, inFlight=${inFlight}`, ); } + // Re-queue any task that was waiting for this one to complete + // (mirrors done() behavior for the "done never fires" scenario) + const waiting = waitingByPaneId.get(paneId); + if (waiting && !waiting.canceled) { + waitingByPaneId.delete(paneId); + queue.push(waiting); + if (DEBUG_SCHEDULER) { + console.log( + `[AttachScheduler] Re-queued waiting task after cancel: ${paneId}`, + ); + } + } // Pump to start any waiting tasks now that we have capacity pump(); } else if (DEBUG_SCHEDULER) { From bb638197721b7a2aca27bba207c4fe44a979d8eb Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Thu, 8 Jan 2026 20:44:17 +0200 Subject: [PATCH 25/62] docs(desktop): document ordering assumption in sendRequestOnStream Per oracle review suggestion: add JSDoc comment explaining that the daemon's hello handler guarantees response is first frame. Documents when this assumption would need to change (if daemon ever emits events before hello response). --- apps/desktop/src/main/lib/terminal-host/client.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 6a4cb25def7..62aa71a52c0 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -664,6 +664,17 @@ export class TerminalHostClient extends EventEmitter { this.streamAuthenticated = true; } + /** + * Send a request on the stream socket and wait for response. + * + * ORDERING ASSUMPTION: The daemon's hello handler writes the response synchronously + * and only broadcasts to authenticated/registered stream sockets, so the response + * is guaranteed to be the first frame. Any additional data in the same TCP read + * (e.g., events that arrive immediately after auth) is fed to streamParser. + * + * If the daemon ever changes to emit events before the hello response, this method + * would need to parse NDJSON frames in a loop until the matching id is found. + */ private async sendRequestOnStream({ type, payload, From 15b299d81c92d2ce7f8a7c0aabc3aeeb08b1b047 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 10:32:28 +0200 Subject: [PATCH 26/62] fix(desktop): resolve type errors after rebase onto main - Add stub terminal-history module (real impl in Phase 4) - Fix port-manager: add checkOutputForHint method, await async calls - Fix TabsContent: add panes store selector - Fix TabView: add Pane type import - Fix daemon-manager: handle null scrollback from stub reader --- .../src/lib/trpc/routers/terminal/terminal.ts | 6 +- apps/desktop/src/main/lib/terminal-history.ts | 94 +++++++++++++++++++ .../src/main/lib/terminal/daemon-manager.ts | 60 ++++++------ .../src/main/lib/terminal/port-manager.ts | 43 ++++++++- .../ContentView/TabsContent/TabView/index.tsx | 2 +- 5 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/main/lib/terminal-history.ts diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 0ba11690132..1c1bd617d5c 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -8,7 +8,6 @@ import { DaemonTerminalManager, getActiveTerminalManager, } from "main/lib/terminal"; -import { getTerminalHistoryRootDir } from "main/lib/terminal-history"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; @@ -296,9 +295,8 @@ export const createTerminalRouter = () => { }), clearTerminalHistory: publicProcedure.mutation(async () => { - const historyRoot = getTerminalHistoryRootDir(); - await fs.rm(historyRoot, { recursive: true, force: true }); - + // Note: Disk-based terminal history was removed. This is now a no-op + // for non-daemon mode. In daemon mode, it resets the history persistence. if (terminalManager instanceof DaemonTerminalManager) { await terminalManager.resetHistoryPersistence(); } diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts new file mode 100644 index 00000000000..afcda8420ac --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -0,0 +1,94 @@ +/** + * Stub terminal history module. + * + * Disk-based terminal history was removed from main (PR #684). + * This stub provides no-op implementations to maintain API compatibility + * with daemon-manager.ts. Phase 4 of the terminal persistence plan will + * implement proper cold restore with hybrid storage (raw PTY log + checkpoints). + * + * TODO: Replace with proper hybrid storage implementation in Phase 4. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { SUPERSET_DIR_NAME } from "shared/constants"; + +const TERMINAL_HISTORY_DIR_NAME = "terminal-history"; + +export function getTerminalHistoryRootDir(): string { + return join(homedir(), SUPERSET_DIR_NAME, TERMINAL_HISTORY_DIR_NAME); +} + +/** + * Stub HistoryWriter - no-op implementation. + * Cold restore will be implemented properly in Phase 4. + */ +export class HistoryWriter { + constructor( + _workspaceId: string, + _paneId: string, + _cwd: string, + _cols: number, + _rows: number, + ) { + // No-op + } + + async init(_initialScrollback?: string): Promise { + // No-op - cold restore implementation pending Phase 4 + } + + async write(_data: string): Promise { + // No-op - cold restore implementation pending Phase 4 + } + + async flush(): Promise { + // No-op + } + + async close(_exitCode?: number): Promise { + // No-op + } + + async reinitialize(): Promise { + // No-op + } + + async deleteHistory(): Promise { + // No-op + } +} + +/** + * Stub HistoryReader - no-op implementation. + * Cold restore will be implemented properly in Phase 4. + */ +export class HistoryReader { + constructor(_workspaceId: string, _paneId: string) { + // No-op + } + + async readMetadata(): Promise<{ + cols: number; + rows: number; + cwd: string; + endedAt?: string; + } | null> { + // No-op - return null to indicate no history available + return null; + } + + async readScrollback(): Promise { + // No-op - return null to indicate no scrollback available + return null; + } + + async exists(): Promise { + // No-op - return false to indicate no history exists + return false; + } + + cleanup(): void { + // No-op + } +} diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 3a6928421d0..2bf87a28f3f 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -538,35 +538,41 @@ export class DaemonTerminalManager extends EventEmitter { if (wasUncleanShutdown) { const rawScrollback = await historyReader.readScrollback(); - const scrollback = - rawScrollback.length > MAX_SCROLLBACK_CHARS - ? rawScrollback.slice(-MAX_SCROLLBACK_CHARS) - : rawScrollback; - - // Store sticky info so StrictMode remounts still show cold restore. - this.coldRestoreInfo.set(paneId, { - scrollback, - previousCwd: metadata.cwd, - cols: metadata.cols || cols, - rows: metadata.rows || rows, - }); - - return { - isNew: false, - scrollback, - wasRecovered: true, - isColdRestore: true, - previousCwd: metadata.cwd, - snapshot: { - snapshotAnsi: scrollback, - rehydrateSequences: "", - cwd: metadata.cwd, - modes: {}, + // Handle null scrollback (no history available) + if (!rawScrollback) { + historyReader.cleanup(); + // Fall through to create new session + } else { + const scrollback = + rawScrollback.length > MAX_SCROLLBACK_CHARS + ? rawScrollback.slice(-MAX_SCROLLBACK_CHARS) + : rawScrollback; + + // Store sticky info so StrictMode remounts still show cold restore. + this.coldRestoreInfo.set(paneId, { + scrollback, + previousCwd: metadata.cwd, cols: metadata.cols || cols, rows: metadata.rows || rows, - scrollbackLines: 0, - }, - }; + }); + + return { + isNew: false, + scrollback, + wasRecovered: true, + isColdRestore: true, + previousCwd: metadata.cwd, + snapshot: { + snapshotAnsi: scrollback, + rehydrateSequences: "", + cwd: metadata.cwd, + modes: {}, + cols: metadata.cols || cols, + rows: metadata.rows || rows, + scrollbackLines: 0, + }, + }; + } } } diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index d00d920fea8..bdd0fd5cac1 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -6,9 +6,28 @@ import type { TerminalSession } from "./types"; // How often to poll for port changes (in ms) const SCAN_INTERVAL_MS = 2500; +// Delay before scanning after a port hint is detected (in ms) +const HINT_SCAN_DELAY_MS = 500; + // Ports to ignore (common system ports that are usually not dev servers) const IGNORED_PORTS = new Set([22, 80, 443, 5432, 3306, 6379, 27017]); +/** + * Check if terminal output contains hints that a port may have been opened. + * Common patterns from dev servers, test frameworks, etc. + */ +function containsPortHint(data: string): boolean { + // Common patterns: "listening on port X", "server started on :X", etc. + const portPatterns = [ + /listening\s+on\s+(?:port\s+)?(\d+)/i, + /server\s+(?:started|running)\s+(?:on|at)\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, + /ready\s+on\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, + /port\s+(\d+)/i, + /:(\d{4,5})\s*$/, + ]; + return portPatterns.some((pattern) => pattern.test(data)); +} + interface RegisteredSession { session: TerminalSession; workspaceId: string; @@ -89,6 +108,26 @@ class PortManager extends EventEmitter { } } + /** + * Check terminal output for hints that a port may have been opened. + * If a hint is detected, schedule an immediate scan for that pane. + */ + checkOutputForHint(data: string, paneId: string): void { + if (!containsPortHint(data)) return; + + const existing = this.pendingHintScans.get(paneId); + if (existing) { + clearTimeout(existing); + } + + const timeout = setTimeout(() => { + this.pendingHintScans.delete(paneId); + this.scanPane(paneId).catch(() => {}); + }, HINT_SCAN_DELAY_MS); + + this.pendingHintScans.set(paneId, timeout); + } + /** * Start periodic scanning of all registered sessions */ @@ -140,7 +179,7 @@ class PortManager extends EventEmitter { return; } - const portInfos = getListeningPortsForPids(pids); + const portInfos = await getListeningPortsForPids(pids); this.updatePortsForPane(paneId, workspaceId, portInfos); } catch (error) { console.error(`[PortManager] Error scanning pane ${paneId}:`, error); @@ -163,7 +202,7 @@ class PortManager extends EventEmitter { return; } - const portInfos = getListeningPortsForPids(pids); + const portInfos = await getListeningPortsForPids(pids); this.updatePortsForPane(paneId, workspaceId, portInfos); } catch (error) { console.error( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index ed54260745f..3cf274bf6b3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -10,7 +10,7 @@ import { import { dragDropManager } from "renderer/lib/dnd"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; -import type { Tab } from "renderer/stores/tabs/types"; +import type { Pane, Tab } from "renderer/stores/tabs/types"; import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; import { cleanLayout, From 9bf9a590f0aad4e427f1a0f7f7004b1bd2ef83cf Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 11:45:57 +0200 Subject: [PATCH 27/62] fix(desktop): implement daemon signal() support for SIGINT/SIGTERM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon's signal() method was a no-op, which meant Ctrl+C through the daemon pathway would silently fail. This adds full signal support: - Add SignalRequest type and signal to RequestTypeMap - Add Signal IPC frame type (6) distinct from Kill - Implement handleSignal in pty-subprocess without kill escalation - Add sendSignal chain through session → terminal-host → daemon → client - Update daemon-manager to use client.signal() instead of no-op Unlike kill(), signal() does not mark the session as terminating and does not escalate to SIGKILL, allowing the process to continue running. --- .../src/main/lib/terminal-host/client.ts | 9 ++++++++ .../src/main/lib/terminal-host/types.ts | 9 ++++++++ .../src/main/lib/terminal/daemon-manager.ts | 14 +++++++----- apps/desktop/src/main/terminal-host/index.ts | 16 ++++++++++++++ .../main/terminal-host/pty-subprocess-ipc.ts | 1 + .../src/main/terminal-host/pty-subprocess.ts | 22 +++++++++++++++++++ .../desktop/src/main/terminal-host/session.ts | 19 ++++++++++++++++ .../src/main/terminal-host/terminal-host.ts | 17 ++++++++++++++ 8 files changed, 101 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 62aa71a52c0..480ad969b94 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -41,6 +41,7 @@ import { PROTOCOL_VERSION, type ResizeRequest, type ShutdownRequest, + type SignalRequest, type TerminalDataEvent, type TerminalErrorEvent, type TerminalExitEvent, @@ -1266,6 +1267,14 @@ export class TerminalHostClient extends EventEmitter { return this.sendRequest("detach", request); } + /** + * Send a signal to a terminal session (e.g., SIGINT for Ctrl+C) + */ + async signal(request: SignalRequest): Promise { + await this.ensureConnected(); + return this.sendRequest("signal", request); + } + /** * Kill a terminal session */ diff --git a/apps/desktop/src/main/lib/terminal-host/types.ts b/apps/desktop/src/main/lib/terminal-host/types.ts index 32d9b8d2523..aedf7abb39c 100644 --- a/apps/desktop/src/main/lib/terminal-host/types.ts +++ b/apps/desktop/src/main/lib/terminal-host/types.ts @@ -202,6 +202,14 @@ export interface DetachRequest { sessionId: string; } +/** + * Send a signal to a terminal session (e.g., SIGINT for Ctrl+C) + */ +export interface SignalRequest { + sessionId: string; + signal: string; +} + /** * Kill a terminal session */ @@ -348,6 +356,7 @@ export type RequestTypeMap = { write: { request: WriteRequest; response: EmptyResponse }; resize: { request: ResizeRequest; response: EmptyResponse }; detach: { request: DetachRequest; response: EmptyResponse }; + signal: { request: SignalRequest; response: EmptyResponse }; kill: { request: KillRequest; response: EmptyResponse }; killAll: { request: KillAllRequest; response: EmptyResponse }; listSessions: { request: undefined; response: ListSessionsResponse }; diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 2bf87a28f3f..56e15e2f321 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -785,7 +785,7 @@ export class DaemonTerminalManager extends EventEmitter { } signal(params: { paneId: string; signal?: string }): void { - const { paneId, signal = "SIGTERM" } = params; + const { paneId, signal = "SIGINT" } = params; const session = this.sessions.get(paneId); if (!session || !session.isAlive) { @@ -795,11 +795,13 @@ export class DaemonTerminalManager extends EventEmitter { return; } - // Daemon doesn't have a signal method, use kill - // For now, just log - we may need to add signal support to daemon - console.warn( - `[DaemonTerminalManager] Signal ${signal} not yet supported for daemon sessions`, - ); + // Send signal to daemon (fire and forget) + this.client.signal({ sessionId: paneId, signal }).catch((error) => { + console.warn( + `[DaemonTerminalManager] Failed to send signal ${signal} to ${paneId}:`, + error, + ); + }); } async kill(params: { diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index 490159699a8..20d4a78a7b0 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -39,6 +39,7 @@ import { PROTOCOL_VERSION, type ResizeRequest, type ShutdownRequest, + type SignalRequest, type TerminalErrorEvent, type TerminalExitEvent, type WriteRequest, @@ -448,6 +449,21 @@ const handlers: Record = { log("info", `Session ${request.sessionId} killed`); }, + signal: (socket, id, payload, clientState) => { + if (!clientState.authenticated) { + sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); + return; + } + if (clientState.role !== "control") { + sendError(socket, id, "INVALID_ROLE", "signal requires control"); + return; + } + + const request = payload as SignalRequest; + const response = terminalHost.signal(request); + sendSuccess(socket, id, response); + }, + killAll: (socket, id, payload, clientState) => { if (!clientState.authenticated) { sendError(socket, id, "NOT_AUTHENTICATED", "Must authenticate first"); diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts b/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts index c4eb0780d1e..d4d0680ee7a 100644 --- a/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts +++ b/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts @@ -5,6 +5,7 @@ export enum PtySubprocessIpcType { Resize = 3, Kill = 4, Dispose = 5, + Signal = 6, // Send signal without marking as terminating (e.g., SIGINT) // Subprocess -> daemon events Ready = 101, diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess.ts b/apps/desktop/src/main/terminal-host/pty-subprocess.ts index 8b508262e04..e01c44a8d2b 100644 --- a/apps/desktop/src/main/terminal-host/pty-subprocess.ts +++ b/apps/desktop/src/main/terminal-host/pty-subprocess.ts @@ -409,6 +409,25 @@ function handleKill(payload: Buffer): void { escalationTimer.unref(); } +/** + * Send a signal to the PTY process without escalation. + * Unlike handleKill, this does not escalate to SIGKILL or force exit. + * Used for signals like SIGINT (Ctrl+C) where the process should continue running. + */ +function handleSignal(payload: Buffer): void { + const signal = payload.length > 0 ? payload.toString("utf8") : "SIGINT"; + + if (!ptyProcess) { + return; + } + + try { + ptyProcess.kill(signal); + } catch { + // Process may already be dead + } +} + function handleDispose(): void { flushOutput(); @@ -455,6 +474,9 @@ process.stdin.on("data", (chunk: Buffer) => { case PtySubprocessIpcType.Kill: handleKill(frame.payload); break; + case PtySubprocessIpcType.Signal: + handleSignal(frame.payload); + break; case PtySubprocessIpcType.Dispose: handleDispose(); break; diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index f62244373e1..10972a41726 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -500,6 +500,11 @@ export class Session { return this.sendFrameToSubprocess(PtySubprocessIpcType.Kill, payload); } + private sendSignalToSubprocess(signal: string): boolean { + const payload = Buffer.from(signal, "utf8"); + return this.sendFrameToSubprocess(PtySubprocessIpcType.Signal, payload); + } + private sendDisposeToSubprocess(): boolean { return this.sendFrameToSubprocess(PtySubprocessIpcType.Dispose); } @@ -788,6 +793,20 @@ export class Session { }; } + /** + * Send a signal to the PTY process without marking the session as terminating. + * Used for signals like SIGINT (Ctrl+C) where the process should continue running. + */ + sendSignal(signal: string): void { + if (this.terminatingAt !== null || this.disposed) { + return; + } + + if (this.subprocess && this.subprocessReady) { + this.sendSignalToSubprocess(signal); + } + } + /** * Kill the PTY process. * Marks the session as terminating immediately (idempotent). diff --git a/apps/desktop/src/main/terminal-host/terminal-host.ts b/apps/desktop/src/main/terminal-host/terminal-host.ts index ff8f42eb928..ae0f57a09c4 100644 --- a/apps/desktop/src/main/terminal-host/terminal-host.ts +++ b/apps/desktop/src/main/terminal-host/terminal-host.ts @@ -19,6 +19,7 @@ import type { KillRequest, ListSessionsResponse, ResizeRequest, + SignalRequest, WriteRequest, } from "../lib/terminal-host/types"; import { createSession, type Session } from "./session"; @@ -227,6 +228,22 @@ export class TerminalHost { return { success: true }; } + /** + * Send a signal to a terminal session (e.g., SIGINT for Ctrl+C). + * Unlike kill, this does NOT mark the session as terminating. + */ + signal(request: SignalRequest): EmptyResponse { + const { sessionId, signal } = request; + const session = this.sessions.get(sessionId); + + if (!session || !session.isAttachable) { + return { success: true }; + } + + session.sendSignal(signal); + return { success: true }; + } + /** * Kill a terminal session. * The session is marked as terminating immediately (non-attachable). From cb993c7c6218998ad2072af5507956605f287b5a Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 11:46:49 +0200 Subject: [PATCH 28/62] docs(desktop): add terminal host event semantics documentation Documents the event delivery model for the daemon protocol: - Event types (data, exit, error) - Dual-socket model (control vs stream) - At-most-once delivery semantics (no durability/retries) - In-order guarantees within sessions - Multi-level backpressure handling - Error codes and race condition handling --- apps/desktop/docs/TERMINAL_HOST_EVENTS.md | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/desktop/docs/TERMINAL_HOST_EVENTS.md diff --git a/apps/desktop/docs/TERMINAL_HOST_EVENTS.md b/apps/desktop/docs/TERMINAL_HOST_EVENTS.md new file mode 100644 index 00000000000..6285f19bc40 --- /dev/null +++ b/apps/desktop/docs/TERMINAL_HOST_EVENTS.md @@ -0,0 +1,128 @@ +# Terminal Host Event Semantics + +This document describes the event delivery model for the Terminal Host daemon protocol. + +## Event Types + +The daemon emits three event types to attached clients: + +| Event | Payload | Description | +|---------|-------------------------------------|------------------------------------------| +| `data` | `{ type: "data", data: string }` | PTY output (terminal content) | +| `exit` | `{ type: "exit", exitCode, signal?}` | PTY process terminated | +| `error` | `{ type: "error", error, code? }` | Error condition (e.g., write queue full) | + +## Socket Model + +Clients connect with two sockets sharing a `clientId`: + +- **Control socket** (`role: "control"`): RPC request/response (write, resize, kill, etc.) +- **Stream socket** (`role: "stream"`): Receives unsolicited events + +Events are broadcast only to stream sockets. This separation prevents event floods from blocking RPC responses. + +## Delivery Semantics + +### At-Most-Once Delivery + +Events are delivered **at-most-once** per attached client: +- No acknowledgment or retry mechanism +- If a client socket buffer is full, data is queued but may be lost on disconnect +- Clients must be prepared to miss events (especially `data` during reconnection) + +### No Durability + +Events are not persisted. If no clients are attached, events are emitted but not stored. +For cold restore, use `createOrAttach` which returns a `TerminalSnapshot` containing the current screen state. + +## Ordering Guarantees + +### Within a Session + +Events for a single session are delivered **in-order** relative to each other: +1. PTY output order is preserved (data events arrive in the order produced) +2. Exit event is always delivered after all data events for that session +3. Error events may interleave with data events + +### Across Sessions + +No ordering guarantees across different sessions. Events from session A and session B may interleave arbitrarily. + +## Backpressure Handling + +The system implements multi-level backpressure to prevent memory exhaustion: + +### Level 1: Client Socket Backpressure +``` +Client socket buffer full + → Session pauses subprocess stdout reads + → Subprocess backpressures PTY reads + → PTY write buffer fills → kernel blocks PTY writes +``` + +When the client drains its buffer, the chain resumes. + +### Level 2: Subprocess stdin Backpressure +``` +Write requests exceed MAX_SUBPROCESS_STDIN_QUEUE_BYTES (2MB) + → Frame dropped + → Error event emitted: { code: "WRITE_QUEUE_FULL" } +``` + +### Level 3: PTY Write Backpressure (in subprocess) +``` +PTY kernel buffer full (EAGAIN/EWOULDBLOCK) + → Exponential backoff retry (2ms → 50ms) + → Write queue accumulates up to 64MB hard limit + → Beyond limit: frames dropped, error reported +``` + +## Error Codes + +| Code | Meaning | +|---------------------|----------------------------------------------| +| `WRITE_QUEUE_FULL` | Input queue exceeded limit, data dropped | +| `SUBPROCESS_ERROR` | PTY subprocess reported an error | +| `WRITE_FAILED` | Failed to write to PTY | +| `UNKNOWN` | Unclassified error | + +## Race Conditions + +### Kill vs Attach Race + +Sessions track `terminatingAt` timestamp when `kill()` is called. The `isAttachable` property returns false for terminating sessions, preventing new attachments to sessions about to exit. + +### Data vs Exit Race + +The subprocess flushes all buffered output before sending the exit frame, so clients receive all terminal output before the exit event. + +## Usage Example + +```typescript +// Stream socket receives events as NDJSON +socket.on("data", (chunk) => { + for (const line of chunk.toString().split("\n").filter(Boolean)) { + const event = JSON.parse(line) as IpcEvent; + if (event.type !== "event") continue; + + switch (event.event) { + case "data": + terminal.write(event.payload.data); + break; + case "exit": + console.log(`Session ${event.sessionId} exited: ${event.payload.exitCode}`); + break; + case "error": + console.error(`Error in ${event.sessionId}: ${event.payload.error}`); + break; + } + } +}); +``` + +## Related Files + +- `types.ts` - Event type definitions +- `session.ts` - Event emission and backpressure logic +- `pty-subprocess.ts` - PTY-level backpressure handling +- `client.ts` - Client-side event handling From c54ed741d5eb56f0db3566eb701608dd1c535d8d Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 11:48:36 +0200 Subject: [PATCH 29/62] feat(desktop): implement cold restore terminal history persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stub terminal-history.ts with a working implementation for Phase 4 of terminal persistence. Enables terminal recovery after app/system restarts when the daemon is not running. Storage format: - scrollback.bin: Raw PTY output (append-only) - meta.json: Session metadata (cols, rows, cwd, timestamps) Cold restore detection: - meta.json without endedAt → unclean shutdown → can restore - meta.json with endedAt → clean shutdown → no restore HistoryWriter API: - init(initialScrollback?) - create directory and files - write(data) - append PTY output - flush() - flush pending writes - close(exitCode?) - write endedAt to meta.json - reinitialize() - reset for clear scrollback - deleteHistory() - remove all files HistoryReader API: - exists() - check if history available - readMetadata() - get cols/rows/cwd/endedAt - readScrollback() - get terminal content - cleanup() - delete history files --- apps/desktop/src/main/lib/terminal-history.ts | 326 ++++++++++++++++-- 1 file changed, 289 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index afcda8420ac..5128a45946e 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -1,94 +1,346 @@ /** - * Stub terminal history module. + * Terminal History Persistence (Phase 4) * - * Disk-based terminal history was removed from main (PR #684). - * This stub provides no-op implementations to maintain API compatibility - * with daemon-manager.ts. Phase 4 of the terminal persistence plan will - * implement proper cold restore with hybrid storage (raw PTY log + checkpoints). + * Provides cold restore capability by persisting terminal scrollback to disk. + * This enables terminal recovery after app/system restarts when the daemon + * is not running (unlike warm attach which reconnects to live daemon sessions). * - * TODO: Replace with proper hybrid storage implementation in Phase 4. + * Storage format: + * - scrollback.bin: Raw PTY output (append-only during session) + * - meta.json: Session metadata (cols, rows, cwd, timestamps) + * + * Cold restore detection: + * - meta.json exists but has no endedAt → unclean shutdown → can restore + * - meta.json has endedAt → clean shutdown → no restore needed */ +import { createWriteStream, promises as fs, type WriteStream } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { SUPERSET_DIR_NAME } from "shared/constants"; +// ============================================================================= +// Types +// ============================================================================= + +export interface SessionMetadata { + cwd: string; + cols: number; + rows: number; + startedAt: string; + endedAt?: string; + exitCode?: number; +} + +// ============================================================================= +// Path Helpers +// ============================================================================= + const TERMINAL_HISTORY_DIR_NAME = "terminal-history"; export function getTerminalHistoryRootDir(): string { return join(homedir(), SUPERSET_DIR_NAME, TERMINAL_HISTORY_DIR_NAME); } +function getHistoryDir(workspaceId: string, paneId: string): string { + return join(getTerminalHistoryRootDir(), workspaceId, paneId); +} + +function getScrollbackPath(workspaceId: string, paneId: string): string { + return join(getHistoryDir(workspaceId, paneId), "scrollback.bin"); +} + +function getMetadataPath(workspaceId: string, paneId: string): string { + return join(getHistoryDir(workspaceId, paneId), "meta.json"); +} + +// ============================================================================= +// HistoryWriter +// ============================================================================= + /** - * Stub HistoryWriter - no-op implementation. - * Cold restore will be implemented properly in Phase 4. + * Writes terminal output to disk for cold restore. + * + * Usage: + * 1. Create writer with session params + * 2. Call init() with optional initial scrollback (from daemon snapshot) + * 3. Call write() for each data event from PTY + * 4. Call close() when session ends (writes endedAt to meta.json) */ export class HistoryWriter { + private stream: WriteStream | null = null; + private dir: string; + private scrollbackPath: string; + private metaPath: string; + private metadata: SessionMetadata; + private streamErrored = false; + private closed = false; + constructor( - _workspaceId: string, - _paneId: string, - _cwd: string, - _cols: number, - _rows: number, + private workspaceId: string, + private paneId: string, + cwd: string, + cols: number, + rows: number, ) { - // No-op + this.dir = getHistoryDir(workspaceId, paneId); + this.scrollbackPath = getScrollbackPath(workspaceId, paneId); + this.metaPath = getMetadataPath(workspaceId, paneId); + this.metadata = { + cwd, + cols, + rows, + startedAt: new Date().toISOString(), + }; } - async init(_initialScrollback?: string): Promise { - // No-op - cold restore implementation pending Phase 4 + /** + * Initialize the history file. + * Creates the directory, writes initial scrollback, and opens append stream. + */ + async init(initialScrollback?: string): Promise { + await fs.mkdir(this.dir, { recursive: true }); + + // Write initial scrollback or create empty file + // node-pty produces UTF-8 strings, so we store as UTF-8 + if (initialScrollback) { + await fs.writeFile( + this.scrollbackPath, + Buffer.from(initialScrollback, "utf8"), + ); + } else { + await fs.writeFile(this.scrollbackPath, Buffer.alloc(0)); + } + + // Open stream in append mode for subsequent writes + this.stream = createWriteStream(this.scrollbackPath, { flags: "a" }); + this.stream.on("error", (error) => { + console.error( + `[HistoryWriter] Stream error for ${this.paneId}:`, + error.message, + ); + this.streamErrored = true; + this.stream = null; + }); + + // Write meta.json immediately (without endedAt) + // This enables cold restore detection - if app crashes, + // meta.json exists but has no endedAt, indicating unclean shutdown + await this.writeMetadata(); } - async write(_data: string): Promise { - // No-op - cold restore implementation pending Phase 4 + /** + * Write terminal data to the scrollback file. + * Non-blocking - errors are swallowed to avoid disrupting terminal operation. + */ + write(data: string): void { + if (this.closed || this.streamErrored || !this.stream) { + return; + } + + try { + // node-pty produces UTF-8 strings + this.stream.write(Buffer.from(data, "utf8")); + } catch { + this.streamErrored = true; + } } + /** + * Flush pending writes to disk. + * Returns a promise that resolves when data is flushed. + */ async flush(): Promise { - // No-op + if (this.closed || this.streamErrored || !this.stream) { + return; + } + + return new Promise((resolve) => { + // Cork and uncork forces a flush + this.stream?.once("drain", resolve); + // If nothing to drain, resolve immediately + if (this.stream?.writableLength === 0) { + resolve(); + } + }); } - async close(_exitCode?: number): Promise { - // No-op + /** + * Close the history file and write endedAt to metadata. + */ + async close(exitCode?: number): Promise { + if (this.closed) { + return; + } + this.closed = true; + + // Close the stream + if (this.stream && !this.streamErrored) { + await new Promise((resolve) => { + this.stream?.end(() => resolve()); + }).catch(() => { + // Ignore stream close errors + }); + } + this.stream = null; + + // Update metadata with end time + this.metadata.endedAt = new Date().toISOString(); + if (exitCode !== undefined) { + this.metadata.exitCode = exitCode; + } + + await this.writeMetadata(); } + /** + * Reinitialize the history file (e.g., after clear scrollback). + * Closes the current stream and creates a fresh empty file. + */ async reinitialize(): Promise { - // No-op + // Close existing stream without writing endedAt + if (this.stream && !this.streamErrored) { + await new Promise((resolve) => { + this.stream?.end(() => resolve()); + }).catch(() => { + // Ignore + }); + } + this.stream = null; + this.streamErrored = false; + this.closed = false; + + // Reset metadata with new start time + this.metadata.startedAt = new Date().toISOString(); + delete this.metadata.endedAt; + delete this.metadata.exitCode; + + // Reinitialize with empty scrollback + await this.init(); } + /** + * Delete all history files for this session. + */ async deleteHistory(): Promise { - // No-op + // Close stream first + if (this.stream && !this.streamErrored) { + await new Promise((resolve) => { + this.stream?.end(() => resolve()); + }).catch(() => { + // Ignore + }); + } + this.stream = null; + this.closed = true; + + // Delete the directory + await fs.rm(this.dir, { recursive: true, force: true }).catch((error) => { + console.warn( + `[HistoryWriter] Failed to delete history for ${this.paneId}:`, + error.message, + ); + }); + } + + private async writeMetadata(): Promise { + try { + await fs.writeFile(this.metaPath, JSON.stringify(this.metadata, null, 2)); + } catch (error) { + console.warn( + `[HistoryWriter] Failed to write metadata for ${this.paneId}:`, + error instanceof Error ? error.message : String(error), + ); + } } } +// ============================================================================= +// HistoryReader +// ============================================================================= + /** - * Stub HistoryReader - no-op implementation. - * Cold restore will be implemented properly in Phase 4. + * Reads terminal history for cold restore. + * + * Usage: + * 1. Create reader with workspace/pane IDs + * 2. Check exists() to see if history is available + * 3. Read metadata to check for unclean shutdown (no endedAt) + * 4. Read scrollback to restore terminal content */ export class HistoryReader { - constructor(_workspaceId: string, _paneId: string) { - // No-op + private dir: string; + private scrollbackPath: string; + private metaPath: string; + + constructor( + private workspaceId: string, + private paneId: string, + ) { + this.dir = getHistoryDir(workspaceId, paneId); + this.scrollbackPath = getScrollbackPath(workspaceId, paneId); + this.metaPath = getMetadataPath(workspaceId, paneId); + } + + /** + * Check if history exists for this session. + */ + async exists(): Promise { + try { + await fs.access(this.metaPath); + return true; + } catch { + return false; + } } + /** + * Read session metadata. + * Returns null if metadata doesn't exist or is invalid. + */ async readMetadata(): Promise<{ cols: number; rows: number; cwd: string; endedAt?: string; } | null> { - // No-op - return null to indicate no history available - return null; - } + try { + const content = await fs.readFile(this.metaPath, "utf8"); + const metadata = JSON.parse(content) as SessionMetadata; - async readScrollback(): Promise { - // No-op - return null to indicate no scrollback available - return null; + return { + cols: metadata.cols, + rows: metadata.rows, + cwd: metadata.cwd, + endedAt: metadata.endedAt, + }; + } catch { + return null; + } } - async exists(): Promise { - // No-op - return false to indicate no history exists - return false; + /** + * Read scrollback content. + * Returns null if scrollback doesn't exist. + */ + async readScrollback(): Promise { + try { + // Read as UTF-8 to match how node-pty produces terminal output + return await fs.readFile(this.scrollbackPath, "utf8"); + } catch { + return null; + } } + /** + * Delete history files for this session. + */ cleanup(): void { - // No-op + fs.rm(this.dir, { recursive: true, force: true }).catch((error) => { + console.warn( + `[HistoryReader] Failed to cleanup history for ${this.paneId}:`, + error instanceof Error ? error.message : String(error), + ); + }); } } From 87bfe809d97beb4eba8bc3a8ebd9d37b90125fff Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 11:49:50 +0200 Subject: [PATCH 30/62] feat(desktop): add telemetry for terminal persistence events Adds tracking for key terminal persistence lifecycle events: - terminal_cold_restored: Triggered when recovering terminal after reboot with scrollback_bytes to measure restoration payload size - terminal_warm_attached: Triggered when reconnecting to existing daemon session with snapshot_bytes for payload metrics - terminal_daemon_disconnected: Triggered on daemon connection loss with active_session_count for impact assessment --- .../src/main/lib/terminal/daemon-manager.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 56e15e2f321..75e50904a97 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -246,6 +246,12 @@ export class DaemonTerminalManager extends EventEmitter { // Handle client disconnection - notify all active sessions this.client.on("disconnected", () => { console.warn("[DaemonTerminalManager] Disconnected from daemon"); + const activeSessionCount = Array.from(this.sessions.values()).filter( + (s) => s.isAlive, + ).length; + track("terminal_daemon_disconnected", { + active_session_count: activeSessionCount, + }); this.daemonAliveSessionIds.clear(); this.daemonSessionIdsHydrated = false; // Emit disconnect event for all active sessions so terminals can show error UI @@ -556,6 +562,13 @@ export class DaemonTerminalManager extends EventEmitter { rows: metadata.rows || rows, }); + // Track cold restore event + track("terminal_cold_restored", { + workspace_id: workspaceId, + pane_id: paneId, + scrollback_bytes: scrollback.length, + }); + return { isNew: false, scrollback, @@ -672,12 +685,19 @@ export class DaemonTerminalManager extends EventEmitter { ); } - // Track terminal opened (only on actual daemon session creation). + // Track terminal events if (response.isNew) { track("terminal_opened", { workspace_id: workspaceId, pane_id: paneId, }); + } else if (response.wasRecovered) { + // Warm attach - reconnected to existing daemon session + track("terminal_warm_attached", { + workspace_id: workspaceId, + pane_id: paneId, + snapshot_bytes: response.snapshot.snapshotAnsi?.length ?? 0, + }); } return { From 49516fb3cc39f34d3440db94714b34b49590bcd9 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 15:23:08 +0200 Subject: [PATCH 31/62] fix(desktop): trigger cold restore on daemon session loss When daemon restarts and loses sessions, "Session not found" errors were treated as non-fatal (just showed toast). This prevented the retry UI from appearing and cold restore from triggering. Changes: - Promote "Session not found" WRITE_FAILED errors to connection error so retry UI appears instead of endless toast spam - Handle isColdRestore in handleRetryConnection so clicking retry can trigger cold restore if disk history is available - Update both event handler locations for consistency Now when daemon dies and user clicks retry, cold restore kicks in if history exists on disk (meta.json without endedAt). --- .../TabsContent/Terminal/Terminal.tsx | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index fb2d9b89403..b27b2a62476 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -391,7 +391,14 @@ export const Terminal = ({ // Don't block interaction for non-fatal issues like a paste drop or a // transient write failure (we keep the session alive). + // EXCEPTION: "Session not found" means daemon restarted and lost our session - + // promote to connection error so retry UI appears and cold restore can kick in. if ( + event.code === "WRITE_FAILED" && + event.error?.includes("Session not found") + ) { + setConnectionError("Session lost - click to reconnect"); + } else if ( event.code === "WRITE_QUEUE_FULL" || event.code === "WRITE_FAILED" ) { @@ -658,6 +665,30 @@ export const Terminal = ({ { onSuccess: (result) => { setConnectionError(null); + + // Handle cold restore on retry (daemon lost session, disk history available) + if (result.isColdRestore) { + const scrollback = + result.snapshot?.snapshotAnsi ?? result.scrollback; + coldRestoreState.set(paneId, { + isRestored: true, + cwd: result.previousCwd || null, + scrollback, + }); + setIsRestoredMode(true); + setRestoredCwd(result.previousCwd || null); + + // Clear retry message and write scrollback + xterm.clear(); + if (scrollback) { + xterm.write(scrollback); + } + + // Don't enable streaming - user must click Start Shell + didFirstRenderRef.current = true; + return; + } + pendingInitialStateRef.current = result; maybeApplyInitialState(); }, @@ -674,6 +705,8 @@ export const Terminal = ({ maybeApplyInitialState, flushPendingEvents, setConnectionError, + setIsRestoredMode, + setRestoredCwd, ]); // biome-ignore lint/correctness/useExhaustiveDependencies: refs (createOrAttachRef, resizeRef) used intentionally to read latest values without recreating callback @@ -794,7 +827,17 @@ export const Terminal = ({ description: message, }); - if (event.code === "WRITE_QUEUE_FULL" || event.code === "WRITE_FAILED") { + // "Session not found" means daemon restarted and lost our session - + // promote to connection error so retry UI appears and cold restore can kick in. + if ( + event.code === "WRITE_FAILED" && + event.error?.includes("Session not found") + ) { + setConnectionError("Session lost - click to reconnect"); + } else if ( + event.code === "WRITE_QUEUE_FULL" || + event.code === "WRITE_FAILED" + ) { xtermRef.current.writeln(`\r\n[Terminal] ${message}`); } else { setConnectionError(message); From ff354e2126630e263ef18456f6300903d0fde01c Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 15:33:07 +0200 Subject: [PATCH 32/62] fix(desktop): suppress toast when showing retry UI for session loss When daemon restarts and loses terminal sessions, show only the retry UI without also showing a toast notification. This prevents confusing UX where both a toast and the retry overlay appear simultaneously. --- .../TabsContent/Terminal/Terminal.tsx | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index b27b2a62476..942633da3db 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -385,20 +385,25 @@ export const Terminal = ({ : event.error; console.warn("[Terminal] stream error:", message); + // "Session not found" means daemon restarted and lost our session - + // promote to connection error so retry UI appears and cold restore can kick in. + // Don't show toast for this case since we're showing the retry UI. + if ( + event.code === "WRITE_FAILED" && + event.error?.includes("Session not found") + ) { + setConnectionError("Session lost - click to reconnect"); + return; + } + + // Show toast for other errors toast.error("Terminal error", { description: message, }); // Don't block interaction for non-fatal issues like a paste drop or a // transient write failure (we keep the session alive). - // EXCEPTION: "Session not found" means daemon restarted and lost our session - - // promote to connection error so retry UI appears and cold restore can kick in. if ( - event.code === "WRITE_FAILED" && - event.error?.includes("Session not found") - ) { - setConnectionError("Session lost - click to reconnect"); - } else if ( event.code === "WRITE_QUEUE_FULL" || event.code === "WRITE_FAILED" ) { @@ -823,18 +828,25 @@ export const Terminal = ({ : event.error; console.warn("[Terminal] stream error:", message); - toast.error("Terminal error", { - description: message, - }); - // "Session not found" means daemon restarted and lost our session - // promote to connection error so retry UI appears and cold restore can kick in. + // Don't show toast for this case since we're showing the retry UI. if ( event.code === "WRITE_FAILED" && event.error?.includes("Session not found") ) { setConnectionError("Session lost - click to reconnect"); - } else if ( + return; + } + + // Show toast for other errors + toast.error("Terminal error", { + description: message, + }); + + // Don't block interaction for non-fatal issues like a paste drop or a + // transient write failure (we keep the session alive). + if ( event.code === "WRITE_QUEUE_FULL" || event.code === "WRITE_FAILED" ) { From edcbec1a39bfe98fcb0a872a0b79970d30c986e7 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 15:39:01 +0200 Subject: [PATCH 33/62] fix(desktop): suppress toast for transient PTY not spawned errors During daemon recovery, writes may arrive before the PTY subprocess is fully initialized. Treat "PTY not spawned" as a transient error that doesn't need a toast notification - just log to terminal. --- .../TabsContent/Terminal/Terminal.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 942633da3db..c7349b00b7b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -396,6 +396,17 @@ export const Terminal = ({ return; } + // "PTY not spawned" is a transient race condition during recovery - + // the write was attempted before PTY finished initializing. Skip toast, + // just log to terminal. The session will recover on its own. + if ( + event.code === "WRITE_FAILED" && + event.error?.includes("PTY not spawned") + ) { + xterm.writeln(`\r\n[Terminal] ${message}`); + return; + } + // Show toast for other errors toast.error("Terminal error", { description: message, @@ -839,6 +850,17 @@ export const Terminal = ({ return; } + // "PTY not spawned" is a transient race condition during recovery - + // the write was attempted before PTY finished initializing. Skip toast, + // just log to terminal. The session will recover on its own. + if ( + event.code === "WRITE_FAILED" && + event.error?.includes("PTY not spawned") + ) { + xtermRef.current.writeln(`\r\n[Terminal] ${message}`); + return; + } + // Show toast for other errors toast.error("Terminal error", { description: message, From 08ad42080e194378701a98d972cbe242619e4ea0 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 15:39:30 +0200 Subject: [PATCH 34/62] fix(desktop): clear connection error on successful initial attach When daemon restarts and component remounts, the background createOrAttach may succeed while the error overlay is still visible. Clear connectionError on success to dismiss the overlay automatically. --- .../ContentView/TabsContent/Terminal/Terminal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index c7349b00b7b..9c15a3bb5b2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -1112,6 +1112,9 @@ export const Terminal = ({ }, { onSuccess: (result) => { + // Clear any connection error from previous daemon loss + setConnectionError(null); + if (DEBUG_TERMINAL) { console.log( `[Terminal] createOrAttach success: ${paneId} (${Date.now() - createOrAttachStartTime}ms)`, From 4f808974cf1aedc8a8786e6fe7256d0090ab625b Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 15:48:54 +0200 Subject: [PATCH 35/62] fix(desktop): trigger cold restore on daemon session loss When "Session not found" error occurs, clear the stale cache entry so the next createOrAttach properly checks disk history and triggers cold restore instead of creating a new session. --- .../src/main/lib/terminal/daemon-manager.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 75e50904a97..fc621c35dbc 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -285,6 +285,21 @@ export class DaemonTerminalManager extends EventEmitter { console.error( `[DaemonTerminalManager] Terminal error for ${paneId}: ${code ?? "UNKNOWN"}: ${error}`, ); + + // "Session not found" means daemon restarted and lost this session. + // Clear the stale cache entry so next createOrAttach triggers cold restore. + if (error.includes("Session not found")) { + this.daemonAliveSessionIds.delete(paneId); + // Also mark session as not alive in our local tracking + const session = this.sessions.get(paneId); + if (session) { + session.isAlive = false; + } + console.log( + `[DaemonTerminalManager] Session ${paneId} lost - will trigger cold restore on next attach`, + ); + } + this.emit(`error:${paneId}`, { error, code }); }, ); From 6a5ebc8b923a84952c4175a6a649f79be043dd8a Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 15:55:00 +0200 Subject: [PATCH 36/62] fix(desktop): re-focus terminal after successful retry connection After clicking "Retry Connection" and the connection succeeds, re-focus the terminal so keyboard input works immediately. Skip focus for cold restore since user needs to click overlay button. --- .../ContentView/TabsContent/Terminal/Terminal.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 9c15a3bb5b2..288981f5675 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -702,11 +702,17 @@ export const Terminal = ({ // Don't enable streaming - user must click Start Shell didFirstRenderRef.current = true; + // Don't focus - user needs to interact with overlay button return; } pendingInitialStateRef.current = result; maybeApplyInitialState(); + + // Re-focus terminal after successful reconnection (non-cold-restore) + if (isFocusedRef.current) { + xterm.focus(); + } }, onError: (error) => { setConnectionError(error.message || "Connection failed"); From 1ec22791f93b56071270028b59799dceba5ce986 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 16:06:22 +0200 Subject: [PATCH 37/62] fix(desktop): cold restore for TUI apps with empty scrollback Two fixes: 1. Check rawScrollback === null instead of !rawScrollback. TUI apps in alternate screen may have empty normal buffer, which is still valid for cold restore (empty string is truthy check fix). 2. Use fresh xterm ref in handleRetryConnection onSuccess callback to handle potential component remount during async operation. --- apps/desktop/src/main/lib/terminal/daemon-manager.ts | 5 +++-- .../ContentView/TabsContent/Terminal/Terminal.tsx | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index fc621c35dbc..8bbe0934e87 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -559,8 +559,9 @@ export class DaemonTerminalManager extends EventEmitter { if (wasUncleanShutdown) { const rawScrollback = await historyReader.readScrollback(); - // Handle null scrollback (no history available) - if (!rawScrollback) { + // Handle null scrollback (no history available). + // Note: empty string is valid (TUI apps in alt screen may have empty normal buffer). + if (rawScrollback === null) { historyReader.cleanup(); // Fall through to create new session } else { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 288981f5675..68f37a7b1a8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -680,6 +680,10 @@ export const Terminal = ({ }, { onSuccess: (result) => { + // Use fresh xterm ref in case component remounted during async operation + const currentXterm = xtermRef.current; + if (!currentXterm) return; + setConnectionError(null); // Handle cold restore on retry (daemon lost session, disk history available) @@ -695,9 +699,9 @@ export const Terminal = ({ setRestoredCwd(result.previousCwd || null); // Clear retry message and write scrollback - xterm.clear(); + currentXterm.clear(); if (scrollback) { - xterm.write(scrollback); + currentXterm.write(scrollback); } // Don't enable streaming - user must click Start Shell @@ -711,7 +715,7 @@ export const Terminal = ({ // Re-focus terminal after successful reconnection (non-cold-restore) if (isFocusedRef.current) { - xterm.focus(); + currentXterm.focus(); } }, onError: (error) => { From eadbe81a31bad7e07bf34f21ed1e76a64a65df54 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 16:08:55 +0200 Subject: [PATCH 38/62] fix(desktop): focus terminal after clicking Start Shell After cold restore, clicking "Start Shell" creates a new session but wasn't focusing the terminal, causing keystrokes to go elsewhere. --- .../ContentView/TabsContent/Terminal/Terminal.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 68f37a7b1a8..8325c9771dc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -778,6 +778,12 @@ export const Terminal = ({ onSuccess: (result) => { pendingInitialStateRef.current = result; maybeApplyInitialState(); + + // Focus terminal after starting new shell + const currentXterm = xtermRef.current; + if (currentXterm && isFocusedRef.current) { + currentXterm.focus(); + } }, onError: (error) => { console.error("[Terminal] Failed to start shell:", error); From a569fe43c6ec80470e78474117514ccd5af6550a Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 19:08:43 +0200 Subject: [PATCH 39/62] fix(desktop): keep terminal stream alive on exit --- apps/desktop/docs/TERMINAL_HOST_EVENTS.md | 18 + ...-0952-terminal-persistence-rebase-merge.md | 703 ++++++++++++++++++ .../routers/terminal/terminal.stream.test.ts | 62 ++ .../src/lib/trpc/routers/terminal/terminal.ts | 2 +- .../src/main/lib/terminal/daemon-manager.ts | 13 +- .../TabsContent/Terminal/Terminal.tsx | 49 +- 6 files changed, 834 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/plans/20260109-0952-terminal-persistence-rebase-merge.md create mode 100644 apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts diff --git a/apps/desktop/docs/TERMINAL_HOST_EVENTS.md b/apps/desktop/docs/TERMINAL_HOST_EVENTS.md index 6285f19bc40..f80796691be 100644 --- a/apps/desktop/docs/TERMINAL_HOST_EVENTS.md +++ b/apps/desktop/docs/TERMINAL_HOST_EVENTS.md @@ -96,6 +96,24 @@ Sessions track `terminatingAt` timestamp when `kill()` is called. The `isAttacha The subprocess flushes all buffered output before sending the exit frame, so clients receive all terminal output before the exit event. +## Renderer Integration Notes (tRPC) + +The renderer does **not** talk to the daemon directly. It consumes terminal output via the `terminal.stream` tRPC subscription (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`), which bridges the main-process `TerminalManager`/`DaemonTerminalManager` EventEmitter. + +### `exit` must not complete the subscription + +Treat `exit` as a **state transition**, not a terminal end-of-stream: + +- The renderer subscribes with a stable `paneId` input (`trpc.terminal.stream.useSubscription(paneId)`). +- `@trpc/react-query` does **not** auto-resubscribe after a subscription completes unless the input/key changes. +- We reuse the same `paneId` across restarts / cold restore (new session, same pane). + +So the server-side observable must **not** call `emit.complete()` on `exit`, otherwise the pane becomes permanently detached from output (`listeners=0` in `DaemonTerminalManager` logs) even after a new shell is started. + +### Cold restore overlay: drop stale queued events + +During cold restore, the renderer intentionally pauses streaming (`isStreamReady=false`) while showing a read-only overlay. Stream events can be queued during this period. Before starting a new shell, the renderer should discard any queued events from the pre-restore session (especially stale `exit`) so they can't mark the new session as exited and trigger an unintended `restartTerminal()` (which clears the UI). + ## Usage Example ```typescript diff --git a/apps/desktop/plans/20260109-0952-terminal-persistence-rebase-merge.md b/apps/desktop/plans/20260109-0952-terminal-persistence-rebase-merge.md new file mode 100644 index 00000000000..8ba6aec86c7 --- /dev/null +++ b/apps/desktop/plans/20260109-0952-terminal-persistence-rebase-merge.md @@ -0,0 +1,703 @@ +# Rebase PR #619 (Daemon Terminal Persistence) onto Main + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Reference: This plan follows conventions from AGENTS.md and the ExecPlan template. + +## Purpose / Big Picture + +After this change, users gain two terminal persistence levels: +1. **Default (PR #686 on main)**: Terminal state survives tab switches and renderer reloads via headless xterm in main process +2. **Opt-in Daemon Mode (PR #619)**: Terminal sessions survive app restarts via a background daemon process + +The rebase aligns PR #619 with the architectural decisions made in PR #686 (headless xterm as source of truth), while preserving daemon-mode's unique value: app-restart persistence. + +**Observable outcome**: After enabling "Persist terminals across restarts (beta)" in Settings, a user can: +1. Open a terminal, run a long command (e.g., `sleep 1000`) +2. Quit the app completely +3. Reopen the app and see the terminal still running with the command active + +## Assumptions + +1. PR #686's headless xterm approach is architecturally correct (validated by Oracle and Kiet's journey) +2. The daemon approach should implement the same "TerminalBackend contract" as the non-daemon path +3. Renderer should not know whether it's talking to daemon or in-process backend +4. Performance regression risks require telemetry before daemon-as-default + +## Open Questions + +1. **[RESOLVED]** Should daemon-manager.ts adopt main's headless xterm patterns? + - **Decision**: Yes, for consistency. See Decision Log. + +2. **[RESOLVED]** Should we keep PtyWriteQueue in daemon mode? + - **Decision**: Yes, keep write serialization/backpressure at backend boundary. See Decision Log. + +3. **[RESOLVED]** How to handle cold restore scrollback - use headless xterm or raw scrollback? + - **Decision**: HYBRID (Option C) - append-only raw PTY log + periodic headless xterm checkpoints. See Decision Log. + +4. **[RESOLVED]** Should Terminal.tsx start from PR #619 or main? + - **Decision**: Start from main's Terminal.tsx and port daemon features incrementally. See Decision Log. + +## Progress + +### Phase 1: Contract + Conformance +- [ ] Finalize TerminalBackend interface + shared event semantics +- [ ] Document event ordering, delivery guarantees, "exit" meanings +- [x] Both backends compile with feature flag + +### Phase 2: Non-Daemon Parity (Most Critical) +- [x] Resolve session.ts, manager.ts, types.ts conflicts (keep main's headless xterm) +- [x] Resolve terminal.ts router conflicts +- [x] Start from main's Terminal.tsx (merged SCROLL_TO_BOTTOM hotkey + daemon lint comment) +- [x] Run typecheck and lint ✅ Passes +- [ ] **Verify**: Non-daemon mode works identically to current main (tab switching, resize, paste, clear scrollback, exit) + +### Phase 3: Daemon Attach (Warm) +- [x] Resolve daemon-manager.ts conflicts to match new TerminalBackend contract +- [x] Port daemon features to Terminal.tsx incrementally +- [x] Add getActiveTerminalManager() pattern +- [ ] **Verify**: Daemon mode works for fresh sessions + warm attaches + +### Phase 4: Cold Restore +- [x] Implement cold restore with proper semantics +- [ ] Add history retention/caps (TTL/cleanup policies) +- [x] Add cold restore acknowledgment flow +- [x] **Verify**: Cold restore shows read-only view, "Start Shell" works + +### Phase 5: Telemetry + Stability Gate +- [ ] Add terminal_attach event with all timings +- [ ] Add terminal_io_stats (sampled) +- [ ] Add terminal_error event +- [ ] Add write queue metrics to PtyWriteQueue +- [x] Resolve remaining conflicts (package.json, bun.lock, stores) +- [ ] Full manual QA both modes + +### Exit Criteria for Daemon-as-Default Discussion +- [ ] 1-2 weeks of real telemetry (or N sessions defined) +- [ ] attach p95 comparable or better +- [ ] error rate acceptable +- [ ] no crash/orphan rate increase +- [ ] disk growth under control + +## Surprises & Discoveries + +### 2026-01-09: Rebase Completion + +**Rebase Stats**: 25 commits rebased onto origin/main with ~17 conflicting files resolved. + +**Key Conflict Resolutions**: +1. `types.ts`, `session.ts`, `manager.ts` - Kept main's headless xterm approach, added PtyWriteQueue +2. `port-manager.ts` - Kept daemon session methods, added missing `checkOutputForHint`, fixed async/await +3. `Terminal.tsx` - Merged main's SCROLL_TO_BOTTOM hotkey with daemon's lint comment +4. `workspaces.ts` - Accepted main's modular router refactoring (mergeRouters approach) +5. `terminal-history.ts` - Deleted (main removed disk-based history), then re-created (Phase 4) for cold restore + +**Update (2026-01-09)**: Cold restore is implemented with disk-backed history via `HistoryWriter`/`HistoryReader` and a renderer acknowledgment flow (`ackColdRestore`). + +### 2026-01-09: Cold Restore “listeners=0” Regression Fix + +**Symptom**: After daemon restart/session loss, clicking **Start Shell** created a new session but terminal output never rendered; input went to the tab name. Daemon logs showed data flowing but `listeners=0` on the main-process EventEmitter. + +**Root cause**: `terminal.write` can emit `exit:${paneId}` when the session is missing; the server-side `terminal.stream` observable used to call `emit.complete()` on `exit`. Since the renderer subscription key is stable (`paneId`), `@trpc/react-query` does not auto-resubscribe after completion, leaving the pane permanently detached from output. + +**Fix**: +- Do **not** complete the `terminal.stream` observable on `exit` (treat exit as a state transition, not end-of-stream) +- In cold restore UI, drop any queued pre-restore events before starting the new shell and ignore terminal input while overlays are visible (prevents stale `exit` from triggering an unintended restart that clears the terminal) + +**Oracle Review Flags** (2026-01-09): +- Daemon `signal()` is no-op (warn only) - potential divergence if UI relies on signals +- `kill` no longer takes `deleteHistory` at router boundary - needs privacy/retention story +- Some main-process logging not gated behind debug flag +- `TabView` takes `panes` prop but doesn't use it (lint hygiene) +- `sessionId` vs `paneId` naming inconsistency in daemon inventory + +## Decision Log + +- **Decision**: PR #619 should implement the same TerminalBackend contract as main's TerminalManager + - **Rationale**: Oracle recommendation - "Define a single terminal backend contract...make the renderer talk only to that contract via existing IPC." Keeps both paths testable and allows easy switching. + - **Date/Author**: 2026-01-09 / Andreas + Oracle consultation + +- **Decision**: Keep PR #686 as default, gate daemon behind feature flag + - **Rationale**: PR #686 already solves common pain (tab-switch persistence) with minimal complexity. Daemon adds new failure modes. Need soak time and telemetry before default. + - **Date/Author**: 2026-01-09 / Andreas + Oracle consultation + +- **Decision**: Keep PtyWriteQueue (or equivalent write serialization) + - **Rationale**: Oracle: "Headless xterm being async doesn't guarantee ordered, bounded pty writes or safe shutdown behavior." Write serialization provides ordering, backpressure, and metrics at the backend boundary. Add queue depth metrics. + - **Date/Author**: 2026-01-09 / Oracle review + +- **Decision**: Start from main's Terminal.tsx, port daemon features incrementally + - **Rationale**: Oracle: "Treating the Terminal.tsx rewrite as 'just a conflict' instead of a high-risk product refactor" is dangerous. Starting from main preserves recently-merged #686 behavior; daemon features can be added as guarded incremental changes with explicit verification of each behavior. + - **Date/Author**: 2026-01-09 / Oracle review + +- **Decision**: Cold restore uses HYBRID approach (Option C) + - **Rationale**: Oracle recommendation: "Persist an append-only raw PTY output log as the durable source of truth, plus periodic headless-xterm serialized checkpoints (best-effort) to make cold restore fast, clean, and truncatable without rendering artifacts." + - **Why not A-only (raw replay)**: Slow for big histories, truncation breaks terminal state, can re-trigger side-effects (clipboard/title queries) + - **Why not B-only (serialized state)**: Fragile across xterm upgrades, crash-unfriendly (no clean snapshot), if B breaks = nothing works + - **Why C (hybrid)**: Raw bytes guarantee reconstruction, checkpoints make it fast/clean, allows safe truncation via epochs + - **Date/Author**: 2026-01-09 / Oracle review + +## Risks Identified by Oracle Review + +1. **Lifecycle races / orphan processes**: Attach/detach/kill during shutdown or daemon reconnect can orphan PTYs or leave sessions half-closed. Ensure "kill" is idempotent. + +2. **Event ordering + replay**: Cold restore implies replaying buffered history + live stream. Without defined ordering, can double-append or interleave history/live chunks. + +3. **clearScrollback semantics divergence**: Daemon mode has xterm buffer, persisted scrollback file, and renderer-side history. If clearScrollback doesn't clear all, behavior diverges by mode. + +4. **Memory/disk growth**: Persisted history needs caps (bytes, lines, time) and cleanup policy. Otherwise accumulates GBs silently. + +5. **Version handshake limitations**: Also need to handle stale daemon, multiple app versions, downgrade scenarios, schema migrations. + +6. **Security boundary**: Daemon socket must be local-only, per-user isolation, auth token. Other local processes shouldn't read terminal output. + +7. **Observability blind spots**: Without error-rate + retry telemetry, may only see "latency looks fine" while users experience silent failures. + +## Additional Gaps Identified (Second Oracle Review) + +8. **State machine not defined**: Need explicit session states (`new → running → detached → restored(readonly) → restarted → disposed`) and legal transitions per backend. Most orphan/race bugs disappear once explicit. + +9. **On-disk format versioning**: Need `manifestVersion` evolution plan and upgrade behavior (e.g., "can't deserialize checkpoint → fallback to raw replay"). + +10. **Backend fallback policy**: Make deterministic and observable (e.g., "daemon requested but unhealthy → fallback to in-process with banner + telemetry"). + +11. **Crash consistency**: Chunk writes + atomic manifest updates (write temp → fsync → rename) so reboot doesn't corrupt history. + +12. **Load testing targets**: Need explicit perf gate before phase 5 (large scrollback, high-throughput, long-lived sessions, sleep/resume). + +13. **Privacy controls**: Persisted terminal output is sensitive. Need user-facing toggle + "Delete history" + retention defaults. + +14. **DSR/queries on replay**: xterm may emit responses during replay; if wiring forwards them, creates loops/errors during cold restore. Need read-only mode that blocks these. + +15. **Terminal size mismatch**: Checkpoints tied to cols/rows. Decide whether to restore at original size or reflow. + +## Outcomes & Retrospective + +(To be filled at completion) + +--- + +## Context and Orientation + +### Apps/Packages Affected + +- **apps/desktop**: Primary - all terminal-related code +- **packages/local-db**: Schema for terminal persistence setting + +### Key Files by Category + +**Core Terminal Backend (Main Process)**: +- `apps/desktop/src/main/lib/terminal/session.ts` - PTY session creation and lifecycle +- `apps/desktop/src/main/lib/terminal/manager.ts` - TerminalManager class (non-daemon) +- `apps/desktop/src/main/lib/terminal/daemon-manager.ts` - DaemonTerminalManager class (PR #619) +- `apps/desktop/src/main/lib/terminal/types.ts` - TypeScript interfaces for sessions +- `apps/desktop/src/main/lib/terminal/index.ts` - Exports and getActiveTerminalManager() + +**Terminal Router (IPC Bridge)**: +- `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` - tRPC procedures for renderer + +**Renderer Terminal Component**: +- `apps/desktop/src/renderer/.../Terminal/Terminal.tsx` - Main terminal UI component +- `apps/desktop/src/renderer/.../Terminal/helpers.ts` - Terminal helper functions +- `apps/desktop/src/renderer/.../TabsContent/index.tsx` - Tab mounting logic + +**Daemon Infrastructure (PR #619 only)**: +- `apps/desktop/src/main/terminal-host/` - Daemon process code +- `apps/desktop/src/main/lib/terminal-host/client.ts` - Client for main→daemon communication + +### What Changed Where + +| File | Main (PR #686) | PR #619 | Conflict Severity | +|------|----------------|---------|-------------------| +| session.ts | Added headless xterm, removed HistoryWriter | Added PtyWriteQueue, debug logging | **HIGH** | +| manager.ts | Uses headless.resize(), getSerializedScrollback() | Uses writeQueue, new events | **HIGH** | +| types.ts | Added headless, serializer; removed scrollback | Added writeQueue, kept scrollback | **HIGH** | +| terminal.ts | Removed deleteHistory param | Added daemon support, skipColdRestore | **MEDIUM** | +| Terminal.tsx | ~127 lines changed | ~1085 lines changed (major rewrite) | **HIGH** | +| helpers.ts | Minor changes | Significant changes for daemon | **MEDIUM** | +| TabsContent/index.tsx | Both modified | Both modified | **MEDIUM** | +| tabs/store.ts | Both modified | Both modified | **MEDIUM** | + +### Terminology + +- **Headless xterm**: `@xterm/headless` - Node.js terminal emulator that processes escape sequences without rendering. Used to maintain authoritative terminal state in main process. +- **SerializeAddon**: xterm addon that serializes terminal state to clean ANSI output +- **PTY**: Pseudo-terminal - the OS-level interface for terminal I/O +- **Daemon**: Background process that survives app restarts, owns PTY processes +- **Cold restore**: Recovery when daemon is missing but disk history exists (read-only view) +- **Warm attach**: Reconnecting to a live daemon session + +--- + +## Plan of Work + +### Milestone 1: Resolve Core Terminal Backend Conflicts + +**Goal**: Get session.ts, manager.ts, types.ts compiling with both headless xterm (from main) and daemon support (from PR #619). + +**Strategy**: The non-daemon path (TerminalManager) adopts main's headless xterm as-is. The daemon path (DaemonTerminalManager) is updated to match the same interface but delegates to daemon. + +#### 1.1 session.ts + +**Main's approach (keep)**: +```typescript +// Creates headless terminal for processing PTY output +export function createHeadlessTerminal(params: { + cols: number; + rows: number; + scrollback?: number; +}): { headless: HeadlessTerminal; serializer: SerializeAddon } + +// Serializes terminal state +export function getSerializedScrollback(session: TerminalSession): string + +// Writes existing scrollback to headless terminal +export function recoverScrollback(params: { + existingScrollback: string | null; + headless: HeadlessTerminal; +}): boolean +``` + +**PR #619's additions to evaluate**: +- `PtyWriteQueue` - May not be needed with headless xterm's async writes +- Debug logging - Keep, useful for troubleshooting + +**Resolution**: Accept main's headless xterm implementation. Evaluate if PtyWriteQueue is still needed (Q2). + +#### 1.2 manager.ts + +**Main's approach (keep)**: +- `getSerializedScrollback(session)` instead of `session.scrollback` +- `session.headless.resize(cols, rows)` on resize +- `createHeadlessTerminal()` on clearScrollback +- Removed `closeSessionHistory`, `reinitializeHistory` + +**PR #619's additions to keep**: +- `terminalExit` event emission (useful for notifications router) +- `ackColdRestore()` method (no-op in non-daemon mode) +- `getSessionCountByWorkspaceId()` returning Promise (for daemon compatibility) + +**Resolution**: Merge by taking main's headless xterm code, then adding PR #619's daemon-compatible interface additions. + +#### 1.3 types.ts + +**Main's types**: +```typescript +interface TerminalSession { + // ... common fields + headless: HeadlessTerminal; + serializer: SerializeAddon; + // removed: scrollback, historyWriter +} +``` + +**PR #619's types**: +```typescript +interface TerminalSession { + // ... common fields + scrollback: string; + historyWriter: HistoryWriter; + writeQueue: PtyWriteQueue; +} +``` + +**Resolution**: Use main's types for TerminalSession (headless-based). Keep separate SessionResult type that works for both backends. + +### Milestone 2: Resolve Terminal Router Conflicts + +**Goal**: terminal.ts works with both backends through `getActiveTerminalManager()`. + +**Main's changes**: +- Removed `deleteHistory` parameter from kill mutation + +**PR #619's changes**: +- Uses `getActiveTerminalManager()` to get either TerminalManager or DaemonTerminalManager +- Added `skipColdRestore` parameter +- Added `isColdRestore`, `previousCwd`, `snapshot` to response +- Added extensive debug logging + +**Resolution**: +1. Keep main's removal of `deleteHistory` (disk history persistence is removed) +2. Keep PR #619's `getActiveTerminalManager()` pattern +3. Keep PR #619's cold restore fields (daemon-only) +4. Keep debug logging with DEBUG_TERMINAL flag + +### Milestone 3: Resolve Renderer Conflicts (Terminal.tsx) + +**This is the highest-risk area** - PR #619 has ~1085 lines of changes vs main's ~127. + +**Main's changes**: +- Uses `serializedState` from backend +- Simplified reattach logic + +**PR #619's changes**: +- Warm set mounting (CSS visibility) +- Progressive attach scheduling +- Cold restore UI (read-only until "Start Shell") +- Connection state management +- Daemon-specific lifecycle handling + +**Resolution Strategy (UPDATED per Oracle review)**: +1. **Start from main's Terminal.tsx** (preserves #686 behavior) +2. Create a checklist of daemon features to port from PR #619 +3. Port each feature incrementally with explicit verification +4. Guard daemon-specific features with response fields (not `isDaemonMode` global) + +**Daemon Features to Port (in order)**: +- [ ] Connection state management (attach/detach lifecycle) +- [ ] Response field guards (`isColdRestore`, `snapshot`) +- [ ] Cold restore UI (read-only view with "Start Shell" button) +- [ ] Warm set mounting (CSS visibility) - may belong in TabsContent +- [ ] Progressive attach scheduling - may belong in attach-scheduler.ts + +**Verification for each port**: +- Non-daemon mode still works identically to main +- No regressions in tab switching +- Feature is guarded and only activates with daemon response fields + +### Milestone 4: Add Telemetry Instrumentation (EXPANDED per Oracle review) + +**Goal**: Enable credible performance comparison between daemon and non-daemon modes before considering daemon-as-default. + +**Oracle's minimum telemetry for "credible comparison"**: + +**Event 1: `terminal_attach`** (single event, replaces duplicates) +```typescript +track("terminal_attach", { + // Dimensions + mode: "daemon" | "in-process", + is_new: boolean, + is_cold_restore: boolean, + workspace_id_hash: string, // Hashed for privacy + pane_count: number, + backend_version: string, + daemon_version: string | null, + app_version: string, + + // Timings (all in ms) + attach_latency_ms: number, // createOrAttach call → response + ttfb_ms: number, // Time to first data event + ready_ms: number, // Time to renderer "ready" state + cold_restore_ms: number | null, // Cold restore duration if applicable +}); +``` + +**Event 2: `terminal_io_stats`** (sampled, e.g., 1% sessions or once/minute) +```typescript +track("terminal_io_stats", { + mode: "daemon" | "in-process", + bytes_in: number, + bytes_out: number, + chunks_in: number, + avg_chunk_size: number, + write_queue_max_depth: number, + write_queue_drain_ms_p95: number, +}); +``` + +**Event 3: `terminal_error`** (on any failure) +```typescript +track("terminal_error", { + mode: "daemon" | "in-process", + stage: "spawn" | "handshake" | "attach" | "history" | "load" | "write", + error_code: string, // Normalized error type + is_retryable: boolean, +}); +``` + +**Implementation**: +1. Add timing instrumentation in terminal router's createOrAttach +2. Track `ttfb_ms` by measuring first `data` event after attach +3. Track `ready_ms` in renderer when terminal becomes interactive +4. Add write queue metrics to PtyWriteQueue +5. Add error tracking with normalized error codes + +### Milestone 5: Integration Testing & QA + +**Validation commands**: +```bash +cd apps/desktop +bun run typecheck # No type errors +bun run lint # No lint errors +bun test # All tests pass +``` + +**Manual QA - Non-Daemon Mode**: +1. Start app with persistence disabled +2. Open terminal, run `echo "test"` +3. Switch tabs, come back - output preserved +4. Quit app, reopen - terminal is fresh (expected) + +**Manual QA - Daemon Mode**: +1. Enable "Persist terminals across restarts (beta)" in Settings +2. Restart app +3. Open terminal, run `sleep 1000` +4. Quit app completely +5. Reopen app - terminal shows, `sleep` still running + +--- + +## Concrete Steps + +### Step 1: Create merge branch and identify conflicts + +```bash +cd /Users/andreasasprou/projects/superset +git fetch origin +git checkout terminal-persistence-v2 +git checkout -b terminal-persistence-v2-rebase +git merge origin/main --no-commit +# Review conflicts, then abort to start clean resolution +git merge --abort +``` + +### Step 2: Rebase approach (alternative - cleaner history) + +```bash +git checkout terminal-persistence-v2 +git checkout -b terminal-persistence-v2-rebase +git rebase origin/main +# Resolve conflicts commit-by-commit +``` + +### Step 3: After resolving all conflicts + +```bash +cd apps/desktop +bun run typecheck +bun run lint +bun test +``` + +### Step 4: Run dev and test manually + +```bash +bun dev +# Test both modes per QA checklist above +``` + +--- + +## Validation and Acceptance + +**Typecheck passes**: +```bash +bun run typecheck +# Expected: No errors +``` + +**Lint passes**: +```bash +bun run lint +# Expected: No errors (or only pre-existing warnings) +``` + +**Tests pass**: +```bash +bun test +# Expected: All tests pass +``` + +**Non-daemon mode works**: +1. Disable persistence in Settings (or use default) +2. Open terminal, type commands, switch tabs, return +3. Terminal state preserved + +**Daemon mode works**: +1. Enable "Persist terminals across restarts (beta)" +2. Restart app (required for daemon to start) +3. Open terminal, run long command +4. Quit app, reopen +5. Terminal still shows command running + +--- + +## Idempotence and Recovery + +**Rebase can be restarted**: If rebase fails partway, run: +```bash +git rebase --abort +git checkout terminal-persistence-v2 +# Start fresh +``` + +**Branch is safe**: We work on `terminal-persistence-v2-rebase`, not the original branch. + +**Conflicts are deterministic**: Same conflicts will appear each time since they're based on file history. + +--- + +## Interfaces and Dependencies + +### TerminalBackend Contract (EXPANDED per second Oracle review) + +Both TerminalManager and DaemonTerminalManager must implement: + +```typescript +interface TerminalBackend { + // Capabilities (for negotiation) + readonly capabilities: { + supportsWarmAttach: boolean; + supportsColdRestore: boolean; + supportsSerialize: boolean; + supportsReplay: boolean; + supportsReadOnly: boolean; + }; + + // Create new session (spawns PTY) + create(params: CreateSessionParams): Promise; + + // Attach to existing session (with ordering handshake) + attach(params: { + paneId: string; + mode?: 'normal' | 'readonly'; // readonly for cold restore + }): Promise; + + // Detach from session (keeps PTY alive in daemon mode) + detach(params: { paneId: string }): void; + + // Write data to terminal + write(params: { paneId: string; data: string }): void; + + // Resize terminal + resize(params: { paneId: string; cols: number; rows: number }): void; + + // Kill terminal session + kill(params: { paneId: string }): Promise; + + // Clear scrollback (starts new "epoch" in hybrid storage) + clearScrollback(params: { paneId: string }): void; + + // Acknowledge cold restore - converts readonly → start new shell + ackColdRestore(paneId: string): void; + + // Set read-only mode (blocks onData wiring, DSR/OSC responses) + setReadOnly(params: { paneId: string; enabled: boolean }): void; + + // Get session count for workspace + getSessionCountByWorkspaceId(workspaceId: string): Promise; + + // Events + on(event: `data:${string}`, listener: (data: string) => void): this; + on(event: `exit:${string}`, listener: (code: number, signal: number) => void): this; + on(event: 'terminalExit', listener: (info: { paneId: string; exitCode: number; signal: number }) => void): this; + on(event: `title:${string}`, listener: (title: string) => void): this; + on(event: `cwd:${string}`, listener: (cwd: string) => void): this; + on(event: `error:${string}`, listener: (error: Error) => void): this; + on(event: 'backendFallback', listener: (info: { paneId: string; from: string; to: string; reason: string }) => void): this; +} + +interface AttachResult { + snapshot?: string; // Serialized state if available + snapshotSeq?: number; // Sequence number of snapshot + replayFromSeq?: number; // Where to start replay from + replayFromByteOffset?: number; + mode: 'normal' | 'readonly'; +} +``` + +### SessionResult (Response from createOrAttach) + +```typescript +interface SessionResult { + isNew: boolean; + scrollback: string; // Serialized terminal state + wasRecovered: boolean; // True if reattached to existing session + + // Daemon-only fields (undefined in non-daemon mode) + isColdRestore?: boolean; // True if restored from disk (no live session) + previousCwd?: string; // CWD from before restart + snapshot?: string; // Full terminal snapshot for renderer +} +``` + +### Dependencies + +**Existing (no changes needed)**: +- `@xterm/headless` - Headless terminal emulator +- `@xterm/addon-serialize` - Serialization addon +- `node-pty` - PTY interface +- `posthog-node` - Analytics + +**PR #619 additions (to keep)**: +- Daemon process infrastructure (already in PR #619) + +--- + +## Telemetry Events to Add + +```typescript +// In terminal router or manager +track("terminal_session_created", { + pane_id: paneId, + workspace_id: workspaceId, + mode: isDaemonMode ? "daemon" : "in-process", + is_cold_restore: result.isColdRestore ?? false, + is_warm_attach: !result.isNew && !result.isColdRestore, + attach_latency_ms: Date.now() - startTime, +}); + +// For performance comparison +track("terminal_attach_latency", { + mode: isDaemonMode ? "daemon" : "in-process", + latency_ms: attachLatency, + is_new: result.isNew, +}); +``` + +--- + +## Artifacts and Notes + +### Conflict File List (17 files) + +``` +apps/desktop/package.json +apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +apps/desktop/src/main/lib/terminal-history.ts +apps/desktop/src/main/lib/terminal/manager.test.ts +apps/desktop/src/main/lib/terminal/manager.ts +apps/desktop/src/main/lib/terminal/port-manager.ts +apps/desktop/src/main/lib/terminal/session.ts +apps/desktop/src/main/lib/terminal/types.ts +apps/desktop/src/main/lib/window-state/bounds-validation.test.ts +apps/desktop/src/renderer/.../TabsContent/index.tsx +apps/desktop/src/renderer/.../TabsContent/TabView/index.tsx +apps/desktop/src/renderer/.../TabsContent/TabView/TabPane.tsx +apps/desktop/src/renderer/.../Terminal/helpers.ts +apps/desktop/src/renderer/.../Terminal/Terminal.tsx +apps/desktop/src/renderer/stores/tabs/store.ts +bun.lock +``` + +### Key Insight from Oracle + +> "Keep #686 as the default backend implementation; gate the daemon backend behind a feature flag. Add protocol/version handshake between app ↔ daemon and a hard rule: on mismatch, restart daemon or fall back to non-daemon." + +### Risk Mitigation + +1. **Feature flag**: Daemon mode is opt-in beta +2. **Fallback**: If daemon fails, can fall back to in-process mode +3. **Telemetry**: Compare performance before considering daemon-as-default +4. **Version handshake**: Prevent undefined behavior on app/daemon version mismatch + +--- + +## Revision History + +- **2026-01-09 11:00**: Second Oracle review (fresh context): + - **Resolved Q3**: Cold restore uses HYBRID approach - append-only raw PTY log + periodic headless xterm checkpoints + - Added 8 additional gaps/risks (state machine, on-disk versioning, fallback policy, crash consistency, load testing, privacy, DSR on replay, size mismatch) + - Expanded TerminalBackend contract: capabilities negotiation, attach handshake with ordering, explicit read-only mode, additional events (title, cwd, error, backendFallback) + - Defined AttachResult interface for proper ordering handshake + - Effort estimate: Large (3d+) for full rebase; Short-Medium (1-2d) for cold-restore storage shape + +- **2026-01-09 10:30**: Updated with Oracle review feedback: + - Changed Terminal.tsx strategy: start from main, port daemon features incrementally (not start from PR #619) + - Resolved PtyWriteQueue question: keep it for write serialization/backpressure + - Expanded telemetry to Oracle's "credible comparison" spec (terminal_attach, terminal_io_stats, terminal_error) + - Added Risks section from Oracle review + - Restructured Progress into 5 phases with explicit verification gates + - Added Exit Criteria for Daemon-as-Default discussion + +- **2026-01-09 09:52**: Initial draft created based on conflict analysis and Oracle consultation diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts new file mode 100644 index 00000000000..c14ab5717ed --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, mock } from "bun:test"; +import { EventEmitter } from "node:events"; + +let terminalManager: EventEmitter = new EventEmitter(); + +mock.module("main/lib/terminal", () => ({ + DaemonTerminalManager: class DaemonTerminalManager extends EventEmitter {}, + getActiveTerminalManager: () => terminalManager, +})); + +// Avoid importing Electron/local-db during test bootstrap. +mock.module("main/lib/local-db", () => ({ + localDb: { + select: () => ({ + from: () => ({ + where: () => ({ + get: () => undefined, + }), + }), + }), + }, +})); + +const { createTerminalRouter } = await import("./terminal"); + +describe("terminal.stream", () => { + it("does not complete on exit (paneId is stable across restarts)", async () => { + terminalManager = new EventEmitter(); + + const router = createTerminalRouter(); + const caller = router.createCaller({} as any); + const stream$ = await caller.stream("pane-1"); + + const events: Array<{ type: string }> = []; + let didComplete = false; + + const subscription = stream$.subscribe({ + next: (event) => { + events.push(event); + }, + complete: () => { + didComplete = true; + }, + }); + + terminalManager.emit("exit:pane-1", 0, 15); + + expect(didComplete).toBe(false); + expect(terminalManager.listenerCount("data:pane-1")).toBeGreaterThan(0); + + terminalManager.emit("data:pane-1", "echo ok\r\n"); + + expect(events.map((e) => e.type)).toEqual(["exit", "data"]); + + subscription.unsubscribe(); + + expect(terminalManager.listenerCount("data:pane-1")).toBe(0); + expect(terminalManager.listenerCount("exit:pane-1")).toBe(0); + expect(terminalManager.listenerCount("disconnect:pane-1")).toBe(0); + expect(terminalManager.listenerCount("error:pane-1")).toBe(0); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 1c1bd617d5c..7ed70708ed1 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -406,7 +406,7 @@ export const createTerminalRouter = () => { if (DEBUG_TERMINAL && !firstDataReceived) { firstDataReceived = true; console.log( - `[Terminal Stream] First data event for ${paneId}: ${data.length} bytes`, + `[Terminal Stream] First data for ${paneId}: ${data.length} bytes`, ); } emit.next({ type: "data", data }); diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 8bbe0934e87..6e93a6bd844 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -193,6 +193,12 @@ export class DaemonTerminalManager extends EventEmitter { this.client.on("data", (sessionId: string, data: string) => { // The sessionId from daemon is the paneId const paneId = sessionId; + if (DEBUG_TERMINAL) { + const listenerCount = this.listenerCount(`data:${paneId}`); + console.log( + `[DaemonTerminalManager] Received data from daemon: paneId=${paneId}, bytes=${data.length}, listeners=${listenerCount}`, + ); + } // Update session state const session = this.sessions.get(paneId); @@ -847,11 +853,10 @@ export class DaemonTerminalManager extends EventEmitter { const { paneId, deleteHistory = false } = params; this.daemonAliveSessionIds.delete(paneId); - // Emit exit event BEFORE killing so tRPC subscriptions complete cleanly. + // Emit exit event BEFORE killing so the renderer can stop sending input. // This prevents WRITE_FAILED errors when the daemon kills the session // but React components are still mounted with active subscriptions. - // The daemon will also emit an exit event, but duplicate events are - // harmless since emit.complete() has already been called. + // The daemon will also emit an exit event; duplicates are harmless. const session = this.sessions.get(paneId); if (session?.isAlive) { session.isAlive = false; @@ -1028,7 +1033,7 @@ export class DaemonTerminalManager extends EventEmitter { for (const paneId of paneIdsToKill) { try { - // Emit exit event BEFORE killing so tRPC subscriptions complete cleanly. + // Emit exit event BEFORE killing so the renderer can stop sending input. // This prevents WRITE_FAILED error toast floods when deleting workspaces. const session = this.sessions.get(paneId); if (session?.isAlive) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 8325c9771dc..28e0c6926bb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -142,6 +142,12 @@ export const Terminal = ({ }, } = useTerminalConnection({ workspaceId }); + // Avoid effect re-runs: track overlay states via refs for input gating. + const isRestoredModeRef = useRef(isRestoredMode); + isRestoredModeRef.current = isRestoredMode; + const connectionErrorRef = useRef(connectionError); + connectionErrorRef.current = connectionError; + // Ref for initial theme to avoid recreating terminal on theme change const initialThemeRef = useRef(terminalTheme); @@ -741,9 +747,13 @@ export const Terminal = ({ const fitAddon = fitAddonRef.current; if (!xterm || !fitAddon) return; - // Clear restored mode (both React state and module-level map) - setIsRestoredMode(false); - coldRestoreState.delete(paneId); + // Keep the overlay up while we create the new session; clear it on success. + + // Drop any queued events from the pre-restore session. In cold restore mode + // streaming is intentionally paused, so stale `exit` events can accumulate. + // If we replay them after starting a new shell, the terminal gets marked as + // exited and future input triggers an unintended restart (which clears the UI). + pendingEventsRef.current = []; // Acknowledge cold restore to main process (clears sticky state) trpcClient.terminal.ackColdRestore.mutate({ paneId }).catch((error) => { @@ -758,6 +768,7 @@ export const Terminal = ({ // Reset state for new session isStreamReadyRef.current = false; + isExitedRef.current = false; // Critical: reset so handleTerminalInput writes to shell pendingInitialStateRef.current = null; isAlternateScreenRef.current = false; isBracketedPasteRef.current = false; @@ -779,15 +790,26 @@ export const Terminal = ({ pendingInitialStateRef.current = result; maybeApplyInitialState(); - // Focus terminal after starting new shell - const currentXterm = xtermRef.current; - if (currentXterm && isFocusedRef.current) { - currentXterm.focus(); - } + // Clear restored mode AFTER session is ready so the overlay doesn't + // disappear until we have a live session to show. + setIsRestoredMode(false); + coldRestoreState.delete(paneId); + + // Always focus terminal after Start Shell - user explicitly clicked to start + // Use setTimeout to ensure DOM is ready after overlay removal + setTimeout(() => { + const currentXterm = xtermRef.current; + if (currentXterm) { + currentXterm.focus(); + } + }, 0); }, onError: (error) => { console.error("[Terminal] Failed to start shell:", error); setConnectionError(error.message || "Failed to start shell"); + // Clear restored mode on error too so user can retry + setIsRestoredMode(false); + coldRestoreState.delete(paneId); isStreamReadyRef.current = true; flushPendingEvents(); }, @@ -800,6 +822,7 @@ export const Terminal = ({ maybeApplyInitialState, flushPendingEvents, setConnectionError, + setIsRestoredMode, ]); // Track first data event for debugging @@ -1050,6 +1073,12 @@ export const Terminal = ({ }; const handleTerminalInput = (data: string) => { + // When overlays are visible, ignore input completely: + // - Cold restore overlay: no live session yet + // - Connection error overlay: daemon may be unavailable + if (isRestoredModeRef.current || connectionErrorRef.current) { + return; + } if (isExitedRef.current) { restartTerminal(); return; @@ -1061,6 +1090,10 @@ export const Terminal = ({ key: string; domEvent: KeyboardEvent; }) => { + // Don't treat overlay interactions as terminal typing. + if (isRestoredModeRef.current || connectionErrorRef.current) { + return; + } const { domEvent } = event; if (domEvent.key === "Enter") { // Don't auto-title from keyboard when in alternate screen (TUI apps like vim, codex) From c37c3d21ba1fe5ea938f6e679c2b244cb2bbf5dc Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 19:18:07 +0200 Subject: [PATCH 40/62] chore(desktop): fix biome check --- apps/desktop/src/main/lib/terminal-history.ts | 4 +- .../src/main/lib/terminal/daemon-manager.ts | 38 +++++++++---------- .../TabsContent/Terminal/Terminal.tsx | 5 +-- .../TabsContent/Terminal/attach-scheduler.ts | 12 +++--- 4 files changed, 27 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index 5128a45946e..77f35c2c5be 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -77,7 +77,7 @@ export class HistoryWriter { private closed = false; constructor( - private workspaceId: string, + workspaceId: string, private paneId: string, cwd: string, cols: number, @@ -274,7 +274,7 @@ export class HistoryReader { private metaPath: string; constructor( - private workspaceId: string, + workspaceId: string, private paneId: string, ) { this.dir = getHistoryDir(workspaceId, paneId); diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 6e93a6bd844..ed79b9a5c1e 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -350,25 +350,25 @@ export class DaemonTerminalManager extends EventEmitter { } } - try { - const writer = new HistoryWriter(workspaceId, paneId, cwd, cols, rows); - await writer.init(safeScrollback); - this.historyWriters.set(paneId, writer); - - // Flush any buffered data. Important: mark init as complete BEFORE replaying, - // otherwise writeToHistory() would re-buffer into the same array we're - // iterating (infinite loop / RangeError: Invalid array length). - const buffered = this.pendingHistoryData.get(paneId) || []; - this.historyInitializing.delete(paneId); - this.pendingHistoryData.delete(paneId); - for (const data of buffered) { - writer.write(data); - } - } catch (error) { - console.error( - `[DaemonTerminalManager] Failed to init history writer for ${paneId}:`, - error, - ); + try { + const writer = new HistoryWriter(workspaceId, paneId, cwd, cols, rows); + await writer.init(safeScrollback); + this.historyWriters.set(paneId, writer); + + // Flush any buffered data. Important: mark init as complete BEFORE replaying, + // otherwise writeToHistory() would re-buffer into the same array we're + // iterating (infinite loop / RangeError: Invalid array length). + const buffered = this.pendingHistoryData.get(paneId) || []; + this.historyInitializing.delete(paneId); + this.pendingHistoryData.delete(paneId); + for (const data of buffered) { + writer.write(data); + } + } catch (error) { + console.error( + `[DaemonTerminalManager] Failed to init history writer for ${paneId}:`, + error, + ); } finally { this.historyInitializing.delete(paneId); this.pendingHistoryData.delete(paneId); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 28e0c6926bb..c5e03d6a815 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -907,10 +907,7 @@ export const Terminal = ({ // Don't block interaction for non-fatal issues like a paste drop or a // transient write failure (we keep the session alive). - if ( - event.code === "WRITE_QUEUE_FULL" || - event.code === "WRITE_FAILED" - ) { + if (event.code === "WRITE_QUEUE_FULL" || event.code === "WRITE_FAILED") { xtermRef.current.writeln(`\r\n[Terminal] ${message}`); } else { setConnectionError(message); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts index 412e53c97bc..21ba0b0b40f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts @@ -35,9 +35,7 @@ function pump(): void { if (!task) return; if (task.canceled) { if (DEBUG_SCHEDULER) { - console.log( - `[AttachScheduler] Skipping canceled task: ${task.paneId}`, - ); + console.log(`[AttachScheduler] Skipping canceled task: ${task.paneId}`); } continue; } @@ -46,9 +44,7 @@ function pump(): void { const current = pendingByPaneId.get(task.paneId); if (current !== task) { if (DEBUG_SCHEDULER) { - console.log( - `[AttachScheduler] Skipping replaced task: ${task.paneId}`, - ); + console.log(`[AttachScheduler] Skipping replaced task: ${task.paneId}`); } continue; } @@ -151,7 +147,9 @@ export function scheduleTerminalAttach({ existing.canceled = true; pendingByPaneId.delete(paneId); if (DEBUG_SCHEDULER) { - console.log(`[AttachScheduler] Canceled existing pending task: ${paneId}`); + console.log( + `[AttachScheduler] Canceled existing pending task: ${paneId}`, + ); } } From e7b2183342bc9811ae35ccc6b72fa178218feaee Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 19:32:07 +0200 Subject: [PATCH 41/62] chore(desktop): fix lint warnings --- .../src/lib/trpc/routers/terminal/terminal.stream.test.ts | 2 +- .../WorkspaceView/ContentView/TabsContent/TabView/index.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts index c14ab5717ed..954af76369c 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts @@ -28,7 +28,7 @@ describe("terminal.stream", () => { terminalManager = new EventEmitter(); const router = createTerminalRouter(); - const caller = router.createCaller({} as any); + const caller = router.createCaller({} as never); const stream$ = await caller.stream("pane-1"); const events: Array<{ type: string }> = []; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 3cf274bf6b3..a9f73b6d720 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -10,7 +10,7 @@ import { import { dragDropManager } from "renderer/lib/dnd"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; -import type { Pane, Tab } from "renderer/stores/tabs/types"; +import type { Tab } from "renderer/stores/tabs/types"; import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; import { cleanLayout, @@ -21,10 +21,10 @@ import { TabPane } from "./TabPane"; interface TabViewProps { tab: Tab; - isTabVisible: boolean; + isTabVisible?: boolean; } -export function TabView({ tab, isTabVisible }: TabViewProps) { +export function TabView({ tab, isTabVisible = true }: TabViewProps) { const updateTabLayout = useTabsStore((s) => s.updateTabLayout); const removePane = useTabsStore((s) => s.removePane); const removeTab = useTabsStore((s) => s.removeTab); From b74b76ec03247a463f864f5ff7d6ca41d0f08ae1 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 20:39:56 +0200 Subject: [PATCH 42/62] fix(desktop): address persistence review blockers --- .../routers/workspaces/procedures/branch.ts | 4 +- .../routers/workspaces/procedures/delete.ts | 17 +- apps/desktop/src/main/lib/terminal-history.ts | 148 +++++++++++++++++- .../src/main/lib/terminal/daemon-manager.ts | 10 +- apps/desktop/src/main/lib/terminal/session.ts | 20 +-- 5 files changed, 168 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/branch.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/branch.ts index 91f734e1b26..16e3e3339d4 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/branch.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/branch.ts @@ -1,7 +1,7 @@ import { projects, workspaces } from "@superset/local-db"; import { and, eq, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; -import { terminalManager } from "main/lib/terminal"; +import { getActiveTerminalManager } from "main/lib/terminal"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { @@ -87,7 +87,7 @@ export const createBranchProcedures = () => { await safeCheckoutBranch(project.mainRepoPath, input.branch); // Send newline to terminals so their prompts refresh with new branch - terminalManager.refreshPromptsForWorkspace(workspace.id); + getActiveTerminalManager().refreshPromptsForWorkspace(workspace.id); // Update the workspace - name is always the branch for branch workspaces touchWorkspace(workspace.id, { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 7602d20554f..0311b7d230d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -1,6 +1,6 @@ import type { SelectWorktree } from "@superset/local-db"; import { track } from "main/lib/analytics"; -import { terminalManager } from "main/lib/terminal"; +import { getActiveTerminalManager } from "main/lib/terminal"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; @@ -59,7 +59,9 @@ export const createDeleteProcedures = () => { } const activeTerminalCount = - terminalManager.getSessionCountByWorkspaceId(input.id); + await getActiveTerminalManager().getSessionCountByWorkspaceId( + input.id, + ); // Branch workspaces are non-destructive to close - no git checks needed if (workspace.type === "branch") { @@ -184,9 +186,9 @@ export const createDeleteProcedures = () => { } } - const terminalResult = await terminalManager.killByWorkspaceId( - input.id, - ); + // Kill all terminal processes in this workspace first + const terminalResult = + await getActiveTerminalManager().killByWorkspaceId(input.id); const project = getProject(workspace.projectId); @@ -275,9 +277,8 @@ export const createDeleteProcedures = () => { throw new Error("Workspace not found"); } - const terminalResult = await terminalManager.killByWorkspaceId( - input.id, - ); + const terminalResult = + await getActiveTerminalManager().killByWorkspaceId(input.id); deleteWorkspace(input.id); // keeps worktree on disk hideProjectIfNoWorkspaces(workspace.projectId); diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index 77f35c2c5be..c4296a7d76a 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -19,6 +19,10 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { SUPERSET_DIR_NAME } from "shared/constants"; +const MAX_HISTORY_BYTES = 5 * 1024 * 1024; // 5MB per session +const MAX_PENDING_WRITE_BYTES = 256 * 1024; // cap in-memory backlog when disk is slow +const DRAIN_TIMEOUT_MS = 1000; + // ============================================================================= // Types // ============================================================================= @@ -73,8 +77,14 @@ export class HistoryWriter { private scrollbackPath: string; private metaPath: string; private metadata: SessionMetadata; + private bytesWritten = 0; private streamErrored = false; private closed = false; + private isBackpressured = false; + private pendingWrites: Array<{ data: string; bytes: number }> = []; + private pendingWriteBytes = 0; + private warnedCapReached = false; + private warnedBackpressureDrop = false; constructor( workspaceId: string, @@ -104,12 +114,23 @@ export class HistoryWriter { // Write initial scrollback or create empty file // node-pty produces UTF-8 strings, so we store as UTF-8 if (initialScrollback) { - await fs.writeFile( - this.scrollbackPath, - Buffer.from(initialScrollback, "utf8"), - ); + // Ensure initial scrollback doesn't exceed our per-session cap. + const initialBytes = Buffer.byteLength(initialScrollback, "utf8"); + if (initialBytes > MAX_HISTORY_BYTES) { + const truncated = initialScrollback.slice(-MAX_HISTORY_BYTES); + await fs.writeFile(this.scrollbackPath, truncated, "utf8"); + this.bytesWritten = Buffer.byteLength(truncated, "utf8"); + this.warnedCapReached = true; + console.warn( + `[HistoryWriter] Initial scrollback truncated for ${this.paneId} (${initialBytes} bytes > ${MAX_HISTORY_BYTES})`, + ); + } else { + await fs.writeFile(this.scrollbackPath, initialScrollback, "utf8"); + this.bytesWritten = initialBytes; + } } else { await fs.writeFile(this.scrollbackPath, Buffer.alloc(0)); + this.bytesWritten = 0; } // Open stream in append mode for subsequent writes @@ -121,6 +142,12 @@ export class HistoryWriter { ); this.streamErrored = true; this.stream = null; + this.pendingWrites = []; + this.pendingWriteBytes = 0; + }); + this.stream.on("drain", () => { + this.isBackpressured = false; + this.flushPendingWrites(); }); // Write meta.json immediately (without endedAt) @@ -139,13 +166,81 @@ export class HistoryWriter { } try { + const bytes = Buffer.byteLength(data, "utf8"); + if (bytes === 0) { + return; + } + + // Hard cap disk usage per session (best-effort; drop beyond cap). + if (this.bytesWritten + bytes > MAX_HISTORY_BYTES) { + if (!this.warnedCapReached) { + this.warnedCapReached = true; + console.warn( + `[HistoryWriter] History cap reached for ${this.paneId} (${MAX_HISTORY_BYTES} bytes); dropping additional output`, + ); + } + return; + } + + // Respect filesystem backpressure. When disk is slow, stop feeding the + // stream buffer and keep a small in-memory backlog; beyond that we drop. + if (this.isBackpressured || this.pendingWrites.length > 0) { + if (this.pendingWriteBytes + bytes > MAX_PENDING_WRITE_BYTES) { + if (!this.warnedBackpressureDrop) { + this.warnedBackpressureDrop = true; + console.warn( + `[HistoryWriter] Write backlog cap reached for ${this.paneId} (${MAX_PENDING_WRITE_BYTES} bytes); dropping history until drain`, + ); + } + return; + } + + this.pendingWrites.push({ data, bytes }); + this.pendingWriteBytes += bytes; + this.bytesWritten += bytes; + return; + } + // node-pty produces UTF-8 strings - this.stream.write(Buffer.from(data, "utf8")); + this.bytesWritten += bytes; + const ok = this.stream.write(data, "utf8"); + if (!ok) { + this.isBackpressured = true; + } } catch { this.streamErrored = true; } } + private flushPendingWrites(): void { + if (this.closed || this.streamErrored || !this.stream) { + return; + } + if (this.isBackpressured) { + return; + } + + while (this.pendingWrites.length > 0) { + const next = this.pendingWrites.shift(); + if (!next) return; + this.pendingWriteBytes = Math.max(0, this.pendingWriteBytes - next.bytes); + + try { + const ok = this.stream.write(next.data, "utf8"); + if (!ok) { + this.isBackpressured = true; + return; + } + } catch { + this.streamErrored = true; + this.stream = null; + this.pendingWrites = []; + this.pendingWriteBytes = 0; + return; + } + } + } + /** * Flush pending writes to disk. * Returns a promise that resolves when data is flushed. @@ -156,6 +251,7 @@ export class HistoryWriter { } return new Promise((resolve) => { + this.flushPendingWrites(); // Cork and uncork forces a flush this.stream?.once("drain", resolve); // If nothing to drain, resolve immediately @@ -174,6 +270,36 @@ export class HistoryWriter { } this.closed = true; + // Best-effort: flush any pending backlog before closing. + while ( + !this.streamErrored && + this.stream && + this.pendingWrites.length > 0 + ) { + this.flushPendingWrites(); + if (this.isBackpressured) { + const stream = this.stream; + if (!stream) break; + + const drained = await Promise.race([ + new Promise((resolve) => + stream.once("drain", () => resolve(true)), + ), + new Promise((resolve) => + setTimeout(() => resolve(false), DRAIN_TIMEOUT_MS), + ), + ]); + + if (!drained) { + break; + } + + this.isBackpressured = false; + } + } + this.pendingWrites = []; + this.pendingWriteBytes = 0; + // Close the stream if (this.stream && !this.streamErrored) { await new Promise((resolve) => { @@ -209,6 +335,12 @@ export class HistoryWriter { this.stream = null; this.streamErrored = false; this.closed = false; + this.isBackpressured = false; + this.pendingWrites = []; + this.pendingWriteBytes = 0; + this.bytesWritten = 0; + this.warnedCapReached = false; + this.warnedBackpressureDrop = false; // Reset metadata with new start time this.metadata.startedAt = new Date().toISOString(); @@ -232,6 +364,8 @@ export class HistoryWriter { }); } this.stream = null; + this.pendingWrites = []; + this.pendingWriteBytes = 0; this.closed = true; // Delete the directory @@ -335,8 +469,8 @@ export class HistoryReader { /** * Delete history files for this session. */ - cleanup(): void { - fs.rm(this.dir, { recursive: true, force: true }).catch((error) => { + async cleanup(): Promise { + await fs.rm(this.dir, { recursive: true, force: true }).catch((error) => { console.warn( `[HistoryReader] Failed to cleanup history for ${this.paneId}:`, error instanceof Error ? error.message : String(error), diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index ed79b9a5c1e..61b74c5c212 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -568,7 +568,7 @@ export class DaemonTerminalManager extends EventEmitter { // Handle null scrollback (no history available). // Note: empty string is valid (TUI apps in alt screen may have empty normal buffer). if (rawScrollback === null) { - historyReader.cleanup(); + await historyReader.cleanup(); // Fall through to create new session } else { const scrollback = @@ -861,8 +861,8 @@ export class DaemonTerminalManager extends EventEmitter { if (session?.isAlive) { session.isAlive = false; session.pid = null; - this.emit(`exit:${paneId}`, 0, "SIGTERM"); - this.emit("terminalExit", { paneId, exitCode: 0, signal: undefined }); + this.emit(`exit:${paneId}`, 0, 15); + this.emit("terminalExit", { paneId, exitCode: 0, signal: 15 }); } // Unregister from port manager @@ -1039,8 +1039,8 @@ export class DaemonTerminalManager extends EventEmitter { if (session?.isAlive) { session.isAlive = false; session.pid = null; - this.emit(`exit:${paneId}`, 0, "SIGTERM"); - this.emit("terminalExit", { paneId, exitCode: 0, signal: undefined }); + this.emit(`exit:${paneId}`, 0, 15); + this.emit("terminalExit", { paneId, exitCode: 0, signal: 15 }); } // Unregister from port manager diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index e4d67218da2..54010ff3e16 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -17,6 +17,7 @@ const DEFAULT_ROWS = 24; const DEFAULT_SCROLLBACK = 10000; /** Max time to wait for agent hooks before running initial commands */ const AGENT_HOOKS_TIMEOUT_MS = 2000; +const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; export function createHeadlessTerminal(params: { cols: number; @@ -99,15 +100,16 @@ export async function createSession( const terminalCols = cols || DEFAULT_COLS; const terminalRows = rows || DEFAULT_ROWS; - // Debug: Log PTY spawn parameters - console.log("[Terminal Session] Creating session:", { - paneId, - shell, - workingDir, - terminalCols, - terminalRows, - useFallbackShell, - }); + if (DEBUG_TERMINAL) { + console.log("[Terminal Session] Creating session:", { + paneId, + shell, + workingDir, + terminalCols, + terminalRows, + useFallbackShell, + }); + } const env = buildTerminalEnv({ shell, From 8333e40ff5e6b5856ae118f816b0f19b32b8760b Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 9 Jan 2026 21:20:07 +0200 Subject: [PATCH 43/62] fix(desktop): harden terminal history caps --- apps/desktop/src/main/lib/terminal-history.ts | 62 ++++++++++++++++--- .../src/main/lib/terminal/daemon-manager.ts | 44 +++++++++---- 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index c4296a7d76a..ddfd9f6a681 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -22,6 +22,30 @@ import { SUPERSET_DIR_NAME } from "shared/constants"; const MAX_HISTORY_BYTES = 5 * 1024 * 1024; // 5MB per session const MAX_PENDING_WRITE_BYTES = 256 * 1024; // cap in-memory backlog when disk is slow const DRAIN_TIMEOUT_MS = 1000; +const HISTORY_DIR_MODE = 0o700; +const HISTORY_FILE_MODE = 0o600; + +function isUtf8ContinuationByte(value: number): boolean { + // Continuation bytes are 10xxxxxx. + return (value & 0b1100_0000) === 0b1000_0000; +} + +export function truncateUtf8ToLastBytes( + input: string, + maxBytes: number, +): string { + if (maxBytes <= 0) return ""; + + const buffer = Buffer.from(input, "utf8"); + if (buffer.length <= maxBytes) return input; + + let start = buffer.length - maxBytes; + while (start < buffer.length && isUtf8ContinuationByte(buffer[start] ?? 0)) { + start++; + } + + return buffer.subarray(start).toString("utf8"); +} // ============================================================================= // Types @@ -109,7 +133,7 @@ export class HistoryWriter { * Creates the directory, writes initial scrollback, and opens append stream. */ async init(initialScrollback?: string): Promise { - await fs.mkdir(this.dir, { recursive: true }); + await fs.mkdir(this.dir, { recursive: true, mode: HISTORY_DIR_MODE }); // Write initial scrollback or create empty file // node-pty produces UTF-8 strings, so we store as UTF-8 @@ -117,24 +141,38 @@ export class HistoryWriter { // Ensure initial scrollback doesn't exceed our per-session cap. const initialBytes = Buffer.byteLength(initialScrollback, "utf8"); if (initialBytes > MAX_HISTORY_BYTES) { - const truncated = initialScrollback.slice(-MAX_HISTORY_BYTES); - await fs.writeFile(this.scrollbackPath, truncated, "utf8"); + const truncated = truncateUtf8ToLastBytes( + initialScrollback, + MAX_HISTORY_BYTES, + ); + await fs.writeFile(this.scrollbackPath, truncated, { + encoding: "utf8", + mode: HISTORY_FILE_MODE, + }); this.bytesWritten = Buffer.byteLength(truncated, "utf8"); this.warnedCapReached = true; console.warn( `[HistoryWriter] Initial scrollback truncated for ${this.paneId} (${initialBytes} bytes > ${MAX_HISTORY_BYTES})`, ); } else { - await fs.writeFile(this.scrollbackPath, initialScrollback, "utf8"); + await fs.writeFile(this.scrollbackPath, initialScrollback, { + encoding: "utf8", + mode: HISTORY_FILE_MODE, + }); this.bytesWritten = initialBytes; } } else { - await fs.writeFile(this.scrollbackPath, Buffer.alloc(0)); + await fs.writeFile(this.scrollbackPath, Buffer.alloc(0), { + mode: HISTORY_FILE_MODE, + }); this.bytesWritten = 0; } // Open stream in append mode for subsequent writes - this.stream = createWriteStream(this.scrollbackPath, { flags: "a" }); + this.stream = createWriteStream(this.scrollbackPath, { + flags: "a", + mode: HISTORY_FILE_MODE, + }); this.stream.on("error", (error) => { console.error( `[HistoryWriter] Stream error for ${this.paneId}:`, @@ -150,6 +188,9 @@ export class HistoryWriter { this.flushPendingWrites(); }); + // Best-effort permission hardening (mode isn't updated on existing files). + await fs.chmod(this.scrollbackPath, HISTORY_FILE_MODE).catch(() => {}); + // Write meta.json immediately (without endedAt) // This enables cold restore detection - if app crashes, // meta.json exists but has no endedAt, indicating unclean shutdown @@ -379,7 +420,14 @@ export class HistoryWriter { private async writeMetadata(): Promise { try { - await fs.writeFile(this.metaPath, JSON.stringify(this.metadata, null, 2)); + await fs.writeFile( + this.metaPath, + JSON.stringify(this.metadata, null, 2), + { + mode: HISTORY_FILE_MODE, + }, + ); + await fs.chmod(this.metaPath, HISTORY_FILE_MODE).catch(() => {}); } catch (error) { console.warn( `[HistoryWriter] Failed to write metadata for ${this.paneId}:`, diff --git a/apps/desktop/src/main/lib/terminal/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon-manager.ts index 61b74c5c212..bced3afa417 100644 --- a/apps/desktop/src/main/lib/terminal/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon-manager.ts @@ -18,7 +18,11 @@ import { containsClearScrollbackSequence, extractContentAfterClear, } from "../terminal-escape-filter"; -import { HistoryReader, HistoryWriter } from "../terminal-history"; +import { + HistoryReader, + HistoryWriter, + truncateUtf8ToLastBytes, +} from "../terminal-history"; import { disposeTerminalHostClient, getTerminalHostClient, @@ -341,12 +345,21 @@ export class DaemonTerminalManager extends EventEmitter { `[DaemonTerminalManager] initialScrollback for ${paneId} is not a string, ignoring`, ); safeScrollback = undefined; - } else if (initialScrollback.length > MAX_SCROLLBACK_BYTES) { - console.warn( - `[DaemonTerminalManager] initialScrollback for ${paneId} too large (${initialScrollback.length} bytes), truncating to ${MAX_SCROLLBACK_BYTES}`, + } else { + const initialScrollbackBytes = Buffer.byteLength( + initialScrollback, + "utf8", ); - // Keep the most recent content (end of scrollback) - safeScrollback = initialScrollback.slice(-MAX_SCROLLBACK_BYTES); + if (initialScrollbackBytes > MAX_SCROLLBACK_BYTES) { + console.warn( + `[DaemonTerminalManager] initialScrollback for ${paneId} too large (${initialScrollbackBytes} bytes), truncating to ${MAX_SCROLLBACK_BYTES}`, + ); + // Keep the most recent content (end of scrollback) + safeScrollback = truncateUtf8ToLastBytes( + initialScrollback, + MAX_SCROLLBACK_BYTES, + ); + } } } @@ -522,7 +535,7 @@ export class DaemonTerminalManager extends EventEmitter { skipColdRestore, } = params; - const MAX_SCROLLBACK_CHARS = 500_000; + const MAX_SCROLLBACK_BYTES = 500_000; try { // Sticky cold restore info (survives React StrictMode remounts). @@ -571,10 +584,12 @@ export class DaemonTerminalManager extends EventEmitter { await historyReader.cleanup(); // Fall through to create new session } else { + const rawScrollbackBytes = Buffer.byteLength(rawScrollback, "utf8"); const scrollback = - rawScrollback.length > MAX_SCROLLBACK_CHARS - ? rawScrollback.slice(-MAX_SCROLLBACK_CHARS) + rawScrollbackBytes > MAX_SCROLLBACK_BYTES + ? truncateUtf8ToLastBytes(rawScrollback, MAX_SCROLLBACK_BYTES) : rawScrollback; + const scrollbackBytes = Buffer.byteLength(scrollback, "utf8"); // Store sticky info so StrictMode remounts still show cold restore. this.coldRestoreInfo.set(paneId, { @@ -588,7 +603,7 @@ export class DaemonTerminalManager extends EventEmitter { track("terminal_cold_restored", { workspace_id: workspaceId, pane_id: paneId, - scrollback_bytes: scrollback.length, + scrollback_bytes: scrollbackBytes, }); return { @@ -682,9 +697,10 @@ export class DaemonTerminalManager extends EventEmitter { // Initialize history writer for reboot persistence. const snapshotAnsi = response.snapshot.snapshotAnsi || ""; + const snapshotAnsiBytes = Buffer.byteLength(snapshotAnsi, "utf8"); const initialScrollback = - snapshotAnsi.length > MAX_SCROLLBACK_CHARS - ? snapshotAnsi.slice(-MAX_SCROLLBACK_CHARS) + snapshotAnsiBytes > MAX_SCROLLBACK_BYTES + ? truncateUtf8ToLastBytes(snapshotAnsi, MAX_SCROLLBACK_BYTES) : snapshotAnsi; if (effectiveCols >= 1 && effectiveRows >= 1) { @@ -718,7 +734,9 @@ export class DaemonTerminalManager extends EventEmitter { track("terminal_warm_attached", { workspace_id: workspaceId, pane_id: paneId, - snapshot_bytes: response.snapshot.snapshotAnsi?.length ?? 0, + snapshot_bytes: response.snapshot.snapshotAnsi + ? Buffer.byteLength(response.snapshot.snapshotAnsi, "utf8") + : 0, }); } From 1068f6c084ec21077b9a788714663307218d26ac Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 10 Jan 2026 01:04:33 +0200 Subject: [PATCH 44/62] docs(desktop): add terminal runtime abstraction plan --- ...13-terminal-runtime-abstraction-rewrite.md | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md diff --git a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md new file mode 100644 index 00000000000..6726942ea53 --- /dev/null +++ b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md @@ -0,0 +1,274 @@ +# Terminal Runtime Abstraction (Daemon vs In-Process) + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Reference: This plan follows conventions from `AGENTS.md`, `apps/desktop/AGENTS.md`, and the ExecPlan template in `.agents/commands/create-plan.md`. + + +## Purpose / Big Picture + +After this change, the desktop app still supports terminal persistence (daemon mode with cold restore) exactly as it does today, but the codebase no longer leaks “daemon vs in-process” branching across the tRPC router and UI. The backend selection becomes a single responsibility owned by `apps/desktop/src/main/lib/terminal/`, making the feature easier to review, less fragile, and a better foundation for future “cloud terminal” backends. + +Observable outcomes: + +1. With terminal persistence disabled, terminals behave as before (no persistence across app restarts), and Settings → Terminal “Manage sessions” shows that daemon management is unavailable. +2. With terminal persistence enabled, terminals survive app restarts, cold restore works, and Settings → Terminal “Manage sessions” continues to list/kill sessions. +3. The tRPC `terminal.*` router no longer needs `instanceof DaemonTerminalManager` checks; daemon awareness is centralized in the terminal runtime layer. + + +## Context / Orientation (Repository Map) + +Superset Desktop is an Electron app. In this repo: + +1. “Main process” code runs in Node.js and can import Node modules. It lives under `apps/desktop/src/main/`. +2. “Renderer” code runs in a browser-like environment and must not import Node modules. It lives under `apps/desktop/src/renderer/`. +3. IPC between renderer and main is implemented using tRPC (“tRPC router” code lives under `apps/desktop/src/lib/trpc/routers/`). Subscriptions in this repo must use the `observable` pattern (`apps/desktop/AGENTS.md`), not async generators. + +The terminal system currently has two possible backends: + +1. In-process backend: `apps/desktop/src/main/lib/terminal/manager.ts` (`TerminalManager`). This owns PTYs directly in the Electron main process. +2. Daemon backend: `apps/desktop/src/main/lib/terminal/daemon-manager.ts` (`DaemonTerminalManager`). This delegates PTY ownership to a background “terminal host” process and communicates via a client (`apps/desktop/src/main/lib/terminal-host/client.ts`) over a Unix domain socket. + +Terminal APIs exposed to the renderer are implemented in `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`. + + +## Problem Statement + +The daemon persistence feature is working, but the PR is hard to review and maintain because “daemon vs non-daemon” concerns appear outside the terminal subsystem boundary. Examples include `instanceof DaemonTerminalManager` checks in the tRPC router and UI code paths that must reason about backend-specific behavior. + +This plan refactors the code so backend selection and backend-specific capabilities live behind a single “terminal runtime” abstraction, while preserving current behavior and test coverage. This also positions us for a future backend that executes terminals in the cloud, without re-spreading backend-specific branching throughout the application. + + +## Definitions (Plain Language) + +Pane ID (`paneId`): a stable identifier for a terminal pane in the renderer’s tab layout. It is used as the session key across restarts and reattaches. + +Terminal session: the running PTY process and its terminal emulator state. + +Warm attach: reconnecting to a still-running session (daemon still has the PTY). + +Cold restore: restoring scrollback from disk after an unclean shutdown or daemon session loss, before starting a new shell. + +Terminal runtime: a single module-level API in `apps/desktop/src/main/lib/terminal/` that selects the active terminal backend (daemon or in-process) and exposes a unified surface to callers. + +Capabilities: optional features that exist only for some backends (for example “list/manage persistent sessions”). Callers should not use `instanceof` checks. Capability presence must be represented structurally (for example `daemon: null` when unavailable) and via explicit capability flags, so “unsupported” cannot be confused with “success”. + + +## Non-Goals + +This refactor is intentionally conservative to avoid regressions: + +1. No protocol redesign between main and terminal-host. +2. No behavioral change to cold restore, attach scheduling, warm set mounting, or stream lifecycle. +3. No implementation of cloud terminals in this PR. The plan only ensures the abstraction boundary is compatible with adding a cloud backend later. + + +## Assumptions + +1. Windows is not a supported desktop target right now, so Unix-domain socket constraints are acceptable. +2. The terminal persistence setting (`settings.terminalPersistence`) is treated as “requires restart” today; we keep that behavior for this refactor. +3. tRPC subscriptions must use `observable` (per `apps/desktop/AGENTS.md`); we will not introduce generator-based subscriptions. +4. The most important regression to prevent is the “listeners=0” cold-restore failure mode; specifically, the `terminal.stream` subscription must not complete on exit. + + +## Open Questions + +1. Naming: should the abstraction be named `TerminalRuntime`, `TerminalService`, or keep `getActiveTerminalManager()` and add a new `getTerminalRuntime()` alongside it? (This plan assumes `getTerminalRuntime()` returning a `TerminalRuntime` facade exported from `apps/desktop/src/main/lib/terminal/index.ts`.) +2. Should we keep the existing tRPC endpoint names (`terminal.listDaemonSessions`, `terminal.killAllDaemonSessions`, etc.) for backwards compatibility in the renderer? (This plan assumes “yes” to minimize churn and risk.) +3. For future cloud terminals, do we want to preserve the current “`paneId` == `sessionId`” mapping, or introduce a distinct backend session identifier (for example `backendSessionId`) and map panes to backend sessions? (This plan assumes we do not change identity mapping in this PR to reduce regression risk, but we keep the runtime contract compatible with adding a distinct backend session identifier later.) + + +## Plan of Work + +This work is a refactor, so milestones are organized to keep behavior stable and to validate frequently. + + +### Milestone 1: Establish a Backend Contract and Invariants + +This milestone documents and codifies the contract we must preserve during the refactor. At completion, a reader can point to a single place in the codebase that defines “what the terminal backend must do”, and a single set of invariants that all implementations must satisfy. + +Scope: + +1. Identify the backend API surface currently used by callers outside `apps/desktop/src/main/lib/terminal/` by searching for usages of: + - `getActiveTerminalManager()` + - events `data:${paneId}`, `exit:${paneId}`, `disconnect:${paneId}`, `error:${paneId}`, and `terminalExit` +2. Write an explicit “TerminalSessionBackend contract” type in `apps/desktop/src/main/lib/terminal/` (likely in `apps/desktop/src/main/lib/terminal/types.ts` or a new `runtime.ts`). This contract should include: + - The core operations used by the renderer (create/attach/write/resize/signal/kill/detach/clearScrollback). + - The workspace operations used by other routers (killByWorkspaceId, getSessionCountByWorkspaceId, refreshPromptsForWorkspace). + - The EventEmitter event names used by the tRPC stream and notifications bridge. +3. Record invariants in code comments near the contract: + - `terminal.stream` must not complete on `exit`. + - `exit` is a state transition, not an end-of-stream. + - The output stream lifecycle is separate from session lifecycle: the stream completes only when the client unsubscribes (dispose), not when a session exits. + - All backend operations must be normalized to async (Promise-returning) at the contract boundary, even if an implementation currently has a sync method (example: `clearScrollback`). + - The terminal EventEmitter must be owned by the backend instance; the runtime facade must not introduce a shared/global EventEmitter or re-emit events in a way that can cause cross-talk or duplicate listeners. + +Acceptance: + +1. A developer can find the contract definition in one place and see the invariants described in plain language. +2. No runtime behavior changes yet. + + +### Milestone 2: Implement `TerminalRuntime` Facade + Capabilities + +This milestone introduces a single runtime entry point that owns backend selection and exposes backend-specific capabilities in a consistent, no-branching way to callers. + +Approach: + +1. Create a small facade in `apps/desktop/src/main/lib/terminal/` (recommended: `apps/desktop/src/main/lib/terminal/runtime.ts`) that exports: + - `getTerminalRuntime(): TerminalRuntime` +2. `TerminalRuntime` should have three parts: + - `sessions: TerminalSessionBackend` (the active backend implementing the normalized async session contract) + - `daemon: DaemonManagement | null` (nullable capability object; `null` when daemon management is not supported/active) + - `capabilities: { persistent: boolean; coldRestore: boolean; remoteManagement: boolean }` (feature flags that do not encode implementation details and leave room for a future cloud backend) +3. Do not use “no-op admin methods”. The absence of daemon capabilities must be represented structurally (`daemon: null`) so callers cannot confuse “unsupported” with “success”. +4. Ensure the facade is process-scoped and constructed once. The tRPC router should capture the runtime once at router construction time (not per request) to avoid multiplying event listeners or daemon client connections. +5. Export the runtime from `apps/desktop/src/main/lib/terminal/index.ts` as the only supported way to reach daemon-specific functionality. + +Acceptance: + +1. The runtime and capabilities surface is defined in `apps/desktop/src/main/lib/terminal/` and is the only code that knows which backend is active. +2. In non-daemon mode, `runtime.daemon` is `null` and callers must handle that explicitly; unsupported operations are not silently treated as success. + + +### Milestone 3: Migrate tRPC `terminal.*` Router to the Runtime + +This milestone removes daemon branching from the tRPC router by routing all terminal work through `getTerminalRuntime()`. + +Scope: + +1. Update `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to use: + - `const runtime = getTerminalRuntime()` (or equivalent) + - Replace `instanceof DaemonTerminalManager` checks with checks on `runtime.daemon` capability presence. +2. Preserve the existing endpoint names and response shapes so the renderer does not need behavioral changes: + - `listDaemonSessions` returns `{ daemonModeEnabled, sessions }` + - `killAllDaemonSessions` returns `{ daemonModeEnabled, killedCount }` + - `killDaemonSessionsForWorkspace` returns `{ daemonModeEnabled, killedCount }` + - `clearTerminalHistory` returns `{ success: true }` but calls daemon history reset when the daemon capability is present +3. Ensure the `stream` subscription continues to use `observable` and continues to not complete on `exit`. +4. Error semantics must be explicit: + - If daemon capability is absent, return `daemonModeEnabled: false` (UI will show “restart app after enabling persistence” messaging). + - If daemon capability is present but the operation fails (daemon unreachable, request fails), surface the error (do not convert it into `daemonModeEnabled: false`). + +Acceptance: + +1. No usage of `instanceof DaemonTerminalManager` remains in `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`. +2. The renderer does not need to change its API calls. + + +### Milestone 4: Add Regression Coverage for the Abstraction Boundary + +This milestone makes the new boundary hard to accidentally regress later. + +Scope: + +1. Add a unit test that asserts the non-daemon runtime returns `daemon: null` (capability absent) without requiring daemon availability. This test must not spawn a real daemon. +2. Keep and/or extend the existing “stream does not complete on exit” regression test in `apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts`. +3. If we add any new helper modules, ensure they are covered by at least one focused unit test. +4. Add a test that ensures admin operations fail loudly on error (for example, simulate a daemon management call throwing and assert the error propagates), so we do not accidentally reintroduce silent “disabled” fallbacks for real failures. + +Acceptance: + +1. Tests fail if someone reintroduces daemon-specific branching in the router or reintroduces “complete on exit”. + + +### Milestone 5: Manual Verification (High-Coverage, Low Surprises) + +This milestone uses the existing PR verification matrix (kept in the PR description) and focuses on the specific regressions most likely during a refactor: missing output, stuck exits, incorrect detach behavior, and workspace deletion behavior. + +Validation should be run both with terminal persistence disabled and enabled. + +Acceptance: + +1. The matrix items for non-daemon, daemon warm attach, and daemon cold restore all pass. + + +## Validation + +Run these commands from the repo root: + + bun run lint + bun run typecheck --filter=@superset/desktop + bun test --filter=@superset/desktop + +Expected results: + +1. `bun run lint` exits with code 0 (Biome check is strict in this repo). +2. Typecheck passes with no TypeScript errors. +3. Desktop tests pass (some terminal-host lifecycle tests may remain skipped; do not “fix” unrelated skips as part of this refactor). + + +## Idempotence / Safety + +This plan is safe to apply iteratively: + +1. Changes are limited to TypeScript source and tests; no production database access is required. +2. Each milestone should be merged/committed independently so failures can be bisected quickly. +3. If a milestone introduces a regression, revert the milestone commit and re-apply with a smaller diff. + + +## Risks and Mitigations + +Risk: The runtime facade changes event wiring in a way that causes missed output or duplicate listeners. + +Mitigation: Keep the EventEmitter contract unchanged (`data:${paneId}`, `exit:${paneId}`), keep `terminal.stream` semantics unchanged, and use tests + manual matrix to confirm “output still flows after exit/cold restore”. + +Risk: Output loss during attach if the stream subscription attaches after early PTY output (race between `createOrAttach` and `terminal.stream` subscribe). + +Mitigation: Preserve the current renderer sequencing (subscription established while the component is mounted, initial state applied from snapshot/scrollback, and stream events queued until ready). During manual QA, include at least one “immediate output” command (example: `echo READY`) and confirm it is visible reliably. If a reproducible loss exists, add a small per-pane ring buffer (bounded bytes) at the backend boundary and flush it to the first subscriber as a targeted follow-up. + +Risk: Admin capability handling masks real errors (a true daemon failure being reported as “disabled”). + +Mitigation: Represent daemon management as a nullable capability object (`daemon: null` when unavailable). When `daemon` is present but calls fail, propagate errors (and test this explicitly). + +Risk: A future cloud backend would require different identity mapping than `paneId == sessionId`. + +Mitigation: Do not change identity mapping in this refactor, but ensure the runtime contract does not assume Unix sockets or local process ownership. The future cloud backend should implement the same contract behind `TerminalRuntime`. + + +## Progress + +### Milestone 1 + +- [ ] Inventory terminal backend call sites and events +- [ ] Write TerminalBackend contract type and invariants comment +- [ ] Confirm no behavior change; run `bun run lint` + +### Milestone 2 + +- [ ] Implement `getTerminalRuntime()` facade in `apps/desktop/src/main/lib/terminal/` +- [ ] Implement daemon management as `daemon: DaemonManagement | null` (no no-op admin methods) +- [ ] Run `bun run typecheck --filter=@superset/desktop` + +### Milestone 3 + +- [ ] Migrate `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to runtime +- [ ] Remove `instanceof DaemonTerminalManager` checks +- [ ] Run `bun test --filter=@superset/desktop` + +### Milestone 4 + +- [ ] Add/adjust unit tests for capability presence (`daemon: null`) and error propagation +- [ ] Confirm stream exit regression test still covers “no complete on exit” +- [ ] Run full validation commands + +### Milestone 5 + +- [ ] Manual verification with persistence disabled +- [ ] Manual verification with persistence enabled (warm attach) +- [ ] Manual verification for cold restore “Start Shell” path + + +## Surprises & Discoveries + +(Fill this in during implementation with dates and short, factual notes.) + + +## Decision Log + +(Move items from Open Questions here as they are resolved; include rationale and date.) + + +## Outcomes & Retrospective + +(Fill this in at the end: what changed, how to verify, what follow-ups remain, what you would do differently.) From d6e6319739529bbd753ce45777dfb5ce474d87df Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 10 Jan 2026 03:24:41 +0200 Subject: [PATCH 45/62] docs(desktop): expand plan for Terminal.tsx decomposition --- ...13-terminal-runtime-abstraction-rewrite.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md index 6726942ea53..6ba319b40a4 100644 --- a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md +++ b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md @@ -14,6 +14,7 @@ Observable outcomes: 1. With terminal persistence disabled, terminals behave as before (no persistence across app restarts), and Settings → Terminal “Manage sessions” shows that daemon management is unavailable. 2. With terminal persistence enabled, terminals survive app restarts, cold restore works, and Settings → Terminal “Manage sessions” continues to list/kill sessions. 3. The tRPC `terminal.*` router no longer needs `instanceof DaemonTerminalManager` checks; daemon awareness is centralized in the terminal runtime layer. +4. The renderer terminal component remains correct but is easier to reason about because backend-agnostic “session initialization” and “stream event handling” logic is extracted into small, testable helpers rather than being interleaved with UI rendering. ## Context / Orientation (Repository Map) @@ -183,6 +184,31 @@ Acceptance: 1. The matrix items for non-daemon, daemon warm attach, and daemon cold restore all pass. +### Milestone 6: Reduce Branching in `Terminal.tsx` (Renderer Decomposition) + +This milestone reduces complexity in the renderer terminal component without changing behavior. The goal is not to “rewrite the terminal UI”, but to isolate protocol/state-machine logic (snapshot vs scrollback selection, restore sequencing, stream buffering, and cold restore gating) into small units that can be tested. This work is optional from a feature perspective but strongly recommended to reduce regression risk as we add future backends (for example cloud terminals) and expand lifecycle handling (disconnect/retry, auth expiry, etc.). + +Scope: + +1. Add a small “session init adapter” that converts the tRPC `createOrAttach` result into a single normalized “initialization plan”: + - Canonical initial content (`initialAnsi`) is `snapshot.snapshotAnsi ?? scrollback`. + - Rehydrate sequences and mode flags are always present in the plan (with fallbacks where snapshot modes are missing). + - The plan contains a single restore strategy decision, for example “alt-screen redraw” vs “snapshot replay”, based on the same conditions `Terminal.tsx` uses today. +2. Add a “restore applier” helper that owns the strict ordering guarantees during restore: + - Apply rehydrate sequences, then snapshot replay, then mark the stream as ready and flush queued events. + - Preserve the existing “alt-screen reattach” behavior where we enter alt-screen first and trigger a redraw via resize/SIGWINCH sequence (to avoid white screens). +3. Add a small “stream handler” helper (or hook) that owns buffering until ready: + - Queue incoming `terminal.stream` events until the terminal is ready, then flush. + - Keep the important invariant that the subscription does not complete on `exit` (exit is a state transition). +4. Refactor `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` to use these helpers, reducing conditional branches in the component and keeping the UI concerns (overlays, buttons, focus) separate from protocol logic. + +Acceptance: + +1. `Terminal.tsx` still behaves identically (cold restore overlay, Start Shell flow, retry connection flow, exit prompt flow), but the core initialization/stream logic is exercised via helper functions that can be unit tested. +2. At least one unit test exists for the session init adapter to lock in “snapshot vs scrollback” canonicalization and mode fallback behavior. +3. No Node.js imports are introduced in renderer code as part of this refactor. + + ## Validation Run these commands from the repo root: @@ -258,6 +284,14 @@ Mitigation: Do not change identity mapping in this refactor, but ensure the runt - [ ] Manual verification with persistence enabled (warm attach) - [ ] Manual verification for cold restore “Start Shell” path +### Milestone 6 + +- [ ] Implement session init adapter (normalize snapshot vs scrollback, restore strategy) +- [ ] Implement restore applier helper (rehydrate → snapshot → stream ready) +- [ ] Implement stream handler helper/hook (buffer until ready, flush deterministically) +- [ ] Refactor `Terminal.tsx` to use helpers, preserving behavior +- [ ] Add focused unit tests for adapter/helper invariants + ## Surprises & Discoveries From fef4ce9d5931c2b7c00e8cf33fc9328aa789e850 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 10 Jan 2026 03:33:11 +0200 Subject: [PATCH 46/62] docs(desktop): add target architecture snippets to plan --- ...13-terminal-runtime-abstraction-rewrite.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md index 6ba319b40a4..7e5cf81b79d 100644 --- a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md +++ b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md @@ -84,6 +84,239 @@ This refactor is intentionally conservative to avoid regressions: This work is a refactor, so milestones are organized to keep behavior stable and to validate frequently. +## Target Shape (After Refactor) + +This section is illustrative. It shows the intended file layout, key types, and call flows after the refactor. It is not a full implementation, but it should be concrete enough that a new contributor can “see” how responsibilities move out of the tRPC router and out of `Terminal.tsx`. + + +### File Tree (Proposed) + + apps/desktop/src/main/lib/terminal/ + index.ts # exports getTerminalRuntime() + runtime.ts # TerminalRuntime + selection (process-scoped) + runtime-types.ts # TerminalSessionBackend / DaemonManagement / capabilities + manager.ts # in-process backend (existing) + daemon-manager.ts # daemon backend (existing) + types.ts # existing shared terminal types (CreateSessionParams, SessionResult, events) + + apps/desktop/src/lib/trpc/routers/terminal/ + terminal.ts # uses getTerminalRuntime(); no instanceof checks + terminal.stream.test.ts # stream invariants (exit does not complete) + + apps/desktop/src/renderer/.../Terminal/ + Terminal.tsx # UI wiring, minimal branching + init-plan.ts # buildTerminalInitPlan(result) -> TerminalInitPlan + apply-init-plan.ts # applyTerminalInitPlan({ xterm, plan, ... }) + useTerminalStream.ts # buffering + flush until ready (no UI) + types.ts # TerminalInitPlan + stream event types (renderer-only) + hooks/ + useTerminalConnection.ts # tRPC mutations (existing) + + +### Terminal Runtime Types (Main Process) + +The goal is to stop encoding backend choice as a “mode string” that callers branch on. Callers should see capabilities and nullable management objects instead. + + export interface TerminalCapabilities { + /** Sessions can survive app restarts */ + persistent: boolean; + /** Backend supports cold restore (disk-backed or otherwise) */ + coldRestore: boolean; + /** Sessions can be managed remotely (future: cloud terminals) */ + remoteManagement: boolean; + } + + export interface TerminalSessionBackend { + // Core lifecycle (normalized to async, even if an implementation is sync today) + createOrAttach(params: CreateSessionParams): Promise; + write(params: { paneId: string; data: string }): Promise; + resize(params: { paneId: string; cols: number; rows: number; seq?: number }): Promise; + signal(params: { paneId: string; signal?: string }): Promise; + kill(params: { paneId: string }): Promise; + detach(params: { paneId: string }): Promise; + clearScrollback(params: { paneId: string }): Promise; + + // Workspace helpers used outside the terminal router + killByWorkspaceId(workspaceId: string): Promise<{ killed: number; failed: number }>; + getSessionCountByWorkspaceId(workspaceId: string): Promise; + refreshPromptsForWorkspace(workspaceId: string): Promise; + + // Cold restore semantics + ackColdRestore(paneId: string): Promise; + + // EventEmitter contract (must match today) + on(event: string, listener: (...args: unknown[]) => void): this; + off(event: string, listener: (...args: unknown[]) => void): this; + once(event: string, listener: (...args: unknown[]) => void): this; + } + + export interface DaemonManagement { + listSessions(): Promise; + forceKillAll(): Promise; + resetHistoryPersistence(): Promise; + } + + export interface TerminalRuntime { + sessions: TerminalSessionBackend; + daemon: DaemonManagement | null; + capabilities: TerminalCapabilities; + } + +`getTerminalRuntime()` must return the same instance across the process lifetime (or at minimum the same `sessions` object), so we do not multiply event listeners or daemon connections. + + let cachedRuntime: TerminalRuntime | null = null; + + export function getTerminalRuntime(): TerminalRuntime { + if (cachedRuntime) return cachedRuntime; + + const sessions = getActiveTerminalManager(); // existing selection logic (cached by “requires restart”) + const daemon = sessions instanceof DaemonTerminalManager ? sessions : null; + + cachedRuntime = { + sessions, + daemon: daemon + ? { + listSessions: () => daemon.listDaemonSessions(), + forceKillAll: () => daemon.forceKillAll(), + resetHistoryPersistence: () => daemon.resetHistoryPersistence(), + } + : null, + capabilities: { + persistent: daemon !== null, + coldRestore: daemon !== null, + remoteManagement: false, + }, + }; + + return cachedRuntime; + } + +Notes: + +1. The `sessions instanceof DaemonTerminalManager` check is allowed here because this module is the only backend-selection boundary; the tRPC router and UI must not need it. +2. If daemon capability exists but a call fails (daemon unreachable, request fails), we propagate the error. We do not convert failures into “daemon disabled” states. + + +### tRPC Router Shape (No Daemon Type Checks) + +The terminal router captures the runtime once when the router is created (not per request), and then delegates consistently. It branches only on the presence of a capability object (`runtime.daemon`), never on `instanceof`. + + export const createTerminalRouter = () => { + const runtime = getTerminalRuntime(); + + return router({ + createOrAttach: publicProcedure + .input(...) + .mutation(async ({ input }) => runtime.sessions.createOrAttach(input)), + + stream: publicProcedure + .input(z.string()) + .subscription(({ input: paneId }) => + observable((emit) => { + const onExit = (exitCode: number, signal?: number) => { + // IMPORTANT: do not complete on exit + emit.next({ type: "exit", exitCode, signal }); + }; + ... + }), + ), + + listDaemonSessions: publicProcedure.query(async () => { + if (!runtime.daemon) return { daemonModeEnabled: false, sessions: [] }; + const response = await runtime.daemon.listSessions(); + return { daemonModeEnabled: true, sessions: response.sessions }; + }), + }); + }; + + +### Renderer Decomposition (Reducing `Terminal.tsx` Branching) + +The renderer still needs to implement UI behaviors (cold restore overlay, retry overlay, focus, hotkeys), but it should not be the place where we interleave protocol concerns and restoration sequencing. The refactor decomposes the terminal renderer into three small helpers and keeps `Terminal.tsx` as wiring. + +`init-plan.ts` (pure adapter): + + export interface TerminalInitPlan { + initialAnsi: string; + rehydrateSequences: string; + cwd: string | null; + modes: { alternateScreen: boolean; bracketedPaste: boolean }; + restoreStrategy: "altScreenRedraw" | "snapshotReplay"; + isColdRestore: boolean; + previousCwd: string | null; + } + + // `CreateOrAttachOutput` here refers to the renderer-visible shape returned by + // `trpc.terminal.createOrAttach` (which includes `snapshot` and/or `scrollback`). + export function buildTerminalInitPlan(result: CreateOrAttachOutput): TerminalInitPlan { + const initialAnsi = result.snapshot?.snapshotAnsi ?? result.scrollback ?? ""; + ... + return { ... }; + } + +`apply-init-plan.ts` (ordering guarantees): + + export async function applyTerminalInitPlan(params: { + xterm: Terminal; + fitAddon: FitAddon; + plan: TerminalInitPlan; + onReady: () => void; // marks stream ready + flushes pending events + }): Promise { + // apply rehydrate → apply snapshot → then onReady + // if altScreenRedraw: enter alt screen, then trigger redraw, then onReady + } + +`useTerminalStream.ts` (buffering until ready): + + export function useTerminalStream(params: { + paneId: string; + onEvent: (event: TerminalStreamEvent) => void; + isReady: () => boolean; + onBufferFlush: (events: TerminalStreamEvent[]) => void; + }) { + // subscribe via trpc.terminal.stream.useSubscription + // queue events while !isReady(), then flush deterministically when ready + } + +`Terminal.tsx` becomes composition: + + const plan = buildTerminalInitPlan(result); + await applyTerminalInitPlan({ xterm, fitAddon, plan, onReady: () => setStreamReady(true) }); + +The critical invariants remain unchanged: + +1. The stream subscription does not complete on exit. +2. Events arriving “too early” are buffered until restore is finished. +3. Cold restore remains read-only until Start Shell is clicked, and stale queued events are dropped before starting a new session (prevents “exit clears UI” regressions). + + +### Diagrams (Call Flow) + +Main call flow (today and after refactor; the difference is where switching happens): + + Renderer (Terminal.tsx + helpers) + | + | trpc.terminal.createOrAttach / trpc.terminal.stream + v + Electron Main (tRPC router) + | + | getTerminalRuntime().sessions (no backend checks in router) + v + Terminal Backend (in-process OR daemon-manager) + | + | (daemon only) TerminalHostClient over unix socket + v + Terminal Host Daemon ---> PTY subprocess per session + +Renderer composition after Milestone 6: + + Terminal.tsx + ├─ useTerminalConnection() (tRPC mutations) + ├─ useTerminalStream() (buffer until ready; never completes on exit) + ├─ buildTerminalInitPlan() (normalize snapshot vs scrollback, decide restore strategy) + └─ applyTerminalInitPlan() (rehydrate → snapshot or alt-screen redraw → mark ready) + + ### Milestone 1: Establish a Backend Contract and Invariants This milestone documents and codifies the contract we must preserve during the refactor. At completion, a reader can point to a single place in the codebase that defines “what the terminal backend must do”, and a single set of invariants that all implementations must satisfy. From 0f0b38529df494407ee9347e30fa540cb766fbe6 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 10 Jan 2026 11:13:02 +0200 Subject: [PATCH 47/62] docs(desktop): refine terminal runtime abstraction plan --- ...13-terminal-runtime-abstraction-rewrite.md | 256 ++++++++++++++---- 1 file changed, 202 insertions(+), 54 deletions(-) diff --git a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md index 7e5cf81b79d..de003473045 100644 --- a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md +++ b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md @@ -42,7 +42,9 @@ This plan refactors the code so backend selection and backend-specific capabilit ## Definitions (Plain Language) -Pane ID (`paneId`): a stable identifier for a terminal pane in the renderer’s tab layout. It is used as the session key across restarts and reattaches. +Pane ID (`paneId`): a stable identifier for a terminal pane in the renderer’s tab layout. Today it is also used as the backend session key, but the refactor should avoid assuming `paneId === backendSessionId` forever (cloud terminals will likely need a distinct backend identity). + +Backend session ID (`backendSessionId`): an identifier assigned by the backend for the running session. For local backends, this may continue to equal `paneId`, but future backends (cloud/multi-device) should be free to assign their own IDs and map multiple panes/clients to the same backend session. Terminal session: the running PTY process and its terminal emulator state. @@ -76,7 +78,7 @@ This refactor is intentionally conservative to avoid regressions: 1. Naming: should the abstraction be named `TerminalRuntime`, `TerminalService`, or keep `getActiveTerminalManager()` and add a new `getTerminalRuntime()` alongside it? (This plan assumes `getTerminalRuntime()` returning a `TerminalRuntime` facade exported from `apps/desktop/src/main/lib/terminal/index.ts`.) 2. Should we keep the existing tRPC endpoint names (`terminal.listDaemonSessions`, `terminal.killAllDaemonSessions`, etc.) for backwards compatibility in the renderer? (This plan assumes “yes” to minimize churn and risk.) -3. For future cloud terminals, do we want to preserve the current “`paneId` == `sessionId`” mapping, or introduce a distinct backend session identifier (for example `backendSessionId`) and map panes to backend sessions? (This plan assumes we do not change identity mapping in this PR to reduce regression risk, but we keep the runtime contract compatible with adding a distinct backend session identifier later.) +3. For future cloud terminals, do we want to introduce a distinct backend session identifier (`backendSessionId`) now (even if it equals `paneId` today), or defer it to a follow-up after the daemon vs in-process leakage is fixed? (This plan assumes we defer a wire-contract identity migration to keep this refactor lower-risk, but we explicitly call out a follow-up milestone to introduce `backendSessionId` cleanly if/when cloud is near-term.) ## Plan of Work @@ -94,7 +96,6 @@ This section is illustrative. It shows the intended file layout, key types, and apps/desktop/src/main/lib/terminal/ index.ts # exports getTerminalRuntime() runtime.ts # TerminalRuntime + selection (process-scoped) - runtime-types.ts # TerminalSessionBackend / DaemonManagement / capabilities manager.ts # in-process backend (existing) daemon-manager.ts # daemon backend (existing) types.ts # existing shared terminal types (CreateSessionParams, SessionResult, events) @@ -126,28 +127,41 @@ The goal is to stop encoding backend choice as a “mode string” that callers remoteManagement: boolean; } - export interface TerminalSessionBackend { + export interface TerminalSessionOperations { // Core lifecycle (normalized to async, even if an implementation is sync today) createOrAttach(params: CreateSessionParams): Promise; write(params: { paneId: string; data: string }): Promise; resize(params: { paneId: string; cols: number; rows: number; seq?: number }): Promise; signal(params: { paneId: string; signal?: string }): Promise; kill(params: { paneId: string }): Promise; - detach(params: { paneId: string }): Promise; + detach(params: { paneId: string; viewportY?: number }): Promise; clearScrollback(params: { paneId: string }): Promise; + ackColdRestore(params: { paneId: string }): Promise; + } - // Workspace helpers used outside the terminal router + export interface TerminalWorkspaceOperations { killByWorkspaceId(workspaceId: string): Promise<{ killed: number; failed: number }>; getSessionCountByWorkspaceId(workspaceId: string): Promise; refreshPromptsForWorkspace(workspaceId: string): Promise; + } - // Cold restore semantics - ackColdRestore(paneId: string): Promise; - - // EventEmitter contract (must match today) - on(event: string, listener: (...args: unknown[]) => void): this; - off(event: string, listener: (...args: unknown[]) => void): this; - once(event: string, listener: (...args: unknown[]) => void): this; + export type TerminalPaneEvent = + | { type: "data"; data: string } + | { type: "exit"; exitCode: number; signal?: number } + | { type: "disconnect"; reason: string } + | { type: "error"; error: string; code?: string }; + + export interface TerminalEventSource { + // Backend-agnostic event subscription API (do not expose Node EventEmitter semantics) + subscribePane(params: { + paneId: string; + onEvent: (event: TerminalPaneEvent) => void; + }): () => void; + + // Low-volume lifecycle events used for correctness when panes are unmounted. + subscribeTerminalExit(params: { + onExit: (event: { paneId: string; exitCode: number; signal?: number }) => void; + }): () => void; } export interface DaemonManagement { @@ -157,7 +171,9 @@ The goal is to stop encoding backend choice as a “mode string” that callers } export interface TerminalRuntime { - sessions: TerminalSessionBackend; + sessions: TerminalSessionOperations; + workspaces: TerminalWorkspaceOperations; + events: TerminalEventSource; daemon: DaemonManagement | null; capabilities: TerminalCapabilities; } @@ -169,21 +185,65 @@ The goal is to stop encoding backend choice as a “mode string” that callers export function getTerminalRuntime(): TerminalRuntime { if (cachedRuntime) return cachedRuntime; - const sessions = getActiveTerminalManager(); // existing selection logic (cached by “requires restart”) - const daemon = sessions instanceof DaemonTerminalManager ? sessions : null; + const backend = getActiveTerminalManager(); // existing selection logic (cached by “requires restart”) + const daemonManager = backend instanceof DaemonTerminalManager ? backend : null; cachedRuntime = { - sessions, - daemon: daemon + sessions: { + createOrAttach: (params) => backend.createOrAttach(params), + write: async (params) => backend.write(params), + resize: async (params) => backend.resize(params), + signal: async (params) => backend.signal(params), + kill: (params) => backend.kill(params), + detach: async (params) => backend.detach(params), + clearScrollback: async (params) => backend.clearScrollback(params), + ackColdRestore: async (params) => backend.ackColdRestore(params.paneId), + }, + workspaces: { + killByWorkspaceId: (workspaceId) => backend.killByWorkspaceId(workspaceId), + getSessionCountByWorkspaceId: (workspaceId) => + backend.getSessionCountByWorkspaceId(workspaceId), + refreshPromptsForWorkspace: async (workspaceId) => + backend.refreshPromptsForWorkspace(workspaceId), + }, + events: { + subscribePane: ({ paneId, onEvent }) => { + const onData = (data: string) => onEvent({ type: "data", data }); + const onExit = (exitCode: number, signal?: number) => + onEvent({ type: "exit", exitCode, signal }); + const onDisconnect = (reason: string) => + onEvent({ type: "disconnect", reason }); + const onError = (payload: { error: string; code?: string }) => + onEvent({ type: "error", error: payload.error, code: payload.code }); + + backend.on(`data:${paneId}`, onData); + backend.on(`exit:${paneId}`, onExit); + backend.on(`disconnect:${paneId}`, onDisconnect); + backend.on(`error:${paneId}`, onError); + + return () => { + backend.off(`data:${paneId}`, onData); + backend.off(`exit:${paneId}`, onExit); + backend.off(`disconnect:${paneId}`, onDisconnect); + backend.off(`error:${paneId}`, onError); + }; + }, + subscribeTerminalExit: ({ onExit }) => { + backend.on("terminalExit", onExit); + return () => backend.off("terminalExit", onExit); + }, + }, + daemon: daemonManager ? { - listSessions: () => daemon.listDaemonSessions(), - forceKillAll: () => daemon.forceKillAll(), - resetHistoryPersistence: () => daemon.resetHistoryPersistence(), + listSessions: () => daemonManager.listDaemonSessions(), + forceKillAll: () => daemonManager.forceKillAll(), + resetHistoryPersistence: () => + daemonManager.resetHistoryPersistence(), } : null, capabilities: { - persistent: daemon !== null, - coldRestore: daemon !== null, + persistent: daemonManager !== null, + coldRestore: daemonManager !== null, remoteManagement: false, }, }; @@ -193,8 +253,9 @@ The goal is to stop encoding backend choice as a “mode string” that callers Notes: -1. The `sessions instanceof DaemonTerminalManager` check is allowed here because this module is the only backend-selection boundary; the tRPC router and UI must not need it. +1. The `backend instanceof DaemonTerminalManager` check is allowed here because this module is the only backend-selection boundary; the tRPC router and UI must not need it. 2. If daemon capability exists but a call fails (daemon unreachable, request fails), we propagate the error. We do not convert failures into “daemon disabled” states. +3. `runtime.daemon !== null` indicates the persistent backend is configured/active, not that it is healthy “right now”. If the daemon process crashes or the socket drops mid-session, operations may throw and the backend emits existing per-pane `disconnect:*` / `error:*` events. The runtime does not dynamically flip `daemon` to `null`. ### tRPC Router Shape (No Daemon Type Checks) @@ -212,12 +273,13 @@ The terminal router captures the runtime once when the router is created (not pe stream: publicProcedure .input(z.string()) .subscription(({ input: paneId }) => - observable((emit) => { - const onExit = (exitCode: number, signal?: number) => { - // IMPORTANT: do not complete on exit - emit.next({ type: "exit", exitCode, signal }); - }; - ... + observable((emit) => { + // IMPORTANT: do not complete on exit. + // Exit is a state transition and must not terminate the subscription. + return runtime.events.subscribePane({ + paneId, + onEvent: (event) => emit.next(event), + }); }), ), @@ -244,14 +306,17 @@ The renderer still needs to implement UI behaviors (cold restore overlay, retry restoreStrategy: "altScreenRedraw" | "snapshotReplay"; isColdRestore: boolean; previousCwd: string | null; + /** Used to restore scroll position on reattach (see upstream PR #698) */ + viewportY?: number; } // `CreateOrAttachOutput` here refers to the renderer-visible shape returned by // `trpc.terminal.createOrAttach` (which includes `snapshot` and/or `scrollback`). export function buildTerminalInitPlan(result: CreateOrAttachOutput): TerminalInitPlan { const initialAnsi = result.snapshot?.snapshotAnsi ?? result.scrollback ?? ""; + const viewportY = result.viewportY; ... - return { ... }; + return { ..., viewportY }; } `apply-init-plan.ts` (ordering guarantees): @@ -264,6 +329,7 @@ The renderer still needs to implement UI behaviors (cold restore overlay, retry }): Promise { // apply rehydrate → apply snapshot → then onReady // if altScreenRedraw: enter alt screen, then trigger redraw, then onReady + // if plan.viewportY is set, restore scroll position after initial content is applied } `useTerminalStream.ts` (buffering until ready): @@ -288,6 +354,7 @@ The critical invariants remain unchanged: 1. The stream subscription does not complete on exit. 2. Events arriving “too early” are buffered until restore is finished. 3. Cold restore remains read-only until Start Shell is clicked, and stale queued events are dropped before starting a new session (prevents “exit clears UI” regressions). +4. Reattaching restores the previous scroll position (`viewportY`) when available (upstream main behavior; see PR #698). ### Diagrams (Call Flow) @@ -308,7 +375,7 @@ Main call flow (today and after refactor; the difference is where switching happ v Terminal Host Daemon ---> PTY subprocess per session -Renderer composition after Milestone 6: +Renderer composition after Milestone 6c: Terminal.tsx ├─ useTerminalConnection() (tRPC mutations) @@ -326,16 +393,19 @@ Scope: 1. Identify the backend API surface currently used by callers outside `apps/desktop/src/main/lib/terminal/` by searching for usages of: - `getActiveTerminalManager()` - events `data:${paneId}`, `exit:${paneId}`, `disconnect:${paneId}`, `error:${paneId}`, and `terminalExit` -2. Write an explicit “TerminalSessionBackend contract” type in `apps/desktop/src/main/lib/terminal/` (likely in `apps/desktop/src/main/lib/terminal/types.ts` or a new `runtime.ts`). This contract should include: - - The core operations used by the renderer (create/attach/write/resize/signal/kill/detach/clearScrollback). - - The workspace operations used by other routers (killByWorkspaceId, getSessionCountByWorkspaceId, refreshPromptsForWorkspace). - - The EventEmitter event names used by the tRPC stream and notifications bridge. +2. Write an explicit “terminal backend contract” type in `apps/desktop/src/main/lib/terminal/` (likely in `apps/desktop/src/main/lib/terminal/types.ts` or `runtime.ts`). This contract should include: + - `TerminalSessionOperations` for per-pane session lifecycle (create/attach/write/resize/signal/kill/detach/clearScrollback, cold restore ack). + - `TerminalWorkspaceOperations` for workspace-scoped helpers used by other routers (killByWorkspaceId, getSessionCountByWorkspaceId, refreshPromptsForWorkspace). + - `TerminalEventSource` for event delivery using a backend-agnostic `subscribe...() => unsubscribe` API (no Node EventEmitter semantics in the contract). + - A shared event union type (for example `TerminalPaneEvent`) that matches the tRPC stream payload shapes (`data`, `exit`, `disconnect`, `error`). 3. Record invariants in code comments near the contract: - `terminal.stream` must not complete on `exit`. - `exit` is a state transition, not an end-of-stream. - The output stream lifecycle is separate from session lifecycle: the stream completes only when the client unsubscribes (dispose), not when a session exits. + - Detach/reattach must preserve scroll restoration behavior where supported (currently: pass `viewportY` on detach and restore it on the next attach; see upstream PR #698). - All backend operations must be normalized to async (Promise-returning) at the contract boundary, even if an implementation currently has a sync method (example: `clearScrollback`). - - The terminal EventEmitter must be owned by the backend instance; the runtime facade must not introduce a shared/global EventEmitter or re-emit events in a way that can cause cross-talk or duplicate listeners. + - Event delivery must be expressed via `subscribe` APIs at the boundary. Backends may use Node EventEmitter internally today, but callers must not depend on EventEmitter semantics. + - The terminal event source must be owned by the backend instance; the runtime facade must not introduce a shared/global EventEmitter or re-emit events in a way that can cause cross-talk or duplicate listeners. Acceptance: @@ -352,12 +422,17 @@ Approach: 1. Create a small facade in `apps/desktop/src/main/lib/terminal/` (recommended: `apps/desktop/src/main/lib/terminal/runtime.ts`) that exports: - `getTerminalRuntime(): TerminalRuntime` 2. `TerminalRuntime` should have three parts: - - `sessions: TerminalSessionBackend` (the active backend implementing the normalized async session contract) + - `sessions: TerminalSessionOperations` (per-pane session lifecycle operations; normalized to async) + - `workspaces: TerminalWorkspaceOperations` (workspace-scoped helpers; normalized to async) + - `events: TerminalEventSource` (backend-agnostic subscribe API for per-pane events and `terminalExit`) - `daemon: DaemonManagement | null` (nullable capability object; `null` when daemon management is not supported/active) - `capabilities: { persistent: boolean; coldRestore: boolean; remoteManagement: boolean }` (feature flags that do not encode implementation details and leave room for a future cloud backend) 3. Do not use “no-op admin methods”. The absence of daemon capabilities must be represented structurally (`daemon: null`) so callers cannot confuse “unsupported” with “success”. 4. Ensure the facade is process-scoped and constructed once. The tRPC router should capture the runtime once at router construction time (not per request) to avoid multiplying event listeners or daemon client connections. 5. Export the runtime from `apps/desktop/src/main/lib/terminal/index.ts` as the only supported way to reach daemon-specific functionality. +6. Clarify daemon mid-session failure semantics: + - `runtime.daemon !== null` reflects feature/mode availability, not daemon “health right now”. + - If the daemon disconnects, operations may throw and per-pane disconnect/error events are emitted; the runtime does not dynamically flip `daemon` to `null`. Acceptance: @@ -374,13 +449,15 @@ Scope: 1. Update `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to use: - `const runtime = getTerminalRuntime()` (or equivalent) - Replace `instanceof DaemonTerminalManager` checks with checks on `runtime.daemon` capability presence. -2. Preserve the existing endpoint names and response shapes so the renderer does not need behavioral changes: + - Use `runtime.events.subscribePane(...)` for the `terminal.stream` subscription implementation (no direct EventEmitter usage in the router). +2. Update any other main-process call sites that depend on EventEmitter event names (for example `apps/desktop/src/main/windows/main.ts` listening for `terminalExit`) to use `runtime.events.subscribeTerminalExit(...)` so EventEmitter semantics do not leak beyond the backend boundary. +3. Preserve the existing endpoint names and response shapes so the renderer does not need behavioral changes: - `listDaemonSessions` returns `{ daemonModeEnabled, sessions }` - `killAllDaemonSessions` returns `{ daemonModeEnabled, killedCount }` - `killDaemonSessionsForWorkspace` returns `{ daemonModeEnabled, killedCount }` - `clearTerminalHistory` returns `{ success: true }` but calls daemon history reset when the daemon capability is present -3. Ensure the `stream` subscription continues to use `observable` and continues to not complete on `exit`. -4. Error semantics must be explicit: +4. Ensure the `stream` subscription continues to use `observable` and continues to not complete on `exit`. +5. Error semantics must be explicit: - If daemon capability is absent, return `daemonModeEnabled: false` (UI will show “restart app after enabling persistence” messaging). - If daemon capability is present but the operation fails (daemon unreachable, request fails), surface the error (do not convert it into `daemonModeEnabled: false`). @@ -415,11 +492,12 @@ Validation should be run both with terminal persistence disabled and enabled. Acceptance: 1. The matrix items for non-daemon, daemon warm attach, and daemon cold restore all pass. +2. Reattach scroll restoration passes (detach sends `viewportY`; attach restores it; see upstream PR #698). -### Milestone 6: Reduce Branching in `Terminal.tsx` (Renderer Decomposition) +### Milestone 6a: Build a Terminal Init Plan (Renderer) -This milestone reduces complexity in the renderer terminal component without changing behavior. The goal is not to “rewrite the terminal UI”, but to isolate protocol/state-machine logic (snapshot vs scrollback selection, restore sequencing, stream buffering, and cold restore gating) into small units that can be tested. This work is optional from a feature perspective but strongly recommended to reduce regression risk as we add future backends (for example cloud terminals) and expand lifecycle handling (disconnect/retry, auth expiry, etc.). +This milestone reduces complexity in the renderer terminal component without changing behavior. The goal is not to “rewrite the terminal UI”, but to isolate protocol/state-machine logic (snapshot vs scrollback selection, restore sequencing, cold restore gating, and scroll restoration) into small units that can be tested. Scope: @@ -427,19 +505,67 @@ Scope: - Canonical initial content (`initialAnsi`) is `snapshot.snapshotAnsi ?? scrollback`. - Rehydrate sequences and mode flags are always present in the plan (with fallbacks where snapshot modes are missing). - The plan contains a single restore strategy decision, for example “alt-screen redraw” vs “snapshot replay”, based on the same conditions `Terminal.tsx` uses today. -2. Add a “restore applier” helper that owns the strict ordering guarantees during restore: + - The plan carries `viewportY` (when provided) to preserve scroll restoration on reattach (upstream PR #698 behavior). +2. Add a “restore applier” helper that owns strict ordering guarantees during restore: - Apply rehydrate sequences, then snapshot replay, then mark the stream as ready and flush queued events. - Preserve the existing “alt-screen reattach” behavior where we enter alt-screen first and trigger a redraw via resize/SIGWINCH sequence (to avoid white screens). -3. Add a small “stream handler” helper (or hook) that owns buffering until ready: - - Queue incoming `terminal.stream` events until the terminal is ready, then flush. + +Acceptance: + +1. At least one unit test exists for the init adapter to lock in “snapshot vs scrollback” canonicalization, mode fallbacks, and `viewportY` plumbing. +2. No Node.js imports are introduced in renderer code as part of this refactor. + + +### Milestone 6b: Stream Subscription + Buffering Hook (Renderer) + +Scope: + +1. Add a small “stream handler” helper (or hook) that owns buffering until ready: + - Subscribe to `terminal.stream` and queue incoming events until the terminal is ready, then flush deterministically. - Keep the important invariant that the subscription does not complete on `exit` (exit is a state transition). -4. Refactor `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` to use these helpers, reducing conditional branches in the component and keeping the UI concerns (overlays, buttons, focus) separate from protocol logic. + - Keep the buffering mechanism bounded (by event count or bytes) and drop/compact safely if needed (prefer bounded queues over unbounded arrays). + +Acceptance: + +1. A focused unit test exists for “buffer until ready then flush in order”. +2. The stream still does not complete on exit. + + +### Milestone 6c: Integrate Helpers into `Terminal.tsx` (UI Wiring Only) + +Scope: + +1. Refactor `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` to use the helpers from Milestones 6a/6b: + - Keep UI concerns (overlays, buttons, focus) in `Terminal.tsx`. + - Move protocol concerns (snapshot vs scrollback selection, restore sequencing, stream buffering) out of the component. +2. Preserve scroll restoration behavior on reattach: + - Send `viewportY` during detach (when available). + - Restore it during the next attach at the appropriate time in the restore ordering (after initial content is applied). +3. Clarify `useTerminalConnection` expectations: + - `useTerminalConnection()` remains the tRPC mutation wrapper and is not a target for significant refactors in this milestone, beyond adapting call sites to the new helpers. Acceptance: 1. `Terminal.tsx` still behaves identically (cold restore overlay, Start Shell flow, retry connection flow, exit prompt flow), but the core initialization/stream logic is exercised via helper functions that can be unit tested. -2. At least one unit test exists for the session init adapter to lock in “snapshot vs scrollback” canonicalization and mode fallback behavior. -3. No Node.js imports are introduced in renderer code as part of this refactor. +2. No Node.js imports are introduced in renderer code as part of this refactor. + + +### Milestone 7 (Optional / Cloud-Readiness): Introduce `backendSessionId` + +This milestone is a forward-looking improvement that decouples renderer pane identity (`paneId`) from backend session identity (`backendSessionId`). It should be considered once the daemon vs in-process leakage is resolved and the core refactor is stable. + +Scope: + +1. Extend `createOrAttach` to return `backendSessionId` (for local backends it can equal `paneId`). +2. Store the mapping `{ paneId -> backendSessionId }` in renderer state and use `backendSessionId` for subsequent lifecycle operations (write/resize/signal/kill/detach and stream subscription), while continuing to key UI state by `paneId`. +3. Add lifecycle events needed for cloud-style backends (non-goal to implement now, but define the contract): + - connection lifecycle: `connectionStateChanged`, `authExpired` + - per-operation timeout/retry policy at the boundary (even if implemented as “none” initially) + +Acceptance: + +1. The contract no longer implies `paneId === backendSessionId`, but behavior remains identical for local backends. +2. A future cloud backend can implement the same runtime contract without changing `Terminal.tsx` and the tRPC router again. ## Validation @@ -474,7 +600,7 @@ Mitigation: Keep the EventEmitter contract unchanged (`data:${paneId}`, `exit:${ Risk: Output loss during attach if the stream subscription attaches after early PTY output (race between `createOrAttach` and `terminal.stream` subscribe). -Mitigation: Preserve the current renderer sequencing (subscription established while the component is mounted, initial state applied from snapshot/scrollback, and stream events queued until ready). During manual QA, include at least one “immediate output” command (example: `echo READY`) and confirm it is visible reliably. If a reproducible loss exists, add a small per-pane ring buffer (bounded bytes) at the backend boundary and flush it to the first subscriber as a targeted follow-up. +Mitigation: Preserve the current renderer sequencing (subscription established while the component is mounted, initial state applied from snapshot/scrollback, and stream events queued until ready). During manual QA, include at least one “immediate output” command (example: `echo READY`) and confirm it is visible reliably. If a reproducible loss exists, add a small per-pane ring buffer (bounded bytes) at the backend boundary and flush it to the first subscriber (a “ready/attached handshake”). Risk: Admin capability handling masks real errors (a true daemon failure being reported as “disabled”). @@ -484,6 +610,14 @@ Risk: A future cloud backend would require different identity mapping than `pane Mitigation: Do not change identity mapping in this refactor, but ensure the runtime contract does not assume Unix sockets or local process ownership. The future cloud backend should implement the same contract behind `TerminalRuntime`. +Risk: Reattach scroll restoration regresses during refactor (missing `viewportY` plumbing or restoring at the wrong time). + +Mitigation: Treat `viewportY` as part of the stable contract (detach includes it; init plan carries it; restore applier applies it after initial content). Add explicit verification to the PR matrix and (if needed) a focused unit test around the init plan adapter carrying `viewportY`. + +Risk: A refactor accidentally calls `emit.complete()` on `exit` (observable completion is irreversible), reintroducing the cold-restore failure mode. + +Mitigation: Keep the “stream does not complete on exit” regression test as P0 coverage and treat any adapter/hook changes to stream handling as test-gated. + ## Progress @@ -517,13 +651,27 @@ Mitigation: Do not change identity mapping in this refactor, but ensure the runt - [ ] Manual verification with persistence enabled (warm attach) - [ ] Manual verification for cold restore “Start Shell” path -### Milestone 6 +### Milestone 6a + +- [ ] Implement init plan adapter (normalize snapshot vs scrollback, modes, `viewportY`) +- [ ] Implement restore applier helper (rehydrate → snapshot → scroll restore → stream ready) +- [ ] Add focused unit tests for init plan invariants + +### Milestone 6b -- [ ] Implement session init adapter (normalize snapshot vs scrollback, restore strategy) -- [ ] Implement restore applier helper (rehydrate → snapshot → stream ready) - [ ] Implement stream handler helper/hook (buffer until ready, flush deterministically) +- [ ] Add focused unit tests for buffering + no-complete-on-exit + +### Milestone 6c + - [ ] Refactor `Terminal.tsx` to use helpers, preserving behavior -- [ ] Add focused unit tests for adapter/helper invariants +- [ ] Preserve detach/reattach scroll restoration (`viewportY`) + +### Milestone 7 (Optional) + +- [ ] Add `backendSessionId` to `createOrAttach` response (local backends: equals `paneId`) +- [ ] Store `{ paneId -> backendSessionId }` mapping in renderer state; use backend ID for operations +- [ ] Define/introduce lifecycle events needed for cloud backends (connection/auth) ## Surprises & Discoveries From c1fd9a298fe0d9f68cc6d3a72a1611470ce3e0cc Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 10 Jan 2026 11:56:37 +0200 Subject: [PATCH 48/62] docs(desktop): add remote runner notes to plan --- ...13-terminal-runtime-abstraction-rewrite.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md index de003473045..cc39ea11abb 100644 --- a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md +++ b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md @@ -5,6 +5,42 @@ This ExecPlan is a living document. The sections `Progress`, `Surprises & Discov Reference: This plan follows conventions from `AGENTS.md`, `apps/desktop/AGENTS.md`, and the ExecPlan template in `.agents/commands/create-plan.md`. +## Table of Contents + +- [Purpose / Big Picture](#purpose--big-picture) +- [Context / Orientation (Repository Map)](#context--orientation-repository-map) +- [Problem Statement](#problem-statement) +- [Definitions (Plain Language)](#definitions-plain-language) +- [Non-Goals](#non-goals) +- [Assumptions](#assumptions) +- [Future Backend: Remote Runner / Cloud Terminals](#future-backend-remote-runner--cloud-terminals) +- [Open Questions](#open-questions) +- [Plan of Work](#plan-of-work) +- [Target Shape (After Refactor)](#target-shape-after-refactor) + - [File Tree (Proposed)](#file-tree-proposed) + - [Terminal Runtime Types (Main Process)](#terminal-runtime-types-main-process) + - [tRPC Router Shape (No Daemon Type Checks)](#trpc-router-shape-no-daemon-type-checks) + - [Renderer Decomposition (Reducing `Terminal.tsx` Branching)](#renderer-decomposition-reducing-terminaltsx-branching) + - [Diagrams (Call Flow)](#diagrams-call-flow) +- [Milestones](#milestone-1-establish-a-backend-contract-and-invariants) + - [Milestone 1](#milestone-1-establish-a-backend-contract-and-invariants) + - [Milestone 2](#milestone-2-implement-terminalruntime-facade--capabilities) + - [Milestone 3](#milestone-3-migrate-trpc-terminal-router-to-the-runtime) + - [Milestone 4](#milestone-4-add-regression-coverage-for-the-abstraction-boundary) + - [Milestone 5](#milestone-5-manual-verification-high-coverage-low-surprises) + - [Milestone 6a](#milestone-6a-build-a-terminal-init-plan-renderer) + - [Milestone 6b](#milestone-6b-stream-subscription--buffering-hook-renderer) + - [Milestone 6c](#milestone-6c-integrate-helpers-into-terminaltsx-ui-wiring-only) + - [Milestone 7 (Optional)](#milestone-7-optional--cloud-readiness-introduce-backendsessionid) +- [Validation](#validation) +- [Idempotence / Safety](#idempotence--safety) +- [Risks and Mitigations](#risks-and-mitigations) +- [Progress](#progress) +- [Surprises & Discoveries](#surprises--discoveries) +- [Decision Log](#decision-log) +- [Outcomes & Retrospective](#outcomes--retrospective) + + ## Purpose / Big Picture After this change, the desktop app still supports terminal persistence (daemon mode with cold restore) exactly as it does today, but the codebase no longer leaks “daemon vs in-process” branching across the tRPC router and UI. The backend selection becomes a single responsibility owned by `apps/desktop/src/main/lib/terminal/`, making the feature easier to review, less fragile, and a better foundation for future “cloud terminal” backends. @@ -74,6 +110,60 @@ This refactor is intentionally conservative to avoid regressions: 4. The most important regression to prevent is the “listeners=0” cold-restore failure mode; specifically, the `terminal.stream` subscription must not complete on exit. +## Future Backend: Remote Runner / Cloud Terminals + +This plan intentionally does not implement cloud terminals, but the abstraction boundary should be compatible with adding a backend that runs terminal sessions inside a remote “runner” (a background agent on a server) while preserving Superset Desktop concepts like worktrees, “changes” (diff/status), and agent lifecycle indicators. + +### What’s local-only today (current coupling) + +1. **Terminal IO keys by `paneId` (client identity):** `terminal.createOrAttach`, `terminal.write`, and `terminal.stream` treat `paneId` as the stable session key (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`). +2. **Agent lifecycle events assume localhost hooks:** terminal env injects `SUPERSET_*` and `SUPERSET_PORT` (`apps/desktop/src/main/lib/terminal/env.ts`), and the notify hook script `curl`s `http://127.0.0.1:$SUPERSET_PORT/hook/complete` (`apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh`). This cannot work from a remote runner. +3. **“Changes” assumes local worktree filesystem:** git status/diff/staging/commit/push/pull operate against a local `worktreePath` using `simple-git`, and file reads/writes are guarded by secure path validation (`apps/desktop/src/lib/trpc/routers/changes/*`). + +### How this plan enables remote terminals (what’s already aligned) + +1. **Backend-agnostic event delivery:** `TerminalEventSource.subscribe…() => unsubscribe` is compatible with WebSocket/SSE backends and avoids leaking Node `EventEmitter` semantics. +2. **Capabilities over “mode strings”:** cloud backends can expose a capability surface without introducing a new `"cloud"` mode string that bleeds into callers. +3. **Identity decoupling is planned:** Milestone 7 (optional) calls out introducing `backendSessionId`, which is required for cloud (server-assigned IDs, multi-device access). + +### The key realization: cloud terminals need a Workspace Runtime, not just a Terminal Runtime + +A remote runner cannot be “just a terminal backend” if we want to preserve the current UX. To retain worktrees, diffs, and agent status, the system needs a workspace-scoped runtime with at least these responsibilities: + +1. **terminal:** interactive PTY sessions (create/attach/write/resize/kill/detach + stream events) +2. **agentEvents:** lifecycle signals (“Start/Stop/PermissionRequest”) delivered to the desktop UI +3. **git + files:** status/diff/staging/commit/push/pull + safe file read/write (or an explicit sync layer) +4. **sync (if local stays canonical):** bidirectional worktree synchronization when execution happens remotely + +The `TerminalRuntime` abstraction created in this plan should become one *component* of a broader “WorkspaceRuntime” concept as cloud work gets closer. + +### Preserving “agent interactions” in a remote runner world + +Today, pane statuses are driven by the notifications subscription (`apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts`), which consumes events emitted by a local notifications server (`apps/desktop/src/main/lib/notifications/server.ts`). For remote execution, we need a different source of lifecycle signals: + +- **Hook proxy model (max compatibility):** keep the same CLI hook scripts, but point them to a hook receiver running inside the runner; the runner forwards lifecycle events to the desktop over an authenticated channel (then desktop re-emits to the renderer through the existing notifications subscription path). +- **Native runner events (long-term):** the runner emits lifecycle events directly (no `curl` hook required), still flowing into the same renderer contract (`NOTIFICATION_EVENTS.AGENT_LIFECYCLE`). + +### “Changes” + worktrees: two viable architectures (must decide) + +**A) Remote worktree is source-of-truth** +- Runner owns checkout, git operations, and file access. +- Desktop “changes” router becomes a façade that delegates to local or remote implementations. +- Tradeoff: “open in editor” and local tooling become harder unless we introduce a local mirror/remote FS integration. + +**B) Local worktree is source-of-truth (desktop remains canonical)** +- Keep existing local worktrees and “changes” behavior. +- Runner is compute-only and requires explicit sync (local → remote before execution; remote → local after). +- Tradeoff: requires a real sync protocol (patch-based or git-based), conflict handling, and clear UX around divergence. + +This decision materially changes the scope and correctness model of cloud terminals; we should not start implementing a cloud backend until this is chosen. + +### Compatibility notes (naming + semantics) + +1. The current plan uses `daemon: DaemonManagement | null` as the capability object. For cloud, that concept should generalize to something like `management: TerminalManagement | null` (daemon is an implementation detail). +2. Capability presence should mean “configured/available”, not “healthy right now”; mid-session disconnects should surface errors + explicit connection lifecycle events rather than silently flipping capabilities at runtime. + + ## Open Questions 1. Naming: should the abstraction be named `TerminalRuntime`, `TerminalService`, or keep `getActiveTerminalManager()` and add a new `getTerminalRuntime()` alongside it? (This plan assumes `getTerminalRuntime()` returning a `TerminalRuntime` facade exported from `apps/desktop/src/main/lib/terminal/index.ts`.) From 0abac666da45b42148cc607d3afef12ae1a266b4 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sun, 11 Jan 2026 11:07:58 +0200 Subject: [PATCH 49/62] docs(desktop): align terminal runtime plan with cloud provider direction --- ...13-terminal-runtime-abstraction-rewrite.md | 182 ++++++++++++------ 1 file changed, 120 insertions(+), 62 deletions(-) diff --git a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md index cc39ea11abb..d939754070c 100644 --- a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md +++ b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md @@ -24,14 +24,14 @@ Reference: This plan follows conventions from `AGENTS.md`, `apps/desktop/AGENTS. - [Diagrams (Call Flow)](#diagrams-call-flow) - [Milestones](#milestone-1-establish-a-backend-contract-and-invariants) - [Milestone 1](#milestone-1-establish-a-backend-contract-and-invariants) - - [Milestone 2](#milestone-2-implement-terminalruntime-facade--capabilities) + - [Milestone 2](#milestone-2-implement-terminalruntime-registry--capabilities) - [Milestone 3](#milestone-3-migrate-trpc-terminal-router-to-the-runtime) - [Milestone 4](#milestone-4-add-regression-coverage-for-the-abstraction-boundary) - [Milestone 5](#milestone-5-manual-verification-high-coverage-low-surprises) - [Milestone 6a](#milestone-6a-build-a-terminal-init-plan-renderer) - [Milestone 6b](#milestone-6b-stream-subscription--buffering-hook-renderer) - [Milestone 6c](#milestone-6c-integrate-helpers-into-terminaltsx-ui-wiring-only) - - [Milestone 7 (Optional)](#milestone-7-optional--cloud-readiness-introduce-backendsessionid) + - [Milestone 7 (Cloud Readiness)](#milestone-7-cloud-readiness-introduce-backendsessionid) - [Validation](#validation) - [Idempotence / Safety](#idempotence--safety) - [Risks and Mitigations](#risks-and-mitigations) @@ -43,11 +43,13 @@ Reference: This plan follows conventions from `AGENTS.md`, `apps/desktop/AGENTS. ## Purpose / Big Picture -After this change, the desktop app still supports terminal persistence (daemon mode with cold restore) exactly as it does today, but the codebase no longer leaks “daemon vs in-process” branching across the tRPC router and UI. The backend selection becomes a single responsibility owned by `apps/desktop/src/main/lib/terminal/`, making the feature easier to review, less fragile, and a better foundation for future “cloud terminal” backends. +After this change, the desktop app still supports terminal persistence (daemon mode with cold restore) exactly as it does today, but the codebase no longer leaks “daemon vs in-process” branching across the tRPC router and UI. Backend selection becomes a single responsibility owned by `apps/desktop/src/main/lib/terminal/`. + +This plan also tightens the abstraction so it can become the **local implementation of a workspace-scoped “provider”** later (cloud/SSH/remote runners), without re-introducing backend branching across the application. Observable outcomes: -1. With terminal persistence disabled, terminals behave as before (no persistence across app restarts), and Settings → Terminal “Manage sessions” shows that daemon management is unavailable. +1. With terminal persistence disabled, terminals behave as before (no persistence across app restarts), and Settings → Terminal “Manage sessions” shows that session management is unavailable. 2. With terminal persistence enabled, terminals survive app restarts, cold restore works, and Settings → Terminal “Manage sessions” continues to list/kill sessions. 3. The tRPC `terminal.*` router no longer needs `instanceof DaemonTerminalManager` checks; daemon awareness is centralized in the terminal runtime layer. 4. The renderer terminal component remains correct but is easier to reason about because backend-agnostic “session initialization” and “stream event handling” logic is extracted into small, testable helpers rather than being interleaved with UI rendering. @@ -88,9 +90,13 @@ Warm attach: reconnecting to a still-running session (daemon still has the PTY). Cold restore: restoring scrollback from disk after an unclean shutdown or daemon session loss, before starting a new shell. -Terminal runtime: a single module-level API in `apps/desktop/src/main/lib/terminal/` that selects the active terminal backend (daemon or in-process) and exposes a unified surface to callers. +Terminal runtime: a backend-agnostic surface (sessions/workspaces/events + capabilities) that callers use without knowing the implementation (local in-process, local daemon, cloud/SSH later). + +Terminal runtime registry: a process-scoped module in `apps/desktop/src/main/lib/terminal/` that selects the correct runtime for a given workspace/session and ensures runtimes are cached so we don’t multiply event listeners or backend connections. -Capabilities: optional features that exist only for some backends (for example “list/manage persistent sessions”). Callers should not use `instanceof` checks. Capability presence must be represented structurally (for example `daemon: null` when unavailable) and via explicit capability flags, so “unsupported” cannot be confused with “success”. +Capabilities: optional features that exist only for some backends (for example “list/manage persistent sessions”). Callers should not use `instanceof` checks. Capability presence must be represented structurally (for example `management: null` when unavailable) and via explicit capability flags, so “unsupported” cannot be confused with “success”. + +Workspace provider / runtime: a workspace-scoped backend boundary that can supply terminal IO, agent lifecycle events, and “changes/files” operations for either local worktrees or cloud workspaces. This plan focuses on the terminal portion, but the boundary should be compatible with being embedded in a provider later. ## Non-Goals @@ -114,6 +120,15 @@ This refactor is intentionally conservative to avoid regressions: This plan intentionally does not implement cloud terminals, but the abstraction boundary should be compatible with adding a backend that runs terminal sessions inside a remote “runner” (a background agent on a server) while preserving Superset Desktop concepts like worktrees, “changes” (diff/status), and agent lifecycle indicators. +### Direction for this rewrite (so we don’t paint ourselves into a corner) + +The cloud workspace plan (`docs/CLOUD_WORKSPACE_PLAN.md`) makes a few things explicit: multi-device access, cloud as source-of-truth, SSH terminals, tmux persistence, and optional local sync for IDE users. To align with that direction, this rewrite should: + +1. Avoid a process-global “one runtime forever” assumption. Instead, capture a stable **registry** once, and select the appropriate runtime/provider per workspace or per session. +2. Treat backend session identity as separate from UI pane identity. Even if local stays `backendSessionId === paneId` initially, the contract should not assume it forever. +3. Avoid “daemon” naming at the abstraction boundary. Daemon is an implementation detail; cloud/SSH is another. Prefer provider-neutral naming (e.g. “management/admin capability object”). +4. Keep renderer behavior stable. Any `Terminal.tsx` work should be decomposition-only (init plan + applier + stream buffering), preserving the same attach/detach semantics and invariants (no-complete-on-exit, scroll restoration). + ### What’s local-only today (current coupling) 1. **Terminal IO keys by `paneId` (client identity):** `terminal.createOrAttach`, `terminal.write`, and `terminal.stream` treat `paneId` as the stable session key (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`). @@ -124,7 +139,7 @@ This plan intentionally does not implement cloud terminals, but the abstraction 1. **Backend-agnostic event delivery:** `TerminalEventSource.subscribe…() => unsubscribe` is compatible with WebSocket/SSE backends and avoids leaking Node `EventEmitter` semantics. 2. **Capabilities over “mode strings”:** cloud backends can expose a capability surface without introducing a new `"cloud"` mode string that bleeds into callers. -3. **Identity decoupling is planned:** Milestone 7 (optional) calls out introducing `backendSessionId`, which is required for cloud (server-assigned IDs, multi-device access). +3. **Identity decoupling is planned:** Milestone 7 (cloud readiness) introduces `backendSessionId`, which is required for cloud (server-assigned IDs, multi-device access). ### The key realization: cloud terminals need a Workspace Runtime, not just a Terminal Runtime @@ -160,15 +175,15 @@ This decision materially changes the scope and correctness model of cloud termin ### Compatibility notes (naming + semantics) -1. The current plan uses `daemon: DaemonManagement | null` as the capability object. For cloud, that concept should generalize to something like `management: TerminalManagement | null` (daemon is an implementation detail). +1. This plan uses a **provider-neutral** capability object (`management: TerminalManagement | null`). In local persistence mode, the implementation is backed by the daemon manager, but callers should not depend on “daemon” as a concept. 2. Capability presence should mean “configured/available”, not “healthy right now”; mid-session disconnects should surface errors + explicit connection lifecycle events rather than silently flipping capabilities at runtime. ## Open Questions -1. Naming: should the abstraction be named `TerminalRuntime`, `TerminalService`, or keep `getActiveTerminalManager()` and add a new `getTerminalRuntime()` alongside it? (This plan assumes `getTerminalRuntime()` returning a `TerminalRuntime` facade exported from `apps/desktop/src/main/lib/terminal/index.ts`.) +1. Naming: should the main-process entry point be `getTerminalRuntimeRegistry()` (terminal-only) or `getWorkspaceProviderRegistry()` (terminal + future agentEvents/changes/files)? (This plan assumes `getTerminalRuntimeRegistry()` now, and we can later embed it inside a workspace provider registry without changing the router/UI contracts.) 2. Should we keep the existing tRPC endpoint names (`terminal.listDaemonSessions`, `terminal.killAllDaemonSessions`, etc.) for backwards compatibility in the renderer? (This plan assumes “yes” to minimize churn and risk.) -3. For future cloud terminals, do we want to introduce a distinct backend session identifier (`backendSessionId`) now (even if it equals `paneId` today), or defer it to a follow-up after the daemon vs in-process leakage is fixed? (This plan assumes we defer a wire-contract identity migration to keep this refactor lower-risk, but we explicitly call out a follow-up milestone to introduce `backendSessionId` cleanly if/when cloud is near-term.) +3. Provider selection: how do we decide whether a workspace uses the local terminal runtime vs a cloud/SSH runtime? (Expected: based on workspace metadata such as `cloudWorkspaceId` / workspace type, not on UI state.) ## Plan of Work @@ -184,14 +199,15 @@ This section is illustrative. It shows the intended file layout, key types, and ### File Tree (Proposed) apps/desktop/src/main/lib/terminal/ - index.ts # exports getTerminalRuntime() - runtime.ts # TerminalRuntime + selection (process-scoped) + index.ts # exports getTerminalRuntimeRegistry() + runtime-registry.ts # per-workspace selection + caching (process-scoped registry) + runtime.ts # TerminalRuntime adapters/types (backend-agnostic surface) manager.ts # in-process backend (existing) daemon-manager.ts # daemon backend (existing) types.ts # existing shared terminal types (CreateSessionParams, SessionResult, events) apps/desktop/src/lib/trpc/routers/terminal/ - terminal.ts # uses getTerminalRuntime(); no instanceof checks + terminal.ts # uses getTerminalRuntimeRegistry(); no instanceof checks terminal.stream.test.ts # stream invariants (exit does not complete) apps/desktop/src/renderer/.../Terminal/ @@ -254,31 +270,43 @@ The goal is to stop encoding backend choice as a “mode string” that callers }): () => void; } - export interface DaemonManagement { + export interface TerminalManagement { listSessions(): Promise; forceKillAll(): Promise; resetHistoryPersistence(): Promise; } + export interface TerminalRuntimeRegistry { + // Runtime selection should be workspace-scoped (local vs cloud later). + getForWorkspaceId(workspaceId: string): TerminalRuntime; + // Transitional: allow lookups by pane until backendSessionId is fully introduced. + getForPaneId(paneId: string): TerminalRuntime; + // For legacy/global endpoints that aren't workspace-scoped yet. + getDefault(): TerminalRuntime; + } + export interface TerminalRuntime { sessions: TerminalSessionOperations; workspaces: TerminalWorkspaceOperations; events: TerminalEventSource; - daemon: DaemonManagement | null; + management: TerminalManagement | null; capabilities: TerminalCapabilities; } -`getTerminalRuntime()` must return the same instance across the process lifetime (or at minimum the same `sessions` object), so we do not multiply event listeners or daemon connections. +`getTerminalRuntimeRegistry()` must return the same registry instance across the process lifetime, and the registry must return stable runtime objects (at least stable `events`/listener wiring) so we do not multiply event listeners or backend connections. - let cachedRuntime: TerminalRuntime | null = null; + let cachedRegistry: TerminalRuntimeRegistry | null = null; + const paneToRuntime = new Map(); + // Implementation detail: keep this mapping updated from createOrAttach/detach/kill + // so `getForPaneId()` can route stream subscriptions correctly until backendSessionId. - export function getTerminalRuntime(): TerminalRuntime { - if (cachedRuntime) return cachedRuntime; + export function getTerminalRuntimeRegistry(): TerminalRuntimeRegistry { + if (cachedRegistry) return cachedRegistry; const backend = getActiveTerminalManager(); // existing selection logic (cached by “requires restart”) const daemonManager = backend instanceof DaemonTerminalManager ? backend : null; - cachedRuntime = { + const localRuntime: TerminalRuntime = { sessions: { createOrAttach: (params) => backend.createOrAttach(params), write: async (params) => backend.write(params), @@ -323,7 +351,7 @@ The goal is to stop encoding backend choice as a “mode string” that callers return () => backend.off("terminalExit", onExit); }, }, - daemon: daemonManager + management: daemonManager ? { listSessions: () => daemonManager.listDaemonSessions(), forceKillAll: () => daemonManager.forceKillAll(), @@ -338,27 +366,40 @@ The goal is to stop encoding backend choice as a “mode string” that callers }, }; - return cachedRuntime; + cachedRegistry = { + getForWorkspaceId: (_workspaceId) => { + // Today: all workspaces use the local runtime. Future: cloud/SSH selection here. + return localRuntime; + }, + getForPaneId: (paneId) => paneToRuntime.get(paneId) ?? localRuntime, + getDefault: () => localRuntime, + }; + + return cachedRegistry; } Notes: 1. The `backend instanceof DaemonTerminalManager` check is allowed here because this module is the only backend-selection boundary; the tRPC router and UI must not need it. -2. If daemon capability exists but a call fails (daemon unreachable, request fails), we propagate the error. We do not convert failures into “daemon disabled” states. -3. `runtime.daemon !== null` indicates the persistent backend is configured/active, not that it is healthy “right now”. If the daemon process crashes or the socket drops mid-session, operations may throw and the backend emits existing per-pane `disconnect:*` / `error:*` events. The runtime does not dynamically flip `daemon` to `null`. +2. If management capability exists but a call fails (daemon unreachable, request fails), we propagate the error. We do not convert failures into “persistence disabled” states. +3. `runtime.management !== null` indicates the persistent backend is configured/active, not that it is healthy “right now”. If the daemon process crashes or the socket drops mid-session, operations may throw and the backend emits existing per-pane `disconnect:*` / `error:*` events. The runtime does not dynamically flip `management` to `null`. ### tRPC Router Shape (No Daemon Type Checks) -The terminal router captures the runtime once when the router is created (not per request), and then delegates consistently. It branches only on the presence of a capability object (`runtime.daemon`), never on `instanceof`. +The terminal router captures the **runtime registry** once when the router is created. Each procedure then selects the correct runtime (local vs cloud later) without using `instanceof` checks. + +Key rule: capture the registry once, but do not assume there is only one runtime for the entire process forever. export const createTerminalRouter = () => { - const runtime = getTerminalRuntime(); + const registry = getTerminalRuntimeRegistry(); return router({ createOrAttach: publicProcedure .input(...) - .mutation(async ({ input }) => runtime.sessions.createOrAttach(input)), + .mutation(async ({ input }) => + registry.getForWorkspaceId(input.workspaceId).sessions.createOrAttach(input), + ), stream: publicProcedure .input(z.string()) @@ -366,6 +407,7 @@ The terminal router captures the runtime once when the router is created (not pe observable((emit) => { // IMPORTANT: do not complete on exit. // Exit is a state transition and must not terminate the subscription. + const runtime = registry.getForPaneId(paneId); return runtime.events.subscribePane({ paneId, onEvent: (event) => emit.next(event), @@ -374,8 +416,10 @@ The terminal router captures the runtime once when the router is created (not pe ), listDaemonSessions: publicProcedure.query(async () => { - if (!runtime.daemon) return { daemonModeEnabled: false, sessions: [] }; - const response = await runtime.daemon.listSessions(); + // Note: endpoint name kept for backwards compatibility; capability is provider-neutral. + const runtime = registry.getDefault(); + if (!runtime.management) return { daemonModeEnabled: false, sessions: [] }; + const response = await runtime.management.listSessions(); return { daemonModeEnabled: true, sessions: response.sessions }; }), }); @@ -457,7 +501,7 @@ Main call flow (today and after refactor; the difference is where switching happ v Electron Main (tRPC router) | - | getTerminalRuntime().sessions (no backend checks in router) + | getTerminalRuntimeRegistry().getForWorkspaceId(...) (no backend checks in router) v Terminal Backend (in-process OR daemon-manager) | @@ -503,53 +547,55 @@ Acceptance: 2. No runtime behavior changes yet. -### Milestone 2: Implement `TerminalRuntime` Facade + Capabilities +### Milestone 2: Implement TerminalRuntime Registry + Capabilities -This milestone introduces a single runtime entry point that owns backend selection and exposes backend-specific capabilities in a consistent, no-branching way to callers. +This milestone introduces a single runtime **registry** entry point that owns backend selection and exposes backend-specific capabilities in a consistent, no-branching way to callers. Approach: -1. Create a small facade in `apps/desktop/src/main/lib/terminal/` (recommended: `apps/desktop/src/main/lib/terminal/runtime.ts`) that exports: - - `getTerminalRuntime(): TerminalRuntime` +1. Create a small facade in `apps/desktop/src/main/lib/terminal/` (recommended: `apps/desktop/src/main/lib/terminal/runtime-registry.ts`) that exports: + - `getTerminalRuntimeRegistry(): TerminalRuntimeRegistry` 2. `TerminalRuntime` should have three parts: - `sessions: TerminalSessionOperations` (per-pane session lifecycle operations; normalized to async) - `workspaces: TerminalWorkspaceOperations` (workspace-scoped helpers; normalized to async) - `events: TerminalEventSource` (backend-agnostic subscribe API for per-pane events and `terminalExit`) - - `daemon: DaemonManagement | null` (nullable capability object; `null` when daemon management is not supported/active) + - `management: TerminalManagement | null` (nullable capability object; `null` when persistence/session management is not supported/active) - `capabilities: { persistent: boolean; coldRestore: boolean; remoteManagement: boolean }` (feature flags that do not encode implementation details and leave room for a future cloud backend) -3. Do not use “no-op admin methods”. The absence of daemon capabilities must be represented structurally (`daemon: null`) so callers cannot confuse “unsupported” with “success”. -4. Ensure the facade is process-scoped and constructed once. The tRPC router should capture the runtime once at router construction time (not per request) to avoid multiplying event listeners or daemon client connections. -5. Export the runtime from `apps/desktop/src/main/lib/terminal/index.ts` as the only supported way to reach daemon-specific functionality. +3. Do not use “no-op admin methods”. The absence of management capability must be represented structurally (`management: null`) so callers cannot confuse “unsupported” with “success”. +4. Ensure the registry is process-scoped and constructed once. The tRPC router should capture the registry once at router construction time (not per request) to avoid multiplying event listeners or backend client connections. +5. Export the registry from `apps/desktop/src/main/lib/terminal/index.ts` as the only supported way to reach backend-specific functionality. 6. Clarify daemon mid-session failure semantics: - - `runtime.daemon !== null` reflects feature/mode availability, not daemon “health right now”. - - If the daemon disconnects, operations may throw and per-pane disconnect/error events are emitted; the runtime does not dynamically flip `daemon` to `null`. + - `runtime.management !== null` reflects feature/mode availability, not daemon “health right now”. + - If the daemon disconnects, operations may throw and per-pane disconnect/error events are emitted; the runtime does not dynamically flip `management` to `null`. Acceptance: 1. The runtime and capabilities surface is defined in `apps/desktop/src/main/lib/terminal/` and is the only code that knows which backend is active. -2. In non-daemon mode, `runtime.daemon` is `null` and callers must handle that explicitly; unsupported operations are not silently treated as success. +2. In non-daemon mode, `runtime.management` is `null` and callers must handle that explicitly; unsupported operations are not silently treated as success. ### Milestone 3: Migrate tRPC `terminal.*` Router to the Runtime -This milestone removes daemon branching from the tRPC router by routing all terminal work through `getTerminalRuntime()`. +This milestone removes daemon branching from the tRPC router by routing all terminal work through `getTerminalRuntimeRegistry()`. Scope: 1. Update `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to use: - - `const runtime = getTerminalRuntime()` (or equivalent) - - Replace `instanceof DaemonTerminalManager` checks with checks on `runtime.daemon` capability presence. + - `const registry = getTerminalRuntimeRegistry()` (or equivalent) + - For mutations/queries that have `workspaceId` in input, select `const runtime = registry.getForWorkspaceId(input.workspaceId)` (future: local vs cloud). + - For `stream` while it is still keyed by `paneId`, select `const runtime = registry.getForPaneId(paneId)` (transitional until `backendSessionId` is fully wired). + - Replace `instanceof DaemonTerminalManager` checks with checks on `runtime.management` capability presence. - Use `runtime.events.subscribePane(...)` for the `terminal.stream` subscription implementation (no direct EventEmitter usage in the router). -2. Update any other main-process call sites that depend on EventEmitter event names (for example `apps/desktop/src/main/windows/main.ts` listening for `terminalExit`) to use `runtime.events.subscribeTerminalExit(...)` so EventEmitter semantics do not leak beyond the backend boundary. +2. Update any other main-process call sites that depend on EventEmitter event names (for example `apps/desktop/src/main/windows/main.ts` listening for `terminalExit`) to use `registry.getDefault().events.subscribeTerminalExit(...)` so EventEmitter semantics do not leak beyond the backend boundary. 3. Preserve the existing endpoint names and response shapes so the renderer does not need behavioral changes: - `listDaemonSessions` returns `{ daemonModeEnabled, sessions }` - `killAllDaemonSessions` returns `{ daemonModeEnabled, killedCount }` - `killDaemonSessionsForWorkspace` returns `{ daemonModeEnabled, killedCount }` - - `clearTerminalHistory` returns `{ success: true }` but calls daemon history reset when the daemon capability is present + - `clearTerminalHistory` returns `{ success: true }` but calls history reset when the management capability is present 4. Ensure the `stream` subscription continues to use `observable` and continues to not complete on `exit`. 5. Error semantics must be explicit: - - If daemon capability is absent, return `daemonModeEnabled: false` (UI will show “restart app after enabling persistence” messaging). - - If daemon capability is present but the operation fails (daemon unreachable, request fails), surface the error (do not convert it into `daemonModeEnabled: false`). + - If management capability is absent, return `daemonModeEnabled: false` (UI will show “restart app after enabling persistence” messaging). + - If management capability is present but the operation fails (daemon unreachable, request fails), surface the error (do not convert it into `daemonModeEnabled: false`). Acceptance: @@ -563,7 +609,7 @@ This milestone makes the new boundary hard to accidentally regress later. Scope: -1. Add a unit test that asserts the non-daemon runtime returns `daemon: null` (capability absent) without requiring daemon availability. This test must not spawn a real daemon. +1. Add a unit test that asserts the non-daemon runtime returns `management: null` (capability absent) without requiring daemon availability. This test must not spawn a real daemon. 2. Keep and/or extend the existing “stream does not complete on exit” regression test in `apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts`. 3. If we add any new helper modules, ensure they are covered by at least one focused unit test. 4. Add a test that ensures admin operations fail loudly on error (for example, simulate a daemon management call throwing and assert the error propagates), so we do not accidentally reintroduce silent “disabled” fallbacks for real failures. @@ -640,15 +686,18 @@ Acceptance: 2. No Node.js imports are introduced in renderer code as part of this refactor. -### Milestone 7 (Optional / Cloud-Readiness): Introduce `backendSessionId` +### Milestone 7 (Cloud Readiness): Introduce `backendSessionId` -This milestone is a forward-looking improvement that decouples renderer pane identity (`paneId`) from backend session identity (`backendSessionId`). It should be considered once the daemon vs in-process leakage is resolved and the core refactor is stable. +This milestone is required groundwork for cloud/SSH-style backends: it decouples renderer pane identity (`paneId`) from backend session identity (`backendSessionId`). Complete this before introducing any cloud/SSH provider to avoid reworking the router + renderer contracts again. Scope: 1. Extend `createOrAttach` to return `backendSessionId` (for local backends it can equal `paneId`). 2. Store the mapping `{ paneId -> backendSessionId }` in renderer state and use `backendSessionId` for subsequent lifecycle operations (write/resize/signal/kill/detach and stream subscription), while continuing to key UI state by `paneId`. -3. Add lifecycle events needed for cloud-style backends (non-goal to implement now, but define the contract): +3. Update the runtime registry to route by backend session identity: + - Avoid relying on `getForPaneId(...)` as the long-term selection mechanism. + - Prefer selecting runtimes by workspace/session IDs (cloud-safe), not by UI pane IDs. +4. Add lifecycle events needed for cloud-style backends (define the contract even if some implementations are stubs initially): - connection lifecycle: `connectionStateChanged`, `authExpired` - per-operation timeout/retry policy at the boundary (even if implemented as “none” initially) @@ -684,7 +733,7 @@ This plan is safe to apply iteratively: ## Risks and Mitigations -Risk: The runtime facade changes event wiring in a way that causes missed output or duplicate listeners. +Risk: The runtime registry/adapters change event wiring in a way that causes missed output or duplicate listeners. Mitigation: Keep the EventEmitter contract unchanged (`data:${paneId}`, `exit:${paneId}`), keep `terminal.stream` semantics unchanged, and use tests + manual matrix to confirm “output still flows after exit/cold restore”. @@ -694,11 +743,15 @@ Mitigation: Preserve the current renderer sequencing (subscription established w Risk: Admin capability handling masks real errors (a true daemon failure being reported as “disabled”). -Mitigation: Represent daemon management as a nullable capability object (`daemon: null` when unavailable). When `daemon` is present but calls fail, propagate errors (and test this explicitly). +Mitigation: Represent persistence/session management as a nullable capability object (`management: null` when unavailable). When `management` is present but calls fail, propagate errors (and test this explicitly). Risk: A future cloud backend would require different identity mapping than `paneId == sessionId`. -Mitigation: Do not change identity mapping in this refactor, but ensure the runtime contract does not assume Unix sockets or local process ownership. The future cloud backend should implement the same contract behind `TerminalRuntime`. +Mitigation: Introduce `backendSessionId` (Milestone 7) so the contract no longer implies `paneId === backendSessionId` (local can keep equality as an implementation detail). The future cloud backend should implement the same contract behind `TerminalRuntime` without changing the renderer again. + +Risk: Process-global runtime assumptions block local + cloud workspaces from coexisting (forcing branching to leak back into routers/UI). + +Mitigation: Make runtime selection workspace-/session-scoped via the registry. The router captures the registry, not a single global runtime. Risk: Reattach scroll restoration regresses during refactor (missing `viewportY` plumbing or restoring at the wrong time). @@ -719,19 +772,19 @@ Mitigation: Keep the “stream does not complete on exit” regression test as P ### Milestone 2 -- [ ] Implement `getTerminalRuntime()` facade in `apps/desktop/src/main/lib/terminal/` -- [ ] Implement daemon management as `daemon: DaemonManagement | null` (no no-op admin methods) +- [ ] Implement `getTerminalRuntimeRegistry()` in `apps/desktop/src/main/lib/terminal/` +- [ ] Implement session management as `management: TerminalManagement | null` (no no-op admin methods) - [ ] Run `bun run typecheck --filter=@superset/desktop` ### Milestone 3 -- [ ] Migrate `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to runtime +- [ ] Migrate `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to runtime registry (`getTerminalRuntimeRegistry()`) - [ ] Remove `instanceof DaemonTerminalManager` checks - [ ] Run `bun test --filter=@superset/desktop` ### Milestone 4 -- [ ] Add/adjust unit tests for capability presence (`daemon: null`) and error propagation +- [ ] Add/adjust unit tests for capability presence (`management: null`) and error propagation - [ ] Confirm stream exit regression test still covers “no complete on exit” - [ ] Run full validation commands @@ -757,21 +810,26 @@ Mitigation: Keep the “stream does not complete on exit” regression test as P - [ ] Refactor `Terminal.tsx` to use helpers, preserving behavior - [ ] Preserve detach/reattach scroll restoration (`viewportY`) -### Milestone 7 (Optional) +### Milestone 7 (Cloud Readiness) - [ ] Add `backendSessionId` to `createOrAttach` response (local backends: equals `paneId`) - [ ] Store `{ paneId -> backendSessionId }` mapping in renderer state; use backend ID for operations +- [ ] Update runtime registry selection to be workspace/session-based (avoid long-term reliance on `getForPaneId`) - [ ] Define/introduce lifecycle events needed for cloud backends (connection/auth) ## Surprises & Discoveries -(Fill this in during implementation with dates and short, factual notes.) +- 2026-01-11: Reviewed `docs/CLOUD_WORKSPACE_PLAN.md` — cloud is source of truth with optional local sync; implies a workspace-scoped provider boundary (terminal + agentEvents + changes/files), not “terminal-only”. +- 2026-01-11: Upstream main includes detach/reattach scroll restoration (`viewportY`, PR #698); treat as a stable behavior invariant during refactor. ## Decision Log -(Move items from Open Questions here as they are resolved; include rationale and date.) +- 2026-01-11: Use a process-scoped **runtime registry** (`getTerminalRuntimeRegistry()`), not a single global runtime; router captures the registry and selects runtimes per workspace/session so local + cloud can coexist later. +- 2026-01-11: Keep the abstraction boundary provider-neutral: expose `management: TerminalManagement | null` (capability object) while keeping legacy endpoint names like `listDaemonSessions` for UI compatibility. +- 2026-01-11: Preserve renderer behavior; any `Terminal.tsx` changes are decomposition-only (init plan + applier + stream buffering), preserving “no complete on exit” and `viewportY` scroll restoration. +- 2026-01-11: Treat `backendSessionId` as required groundwork for cloud/SSH backends (Milestone 7). Local may keep `backendSessionId === paneId` as an implementation detail, but the contract must not assume it. ## Outcomes & Retrospective From 7f7bb1e05e5470028147d86e3c50e4f5119ad4ed Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Mon, 12 Jan 2026 10:38:12 +0200 Subject: [PATCH 50/62] docs(desktop): add terminal runtime architecture review packet --- .../docs/TERMINAL_RUNTIME_ARCH_REVIEW.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md diff --git a/apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md b/apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md new file mode 100644 index 00000000000..7178202fce9 --- /dev/null +++ b/apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md @@ -0,0 +1,159 @@ +# Architecture Review Packet: Terminal Runtime + Future Remote Runners + +This doc is intended for an external architecture review. It provides enough context to understand the problem space and asks open-ended questions to help critique our current direction. + +**How to use this:** please read the plan first, then use the questions below as prompts. Feel free to ignore our current approach and propose a better one — we’re explicitly trying to avoid narrowing you into our hypotheses. + +## What we’re trying to build (big picture) + +Superset Desktop is an Electron app that provides: + +- A multi-pane terminal UI inside workspaces (think “IDE terminal panes”). +- Git worktree-based workspaces (multiple isolated working copies). +- “Changes” UX (diff/status/staging) tied to those workspaces. +- Agent/CLI integrations that surface lifecycle/status in the UI (e.g. completion events, indicators). + +Today, terminals can run locally and (optionally) persist via a background “terminal host” daemon. In the future, we want to support executing terminals in the cloud / on a remote runner while keeping the same “Superset UX primitives” (worktrees, changes/diff, agent status, etc.). + +## Why we’re asking for review now + +We have a working implementation of terminal persistence, but it adds a lot of complexity and “mode branching” (daemon vs in-process) across layers (main process, tRPC router, renderer). + +We’re planning a rewrite/refactor to: + +- Centralize backend selection (so most code is backend-agnostic). +- Preserve current behavior (especially around session streaming, attach/detach, and restore). +- Create a foundation that won’t fight us when we introduce remote runners/cloud terminals. + +## Current state (high-level) + +- Electron main process owns terminal backends: + - **In-process backend:** PTYs owned directly in main process. + - **Daemon backend:** PTYs owned by a separate “terminal host” process; main connects via a local socket. +- Renderer talks to main via tRPC (IPC), including a terminal stream subscription. +- Terminals have “attach/detach” semantics and “cold restore” (disk-backed scrollback restore) for daemon persistence. + +## Known constraints (technical + product) + +These are constraints we currently operate under; if you think any should change, call it out. + +- Renderer must not import Node.js modules (browser environment). +- IPC is via tRPC, and subscriptions must use an observable pattern (not async generators). +- The terminal UI must remain responsive under high output (performance/backpressure matters). +- We want to avoid regressions in tricky lifecycle/ordering behavior (attach timing, exit vs tail output, etc.). + +## Critical behaviors we believe we must preserve (please challenge if wrong) + +- The “terminal stream” must not permanently stop delivering data due to a session exit transition (exit is a state change, not the end of the subscription). +- Cold restore should be read-only until the user explicitly starts a new shell. +- Detach/reattach should preserve expected scroll position behavior (when supported). +- Workspace-level actions (delete workspace, refresh prompts, etc.) should affect all active terminal sessions regardless of backend choice. + +## Future use cases we want to be compatible with + +- **Remote runner / cloud terminals:** terminal sessions execute on a server (possibly while the laptop sleeps). +- **Multi-device access:** a backend session may outlive any single client, and multiple clients/panes may view the same session. +- **Provider model:** not just terminals — we likely need a workspace-scoped runtime that can also deliver: + - agent lifecycle events (start/stop/permission requests, etc.) + - git + “changes” functionality (status/diff/staging/commit/push/pull) + - file read/write (or a sync layer) + +We have a separate cloud plan doc that describes the intended product direction (cloud as source of truth, SSH terminals, tmux persistence, optional local sync for IDE users). + +## What we want from you + +1. A critique of our abstraction boundaries: what’s missing, what’s over-coupled, what’s in the wrong place. +2. Alternative architectures that could reduce complexity and improve long-term extensibility. +3. The biggest failure modes/risk areas you see (especially ordering/lifecycle bugs) and how you’d design to prevent them. +4. A suggested “migration plan” that minimizes regressions while moving from today’s implementation to a cleaner architecture. + +## Questions (intentionally open-ended) + +### 1) Abstraction boundaries / layering + +- If you were designing this from scratch, what are the natural layers/modules you would define? +- Where should backend selection happen so it doesn’t leak across the codebase? +- How would you structure the “terminal runtime” so it can support local + daemon + future remote backends without constant branching? +- Should “terminal runtime” be its own concept, or should it be a sub-component of a broader “workspace runtime/provider”? Where should the seam be? + +### 2) Contracts, identity, and lifecycle + +- What should be the stable identities in the system? + - UI pane IDs vs backend session IDs vs workspace IDs vs user IDs + - multi-client / multi-pane viewing the same backend session +- What lifecycle state machine would you define for a session (running/exited/disposed/etc.) and for the output stream? +- How would you make operations idempotent and race-safe (double-create, attach-after-exit, exit-vs-tail-output, detach/reattach ordering)? +- What does a “clean” detach/reattach contract look like across local/daemon/remote backends? + +### 3) Event delivery model (streaming) + +- What is the right event delivery contract between backend and UI? + - How do you avoid coupling to Node EventEmitter semantics while still supporting local implementations? + - What delivery guarantees matter (at-most-once vs at-least-once, ordering, replay for late subscribers)? +- How would you handle “late subscribers” (UI attaches after output already started)? +- How would you represent backend connectivity issues (disconnects, auth expiration, retries) in a backend-agnostic way? + +### 4) Persistence / scrollback / resource management + +- What persistence strategy would you choose for scrollback and session restore? + - What’s the “right” unit of persistence (raw PTY log, terminal emulator snapshot, both)? + - What size limits / retention rules should exist to avoid disk fill and memory pressure? +- How should backpressure be handled end-to-end (PTY → persistence writer → IPC → renderer)? +- Where should truncation/compaction happen, and how should it be tested? + +### 5) Remote runners: integrating “worktrees”, “changes”, and “agent status” + +- If terminal execution moves remote, what should be the source of truth for: + - workspace files + - git operations and “changes” UX + - agent lifecycle/status events +- What architecture patterns have you seen work for this (VSCode-like remote agents, SSH providers, etc.)? +- What’s the minimum viable set of primitives to expose from a remote runner so the desktop UI can remain mostly unchanged? +- How would you approach security/authentication for a remote agent channel? + +### 6) Testing + rollout strategy + +- What invariants would you codify as tests to prevent regressions? +- How would you structure integration vs unit tests to catch ordering/lifecycle bugs? +- If we expect a large refactor, how would you stage it to keep changes reviewable and safe? + +## Reference docs + files to attach (copy/paste) + +Below is a curated set of files you can paste into Slack for context. If you only read a few, start with the plan + the terminal router + the daemon manager. + +### Primary + +1. `apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md` + - The current refactor plan (milestones, invariants, proposed boundaries). +2. `docs/CLOUD_WORKSPACE_PLAN.md` + - Product direction for cloud workspaces / remote execution (high level). + +### Terminal runtime + daemon backend + +3. `apps/desktop/src/main/lib/terminal/manager.ts` + - In-process PTY backend (local). +4. `apps/desktop/src/main/lib/terminal/daemon-manager.ts` + - Daemon-backed backend + cold restore logic (local persistence). +5. `apps/desktop/src/main/lib/terminal-host/client.ts` + - Main-process client that talks to the terminal host daemon. +6. `apps/desktop/src/main/terminal-host/index.ts` + - Terminal host daemon entry point. +7. `apps/desktop/docs/TERMINAL_HOST_EVENTS.md` + - Event/protocol notes for terminal host interactions. + +### IPC surface (tRPC) + renderer terminal + +8. `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` + - Terminal IPC API and stream subscription shape. +9. `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` + - Terminal UI component (current complexity hot-spot). + +### “Changes” + agent lifecycle (related UX primitives to preserve) + +10. `apps/desktop/src/lib/trpc/routers/changes/*` + - Git/status/diff-related IPC endpoints (local worktree-centric today). +11. `apps/desktop/src/main/lib/notifications/server.ts` + - Main-process notifications server that feeds agent lifecycle events. +12. `apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts` + - Renderer listener that consumes agent lifecycle notifications to drive UI state. + From 4b746d90a9fdec687e24e30b3d6bc4d8fec4cfe1 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Mon, 12 Jan 2026 10:48:47 +0200 Subject: [PATCH 51/62] docs(desktop): narrow changes router reference list --- apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md b/apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md index 7178202fce9..86df3dba68e 100644 --- a/apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md +++ b/apps/desktop/docs/TERMINAL_RUNTIME_ARCH_REVIEW.md @@ -150,10 +150,14 @@ Below is a curated set of files you can paste into Slack for context. If you onl ### “Changes” + agent lifecycle (related UX primitives to preserve) -10. `apps/desktop/src/lib/trpc/routers/changes/*` - - Git/status/diff-related IPC endpoints (local worktree-centric today). +10. `apps/desktop/src/lib/trpc/routers/changes/index.ts` + - Git/status/diff-related IPC endpoints (local worktree-centric today). Key related files: + - `apps/desktop/src/lib/trpc/routers/changes/status.ts` + - `apps/desktop/src/lib/trpc/routers/changes/staging.ts` + - `apps/desktop/src/lib/trpc/routers/changes/git-operations.ts` + - `apps/desktop/src/lib/trpc/routers/changes/file-contents.ts` + - `apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts` 11. `apps/desktop/src/main/lib/notifications/server.ts` - Main-process notifications server that feeds agent lifecycle events. 12. `apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts` - Renderer listener that consumes agent lifecycle notifications to drive UI state. - From 016a1209db3f1e562fdb86bed56759cd74263a64 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Mon, 12 Jan 2026 12:22:36 +0200 Subject: [PATCH 52/62] docs(desktop): incorporate architecture feedback into runtime rewrite plan --- ...13-terminal-runtime-abstraction-rewrite.md | 643 ++++++++++-------- 1 file changed, 367 insertions(+), 276 deletions(-) diff --git a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md index d939754070c..f82a0b1bfbd 100644 --- a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md +++ b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md @@ -1,4 +1,4 @@ -# Terminal Runtime Abstraction (Daemon vs In-Process) +# Workspace Runtime Abstraction (Terminals: Daemon vs In-Process) This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. @@ -18,20 +18,21 @@ Reference: This plan follows conventions from `AGENTS.md`, `apps/desktop/AGENTS. - [Plan of Work](#plan-of-work) - [Target Shape (After Refactor)](#target-shape-after-refactor) - [File Tree (Proposed)](#file-tree-proposed) - - [Terminal Runtime Types (Main Process)](#terminal-runtime-types-main-process) + - [Identity + Lifecycle (State Machines)](#identity--lifecycle-state-machines) + - [Workspace Runtime Types (Main Process)](#workspace-runtime-types-main-process) - [tRPC Router Shape (No Daemon Type Checks)](#trpc-router-shape-no-daemon-type-checks) - [Renderer Decomposition (Reducing `Terminal.tsx` Branching)](#renderer-decomposition-reducing-terminaltsx-branching) - [Diagrams (Call Flow)](#diagrams-call-flow) -- [Milestones](#milestone-1-establish-a-backend-contract-and-invariants) - - [Milestone 1](#milestone-1-establish-a-backend-contract-and-invariants) - - [Milestone 2](#milestone-2-implement-terminalruntime-registry--capabilities) - - [Milestone 3](#milestone-3-migrate-trpc-terminal-router-to-the-runtime) - - [Milestone 4](#milestone-4-add-regression-coverage-for-the-abstraction-boundary) - - [Milestone 5](#milestone-5-manual-verification-high-coverage-low-surprises) +- [Milestones](#milestone-1-contract--invariants-workspaceruntime) + - [Milestone 1](#milestone-1-contract--invariants-workspaceruntime) + - [Milestone 2](#milestone-2-workspaceruntime-registry--capabilities) + - [Milestone 3](#milestone-3-trpc-terminal-router-migration) + - [Milestone 4](#milestone-4-identity--stream-contract-backendsessionidclientid) + - [Milestone 5](#milestone-5-regression-coverage) - [Milestone 6a](#milestone-6a-build-a-terminal-init-plan-renderer) - [Milestone 6b](#milestone-6b-stream-subscription--buffering-hook-renderer) - [Milestone 6c](#milestone-6c-integrate-helpers-into-terminaltsx-ui-wiring-only) - - [Milestone 7 (Cloud Readiness)](#milestone-7-cloud-readiness-introduce-backendsessionid) + - [Milestone 7 (Cloud Readiness)](#milestone-7-cloud-readiness-workspaceruntime-skeleton) - [Validation](#validation) - [Idempotence / Safety](#idempotence--safety) - [Risks and Mitigations](#risks-and-mitigations) @@ -43,15 +44,20 @@ Reference: This plan follows conventions from `AGENTS.md`, `apps/desktop/AGENTS. ## Purpose / Big Picture -After this change, the desktop app still supports terminal persistence (daemon mode with cold restore) exactly as it does today, but the codebase no longer leaks “daemon vs in-process” branching across the tRPC router and UI. Backend selection becomes a single responsibility owned by `apps/desktop/src/main/lib/terminal/`. +After this change, the desktop app still supports terminal persistence (daemon mode with cold restore) exactly as it does today, but the codebase no longer leaks “daemon vs in-process” branching across the tRPC router and UI. -This plan also tightens the abstraction so it can become the **local implementation of a workspace-scoped “provider”** later (cloud/SSH/remote runners), without re-introducing backend branching across the application. +The key change in this revised plan is that we **promote a workspace-scoped provider abstraction to be the primary seam**: + +- `WorkspaceRuntime` (aka provider) becomes the long-term boundary for local vs daemon vs cloud/SSH backends. +- `TerminalRuntime` becomes a sub-component (`workspace.terminal`) rather than being “the” top-level runtime. + +This avoids re-cutting seams when we later move “changes/files/agent status” into the same provider boundary for cloud workspaces. Observable outcomes: 1. With terminal persistence disabled, terminals behave as before (no persistence across app restarts), and Settings → Terminal “Manage sessions” shows that session management is unavailable. 2. With terminal persistence enabled, terminals survive app restarts, cold restore works, and Settings → Terminal “Manage sessions” continues to list/kill sessions. -3. The tRPC `terminal.*` router no longer needs `instanceof DaemonTerminalManager` checks; daemon awareness is centralized in the terminal runtime layer. +3. The tRPC `terminal.*` router no longer needs `instanceof DaemonTerminalManager` checks; daemon awareness is centralized in the main-process runtime/provider layer. 4. The renderer terminal component remains correct but is easier to reason about because backend-agnostic “session initialization” and “stream event handling” logic is extracted into small, testable helpers rather than being interleaved with UI rendering. @@ -84,26 +90,34 @@ Pane ID (`paneId`): a stable identifier for a terminal pane in the renderer’s Backend session ID (`backendSessionId`): an identifier assigned by the backend for the running session. For local backends, this may continue to equal `paneId`, but future backends (cloud/multi-device) should be free to assign their own IDs and map multiple panes/clients to the same backend session. +Client ID (`clientId`): a stable identifier for the viewer/client instance attaching to sessions. This is required for multi-device and also maps cleanly to how the daemon protocol already works (it ties a client’s control + stream sockets together). + +Attachment ID (`attachmentId`): an ephemeral identifier for a specific attachment/subscription of a client to a session (a handle). This makes detach idempotent and is the cleanest path to supporting multiple panes viewing the same backend session. + +Event cursor (`eventId` / `cursor`): a monotonic per-session counter used to support bounded replay for late subscribers (“subscribe since cursor”). This prevents the “late subscriber misses early output” class of bugs without requiring UI-level correctness buffering. + Terminal session: the running PTY process and its terminal emulator state. Warm attach: reconnecting to a still-running session (daemon still has the PTY). Cold restore: restoring scrollback from disk after an unclean shutdown or daemon session loss, before starting a new shell. -Terminal runtime: a backend-agnostic surface (sessions/workspaces/events + capabilities) that callers use without knowing the implementation (local in-process, local daemon, cloud/SSH later). +Terminal runtime: a backend-agnostic surface (session ops + events + capabilities) that callers use without knowing the implementation (local in-process, local daemon, cloud/SSH later). + +Workspace runtime (provider): a workspace-scoped boundary that can supply terminal IO, “changes/files”, and agent lifecycle events. Cloud terminals require this broader abstraction if we want to preserve the current UX. -Terminal runtime registry: a process-scoped module in `apps/desktop/src/main/lib/terminal/` that selects the correct runtime for a given workspace/session and ensures runtimes are cached so we don’t multiply event listeners or backend connections. +Workspace runtime registry: a process-scoped module in `apps/desktop/src/main/lib/workspace-runtime/` that selects the correct runtime/provider for a given workspace and caches instances so we don’t multiply event listeners or backend connections. Capabilities: optional features that exist only for some backends (for example “list/manage persistent sessions”). Callers should not use `instanceof` checks. Capability presence must be represented structurally (for example `management: null` when unavailable) and via explicit capability flags, so “unsupported” cannot be confused with “success”. -Workspace provider / runtime: a workspace-scoped backend boundary that can supply terminal IO, agent lifecycle events, and “changes/files” operations for either local worktrees or cloud workspaces. This plan focuses on the terminal portion, but the boundary should be compatible with being embedded in a provider later. +Note: this plan focuses on the terminal portion first, but it intentionally introduces the provider boundary now to avoid creating parallel “runtime registries” for terminals vs changes/files/agentEvents later. ## Non-Goals This refactor is intentionally conservative to avoid regressions: -1. No protocol redesign between main and terminal-host. +1. No large protocol redesign between main and terminal-host. Additive fields (typed error codes, cursors/watermarks, capability bits) are acceptable if they preserve backwards compatibility. 2. No behavioral change to cold restore, attach scheduling, warm set mounting, or stream lifecycle. 3. No implementation of cloud terminals in this PR. The plan only ensures the abstraction boundary is compatible with adding a cloud backend later. @@ -114,6 +128,7 @@ This refactor is intentionally conservative to avoid regressions: 2. The terminal persistence setting (`settings.terminalPersistence`) is treated as “requires restart” today; we keep that behavior for this refactor. 3. tRPC subscriptions must use `observable` (per `apps/desktop/AGENTS.md`); we will not introduce generator-based subscriptions. 4. The most important regression to prevent is the “listeners=0” cold-restore failure mode; specifically, the `terminal.stream` subscription must not complete on exit. + - This applies to `streamV2` as well; session exit is a state transition, not stream completion. ## Future Backend: Remote Runner / Cloud Terminals @@ -131,7 +146,7 @@ The cloud workspace plan (`docs/CLOUD_WORKSPACE_PLAN.md`) makes a few things exp ### What’s local-only today (current coupling) -1. **Terminal IO keys by `paneId` (client identity):** `terminal.createOrAttach`, `terminal.write`, and `terminal.stream` treat `paneId` as the stable session key (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`). +1. **Terminal IO keys by `paneId` (client identity):** today `terminal.createOrAttach`, `terminal.write`, and `terminal.stream` treat `paneId` as the stable session key (`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`). This rewrite moves the boundary to `{ backendSessionId, clientId, attachmentId }` (via `streamV2`) so multi-device/cloud doesn’t require reworking every callsite later. 2. **Agent lifecycle events assume localhost hooks:** terminal env injects `SUPERSET_*` and `SUPERSET_PORT` (`apps/desktop/src/main/lib/terminal/env.ts`), and the notify hook script `curl`s `http://127.0.0.1:$SUPERSET_PORT/hook/complete` (`apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh`). This cannot work from a remote runner. 3. **“Changes” assumes local worktree filesystem:** git status/diff/staging/commit/push/pull operate against a local `worktreePath` using `simple-git`, and file reads/writes are guarded by secure path validation (`apps/desktop/src/lib/trpc/routers/changes/*`). @@ -139,7 +154,7 @@ The cloud workspace plan (`docs/CLOUD_WORKSPACE_PLAN.md`) makes a few things exp 1. **Backend-agnostic event delivery:** `TerminalEventSource.subscribe…() => unsubscribe` is compatible with WebSocket/SSE backends and avoids leaking Node `EventEmitter` semantics. 2. **Capabilities over “mode strings”:** cloud backends can expose a capability surface without introducing a new `"cloud"` mode string that bleeds into callers. -3. **Identity decoupling is planned:** Milestone 7 (cloud readiness) introduces `backendSessionId`, which is required for cloud (server-assigned IDs, multi-device access). +3. **Identity decoupling is planned:** Milestone 4 introduces `backendSessionId` + `clientId` + `attachmentId`, which are required for cloud (server-assigned IDs, multi-device access). ### The key realization: cloud terminals need a Workspace Runtime, not just a Terminal Runtime @@ -150,7 +165,7 @@ A remote runner cannot be “just a terminal backend” if we want to preserve t 3. **git + files:** status/diff/staging/commit/push/pull + safe file read/write (or an explicit sync layer) 4. **sync (if local stays canonical):** bidirectional worktree synchronization when execution happens remotely -The `TerminalRuntime` abstraction created in this plan should become one *component* of a broader “WorkspaceRuntime” concept as cloud work gets closer. +The `TerminalRuntime` abstraction created in this plan is one component of the broader `WorkspaceRuntime` provider boundary. ### Preserving “agent interactions” in a remote runner world @@ -181,9 +196,11 @@ This decision materially changes the scope and correctness model of cloud termin ## Open Questions -1. Naming: should the main-process entry point be `getTerminalRuntimeRegistry()` (terminal-only) or `getWorkspaceProviderRegistry()` (terminal + future agentEvents/changes/files)? (This plan assumes `getTerminalRuntimeRegistry()` now, and we can later embed it inside a workspace provider registry without changing the router/UI contracts.) -2. Should we keep the existing tRPC endpoint names (`terminal.listDaemonSessions`, `terminal.killAllDaemonSessions`, etc.) for backwards compatibility in the renderer? (This plan assumes “yes” to minimize churn and risk.) -3. Provider selection: how do we decide whether a workspace uses the local terminal runtime vs a cloud/SSH runtime? (Expected: based on workspace metadata such as `cloudWorkspaceId` / workspace type, not on UI state.) +1. **Multi-attach semantics:** do we want to allow multiple panes (or multiple devices) to attach to the same `backendSessionId` concurrently? If yes, we must make `clientId` + `attachmentId` first-class and define what “detach” means (viewer gone, not session stopped). +2. **Replay window defaults:** what bounded replay do we want to guarantee for late subscribers (events count + bytes)? (Local can start small; cloud may offer larger server-side replay.) +3. **Cloud terminal transport:** when cloud arrives, is the terminal data plane SSH-only, an authenticated WebSocket proxy, or a runner-native protocol? (This affects where replay/buffering lives and what connection/auth events look like.) +4. **Provider selection:** how do we decide whether a workspace uses the local provider vs a cloud/SSH provider? (Expected: workspace metadata such as `cloudWorkspaceId` / workspace type, not UI state.) +5. **tRPC compatibility:** do we keep legacy endpoint names like `listDaemonSessions` (yes) and add `*V2` endpoints for identity/cursor work, or do we accept a coordinated renderer+router update to evolve existing endpoints? ## Plan of Work @@ -198,16 +215,22 @@ This section is illustrative. It shows the intended file layout, key types, and ### File Tree (Proposed) + apps/desktop/src/main/lib/workspace-runtime/ + index.ts # exports getWorkspaceRuntimeRegistry() + registry.ts # per-workspace selection + caching (process-scoped registry) + types.ts # WorkspaceRuntime contract + capability flags + local.ts # local implementation (terminal + future changes/files/agentEvents) + cloud.ts # (future) remote implementation skeleton (NOT_IMPLEMENTED) + apps/desktop/src/main/lib/terminal/ - index.ts # exports getTerminalRuntimeRegistry() - runtime-registry.ts # per-workspace selection + caching (process-scoped registry) - runtime.ts # TerminalRuntime adapters/types (backend-agnostic surface) + runtime.ts # TerminalRuntime contract + adapters (backend-agnostic surface) manager.ts # in-process backend (existing) daemon-manager.ts # daemon backend (existing) + terminal-history.ts # history persistence (existing) types.ts # existing shared terminal types (CreateSessionParams, SessionResult, events) apps/desktop/src/lib/trpc/routers/terminal/ - terminal.ts # uses getTerminalRuntimeRegistry(); no instanceof checks + terminal.ts # uses getWorkspaceRuntimeRegistry(); no instanceof checks terminal.stream.test.ts # stream invariants (exit does not complete) apps/desktop/src/renderer/.../Terminal/ @@ -220,29 +243,121 @@ This section is illustrative. It shows the intended file layout, key types, and useTerminalConnection.ts # tRPC mutations (existing) -### Terminal Runtime Types (Main Process) +### Identity + Lifecycle (State Machines) + +The plan relies on **separating session lifecycle from subscription lifecycle**. This is the core invariant behind the “stream must not complete on exit” rule, and it becomes even more important for cloud/multi-device. + +Session lifecycle (backend truth; per `backendSessionId`): + +1. `spawning` (optional; cloud or tmux restore) +2. `running` +3. `exited` (PTY exited; session state remains queryable/attachable depending on backend semantics) +4. `terminated` (explicitly killed or deleted; no longer attachable) + +Stream/subscription lifecycle (viewer truth; per `attachmentId`): + +1. `subscribed` → `live` (receiving events) +2. `disconnected` (transport down; may reconnect; session may still be running) +3. `unsubscribed` (the only terminal stream completion trigger — client disposed) + +Rule: **session exit must never transition the stream to “unsubscribed/completed”.** Exit is a state transition delivered as an event (and/or reflected via attach metadata), but the subscription remains open until the client explicitly unsubscribes. + + +### Workspace Runtime Types (Main Process) -The goal is to stop encoding backend choice as a “mode string” that callers branch on. Callers should see capabilities and nullable management objects instead. +The goal is to stop encoding backend choice as a “mode string” that callers branch on. Callers should see: + +1. A workspace-scoped provider (`WorkspaceRuntime`) selected by a registry in main. +2. Provider-neutral capability flags + nullable capability objects (no `instanceof` branching outside the provider boundary). +3. Explicit identities and lifecycle semantics that are compatible with multi-device/cloud. + + export type WorkspaceRuntimeId = string; + + export interface WorkspaceRuntimeRegistry { + getForWorkspaceId(workspaceId: string): WorkspaceRuntime; + + // Transitional: used only by legacy/global endpoints (settings screens). + // Do not use this for per-session routing. + getDefault(): WorkspaceRuntime; + } + + export interface WorkspaceRuntime { + id: WorkspaceRuntimeId; + terminal: TerminalRuntime; + // Future: changes/files/agentEvents become part of this provider boundary. + // Keep these as stubs until cloud work demands them; avoid creating parallel registries. + capabilities: { + terminal: TerminalCapabilities; + // changes/files/agentEvents capability flags will be added here later. + }; + } + +Terminal identities (first-class in contracts): + + export type TerminalClientId = string; + export type TerminalAttachmentId = string; + export type TerminalEventId = number; // monotonic per session (cursor) + + export type TerminalErrorCode = + | "SESSION_NOT_FOUND" + | "WRITE_QUEUE_FULL" + | "WRITE_FAILED" + | "PTY_NOT_SPAWNED" + | "BACKEND_UNAVAILABLE" + | "PROTOCOL_MISMATCH" + | "REPLAY_UNAVAILABLE" + | "NOT_IMPLEMENTED"; + +Terminal capabilities and management: export interface TerminalCapabilities { - /** Sessions can survive app restarts */ - persistent: boolean; - /** Backend supports cold restore (disk-backed or otherwise) */ - coldRestore: boolean; - /** Sessions can be managed remotely (future: cloud terminals) */ - remoteManagement: boolean; + persistent: boolean; // sessions can survive app restarts + coldRestore: boolean; // cold restore is supported + replay: boolean; // stream supports bounded replay via `since` cursor + multiAttach: boolean; // multiple attachments can view one backend session + remoteManagement: boolean; // sessions can be managed remotely (future: cloud) + } + + export interface TerminalManagement { + listSessions(): Promise; + killAllSessions(): Promise; + resetHistoryPersistence(): Promise; + } + +Terminal runtime surface: + + export interface CreateOrAttachResult extends SessionResult { + backendSessionId: string; + clientId: TerminalClientId; + attachmentId: TerminalAttachmentId; + /** + * Watermark cursor: the snapshot/initial state returned by createOrAttach + * includes all events up to (and including) this cursor. + * Clients should subscribe with `since = watermark + 1` to avoid gaps. + */ + watermarkEventId: TerminalEventId; } export interface TerminalSessionOperations { // Core lifecycle (normalized to async, even if an implementation is sync today) - createOrAttach(params: CreateSessionParams): Promise; - write(params: { paneId: string; data: string }): Promise; - resize(params: { paneId: string; cols: number; rows: number; seq?: number }): Promise; - signal(params: { paneId: string; signal?: string }): Promise; - kill(params: { paneId: string }): Promise; - detach(params: { paneId: string; viewportY?: number }): Promise; - clearScrollback(params: { paneId: string }): Promise; - ackColdRestore(params: { paneId: string }): Promise; + createOrAttach(params: CreateSessionParams & { + clientId: TerminalClientId; + attachmentId: TerminalAttachmentId; + }): Promise; + + write(params: { backendSessionId: string; data: string }): Promise; + resize(params: { backendSessionId: string; cols: number; rows: number; seq?: number }): Promise; + signal(params: { backendSessionId: string; signal?: string }): Promise; + kill(params: { backendSessionId: string }): Promise; + + detach(params: { + backendSessionId: string; + attachmentId: TerminalAttachmentId; + viewportY?: number; + }): Promise; + + clearScrollback(params: { backendSessionId: string }): Promise; + ackColdRestore(params: { backendSessionId: string }): Promise; } export interface TerminalWorkspaceOperations { @@ -251,40 +366,38 @@ The goal is to stop encoding backend choice as a “mode string” that callers refreshPromptsForWorkspace(workspaceId: string): Promise; } - export type TerminalPaneEvent = - | { type: "data"; data: string } - | { type: "exit"; exitCode: number; signal?: number } - | { type: "disconnect"; reason: string } - | { type: "error"; error: string; code?: string }; + export type TerminalSessionEvent = + | { type: "data"; backendSessionId: string; eventId: TerminalEventId; data: string } + | { type: "exit"; backendSessionId: string; eventId: TerminalEventId; exitCode: number; signal?: number } + | { type: "disconnect"; backendSessionId: string; eventId: TerminalEventId; reason: string } + | { type: "error"; backendSessionId: string; eventId: TerminalEventId; error: string; code?: TerminalErrorCode } + | { type: "connection_state"; backendSessionId: string; eventId: TerminalEventId; state: "connected" | "disconnected" | "reconnecting"; reason?: string } + | { type: "auth_state"; backendSessionId: string; eventId: TerminalEventId; state: "valid" | "expired"; reauthUrl?: string }; export interface TerminalEventSource { - // Backend-agnostic event subscription API (do not expose Node EventEmitter semantics) - subscribePane(params: { - paneId: string; - onEvent: (event: TerminalPaneEvent) => void; + /** + * Backend-agnostic subscription API (do not expose Node EventEmitter semantics). + * Must NOT complete on `exit`. + * + * Replay contract: + * - If `since` is provided and the backend supports replay, it should replay a bounded window of events. + * - If the replay window cannot satisfy `since`, the backend should still subscribe live but should + * surface `REPLAY_UNAVAILABLE` explicitly (error event or a typed meta event) so the UI can rely on snapshot. + */ + subscribeSession(params: { + backendSessionId: string; + clientId: TerminalClientId; + attachmentId: TerminalAttachmentId; + since?: TerminalEventId; + onEvent: (event: TerminalSessionEvent) => void; }): () => void; // Low-volume lifecycle events used for correctness when panes are unmounted. subscribeTerminalExit(params: { - onExit: (event: { paneId: string; exitCode: number; signal?: number }) => void; + onExit: (event: { backendSessionId: string; exitCode: number; signal?: number }) => void; }): () => void; } - export interface TerminalManagement { - listSessions(): Promise; - forceKillAll(): Promise; - resetHistoryPersistence(): Promise; - } - - export interface TerminalRuntimeRegistry { - // Runtime selection should be workspace-scoped (local vs cloud later). - getForWorkspaceId(workspaceId: string): TerminalRuntime; - // Transitional: allow lookups by pane until backendSessionId is fully introduced. - getForPaneId(paneId: string): TerminalRuntime; - // For legacy/global endpoints that aren't workspace-scoped yet. - getDefault(): TerminalRuntime; - } - export interface TerminalRuntime { sessions: TerminalSessionOperations; workspaces: TerminalWorkspaceOperations; @@ -293,123 +406,54 @@ The goal is to stop encoding backend choice as a “mode string” that callers capabilities: TerminalCapabilities; } -`getTerminalRuntimeRegistry()` must return the same registry instance across the process lifetime, and the registry must return stable runtime objects (at least stable `events`/listener wiring) so we do not multiply event listeners or backend connections. - - let cachedRegistry: TerminalRuntimeRegistry | null = null; - const paneToRuntime = new Map(); - // Implementation detail: keep this mapping updated from createOrAttach/detach/kill - // so `getForPaneId()` can route stream subscriptions correctly until backendSessionId. - - export function getTerminalRuntimeRegistry(): TerminalRuntimeRegistry { - if (cachedRegistry) return cachedRegistry; - - const backend = getActiveTerminalManager(); // existing selection logic (cached by “requires restart”) - const daemonManager = backend instanceof DaemonTerminalManager ? backend : null; - - const localRuntime: TerminalRuntime = { - sessions: { - createOrAttach: (params) => backend.createOrAttach(params), - write: async (params) => backend.write(params), - resize: async (params) => backend.resize(params), - signal: async (params) => backend.signal(params), - kill: (params) => backend.kill(params), - detach: async (params) => backend.detach(params), - clearScrollback: async (params) => backend.clearScrollback(params), - ackColdRestore: async (params) => backend.ackColdRestore(params.paneId), - }, - workspaces: { - killByWorkspaceId: (workspaceId) => backend.killByWorkspaceId(workspaceId), - getSessionCountByWorkspaceId: (workspaceId) => - backend.getSessionCountByWorkspaceId(workspaceId), - refreshPromptsForWorkspace: async (workspaceId) => - backend.refreshPromptsForWorkspace(workspaceId), - }, - events: { - subscribePane: ({ paneId, onEvent }) => { - const onData = (data: string) => onEvent({ type: "data", data }); - const onExit = (exitCode: number, signal?: number) => - onEvent({ type: "exit", exitCode, signal }); - const onDisconnect = (reason: string) => - onEvent({ type: "disconnect", reason }); - const onError = (payload: { error: string; code?: string }) => - onEvent({ type: "error", error: payload.error, code: payload.code }); - - backend.on(`data:${paneId}`, onData); - backend.on(`exit:${paneId}`, onExit); - backend.on(`disconnect:${paneId}`, onDisconnect); - backend.on(`error:${paneId}`, onError); - - return () => { - backend.off(`data:${paneId}`, onData); - backend.off(`exit:${paneId}`, onExit); - backend.off(`disconnect:${paneId}`, onDisconnect); - backend.off(`error:${paneId}`, onError); - }; - }, - subscribeTerminalExit: ({ onExit }) => { - backend.on("terminalExit", onExit); - return () => backend.off("terminalExit", onExit); - }, - }, - management: daemonManager - ? { - listSessions: () => daemonManager.listDaemonSessions(), - forceKillAll: () => daemonManager.forceKillAll(), - resetHistoryPersistence: () => - daemonManager.resetHistoryPersistence(), - } - : null, - capabilities: { - persistent: daemonManager !== null, - coldRestore: daemonManager !== null, - remoteManagement: false, - }, - }; - - cachedRegistry = { - getForWorkspaceId: (_workspaceId) => { - // Today: all workspaces use the local runtime. Future: cloud/SSH selection here. - return localRuntime; - }, - getForPaneId: (paneId) => paneToRuntime.get(paneId) ?? localRuntime, - getDefault: () => localRuntime, - }; - - return cachedRegistry; - } - -Notes: +Provider boundary invariants: -1. The `backend instanceof DaemonTerminalManager` check is allowed here because this module is the only backend-selection boundary; the tRPC router and UI must not need it. -2. If management capability exists but a call fails (daemon unreachable, request fails), we propagate the error. We do not convert failures into “persistence disabled” states. -3. `runtime.management !== null` indicates the persistent backend is configured/active, not that it is healthy “right now”. If the daemon process crashes or the socket drops mid-session, operations may throw and the backend emits existing per-pane `disconnect:*` / `error:*` events. The runtime does not dynamically flip `management` to `null`. +1. The registry must be process-scoped and cached (stable runtime objects; stable event wiring). +2. `management !== null` indicates feature availability, not “health right now”; mid-session disconnects surface as events/errors. +3. Backends must not require string-matching to classify errors. Normalize to `TerminalErrorCode` at the boundary and propagate codes unchanged through tRPC. +4. Backends should enforce resize sequencing (drop stale `seq`); the renderer already provides `seq` today. +5. Replay correctness belongs at the backend/provider boundary (bounded ring buffer + cursor), not in `Terminal.tsx`. ### tRPC Router Shape (No Daemon Type Checks) -The terminal router captures the **runtime registry** once when the router is created. Each procedure then selects the correct runtime (local vs cloud later) without using `instanceof` checks. +The terminal router captures the **workspace runtime registry** once when the router is created. Each procedure then selects the correct provider (local vs cloud later) without using `instanceof` checks. Key rule: capture the registry once, but do not assume there is only one runtime for the entire process forever. export const createTerminalRouter = () => { - const registry = getTerminalRuntimeRegistry(); + const registry = getWorkspaceRuntimeRegistry(); return router({ createOrAttach: publicProcedure .input(...) - .mutation(async ({ input }) => - registry.getForWorkspaceId(input.workspaceId).sessions.createOrAttach(input), - ), - - stream: publicProcedure - .input(z.string()) - .subscription(({ input: paneId }) => - observable((emit) => { + .mutation(async ({ input }) => { + const workspace = registry.getForWorkspaceId(input.workspaceId); + return workspace.terminal.sessions.createOrAttach(input); + }), + + // Prefer a V2 stream contract that is explicit about identity + replay. + // (Keep `stream(paneId)` temporarily only if needed for compatibility.) + streamV2: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + backendSessionId: z.string(), + clientId: z.string(), + attachmentId: z.string(), + since: z.number().optional(), + }), + ) + .subscription(({ input }) => + observable((emit) => { // IMPORTANT: do not complete on exit. // Exit is a state transition and must not terminate the subscription. - const runtime = registry.getForPaneId(paneId); - return runtime.events.subscribePane({ - paneId, + const workspace = registry.getForWorkspaceId(input.workspaceId); + return workspace.terminal.events.subscribeSession({ + backendSessionId: input.backendSessionId, + clientId: input.clientId, + attachmentId: input.attachmentId, + since: input.since, onEvent: (event) => emit.next(event), }); }), @@ -417,7 +461,7 @@ Key rule: capture the registry once, but do not assume there is only one runtime listDaemonSessions: publicProcedure.query(async () => { // Note: endpoint name kept for backwards compatibility; capability is provider-neutral. - const runtime = registry.getDefault(); + const runtime = registry.getDefault().terminal; if (!runtime.management) return { daemonModeEnabled: false, sessions: [] }; const response = await runtime.management.listSessions(); return { daemonModeEnabled: true, sessions: response.sessions }; @@ -470,11 +514,12 @@ The renderer still needs to implement UI behaviors (cold restore overlay, retry export function useTerminalStream(params: { paneId: string; + backendSessionId: string; onEvent: (event: TerminalStreamEvent) => void; isReady: () => boolean; onBufferFlush: (events: TerminalStreamEvent[]) => void; }) { - // subscribe via trpc.terminal.stream.useSubscription + // subscribe via trpc.terminal.streamV2.useSubscription (with since cursor when available) // queue events while !isReady(), then flush deterministically when ready } @@ -497,11 +542,11 @@ Main call flow (today and after refactor; the difference is where switching happ Renderer (Terminal.tsx + helpers) | - | trpc.terminal.createOrAttach / trpc.terminal.stream + | trpc.terminal.createOrAttach / trpc.terminal.streamV2 v Electron Main (tRPC router) | - | getTerminalRuntimeRegistry().getForWorkspaceId(...) (no backend checks in router) + | getWorkspaceRuntimeRegistry().getForWorkspaceId(...) (no backend checks in router) v Terminal Backend (in-process OR daemon-manager) | @@ -518,117 +563,144 @@ Renderer composition after Milestone 6c: └─ applyTerminalInitPlan() (rehydrate → snapshot or alt-screen redraw → mark ready) -### Milestone 1: Establish a Backend Contract and Invariants +### Milestone 1: Contract + Invariants (WorkspaceRuntime) -This milestone documents and codifies the contract we must preserve during the refactor. At completion, a reader can point to a single place in the codebase that defines “what the terminal backend must do”, and a single set of invariants that all implementations must satisfy. +This milestone documents and codifies the contract we must preserve during the refactor, and makes identity + lifecycle explicit. At completion, a reader can point to a single place in the codebase that defines: + +- what the provider boundary is (`WorkspaceRuntime`) +- what a terminal backend must do (`TerminalRuntime`) +- what identities exist (`paneId`, `workspaceId`, `backendSessionId`, `clientId`, `attachmentId`) +- what lifecycle/state machines exist (session vs subscription) +- what errors look like (typed codes, no string matching) Scope: -1. Identify the backend API surface currently used by callers outside `apps/desktop/src/main/lib/terminal/` by searching for usages of: +1. Inventory current backend call sites and implicit contracts: - `getActiveTerminalManager()` - - events `data:${paneId}`, `exit:${paneId}`, `disconnect:${paneId}`, `error:${paneId}`, and `terminalExit` -2. Write an explicit “terminal backend contract” type in `apps/desktop/src/main/lib/terminal/` (likely in `apps/desktop/src/main/lib/terminal/types.ts` or `runtime.ts`). This contract should include: - - `TerminalSessionOperations` for per-pane session lifecycle (create/attach/write/resize/signal/kill/detach/clearScrollback, cold restore ack). - - `TerminalWorkspaceOperations` for workspace-scoped helpers used by other routers (killByWorkspaceId, getSessionCountByWorkspaceId, refreshPromptsForWorkspace). - - `TerminalEventSource` for event delivery using a backend-agnostic `subscribe...() => unsubscribe` API (no Node EventEmitter semantics in the contract). - - A shared event union type (for example `TerminalPaneEvent`) that matches the tRPC stream payload shapes (`data`, `exit`, `disconnect`, `error`). -3. Record invariants in code comments near the contract: - - `terminal.stream` must not complete on `exit`. - - `exit` is a state transition, not an end-of-stream. - - The output stream lifecycle is separate from session lifecycle: the stream completes only when the client unsubscribes (dispose), not when a session exits. - - Detach/reattach must preserve scroll restoration behavior where supported (currently: pass `viewportY` on detach and restore it on the next attach; see upstream PR #698). - - All backend operations must be normalized to async (Promise-returning) at the contract boundary, even if an implementation currently has a sync method (example: `clearScrollback`). - - Event delivery must be expressed via `subscribe` APIs at the boundary. Backends may use Node EventEmitter internally today, but callers must not depend on EventEmitter semantics. - - The terminal event source must be owned by the backend instance; the runtime facade must not introduce a shared/global EventEmitter or re-emit events in a way that can cause cross-talk or duplicate listeners. + - event names `data:*`, `exit:*`, `disconnect:*`, `error:*`, and `terminalExit` + - any string-matching of errors (example: “session not found” heuristics) +2. Introduce provider boundary types in main: + - `apps/desktop/src/main/lib/workspace-runtime/types.ts` for `WorkspaceRuntime` and registry types + - `apps/desktop/src/main/lib/terminal/runtime.ts` for `TerminalRuntime` contract types +3. Codify invariants as comments adjacent to the contract: + - stream must not complete on `exit` (completion only on unsubscribe) + - exit is a state transition; must arrive after all data events + - detach/reattach scroll restoration (`viewportY`) is preserved (PR #698 behavior) + - all operations are Promise-returning at the boundary (normalize sync to async) + - errors are normalized to typed `TerminalErrorCode` (no string matching) + - replay semantics are explicit (`eventId` cursor + bounded replay) Acceptance: -1. A developer can find the contract definition in one place and see the invariants described in plain language. +1. A developer can find the contract definition in one place and understand identity + lifecycle semantics. 2. No runtime behavior changes yet. -### Milestone 2: Implement TerminalRuntime Registry + Capabilities +### Milestone 2: WorkspaceRuntime Registry + Capabilities -This milestone introduces a single runtime **registry** entry point that owns backend selection and exposes backend-specific capabilities in a consistent, no-branching way to callers. +This milestone introduces a process-scoped **workspace runtime registry** entry point that owns backend selection and exposes provider-neutral capabilities in a consistent, no-branching way to callers. -Approach: +Scope: -1. Create a small facade in `apps/desktop/src/main/lib/terminal/` (recommended: `apps/desktop/src/main/lib/terminal/runtime-registry.ts`) that exports: - - `getTerminalRuntimeRegistry(): TerminalRuntimeRegistry` -2. `TerminalRuntime` should have three parts: - - `sessions: TerminalSessionOperations` (per-pane session lifecycle operations; normalized to async) - - `workspaces: TerminalWorkspaceOperations` (workspace-scoped helpers; normalized to async) - - `events: TerminalEventSource` (backend-agnostic subscribe API for per-pane events and `terminalExit`) - - `management: TerminalManagement | null` (nullable capability object; `null` when persistence/session management is not supported/active) - - `capabilities: { persistent: boolean; coldRestore: boolean; remoteManagement: boolean }` (feature flags that do not encode implementation details and leave room for a future cloud backend) -3. Do not use “no-op admin methods”. The absence of management capability must be represented structurally (`management: null`) so callers cannot confuse “unsupported” with “success”. -4. Ensure the registry is process-scoped and constructed once. The tRPC router should capture the registry once at router construction time (not per request) to avoid multiplying event listeners or backend client connections. -5. Export the registry from `apps/desktop/src/main/lib/terminal/index.ts` as the only supported way to reach backend-specific functionality. -6. Clarify daemon mid-session failure semantics: - - `runtime.management !== null` reflects feature/mode availability, not daemon “health right now”. - - If the daemon disconnects, operations may throw and per-pane disconnect/error events are emitted; the runtime does not dynamically flip `management` to `null`. +1. Implement `getWorkspaceRuntimeRegistry()` in `apps/desktop/src/main/lib/workspace-runtime/registry.ts`: + - cached across process lifetime + - returns stable provider instances (stable event wiring) +2. Implement a `LocalWorkspaceRuntime` (initially only `terminal` is real; other components are stubs): + - backend selection is allowed to use `backend instanceof DaemonTerminalManager` internally (provider boundary only) + - expose `terminal.management: TerminalManagement | null` (no no-op admin methods) +3. Implement the “correctness upgrades” at the backend/provider boundary (so the renderer does not have to): + - monotonic `eventId` per `backendSessionId` + - bounded ring buffer of recent events per session (bytes + frames cap) + - `subscribeSession({ since })` best-effort replay from the ring buffer + - include `watermarkEventId` in `createOrAttach` responses so the renderer can subscribe without gaps +4. Normalize errors into `TerminalErrorCode`: + - daemon client/host and local backend must return typed codes (stop string-matching in routers/renderers) +5. Enforce resize sequencing: + - honor `resize.seq` in both in-process and daemon implementations; drop stale resizes Acceptance: -1. The runtime and capabilities surface is defined in `apps/desktop/src/main/lib/terminal/` and is the only code that knows which backend is active. -2. In non-daemon mode, `runtime.management` is `null` and callers must handle that explicitly; unsupported operations are not silently treated as success. +1. Provider selection is centralized and callers can only reach it via the workspace runtime registry. +2. `management === null` correctly represents “unsupported/unavailable”, while real failures propagate as errors. +3. The terminal event contract supports cursor/replay (even if replay window is initially small). -### Milestone 3: Migrate tRPC `terminal.*` Router to the Runtime +### Milestone 3: tRPC Terminal Router Migration -This milestone removes daemon branching from the tRPC router by routing all terminal work through `getTerminalRuntimeRegistry()`. +This milestone removes daemon branching from the tRPC router by routing all terminal work through `getWorkspaceRuntimeRegistry()`. Scope: -1. Update `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to use: - - `const registry = getTerminalRuntimeRegistry()` (or equivalent) - - For mutations/queries that have `workspaceId` in input, select `const runtime = registry.getForWorkspaceId(input.workspaceId)` (future: local vs cloud). - - For `stream` while it is still keyed by `paneId`, select `const runtime = registry.getForPaneId(paneId)` (transitional until `backendSessionId` is fully wired). - - Replace `instanceof DaemonTerminalManager` checks with checks on `runtime.management` capability presence. - - Use `runtime.events.subscribePane(...)` for the `terminal.stream` subscription implementation (no direct EventEmitter usage in the router). -2. Update any other main-process call sites that depend on EventEmitter event names (for example `apps/desktop/src/main/windows/main.ts` listening for `terminalExit`) to use `registry.getDefault().events.subscribeTerminalExit(...)` so EventEmitter semantics do not leak beyond the backend boundary. -3. Preserve the existing endpoint names and response shapes so the renderer does not need behavioral changes: - - `listDaemonSessions` returns `{ daemonModeEnabled, sessions }` - - `killAllDaemonSessions` returns `{ daemonModeEnabled, killedCount }` - - `killDaemonSessionsForWorkspace` returns `{ daemonModeEnabled, killedCount }` - - `clearTerminalHistory` returns `{ success: true }` but calls history reset when the management capability is present -4. Ensure the `stream` subscription continues to use `observable` and continues to not complete on `exit`. -5. Error semantics must be explicit: - - If management capability is absent, return `daemonModeEnabled: false` (UI will show “restart app after enabling persistence” messaging). - - If management capability is present but the operation fails (daemon unreachable, request fails), surface the error (do not convert it into `daemonModeEnabled: false`). +1. Update `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to: + - capture `const registry = getWorkspaceRuntimeRegistry()` once at router creation time + - select `const terminal = registry.getForWorkspaceId(input.workspaceId).terminal` for all workspace-scoped calls + - remove `instanceof DaemonTerminalManager` checks (replace with `terminal.management` and capability flags) +2. Introduce/implement a V2 stream surface (recommended) that is explicit about identity + replay: + - `terminal.streamV2({ workspaceId, backendSessionId, clientId, attachmentId, since? })` + - subscription uses `terminal.events.subscribeSession(...)` and must not complete on exit + - keep legacy `stream(paneId)` only temporarily if needed for incremental migration +3. Preserve legacy settings endpoints for session management (`listDaemonSessions`, etc.), but route them through `terminal.management` and propagate errors: + - `daemonModeEnabled: false` only when capability is absent + - failures when capability is present must throw (do not silently “disable”) +4. Update other call sites that depended on EventEmitter semantics (example: `terminalExit`) to use `terminal.events.subscribeTerminalExit(...)`. Acceptance: -1. No usage of `instanceof DaemonTerminalManager` remains in `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`. -2. The renderer does not need to change its API calls. +1. No daemon branching remains in the terminal router. +2. tRPC subscriptions remain observable-based and do not complete on exit. -### Milestone 4: Add Regression Coverage for the Abstraction Boundary +### Milestone 4: Identity + Stream Contract (backendSessionId/clientId) -This milestone makes the new boundary hard to accidentally regress later. +This milestone pulls forward what used to be “cloud readiness”: it decouples pane identity from backend session identity and makes viewer identity explicit. Scope: -1. Add a unit test that asserts the non-daemon runtime returns `management: null` (capability absent) without requiring daemon availability. This test must not spawn a real daemon. -2. Keep and/or extend the existing “stream does not complete on exit” regression test in `apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts`. -3. If we add any new helper modules, ensure they are covered by at least one focused unit test. -4. Add a test that ensures admin operations fail loudly on error (for example, simulate a daemon management call throwing and assert the error propagates), so we do not accidentally reintroduce silent “disabled” fallbacks for real failures. +1. Renderer generates and persists a stable `clientId` (per window/app instance) and a per-pane `attachmentId` (per mount/attach lifecycle). +2. Extend `createOrAttach` to return: + - `backendSessionId` (local may equal `paneId`) + - `watermarkEventId` for gap-free subscription +3. Store `{ paneId -> backendSessionId }` and `{ paneId -> lastSeenEventId }` in renderer state. +4. Update renderer calls to use backend identity: + - write/resize/signal/kill/detach target `backendSessionId` + - stream uses `streamV2` with `since = watermarkEventId + 1` initially, then `since = lastSeenEventId + 1` on resubscribe +5. Define detach/reattach semantics explicitly: + - detach unregisters the attachment (viewer gone), not the session + - detach is idempotent (safe to call even if session is already exited/terminated) Acceptance: -1. Tests fail if someone reintroduces daemon-specific branching in the router or reintroduces “complete on exit”. +1. The renderer no longer assumes `paneId === sessionId` at the IPC boundary. +2. Late subscribers do not lose early output (replay + snapshot + watermark semantics). + + +### Milestone 5: Regression Coverage +This milestone makes the boundary hard to accidentally regress and expands verification coverage (automated + manual matrix). -### Milestone 5: Manual Verification (High-Coverage, Low Surprises) +Scope (tests): -This milestone uses the existing PR verification matrix (kept in the PR description) and focuses on the specific regressions most likely during a refactor: missing output, stuck exits, incorrect detach behavior, and workspace deletion behavior. +1. Keep and/or extend the “stream does not complete on exit” regression test (`terminal.stream.test.ts`). +2. Add contract/invariant tests for: + - exit arrives after all data (ordering) + - cold restore + Start Shell does not replay stale exit into the new session + - replay cursor semantics (late subscribe sees output; bounded replay emits `REPLAY_UNAVAILABLE` explicitly when needed) + - resize sequencing (stale `seq` dropped) + - error code propagation (no string matching in router/renderer paths) +3. Keep the existing capability presence tests (`management: null`) and add a test that “management present but failing throws loudly”. -Validation should be run both with terminal persistence disabled and enabled. +Scope (manual): + +4. Update the PR verification matrix (kept in PR description) to include: + - non-daemon: tab switch persistence, resize, paste large, exit/restart, multi-pane + - daemon warm attach and cold restore + - detach/reattach scroll restoration (`viewportY`) + - daemon disconnect/retry overlay (if applicable) Acceptance: -1. The matrix items for non-daemon, daemon warm attach, and daemon cold restore all pass. -2. Reattach scroll restoration passes (detach sends `viewportY`; attach restores it; see upstream PR #698). +1. Tests fail if someone reintroduces `emit.complete()` on exit or breaks cursor/replay semantics. +2. Manual matrix passes with persistence disabled and enabled. ### Milestone 6a: Build a Terminal Init Plan (Renderer) @@ -657,9 +729,10 @@ Acceptance: Scope: 1. Add a small “stream handler” helper (or hook) that owns buffering until ready: - - Subscribe to `terminal.stream` and queue incoming events until the terminal is ready, then flush deterministically. + - Subscribe to `terminal.streamV2` and queue incoming events until the terminal is ready, then flush deterministically. - Keep the important invariant that the subscription does not complete on `exit` (exit is a state transition). - Keep the buffering mechanism bounded (by event count or bytes) and drop/compact safely if needed (prefer bounded queues over unbounded arrays). + - Note: buffering here is UI-readiness only (layout/restore ordering). Replay correctness belongs at the backend boundary. Acceptance: @@ -686,25 +759,29 @@ Acceptance: 2. No Node.js imports are introduced in renderer code as part of this refactor. -### Milestone 7 (Cloud Readiness): Introduce `backendSessionId` +### Milestone 7 (Cloud Readiness): WorkspaceRuntime Skeleton -This milestone is required groundwork for cloud/SSH-style backends: it decouples renderer pane identity (`paneId`) from backend session identity (`backendSessionId`). Complete this before introducing any cloud/SSH provider to avoid reworking the router + renderer contracts again. +This milestone ensures we are investing in the right direction for remote runners/cloud workspaces. It does not implement cloud terminals, but it makes the seams concrete so that adding a remote provider later does not require reworking router/UI contracts again. Scope: -1. Extend `createOrAttach` to return `backendSessionId` (for local backends it can equal `paneId`). -2. Store the mapping `{ paneId -> backendSessionId }` in renderer state and use `backendSessionId` for subsequent lifecycle operations (write/resize/signal/kill/detach and stream subscription), while continuing to key UI state by `paneId`. -3. Update the runtime registry to route by backend session identity: - - Avoid relying on `getForPaneId(...)` as the long-term selection mechanism. - - Prefer selecting runtimes by workspace/session IDs (cloud-safe), not by UI pane IDs. -4. Add lifecycle events needed for cloud-style backends (define the contract even if some implementations are stubs initially): - - connection lifecycle: `connectionStateChanged`, `authExpired` - - per-operation timeout/retry policy at the boundary (even if implemented as “none” initially) +1. Implement a `CloudWorkspaceRuntime` skeleton behind the same `WorkspaceRuntime` interface: + - returns capability flags that make “unsupported” explicit + - all operations throw `NOT_IMPLEMENTED` (or equivalent) with clear error codes +2. Add provider selection plumbing (stubbed): + - selection is driven by workspace metadata (ex: `cloudWorkspaceId`), not UI state + - all existing workspaces continue to resolve to `LocalWorkspaceRuntime` in this PR +3. Ensure the terminal contract includes lifecycle events needed for remote: + - connection lifecycle (`connection_state` events) + - authentication lifecycle (`auth_state` events) +4. Add minimal capability negotiation at the provider boundary (not UI branching): + - the provider surfaces `terminal.capabilities` (supportsReplay, supportsMultiAttach, etc.) + - if the daemon protocol needs additive fields to expose these, keep it additive (no redesign), and gate on protocol version. Acceptance: -1. The contract no longer implies `paneId === backendSessionId`, but behavior remains identical for local backends. -2. A future cloud backend can implement the same runtime contract without changing `Terminal.tsx` and the tRPC router again. +1. A future remote provider can plug into the same registry without new `instanceof` checks in routers or renderer. +2. The UI can surface “not supported” vs “failed” distinctly via typed error codes and capability presence. ## Validation @@ -735,11 +812,18 @@ This plan is safe to apply iteratively: Risk: The runtime registry/adapters change event wiring in a way that causes missed output or duplicate listeners. -Mitigation: Keep the EventEmitter contract unchanged (`data:${paneId}`, `exit:${paneId}`), keep `terminal.stream` semantics unchanged, and use tests + manual matrix to confirm “output still flows after exit/cold restore”. +Mitigation: Keep event ownership scoped to the provider instance (no shared/global emitters), and gate changes with regression tests that confirm: +- stream does not complete on exit +- no duplicate listeners/cross-talk +- output still flows after exit/cold restore -Risk: Output loss during attach if the stream subscription attaches after early PTY output (race between `createOrAttach` and `terminal.stream` subscribe). +Risk: Output loss during attach if the stream subscription attaches after early PTY output (race between `createOrAttach` and `streamV2` subscribe). -Mitigation: Preserve the current renderer sequencing (subscription established while the component is mounted, initial state applied from snapshot/scrollback, and stream events queued until ready). During manual QA, include at least one “immediate output” command (example: `echo READY`) and confirm it is visible reliably. If a reproducible loss exists, add a small per-pane ring buffer (bounded bytes) at the backend boundary and flush it to the first subscriber (a “ready/attached handshake”). +Mitigation: Move replay correctness to the backend boundary (Milestone 2): +- `createOrAttach` returns `watermarkEventId` +- renderer subscribes with `since = watermark + 1` +- provider maintains a bounded ring buffer and replays gaps best-effort +Renderer buffering remains UI-readiness only (restore ordering). Risk: Admin capability handling masks real errors (a true daemon failure being reported as “disabled”). @@ -747,7 +831,7 @@ Mitigation: Represent persistence/session management as a nullable capability ob Risk: A future cloud backend would require different identity mapping than `paneId == sessionId`. -Mitigation: Introduce `backendSessionId` (Milestone 7) so the contract no longer implies `paneId === backendSessionId` (local can keep equality as an implementation detail). The future cloud backend should implement the same contract behind `TerminalRuntime` without changing the renderer again. +Mitigation: Introduce `backendSessionId` + `clientId` + `attachmentId` (Milestone 4) so the contract no longer implies `paneId === backendSessionId` (local can keep equality as an implementation detail). The future cloud backend should implement the same contract behind the provider boundary without changing the renderer again. Risk: Process-global runtime assumptions block local + cloud workspaces from coexisting (forcing branching to leak back into routers/UI). @@ -766,33 +850,38 @@ Mitigation: Keep the “stream does not complete on exit” regression test as P ### Milestone 1 -- [ ] Inventory terminal backend call sites and events -- [ ] Write TerminalBackend contract type and invariants comment +- [ ] Inventory terminal backend call sites, events, and error string matching +- [ ] Define `WorkspaceRuntime` + `TerminalRuntime` contracts (identities, lifecycle, error codes, replay) - [ ] Confirm no behavior change; run `bun run lint` ### Milestone 2 -- [ ] Implement `getTerminalRuntimeRegistry()` in `apps/desktop/src/main/lib/terminal/` -- [ ] Implement session management as `management: TerminalManagement | null` (no no-op admin methods) +- [ ] Implement `getWorkspaceRuntimeRegistry()` + `LocalWorkspaceRuntime` in `apps/desktop/src/main/lib/workspace-runtime/` +- [ ] Implement session management as `terminal.management: TerminalManagement | null` (no no-op admin methods) +- [ ] Add event cursor + bounded replay ring buffer at provider boundary +- [ ] Normalize error codes (`TerminalErrorCode`) and enforce resize sequencing (`seq`) - [ ] Run `bun run typecheck --filter=@superset/desktop` ### Milestone 3 -- [ ] Migrate `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to runtime registry (`getTerminalRuntimeRegistry()`) +- [ ] Migrate `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to `getWorkspaceRuntimeRegistry()` - [ ] Remove `instanceof DaemonTerminalManager` checks +- [ ] Add `terminal.streamV2` (identity + since cursor) and migrate router internals to `subscribeSession` - [ ] Run `bun test --filter=@superset/desktop` ### Milestone 4 -- [ ] Add/adjust unit tests for capability presence (`management: null`) and error propagation -- [ ] Confirm stream exit regression test still covers “no complete on exit” +- [ ] Add renderer `clientId` + per-pane `attachmentId` +- [ ] Add `{ paneId -> backendSessionId }` + `{ paneId -> lastSeenEventId }` mapping +- [ ] Migrate renderer write/resize/signal/kill/detach/stream to backend identity + `streamV2` +- [ ] Confirm “no complete on exit” and “no lost first output” invariants end-to-end - [ ] Run full validation commands ### Milestone 5 -- [ ] Manual verification with persistence disabled -- [ ] Manual verification with persistence enabled (warm attach) -- [ ] Manual verification for cold restore “Start Shell” path +- [ ] Add/adjust unit tests for replay/cursor semantics, error codes, and resize sequencing +- [ ] Confirm stream exit regression test still covers “no complete on exit” +- [ ] Update PR verification matrix and run manual verification (non-daemon, warm attach, cold restore) ### Milestone 6a @@ -812,10 +901,9 @@ Mitigation: Keep the “stream does not complete on exit” regression test as P ### Milestone 7 (Cloud Readiness) -- [ ] Add `backendSessionId` to `createOrAttach` response (local backends: equals `paneId`) -- [ ] Store `{ paneId -> backendSessionId }` mapping in renderer state; use backend ID for operations -- [ ] Update runtime registry selection to be workspace/session-based (avoid long-term reliance on `getForPaneId`) -- [ ] Define/introduce lifecycle events needed for cloud backends (connection/auth) +- [ ] Add `CloudWorkspaceRuntime` skeleton and selection plumbing (metadata-driven) +- [ ] Ensure terminal contract includes connection/auth lifecycle events +- [ ] Add minimal capability negotiation (feature flags) at provider boundary ## Surprises & Discoveries @@ -826,10 +914,13 @@ Mitigation: Keep the “stream does not complete on exit” regression test as P ## Decision Log -- 2026-01-11: Use a process-scoped **runtime registry** (`getTerminalRuntimeRegistry()`), not a single global runtime; router captures the registry and selects runtimes per workspace/session so local + cloud can coexist later. -- 2026-01-11: Keep the abstraction boundary provider-neutral: expose `management: TerminalManagement | null` (capability object) while keeping legacy endpoint names like `listDaemonSessions` for UI compatibility. +- 2026-01-11: Promote `WorkspaceRuntime` (provider) to the primary abstraction; `TerminalRuntime` becomes `workspace.terminal` so future cloud work doesn’t re-cut seams for changes/files/agentEvents. +- 2026-01-11: Use a process-scoped **workspace runtime registry** (`getWorkspaceRuntimeRegistry()`), not a single global runtime; router captures the registry and selects runtimes per workspace so local + cloud can coexist later. +- 2026-01-11: Keep the abstraction boundary provider-neutral: expose `terminal.management: TerminalManagement | null` (capability object) while keeping legacy endpoint names like `listDaemonSessions` for UI compatibility. +- 2026-01-11: Make identity explicit at the boundary: `paneId` (UI) is distinct from `backendSessionId` (execution), and multi-device compatibility requires `clientId` + `attachmentId`. +- 2026-01-11: Move correctness buffering to the backend/provider boundary: add event cursor + bounded replay so late subscribers don’t lose output; renderer buffering becomes UI-readiness only. - 2026-01-11: Preserve renderer behavior; any `Terminal.tsx` changes are decomposition-only (init plan + applier + stream buffering), preserving “no complete on exit” and `viewportY` scroll restoration. -- 2026-01-11: Treat `backendSessionId` as required groundwork for cloud/SSH backends (Milestone 7). Local may keep `backendSessionId === paneId` as an implementation detail, but the contract must not assume it. +- 2026-01-11: Standardize typed error codes and enforce resize sequencing (`seq`) to reduce lifecycle/race regressions and avoid string-matching. ## Outcomes & Retrospective From 421d38196d7d22a8202ba75d387d216e893db2e5 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 14 Jan 2026 12:26:46 +0200 Subject: [PATCH 53/62] refactor(desktop): introduce WorkspaceRuntime abstraction Adds a provider-neutral runtime layer that abstracts terminal backend selection: - WorkspaceRuntimeRegistry: process-scoped registry for runtime selection - LocalTerminalRuntime: adapts TerminalManager/DaemonTerminalManager - Capability-based checks: uses `terminal.management !== null` instead of `instanceof DaemonTerminalManager` Key changes: - New workspace-runtime module with types, registry, and local implementation - tRPC terminal router migrated to use registry pattern - All call sites updated to use getForWorkspaceId when workspaceId is in-hand - Regression tests for capability presence and stream contract This foundation enables future cloud workspace providers without spreading backend-specific branching throughout the codebase. --- ...13-terminal-runtime-abstraction-rewrite.md | 136 ++++++--- .../src/lib/trpc/routers/projects/projects.ts | 7 +- .../routers/terminal/terminal.stream.test.ts | 173 +++++++++++- .../src/lib/trpc/routers/terminal/terminal.ts | 75 ++--- .../routers/workspaces/procedures/branch.ts | 6 +- .../routers/workspaces/procedures/delete.ts | 19 +- apps/desktop/src/main/index.ts | 4 +- .../src/main/lib/workspace-runtime/index.ts | 31 ++ .../src/main/lib/workspace-runtime/local.ts | 264 ++++++++++++++++++ .../main/lib/workspace-runtime/registry.ts | 88 ++++++ .../src/main/lib/workspace-runtime/types.ts | 245 ++++++++++++++++ apps/desktop/src/main/windows/main.ts | 26 +- 12 files changed, 965 insertions(+), 109 deletions(-) create mode 100644 apps/desktop/src/main/lib/workspace-runtime/index.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/local.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/registry.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/types.ts diff --git a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md index f82a0b1bfbd..0f43f5fffa4 100644 --- a/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md +++ b/apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md @@ -16,6 +16,8 @@ Reference: This plan follows conventions from `AGENTS.md`, `apps/desktop/AGENTS. - [Future Backend: Remote Runner / Cloud Terminals](#future-backend-remote-runner--cloud-terminals) - [Open Questions](#open-questions) - [Plan of Work](#plan-of-work) +- [PR1 Scope Lock (Runtime Abstraction Only)](#pr1-scope-lock-runtime-abstraction-only) +- [Decisions (Lock Before Implementing)](#decisions-lock-before-implementing) - [Target Shape (After Refactor)](#target-shape-after-refactor) - [File Tree (Proposed)](#file-tree-proposed) - [Identity + Lifecycle (State Machines)](#identity--lifecycle-state-machines) @@ -58,7 +60,7 @@ Observable outcomes: 1. With terminal persistence disabled, terminals behave as before (no persistence across app restarts), and Settings → Terminal “Manage sessions” shows that session management is unavailable. 2. With terminal persistence enabled, terminals survive app restarts, cold restore works, and Settings → Terminal “Manage sessions” continues to list/kill sessions. 3. The tRPC `terminal.*` router no longer needs `instanceof DaemonTerminalManager` checks; daemon awareness is centralized in the main-process runtime/provider layer. -4. The renderer terminal component remains correct but is easier to reason about because backend-agnostic “session initialization” and “stream event handling” logic is extracted into small, testable helpers rather than being interleaved with UI rendering. +4. The renderer terminal component remains correct with minimal required changes for the core refactor. Optional follow-up: extract backend-agnostic “session initialization” and “stream event handling” logic into small, testable helpers to reduce `Terminal.tsx` branching. ## Context / Orientation (Repository Map) @@ -120,6 +122,7 @@ This refactor is intentionally conservative to avoid regressions: 1. No large protocol redesign between main and terminal-host. Additive fields (typed error codes, cursors/watermarks, capability bits) are acceptable if they preserve backwards compatibility. 2. No behavioral change to cold restore, attach scheduling, warm set mounting, or stream lifecycle. 3. No implementation of cloud terminals in this PR. The plan only ensures the abstraction boundary is compatible with adding a cloud backend later. +4. Keep renderer and identity changes optional: `streamV2`/identity decoupling and `Terminal.tsx` decomposition can be deferred to follow-up PRs if scope/review risk is high. ## Assumptions @@ -207,6 +210,55 @@ This decision materially changes the scope and correctness model of cloud termin This work is a refactor, so milestones are organized to keep behavior stable and to validate frequently. +Shipping strategy (based on review feedback): default to keep the initial PR focused on Milestones 1–3 plus Milestone 5 (provider boundary + router migration + invariants/tests). Milestones 4, 6a–6c, and 7 are follow-ups unless a correctness gap forces pulling them forward. + +## PR1 Scope Lock (Runtime Abstraction Only) + +This plan can be executed across multiple PRs. To make handoff safe and reduce regression risk, treat PR1 as “runtime/provider abstraction only”: centralize backend selection + remove `instanceof` branching, while keeping renderer behavior and IPC shapes stable. + +### In Scope (PR1) + +1. Main process: introduce `WorkspaceRuntimeRegistry` + `LocalWorkspaceRuntime` and route daemon vs in-process selection through the provider boundary. +2. Main process: expose session management as `terminal.management: TerminalManagement | null` (capability presence, no “no-op admin methods”). +3. Routers: migrate the terminal router to use the registry/provider (remove `instanceof DaemonTerminalManager` checks) and preserve existing endpoint names/shapes. +4. Keep the legacy `terminal.stream(paneId)` subscription and semantics: + - subscription must use `observable` + - MUST NOT complete on `exit` (exit is a state transition) + - completion happens only when the client unsubscribes/disposes +5. Update non-terminal call sites that currently reach around the boundary via `getActiveTerminalManager()` (examples in this repo today: `apps/desktop/src/main/index.ts`, `apps/desktop/src/main/windows/main.ts`, and workspace flows like `apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts`, plus other references found via ripgrep) to use the provider boundary instead. +6. Regression coverage: keep/extend the existing “stream does not complete on exit” test and add a capability presence test (`management === null` in non-daemon mode). + - Also add/keep coverage that `disconnect`/`error` events do not complete the subscription (completion only on unsubscribe/dispose). + +### Out of Scope (PR1) + +1. Renderer changes (no `Terminal.tsx` refactors; no new hooks/modules in the renderer). +2. Identity separation (`backendSessionId/clientId/attachmentId`) and `streamV2` adoption in the renderer (Milestone 4). +3. Replay/cursor correctness upgrades (ring buffer, `watermarkEventId`, `since` replay), typed error-code normalization across daemon protocol, and resize `seq` enforcement (all follow-ups unless required to fix a real gap). +4. Cloud readiness skeleton/provider selection plumbing (Milestone 7). + +### PR1 Acceptance Gates (Must Pass) + +1. `bun run lint`, `bun run typecheck --filter=@superset/desktop`, and `bun test --filter=@superset/desktop` all pass. +2. Manual smoke (minimum): + - persistence disabled: open terminal, type, exit; Settings “Manage sessions” shows unavailable + - persistence enabled: warm attach + cold restore still works; Settings “Manage sessions” works + - `terminal.stream` does not complete on exit (no “listeners=0” cold-restore regressions) +3. macOS window reopen behavior remains correct (no duplicated terminal lifecycle listeners after closing and reopening the window). + +## Decisions (Lock Before Implementing) + +These are the decisions that should be treated as locked for PR1 to avoid accidental scope creep. If any of these must change during implementation, update this plan explicitly before continuing. + +1. **Renderer scope:** PR1 makes no behavior changes in the renderer. Any renderer decomposition (Milestones 6a–6c) is a follow-up PR. +2. **Identity:** PR1 keeps `paneId` as the session key at the IPC boundary. Do not introduce `backendSessionId/clientId/attachmentId` plumbing or `streamV2` in PR1. +3. **Stream contract:** PR1 preserves the existing `terminal.stream(paneId)` subscription (observable) and MUST NOT complete the stream on `exit` (or on disconnect/error). Only unsubscribe/dispose completes it. +4. **Replay/cursor correctness:** PR1 does not add ring buffers, `watermarkEventId`, or `since` replay semantics unless a concrete “lost first output” bug is demonstrated. +5. **Error semantics:** PR1 preserves existing error behavior and error codes. Do not redesign daemon protocol or require typed error codes end-to-end in PR1; treat that as a follow-up if desired. +6. **Resize sequencing (`seq`):** PR1 does not implement resize `seq` enforcement (the current renderer does not pass a `seq` today). If we want `seq`, include it as part of Milestone 4 when we touch renderer identity/state anyway. +7. **Listener lifecycle:** PR1 must preserve window-close cleanup behavior (no duplicate listeners on macOS reopen) and app-quit cleanup behavior. If the abstraction introduces new listener wiring, ensure the cleanup story is equally explicit (unsubscribe/remove listeners). +8. **tRPC input shapes:** PR1 keeps the existing paneId-only mutation inputs (`write`, `resize`, `signal`, `kill`, `detach`, `clearScrollback`, `ackColdRestore`, `stream`). Do not add `workspaceId` or new identity fields to these inputs in PR1; derive routing internally (local provider, or mapping populated during `createOrAttach`). +9. **Registry invalidation / settings:** terminal persistence remains “requires restart” (existing behavior). The runtime registry is process-scoped and does not reconfigure live if `settings.terminalPersistence` is toggled while the app is running. + ## Target Shape (After Refactor) @@ -308,6 +360,8 @@ Terminal identities (first-class in contracts): | "REPLAY_UNAVAILABLE" | "NOT_IMPLEMENTED"; +Note: In PR1, `TerminalErrorCode` is primarily documentation for the desired taxonomy. PR1 preserves existing error behavior; end-to-end typed error normalization is a follow-up. + Terminal capabilities and management: export interface TerminalCapabilities { @@ -540,6 +594,8 @@ The critical invariants remain unchanged: Main call flow (today and after refactor; the difference is where switching happens): +Note: PR1 keeps `trpc.terminal.stream(paneId)`; `streamV2` is introduced only if Milestone 4 is in scope. + Renderer (Terminal.tsx + helpers) | | trpc.terminal.createOrAttach / trpc.terminal.streamV2 @@ -587,8 +643,8 @@ Scope: - exit is a state transition; must arrive after all data events - detach/reattach scroll restoration (`viewportY`) is preserved (PR #698 behavior) - all operations are Promise-returning at the boundary (normalize sync to async) - - errors are normalized to typed `TerminalErrorCode` (no string matching) - - replay semantics are explicit (`eventId` cursor + bounded replay) + - errors are classified and documented as `TerminalErrorCode` (PR1 preserves existing error behavior; typed codes can be a follow-up) + - replay semantics are documented (`eventId` cursor + bounded replay) (follow-up if we adopt `streamV2`/identity separation) Acceptance: @@ -608,21 +664,21 @@ Scope: 2. Implement a `LocalWorkspaceRuntime` (initially only `terminal` is real; other components are stubs): - backend selection is allowed to use `backend instanceof DaemonTerminalManager` internally (provider boundary only) - expose `terminal.management: TerminalManagement | null` (no no-op admin methods) -3. Implement the “correctness upgrades” at the backend/provider boundary (so the renderer does not have to): +3. (Optional; required for `streamV2`/Milestone 4) Implement the “correctness upgrades” at the backend/provider boundary (so the renderer does not have to): - monotonic `eventId` per `backendSessionId` - bounded ring buffer of recent events per session (bytes + frames cap) - `subscribeSession({ since })` best-effort replay from the ring buffer - include `watermarkEventId` in `createOrAttach` responses so the renderer can subscribe without gaps -4. Normalize errors into `TerminalErrorCode`: - - daemon client/host and local backend must return typed codes (stop string-matching in routers/renderers) -5. Enforce resize sequencing: - - honor `resize.seq` in both in-process and daemon implementations; drop stale resizes +4. (Optional; follow-up) Normalize errors into `TerminalErrorCode`: + - daemon client/host and local backend return typed codes (stop string-matching in routers/renderers) +5. (Optional; follow-up) Enforce resize sequencing: + - honor `resize.seq` in both in-process and daemon implementations; drop stale resizes (requires renderer to send `seq`) Acceptance: 1. Provider selection is centralized and callers can only reach it via the workspace runtime registry. 2. `management === null` correctly represents “unsupported/unavailable”, while real failures propagate as errors. -3. The terminal event contract supports cursor/replay (even if replay window is initially small). +3. (Optional; follow-up) The terminal event contract supports cursor/replay (even if replay window is initially small). ### Milestone 3: tRPC Terminal Router Migration @@ -633,12 +689,12 @@ Scope: 1. Update `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to: - capture `const registry = getWorkspaceRuntimeRegistry()` once at router creation time - - select `const terminal = registry.getForWorkspaceId(input.workspaceId).terminal` for all workspace-scoped calls + - select `const terminal = registry.getForWorkspaceId(...)` for workspace-scoped calls (create/attach + workspace ops); keep paneId-only inputs unchanged in PR1 and route them via the local provider (or a `paneId -> workspaceId` mapping) rather than changing renderer inputs - remove `instanceof DaemonTerminalManager` checks (replace with `terminal.management` and capability flags) -2. Introduce/implement a V2 stream surface (recommended) that is explicit about identity + replay: +2. (Optional; required for Milestone 4) Introduce/implement a V2 stream surface that is explicit about identity + replay: - `terminal.streamV2({ workspaceId, backendSessionId, clientId, attachmentId, since? })` - subscription uses `terminal.events.subscribeSession(...)` and must not complete on exit - - keep legacy `stream(paneId)` only temporarily if needed for incremental migration + - keep legacy `stream(paneId)` for the initial PR if Milestone 4 is deferred; otherwise keep it only temporarily for incremental migration 3. Preserve legacy settings endpoints for session management (`listDaemonSessions`, etc.), but route them through `terminal.management` and propagate errors: - `daemonModeEnabled: false` only when capability is absent - failures when capability is present must throw (do not silently “disable”) @@ -654,6 +710,8 @@ Acceptance: This milestone pulls forward what used to be “cloud readiness”: it decouples pane identity from backend session identity and makes viewer identity explicit. +Note: This is a good follow-up PR candidate if we want to keep the initial refactor smaller and easier to regression-test (the core runtime/provider abstraction can ship while still using the legacy `paneId`-based identity). + Scope: 1. Renderer generates and persists a stable `clientId` (per window/app instance) and a per-pane `attachmentId` (per mount/attach lifecycle). @@ -681,13 +739,15 @@ This milestone makes the boundary hard to accidentally regress and expands verif Scope (tests): 1. Keep and/or extend the “stream does not complete on exit” regression test (`terminal.stream.test.ts`). -2. Add contract/invariant tests for: +2. Add/keep tests for capability presence and error propagation: + - `management: null` in non-daemon mode + - “management present but failing throws loudly” (do not silently “disable” on real failures) +3. Follow-up tests (only if Milestones 2/4 are pulled into scope): - exit arrives after all data (ordering) - cold restore + Start Shell does not replay stale exit into the new session - replay cursor semantics (late subscribe sees output; bounded replay emits `REPLAY_UNAVAILABLE` explicitly when needed) - resize sequencing (stale `seq` dropped) - error code propagation (no string matching in router/renderer paths) -3. Keep the existing capability presence tests (`management: null`) and add a test that “management present but failing throws loudly”. Scope (manual): @@ -699,7 +759,7 @@ Scope (manual): Acceptance: -1. Tests fail if someone reintroduces `emit.complete()` on exit or breaks cursor/replay semantics. +1. Tests fail if someone reintroduces `emit.complete()` on exit. (If Milestones 2/4 are in scope: tests also fail if cursor/replay semantics regress.) 2. Manual matrix passes with persistence disabled and enabled. @@ -707,6 +767,8 @@ Acceptance: This milestone reduces complexity in the renderer terminal component without changing behavior. The goal is not to “rewrite the terminal UI”, but to isolate protocol/state-machine logic (snapshot vs scrollback selection, restore sequencing, cold restore gating, and scroll restoration) into small units that can be tested. +Note: Optional follow-up. This is decomposition-only and can be deferred if we want to keep the initial refactor focused on the main-process runtime/provider boundary. + Scope: 1. Add a small “session init adapter” that converts the tRPC `createOrAttach` result into a single normalized “initialization plan”: @@ -763,6 +825,8 @@ Acceptance: This milestone ensures we are investing in the right direction for remote runners/cloud workspaces. It does not implement cloud terminals, but it makes the seams concrete so that adding a remote provider later does not require reworking router/UI contracts again. +Note: Optional follow-up. Defer this milestone if the goal is to keep the terminal refactor narrowly scoped and land it quickly. + Scope: 1. Implement a `CloudWorkspaceRuntime` skeleton behind the same `WorkspaceRuntime` interface: @@ -790,7 +854,7 @@ Run these commands from the repo root: bun run lint bun run typecheck --filter=@superset/desktop - bun test --filter=@superset/desktop + bun run test --filter=@superset/desktop Expected results: @@ -817,9 +881,11 @@ Mitigation: Keep event ownership scoped to the provider instance (no shared/glob - no duplicate listeners/cross-talk - output still flows after exit/cold restore -Risk: Output loss during attach if the stream subscription attaches after early PTY output (race between `createOrAttach` and `streamV2` subscribe). +Risk: Output loss during attach if the stream subscription attaches after early PTY output (race between `createOrAttach` and stream subscribe). + +Mitigation (PR1): Preserve the current renderer sequencing and buffering (“buffer until ready”), and include an “immediate output” check in manual QA (example: run `echo READY` immediately after attach and confirm it reliably appears). -Mitigation: Move replay correctness to the backend boundary (Milestone 2): +Mitigation (follow-up if a real gap is observed): Move replay correctness to the backend boundary (Milestone 2 + Milestone 4): - `createOrAttach` returns `watermarkEventId` - renderer subscribes with `since = watermark + 1` - provider maintains a bounded ring buffer and replays gaps best-effort @@ -850,27 +916,29 @@ Mitigation: Keep the “stream does not complete on exit” regression test as P ### Milestone 1 -- [ ] Inventory terminal backend call sites, events, and error string matching -- [ ] Define `WorkspaceRuntime` + `TerminalRuntime` contracts (identities, lifecycle, error codes, replay) -- [ ] Confirm no behavior change; run `bun run lint` +- [x] Inventory terminal backend call sites, events, and error string matching +- [x] Define `WorkspaceRuntime` + `TerminalRuntime` contracts (identities, lifecycle, error codes, replay) +- [x] Confirm no behavior change; run `bun run lint` ### Milestone 2 -- [ ] Implement `getWorkspaceRuntimeRegistry()` + `LocalWorkspaceRuntime` in `apps/desktop/src/main/lib/workspace-runtime/` -- [ ] Implement session management as `terminal.management: TerminalManagement | null` (no no-op admin methods) -- [ ] Add event cursor + bounded replay ring buffer at provider boundary -- [ ] Normalize error codes (`TerminalErrorCode`) and enforce resize sequencing (`seq`) -- [ ] Run `bun run typecheck --filter=@superset/desktop` +- [x] Implement `getWorkspaceRuntimeRegistry()` + `LocalWorkspaceRuntime` in `apps/desktop/src/main/lib/workspace-runtime/` +- [x] Implement session management as `terminal.management: TerminalManagement | null` (no no-op admin methods) +- [ ] (Follow-up) Add event cursor + bounded replay ring buffer at provider boundary +- [ ] (Follow-up) Normalize error codes (`TerminalErrorCode`) and enforce resize sequencing (`seq`) +- [x] Run `bun run typecheck --filter=@superset/desktop` ### Milestone 3 -- [ ] Migrate `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to `getWorkspaceRuntimeRegistry()` -- [ ] Remove `instanceof DaemonTerminalManager` checks -- [ ] Add `terminal.streamV2` (identity + since cursor) and migrate router internals to `subscribeSession` -- [ ] Run `bun test --filter=@superset/desktop` +- [x] Migrate `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` to `getWorkspaceRuntimeRegistry()` +- [x] Remove `instanceof DaemonTerminalManager` checks +- [ ] (Follow-up / Milestone 4) Add `terminal.streamV2` (identity + since cursor) and migrate router internals to `subscribeSession` +- [x] Run `bun test --filter=@superset/desktop` ### Milestone 4 +Optional follow-up PR (cloud prep / identity separation). + - [ ] Add renderer `clientId` + per-pane `attachmentId` - [ ] Add `{ paneId -> backendSessionId }` + `{ paneId -> lastSeenEventId }` mapping - [ ] Migrate renderer write/resize/signal/kill/detach/stream to backend identity + `streamV2` @@ -879,12 +947,14 @@ Mitigation: Keep the “stream does not complete on exit” regression test as P ### Milestone 5 -- [ ] Add/adjust unit tests for replay/cursor semantics, error codes, and resize sequencing -- [ ] Confirm stream exit regression test still covers “no complete on exit” +- [ ] (Follow-up) Add/adjust unit tests for replay/cursor semantics, error codes, and resize sequencing +- [x] Confirm stream exit regression test still covers “no complete on exit” - [ ] Update PR verification matrix and run manual verification (non-daemon, warm attach, cold restore) ### Milestone 6a +Optional follow-up PR (renderer decomposition). + - [ ] Implement init plan adapter (normalize snapshot vs scrollback, modes, `viewportY`) - [ ] Implement restore applier helper (rehydrate → snapshot → scroll restore → stream ready) - [ ] Add focused unit tests for init plan invariants @@ -901,6 +971,8 @@ Mitigation: Keep the “stream does not complete on exit” regression test as P ### Milestone 7 (Cloud Readiness) +Optional follow-up PR. + - [ ] Add `CloudWorkspaceRuntime` skeleton and selection plumbing (metadata-driven) - [ ] Ensure terminal contract includes connection/auth lifecycle events - [ ] Add minimal capability negotiation (feature flags) at provider boundary diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index f83c4d9693e..cc9c644c006 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -12,7 +12,7 @@ import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; -import { getActiveTerminalManager } from "main/lib/terminal"; +import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; import simpleGit from "simple-git"; import { z } from "zod"; @@ -658,9 +658,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .all(); let totalFailed = 0; + const registry = getWorkspaceRuntimeRegistry(); for (const workspace of projectWorkspaces) { - const terminalResult = - await getActiveTerminalManager().killByWorkspaceId(workspace.id); + const terminal = registry.getForWorkspaceId(workspace.id).terminal; + const terminalResult = await terminal.killByWorkspaceId(workspace.id); totalFailed += terminalResult.failed; } diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts index 954af76369c..54d5e2a8fa5 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts @@ -1,11 +1,59 @@ import { describe, expect, it, mock } from "bun:test"; import { EventEmitter } from "node:events"; -let terminalManager: EventEmitter = new EventEmitter(); +interface MockManagement { + listSessions: () => Promise<{ + sessions: Array<{ + sessionId: string; + paneId: string; + workspaceId: string; + isAlive: boolean; + }>; + }>; + killAllSessions: () => Promise; + resetHistoryPersistence: () => Promise; +} -mock.module("main/lib/terminal", () => ({ - DaemonTerminalManager: class DaemonTerminalManager extends EventEmitter {}, - getActiveTerminalManager: () => terminalManager, +/** + * Mock terminal runtime for testing. + * Extends EventEmitter and provides the minimal TerminalRuntime interface. + */ +class MockTerminalRuntime extends EventEmitter { + management: MockManagement | null = null; // non-daemon mode + capabilities = { persistent: false, coldRestore: false }; + + detachAllListeners() { + for (const event of this.eventNames()) { + const name = String(event); + if ( + name.startsWith("data:") || + name.startsWith("exit:") || + name.startsWith("disconnect:") || + name.startsWith("error:") || + name === "terminalExit" + ) { + this.removeAllListeners(event); + } + } + } +} + +let mockTerminal: MockTerminalRuntime = new MockTerminalRuntime(); + +// Mock the workspace-runtime module +mock.module("main/lib/workspace-runtime", () => ({ + getWorkspaceRuntimeRegistry: () => ({ + getDefault: () => ({ + id: "local", + terminal: mockTerminal, + capabilities: { terminal: mockTerminal.capabilities }, + }), + getForWorkspaceId: () => ({ + id: "local", + terminal: mockTerminal, + capabilities: { terminal: mockTerminal.capabilities }, + }), + }), })); // Avoid importing Electron/local-db during test bootstrap. @@ -25,7 +73,8 @@ const { createTerminalRouter } = await import("./terminal"); describe("terminal.stream", () => { it("does not complete on exit (paneId is stable across restarts)", async () => { - terminalManager = new EventEmitter(); + // Reset the mock terminal for this test + mockTerminal = new MockTerminalRuntime(); const router = createTerminalRouter(); const caller = router.createCaller({} as never); @@ -43,20 +92,120 @@ describe("terminal.stream", () => { }, }); - terminalManager.emit("exit:pane-1", 0, 15); + // Emit exit event - stream should NOT complete + mockTerminal.emit("exit:pane-1", 0, 15); expect(didComplete).toBe(false); - expect(terminalManager.listenerCount("data:pane-1")).toBeGreaterThan(0); + expect(mockTerminal.listenerCount("data:pane-1")).toBeGreaterThan(0); - terminalManager.emit("data:pane-1", "echo ok\r\n"); + // Data should still be receivable after exit + mockTerminal.emit("data:pane-1", "echo ok\r\n"); expect(events.map((e) => e.type)).toEqual(["exit", "data"]); subscription.unsubscribe(); - expect(terminalManager.listenerCount("data:pane-1")).toBe(0); - expect(terminalManager.listenerCount("exit:pane-1")).toBe(0); - expect(terminalManager.listenerCount("disconnect:pane-1")).toBe(0); - expect(terminalManager.listenerCount("error:pane-1")).toBe(0); + // All listeners should be cleaned up after unsubscribe + expect(mockTerminal.listenerCount("data:pane-1")).toBe(0); + expect(mockTerminal.listenerCount("exit:pane-1")).toBe(0); + expect(mockTerminal.listenerCount("disconnect:pane-1")).toBe(0); + expect(mockTerminal.listenerCount("error:pane-1")).toBe(0); + }); + + it("does not complete on disconnect event", async () => { + mockTerminal = new MockTerminalRuntime(); + + const router = createTerminalRouter(); + const caller = router.createCaller({} as never); + const stream$ = await caller.stream("pane-2"); + + const events: Array<{ type: string }> = []; + let didComplete = false; + + const subscription = stream$.subscribe({ + next: (event) => { + events.push(event); + }, + complete: () => { + didComplete = true; + }, + }); + + // Emit disconnect event - stream should NOT complete + mockTerminal.emit("disconnect:pane-2", "Connection lost"); + + expect(didComplete).toBe(false); + expect(events.map((e) => e.type)).toEqual(["disconnect"]); + + subscription.unsubscribe(); + }); + + it("does not complete on error event", async () => { + mockTerminal = new MockTerminalRuntime(); + + const router = createTerminalRouter(); + const caller = router.createCaller({} as never); + const stream$ = await caller.stream("pane-3"); + + const events: Array<{ type: string }> = []; + let didComplete = false; + + const subscription = stream$.subscribe({ + next: (event) => { + events.push(event); + }, + complete: () => { + didComplete = true; + }, + }); + + // Emit error event - stream should NOT complete + mockTerminal.emit("error:pane-3", { error: "Test error", code: "TEST" }); + + expect(didComplete).toBe(false); + expect(events.map((e) => e.type)).toEqual(["error"]); + + subscription.unsubscribe(); + }); +}); + +describe("terminal.management capability", () => { + it("returns daemonModeEnabled: false when management is null", async () => { + mockTerminal = new MockTerminalRuntime(); + mockTerminal.management = null; // non-daemon mode + + const router = createTerminalRouter(); + const caller = router.createCaller({} as never); + const result = await caller.listDaemonSessions(); + + expect(result.daemonModeEnabled).toBe(false); + expect(result.sessions).toEqual([]); + }); + + it("returns daemonModeEnabled: true when management is present", async () => { + mockTerminal = new MockTerminalRuntime(); + // Mock daemon mode with management capability + mockTerminal.management = { + listSessions: async () => ({ + sessions: [ + { + sessionId: "pane-1", + paneId: "pane-1", + workspaceId: "ws-1", + isAlive: true, + }, + ], + }), + killAllSessions: async () => {}, + resetHistoryPersistence: async () => {}, + }; + + const router = createTerminalRouter(); + const caller = router.createCaller({} as never); + const result = await caller.listDaemonSessions(); + + expect(result.daemonModeEnabled).toBe(true); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].sessionId).toBe("pane-1"); }); }); diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 7ed70708ed1..397cac7aa28 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -4,10 +4,7 @@ import { projects, workspaces, worktrees } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; -import { - DaemonTerminalManager, - getActiveTerminalManager, -} from "main/lib/terminal"; +import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; @@ -32,12 +29,13 @@ let createOrAttachCallCounter = 0; * - SUPERSET_PORT: The hooks server port for agent completion notifications */ export const createTerminalRouter = () => { - // Get the active terminal manager (in-process or daemon-based) - const terminalManager = getActiveTerminalManager(); + // Get the workspace runtime registry (selects backend based on settings) + const registry = getWorkspaceRuntimeRegistry(); + const terminal = registry.getDefault().terminal; if (DEBUG_TERMINAL) { console.log( - "[Terminal Router] Using terminal manager:", - terminalManager.constructor.name, + "[Terminal Router] Using terminal runtime, capabilities:", + terminal.capabilities, ); } @@ -105,7 +103,7 @@ export const createTerminalRouter = () => { : undefined; try { - const result = await terminalManager.createOrAttach({ + const result = await terminal.createOrAttach({ paneId, tabId, workspaceId, @@ -164,7 +162,7 @@ export const createTerminalRouter = () => { ) .mutation(async ({ input }) => { try { - terminalManager.write(input); + terminal.write(input); } catch (error) { const message = error instanceof Error ? error.message : "Write failed"; @@ -173,11 +171,11 @@ export const createTerminalRouter = () => { // This prevents error toast floods when workspaces with terminals are deleted. if (message.includes("not found or not alive")) { // SIGTERM (15) - synthetic signal for consistent event typing. - terminalManager.emit(`exit:${input.paneId}`, 0, 15); + terminal.emit(`exit:${input.paneId}`, 0, 15); return; } - terminalManager.emit(`error:${input.paneId}`, { + terminal.emit(`error:${input.paneId}`, { error: message, code: "WRITE_FAILED", }); @@ -191,7 +189,7 @@ export const createTerminalRouter = () => { ackColdRestore: publicProcedure .input(z.object({ paneId: z.string() })) .mutation(({ input }) => { - terminalManager.ackColdRestore(input.paneId); + terminal.ackColdRestore(input.paneId); }), resize: publicProcedure @@ -204,7 +202,7 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { - terminalManager.resize(input); + terminal.resize(input); }), signal: publicProcedure @@ -215,7 +213,7 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { - terminalManager.signal(input); + terminal.signal(input); }), kill: publicProcedure @@ -225,7 +223,7 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { - await terminalManager.kill(input); + await terminal.kill(input); }), /** @@ -239,7 +237,7 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { - terminalManager.detach(input); + terminal.detach(input); }), /** @@ -253,42 +251,45 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { - await terminalManager.clearScrollback(input); + await terminal.clearScrollback(input); }), listDaemonSessions: publicProcedure.query(async () => { - if (!(terminalManager instanceof DaemonTerminalManager)) { + // Use capability-based check instead of instanceof + if (!terminal.management) { return { daemonModeEnabled: false, sessions: [] }; } - const response = await terminalManager.listDaemonSessions(); + const response = await terminal.management.listSessions(); return { daemonModeEnabled: true, sessions: response.sessions }; }), killAllDaemonSessions: publicProcedure.mutation(async () => { - if (!(terminalManager instanceof DaemonTerminalManager)) { + // Use capability-based check instead of instanceof + if (!terminal.management) { return { daemonModeEnabled: false, killedCount: 0 }; } - const { sessions } = await terminalManager.listDaemonSessions(); - await terminalManager.forceKillAll(); + const { sessions } = await terminal.management.listSessions(); + await terminal.management.killAllSessions(); return { daemonModeEnabled: true, killedCount: sessions.length }; }), killDaemonSessionsForWorkspace: publicProcedure .input(z.object({ workspaceId: z.string() })) .mutation(async ({ input }) => { - if (!(terminalManager instanceof DaemonTerminalManager)) { + // Use capability-based check instead of instanceof + if (!terminal.management) { return { daemonModeEnabled: false, killedCount: 0 }; } - const { sessions } = await terminalManager.listDaemonSessions(); + const { sessions } = await terminal.management.listSessions(); const toKill = sessions.filter( (session) => session.workspaceId === input.workspaceId, ); for (const session of toKill) { - await terminalManager.kill({ paneId: session.sessionId }); + await terminal.kill({ paneId: session.sessionId }); } return { daemonModeEnabled: true, killedCount: toKill.length }; @@ -297,8 +298,8 @@ export const createTerminalRouter = () => { clearTerminalHistory: publicProcedure.mutation(async () => { // Note: Disk-based terminal history was removed. This is now a no-op // for non-daemon mode. In daemon mode, it resets the history persistence. - if (terminalManager instanceof DaemonTerminalManager) { - await terminalManager.resetHistoryPersistence(); + if (terminal.management) { + await terminal.management.resetHistoryPersistence(); } return { success: true }; @@ -307,7 +308,7 @@ export const createTerminalRouter = () => { getSession: publicProcedure .input(z.string()) .query(async ({ input: paneId }) => { - return terminalManager.getSession(paneId); + return terminal.getSession(paneId); }), /** @@ -433,20 +434,20 @@ export const createTerminalRouter = () => { }); }; - terminalManager.on(`data:${paneId}`, onData); - terminalManager.on(`exit:${paneId}`, onExit); - terminalManager.on(`disconnect:${paneId}`, onDisconnect); - terminalManager.on(`error:${paneId}`, onError); + terminal.on(`data:${paneId}`, onData); + terminal.on(`exit:${paneId}`, onExit); + terminal.on(`disconnect:${paneId}`, onDisconnect); + terminal.on(`error:${paneId}`, onError); // Cleanup on unsubscribe return () => { if (DEBUG_TERMINAL) { console.log(`[Terminal Stream] Unsubscribe: ${paneId}`); } - terminalManager.off(`data:${paneId}`, onData); - terminalManager.off(`exit:${paneId}`, onExit); - terminalManager.off(`disconnect:${paneId}`, onDisconnect); - terminalManager.off(`error:${paneId}`, onError); + terminal.off(`data:${paneId}`, onData); + terminal.off(`exit:${paneId}`, onExit); + terminal.off(`disconnect:${paneId}`, onDisconnect); + terminal.off(`error:${paneId}`, onError); }; }); }), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/branch.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/branch.ts index 16e3e3339d4..bd8c4b5fd0b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/branch.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/branch.ts @@ -1,7 +1,7 @@ import { projects, workspaces } from "@superset/local-db"; import { and, eq, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; -import { getActiveTerminalManager } from "main/lib/terminal"; +import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { @@ -87,7 +87,9 @@ export const createBranchProcedures = () => { await safeCheckoutBranch(project.mainRepoPath, input.branch); // Send newline to terminals so their prompts refresh with new branch - getActiveTerminalManager().refreshPromptsForWorkspace(workspace.id); + getWorkspaceRuntimeRegistry() + .getForWorkspaceId(workspace.id) + .terminal.refreshPromptsForWorkspace(workspace.id); // Update the workspace - name is always the branch for branch workspaces touchWorkspace(workspace.id, { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 0311b7d230d..a4b41aca0cf 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -1,7 +1,7 @@ import type { SelectWorktree } from "@superset/local-db"; import { track } from "main/lib/analytics"; -import { getActiveTerminalManager } from "main/lib/terminal"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; +import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { @@ -58,10 +58,9 @@ export const createDeleteProcedures = () => { }; } - const activeTerminalCount = - await getActiveTerminalManager().getSessionCountByWorkspaceId( - input.id, - ); + const activeTerminalCount = await getWorkspaceRuntimeRegistry() + .getForWorkspaceId(input.id) + .terminal.getSessionCountByWorkspaceId(input.id); // Branch workspaces are non-destructive to close - no git checks needed if (workspace.type === "branch") { @@ -187,8 +186,9 @@ export const createDeleteProcedures = () => { } // Kill all terminal processes in this workspace first - const terminalResult = - await getActiveTerminalManager().killByWorkspaceId(input.id); + const terminalResult = await getWorkspaceRuntimeRegistry() + .getForWorkspaceId(input.id) + .terminal.killByWorkspaceId(input.id); const project = getProject(workspace.projectId); @@ -277,8 +277,9 @@ export const createDeleteProcedures = () => { throw new Error("Workspace not found"); } - const terminalResult = - await getActiveTerminalManager().killByWorkspaceId(input.id); + const terminalResult = await getWorkspaceRuntimeRegistry() + .getForWorkspaceId(input.id) + .terminal.killByWorkspaceId(input.id); deleteWorkspace(input.id); // keeps worktree on disk hideProjectIfNoWorkspaces(workspace.projectId); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 92466870389..7760c16806c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -15,10 +15,10 @@ import { setupAutoUpdater } from "./lib/auto-updater"; import { localDb } from "./lib/local-db"; import { ensureShellEnvVars } from "./lib/shell-env"; import { - getActiveTerminalManager, reconcileDaemonSessions, shutdownOrphanedDaemon, } from "./lib/terminal"; +import { getWorkspaceRuntimeRegistry } from "./lib/workspace-runtime"; import { MainWindow } from "./windows/main"; // Initialize local SQLite database (runs migrations + legacy data migration on import) @@ -174,7 +174,7 @@ app.on("before-quit", async (event) => { try { await Promise.all([ - getActiveTerminalManager().cleanup(), + getWorkspaceRuntimeRegistry().getDefault().terminal.cleanup(), posthog?.shutdown(), ]); } finally { diff --git a/apps/desktop/src/main/lib/workspace-runtime/index.ts b/apps/desktop/src/main/lib/workspace-runtime/index.ts new file mode 100644 index 00000000000..1b7a962fe73 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/index.ts @@ -0,0 +1,31 @@ +/** + * Workspace Runtime Module + * + * This module provides the workspace-scoped runtime abstraction. + * Use getWorkspaceRuntimeRegistry() to get the registry and select + * the appropriate runtime for a workspace. + * + * Example usage: + * ```typescript + * const registry = getWorkspaceRuntimeRegistry(); + * const runtime = registry.getForWorkspaceId(workspaceId); + * const result = await runtime.terminal.createOrAttach(params); + * ``` + */ + +export { LocalWorkspaceRuntime } from "./local"; +export { + getWorkspaceRuntimeRegistry, + resetWorkspaceRuntimeRegistry, +} from "./registry"; +export type { + TerminalCapabilities, + TerminalEventSource, + TerminalManagement, + TerminalRuntime, + TerminalSessionOperations, + TerminalWorkspaceOperations, + WorkspaceRuntime, + WorkspaceRuntimeId, + WorkspaceRuntimeRegistry, +} from "./types"; diff --git a/apps/desktop/src/main/lib/workspace-runtime/local.ts b/apps/desktop/src/main/lib/workspace-runtime/local.ts new file mode 100644 index 00000000000..85f15b350da --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/local.ts @@ -0,0 +1,264 @@ +/** + * Local Workspace Runtime + * + * This is the local implementation of WorkspaceRuntime that wraps + * either TerminalManager (in-process) or DaemonTerminalManager (daemon mode). + * + * Backend selection is done once at construction time based on settings. + * The runtime caches the backend and exposes it through the provider-neutral + * TerminalRuntime interface. + */ + +import { + DaemonTerminalManager, + getDaemonTerminalManager, + isDaemonModeEnabled, + type TerminalManager, + terminalManager, +} from "../terminal"; +import type { + TerminalCapabilities, + TerminalManagement, + TerminalRuntime, + WorkspaceRuntime, + WorkspaceRuntimeId, +} from "./types"; + +// ============================================================================= +// Terminal Runtime Adapter +// ============================================================================= + +/** + * Adapts TerminalManager or DaemonTerminalManager to the TerminalRuntime interface. + * + * This adapter: + * 1. Wraps the underlying manager with the common interface + * 2. Exposes management capabilities only when available (daemon mode) + * 3. Provides capability flags for UI feature detection + */ +class LocalTerminalRuntime implements TerminalRuntime { + private readonly backend: TerminalManager | DaemonTerminalManager; + private readonly isDaemon: boolean; + + readonly management: TerminalManagement | null; + readonly capabilities: TerminalCapabilities; + + constructor(backend: TerminalManager | DaemonTerminalManager) { + this.backend = backend; + this.isDaemon = backend instanceof DaemonTerminalManager; + + // Set up capabilities based on backend type + this.capabilities = { + persistent: this.isDaemon, + coldRestore: this.isDaemon, + }; + + // Set up management only for daemon mode + if (this.isDaemon) { + const daemon = backend as DaemonTerminalManager; + this.management = { + listSessions: () => daemon.listDaemonSessions(), + killAllSessions: () => daemon.forceKillAll(), + resetHistoryPersistence: () => daemon.resetHistoryPersistence(), + }; + } else { + this.management = null; + } + } + + // =========================================================================== + // Session Operations (delegate to backend) + // =========================================================================== + + createOrAttach: TerminalRuntime["createOrAttach"] = (params) => { + return this.backend.createOrAttach(params); + }; + + write: TerminalRuntime["write"] = (params) => { + return this.backend.write(params); + }; + + resize: TerminalRuntime["resize"] = (params) => { + return this.backend.resize(params); + }; + + signal: TerminalRuntime["signal"] = (params) => { + return this.backend.signal(params); + }; + + kill: TerminalRuntime["kill"] = (params) => { + return this.backend.kill(params); + }; + + detach: TerminalRuntime["detach"] = (params) => { + return this.backend.detach(params); + }; + + clearScrollback: TerminalRuntime["clearScrollback"] = (params) => { + return this.backend.clearScrollback(params); + }; + + ackColdRestore: TerminalRuntime["ackColdRestore"] = (paneId) => { + return this.backend.ackColdRestore(paneId); + }; + + getSession: TerminalRuntime["getSession"] = (paneId) => { + return this.backend.getSession(paneId); + }; + + // =========================================================================== + // Workspace Operations (delegate to backend) + // =========================================================================== + + killByWorkspaceId: TerminalRuntime["killByWorkspaceId"] = (workspaceId) => { + return this.backend.killByWorkspaceId(workspaceId); + }; + + getSessionCountByWorkspaceId: TerminalRuntime["getSessionCountByWorkspaceId"] = + (workspaceId) => { + return this.backend.getSessionCountByWorkspaceId(workspaceId); + }; + + refreshPromptsForWorkspace: TerminalRuntime["refreshPromptsForWorkspace"] = ( + workspaceId, + ) => { + return this.backend.refreshPromptsForWorkspace(workspaceId); + }; + + // =========================================================================== + // Event Source (delegate to backend EventEmitter) + // =========================================================================== + + // EventEmitter methods - delegate to backend + // Use method syntax to preserve `this` return type correctly + on(event: string | symbol, listener: (...args: unknown[]) => void): this { + this.backend.on(event, listener); + return this; + } + + off(event: string | symbol, listener: (...args: unknown[]) => void): this { + this.backend.off(event, listener); + return this; + } + + once(event: string | symbol, listener: (...args: unknown[]) => void): this { + this.backend.once(event, listener); + return this; + } + + emit(event: string | symbol, ...args: unknown[]): boolean { + return this.backend.emit(event, ...args); + } + + addListener( + event: string | symbol, + listener: (...args: unknown[]) => void, + ): this { + this.backend.addListener(event, listener); + return this; + } + + removeListener( + event: string | symbol, + listener: (...args: unknown[]) => void, + ): this { + this.backend.removeListener(event, listener); + return this; + } + + removeAllListeners(event?: string | symbol): this { + this.backend.removeAllListeners(event); + return this; + } + + setMaxListeners(n: number): this { + this.backend.setMaxListeners(n); + return this; + } + + getMaxListeners(): number { + return this.backend.getMaxListeners(); + } + + // biome-ignore lint/complexity/noBannedTypes: EventEmitter interface requires Function[] + listeners(event: string | symbol): Function[] { + return this.backend.listeners(event); + } + + // biome-ignore lint/complexity/noBannedTypes: EventEmitter interface requires Function[] + rawListeners(event: string | symbol): Function[] { + return this.backend.rawListeners(event); + } + + listenerCount( + event: string | symbol, + listener?: (...args: unknown[]) => void, + ): number { + return this.backend.listenerCount(event, listener); + } + + prependListener( + event: string | symbol, + listener: (...args: unknown[]) => void, + ): this { + this.backend.prependListener(event, listener); + return this; + } + + prependOnceListener( + event: string | symbol, + listener: (...args: unknown[]) => void, + ): this { + this.backend.prependOnceListener(event, listener); + return this; + } + + eventNames(): (string | symbol)[] { + return this.backend.eventNames(); + } + + detachAllListeners(): void { + this.backend.detachAllListeners(); + } + + // =========================================================================== + // Cleanup + // =========================================================================== + + cleanup: TerminalRuntime["cleanup"] = () => { + return this.backend.cleanup(); + }; +} + +// ============================================================================= +// Local Workspace Runtime +// ============================================================================= + +/** + * Local workspace runtime implementation. + * + * This provides the WorkspaceRuntime interface for local workspaces, + * wrapping the terminal manager (either in-process or daemon-based). + */ +export class LocalWorkspaceRuntime implements WorkspaceRuntime { + readonly id: WorkspaceRuntimeId; + readonly terminal: TerminalRuntime; + readonly capabilities: WorkspaceRuntime["capabilities"]; + + constructor() { + this.id = "local"; + + // Select backend based on daemon mode setting + const backend = isDaemonModeEnabled() + ? getDaemonTerminalManager() + : terminalManager; + + // Create terminal runtime adapter + this.terminal = new LocalTerminalRuntime(backend); + + // Aggregate capabilities + this.capabilities = { + terminal: this.terminal.capabilities, + }; + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts new file mode 100644 index 00000000000..c39b7850c2c --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts @@ -0,0 +1,88 @@ +/** + * Workspace Runtime Registry + * + * Process-scoped registry for workspace runtime providers. + * The registry is cached for the lifetime of the process. + * + * Current behavior: + * - All workspaces use the LocalWorkspaceRuntime + * - The runtime is selected once based on settings (requires restart to change) + * + * Future behavior (cloud readiness): + * - Per-workspace selection based on workspace metadata (cloudWorkspaceId, etc.) + * - Local + cloud workspaces can coexist + */ + +import { LocalWorkspaceRuntime } from "./local"; +import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; + +// ============================================================================= +// Registry Implementation +// ============================================================================= + +/** + * Default registry implementation. + * + * Currently returns the same LocalWorkspaceRuntime for all workspaces. + * The interface supports per-workspace selection for future cloud work. + */ +class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { + private localRuntime: LocalWorkspaceRuntime | null = null; + + /** + * Get the runtime for a specific workspace. + * + * Currently always returns the local runtime. + * Future: will check workspace metadata to select local vs cloud. + */ + getForWorkspaceId(_workspaceId: string): WorkspaceRuntime { + // Currently all workspaces use the local runtime + // Future: check workspace metadata for cloudWorkspaceId to select cloud runtime + return this.getDefault(); + } + + /** + * Get the default runtime (for global/legacy endpoints). + * + * Returns the local runtime, lazily initialized. + * The runtime instance is cached for the lifetime of the process. + */ + getDefault(): WorkspaceRuntime { + if (!this.localRuntime) { + this.localRuntime = new LocalWorkspaceRuntime(); + } + return this.localRuntime; + } +} + +// ============================================================================= +// Singleton Instance +// ============================================================================= + +let registryInstance: WorkspaceRuntimeRegistry | null = null; + +/** + * Get the workspace runtime registry. + * + * The registry is process-scoped and cached. Callers should capture it once + * (e.g., when creating a tRPC router) and use it for the lifetime of the router. + * + * This design allows: + * 1. Stable runtime instances (no re-creation on each call) + * 2. Consistent event wiring (same backend for all listeners) + * 3. Future per-workspace selection (local vs cloud) + */ +export function getWorkspaceRuntimeRegistry(): WorkspaceRuntimeRegistry { + if (!registryInstance) { + registryInstance = new DefaultWorkspaceRuntimeRegistry(); + } + return registryInstance; +} + +/** + * Reset the registry (for testing only). + * This should not be called in production code. + */ +export function resetWorkspaceRuntimeRegistry(): void { + registryInstance = null; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/types.ts b/apps/desktop/src/main/lib/workspace-runtime/types.ts new file mode 100644 index 00000000000..255515fbc9a --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/types.ts @@ -0,0 +1,245 @@ +/** + * Workspace Runtime Abstraction Types + * + * This module defines the contracts for workspace-scoped runtime providers. + * The WorkspaceRuntime boundary encapsulates backend-specific behavior + * (local in-process, local daemon, or cloud/SSH in the future). + * + * Key invariants: + * 1. Stream subscriptions MUST NOT complete on session exit (exit is a state transition) + * 2. Capability presence (e.g., management !== null) indicates feature availability, + * not "health right now"; mid-session failures should propagate as errors + * 3. Operations use sync signatures where latency-critical (write, resize, signal, detach); + * async signatures for lifecycle ops (createOrAttach, kill, cleanup) + * + * Reference: apps/desktop/plans/20260109-2313-terminal-runtime-abstraction-rewrite.md + */ + +import type { EventEmitter } from "node:events"; +import type { CreateSessionParams, SessionResult } from "../terminal/types"; +import type { ListSessionsResponse } from "../terminal-host/types"; + +// ============================================================================= +// Identity Types +// ============================================================================= + +/** + * Workspace runtime identifier - unique per runtime instance. + */ +export type WorkspaceRuntimeId = string; + +// ============================================================================= +// Terminal Capabilities +// ============================================================================= + +/** + * Terminal backend capabilities. + * These flags indicate what features are available for this backend. + */ +export interface TerminalCapabilities { + /** Sessions can survive app restarts (daemon mode) */ + persistent: boolean; + /** Cold restore from disk is supported after unclean shutdown */ + coldRestore: boolean; + // Future capabilities (not implemented in PR1): + // replay: boolean; // stream supports bounded replay via `since` cursor + // multiAttach: boolean; // multiple attachments can view one backend session +} + +// ============================================================================= +// Terminal Management (Session Admin) +// ============================================================================= + +/** + * Terminal management capabilities for listing and killing sessions. + * Only available when the backend supports persistent sessions (daemon mode). + * + * When `management` is null, session management is unavailable. + * When `management` is present but a call fails, errors are propagated + * (never silently "disabled"). + */ +export interface TerminalManagement { + /** List all sessions in the daemon */ + listSessions(): Promise; + /** Kill all sessions in the daemon */ + killAllSessions(): Promise; + /** Reset history persistence (reinitialize all history writers) */ + resetHistoryPersistence(): Promise; +} + +// ============================================================================= +// Terminal Session Operations +// ============================================================================= + +/** + * Core terminal session operations. + * These are the backend-agnostic operations that any terminal backend must support. + */ +export interface TerminalSessionOperations { + /** + * Create a new session or attach to an existing one. + * Deduplicates concurrent calls for the same paneId. + */ + createOrAttach(params: CreateSessionParams): Promise; + + /** Write data to the terminal */ + write(params: { paneId: string; data: string }): void; + + /** Resize the terminal */ + resize(params: { paneId: string; cols: number; rows: number }): void; + + /** Send a signal to the terminal process */ + signal(params: { paneId: string; signal?: string }): void; + + /** Kill the terminal session */ + kill(params: { paneId: string }): Promise; + + /** + * Detach from the terminal (keep session alive). + * viewportY is saved for scroll restoration on reattach. + */ + detach(params: { paneId: string; viewportY?: number }): void; + + /** Clear the scrollback buffer */ + clearScrollback(params: { paneId: string }): void | Promise; + + /** + * Acknowledge cold restore - clears sticky cold restore info. + * No-op in non-daemon mode. + */ + ackColdRestore(paneId: string): void; + + /** Get session info */ + getSession( + paneId: string, + ): { isAlive: boolean; cwd: string; lastActive: number } | null; +} + +// ============================================================================= +// Terminal Workspace Operations +// ============================================================================= + +/** + * Workspace-scoped terminal operations. + * These operate on all sessions within a workspace. + */ +export interface TerminalWorkspaceOperations { + /** Kill all sessions for a workspace */ + killByWorkspaceId( + workspaceId: string, + ): Promise<{ killed: number; failed: number }>; + + /** Get count of alive sessions for a workspace */ + getSessionCountByWorkspaceId(workspaceId: string): Promise; + + /** Send newline to all terminals in a workspace to refresh prompts */ + refreshPromptsForWorkspace(workspaceId: string): void; +} + +// ============================================================================= +// Terminal Event Source +// ============================================================================= + +/** + * Terminal event source interface. + * The underlying implementation uses EventEmitter with events like: + * - `data:${paneId}` - terminal output + * - `exit:${paneId}` - session exited (exitCode, signal?) + * - `disconnect:${paneId}` - daemon connection lost (daemon mode only) + * - `error:${paneId}` - terminal error (daemon mode only) + * - `terminalExit` - global exit event for cleanup + * + * CRITICAL INVARIANT: Subscriptions MUST NOT complete on exit. + * Exit is a state transition, not stream completion. + */ +export interface TerminalEventSource extends EventEmitter { + /** Remove all terminal-specific listeners */ + detachAllListeners(): void; +} + +// ============================================================================= +// Terminal Runtime +// ============================================================================= + +/** + * Terminal runtime interface - the backend-agnostic terminal surface. + * + * This combines session operations, workspace operations, event source, + * and optional management capabilities into a single interface. + * + * Implementations: + * - In-process: TerminalManager (no persistence, management === null) + * - Daemon: DaemonTerminalManager (persistent, management !== null) + */ +export interface TerminalRuntime + extends TerminalSessionOperations, + TerminalWorkspaceOperations, + TerminalEventSource { + /** + * Session management capabilities (nullable). + * Only available when backend supports persistent sessions. + * + * When null: Session management UI should show "unavailable" + * When present: Session management is available (errors propagate normally) + */ + management: TerminalManagement | null; + + /** Terminal capabilities for this backend */ + capabilities: TerminalCapabilities; + + /** Cleanup on app quit */ + cleanup(): Promise; +} + +// ============================================================================= +// Workspace Runtime +// ============================================================================= + +/** + * Workspace runtime interface - the workspace-scoped provider boundary. + * + * This is the primary abstraction for local vs daemon vs cloud backends. + * The terminal runtime is a sub-component; future work will add + * changes/files/agentEvents to this same boundary for cloud workspaces. + */ +export interface WorkspaceRuntime { + /** Unique identifier for this runtime instance */ + id: WorkspaceRuntimeId; + + /** Terminal runtime (session ops + events + management) */ + terminal: TerminalRuntime; + + /** Aggregated capabilities for this runtime */ + capabilities: { + terminal: TerminalCapabilities; + // Future: changes, files, agentEvents capabilities + }; +} + +// ============================================================================= +// Workspace Runtime Registry +// ============================================================================= + +/** + * Workspace runtime registry - process-scoped selection of runtime providers. + * + * The registry is captured once when the tRPC router is created. + * It returns stable provider instances (cached) so event wiring is consistent. + * + * This design allows local + cloud workspaces to coexist later without + * re-spreading backend-specific branching throughout the application. + */ +export interface WorkspaceRuntimeRegistry { + /** + * Get the runtime for a specific workspace. + * Currently always returns the default local runtime, + * but the interface supports per-workspace selection for cloud. + */ + getForWorkspaceId(workspaceId: string): WorkspaceRuntime; + + /** + * Get the default runtime (for global/legacy endpoints). + * Used by settings screens and endpoints that don't have workspace context. + */ + getDefault(): WorkspaceRuntime; +} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index ac4ff0494d6..5442eeb6409 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -17,12 +17,12 @@ import { notificationsApp, notificationsEmitter, } from "../lib/notifications/server"; -import { getActiveTerminalManager } from "../lib/terminal"; import { getInitialWindowBounds, loadWindowState, saveWindowState, } from "../lib/window-state"; +import { getWorkspaceRuntimeRegistry } from "../lib/workspace-runtime"; // Singleton IPC handler to prevent duplicate handlers on window reopen (macOS) let ipcHandler: ReturnType | null = null; @@ -173,16 +173,18 @@ export async function MainWindow() { // Forward low-volume terminal lifecycle events to the renderer via the existing // notifications subscription. This is used only for correctness (e.g. clearing // stuck agent lifecycle statuses when terminal panes aren't mounted). - getActiveTerminalManager().on( - "terminalExit", - (event: { paneId: string; exitCode: number; signal?: number }) => { - notificationsEmitter.emit(NOTIFICATION_EVENTS.TERMINAL_EXIT, { - paneId: event.paneId, - exitCode: event.exitCode, - signal: event.signal, - }); - }, - ); + getWorkspaceRuntimeRegistry() + .getDefault() + .terminal.on( + "terminalExit", + (event: { paneId: string; exitCode: number; signal?: number }) => { + notificationsEmitter.emit(NOTIFICATION_EVENTS.TERMINAL_EXIT, { + paneId: event.paneId, + exitCode: event.exitCode, + signal: event.signal, + }); + }, + ); window.webContents.on("did-finish-load", async () => { // Restore maximized state if it was saved @@ -207,7 +209,7 @@ export async function MainWindow() { server.close(); notificationsEmitter.removeAllListeners(); // Remove terminal listeners to prevent duplicates when window reopens on macOS - getActiveTerminalManager().detachAllListeners(); + getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); // Detach window from IPC handler (handler stays alive for window reopen) ipcHandler?.detachWindow(window); // Clear current window reference From 5fdff27aaba2f95901048a29d49881af69cba977 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 14 Jan 2026 17:31:09 +0200 Subject: [PATCH 54/62] WIP: route-based settings/dashboard structure alignment with upstream - Add _dashboard route group with workspace/tasks pages - Rebuild settings routing/layout/sidebar under routes - Port settings pages: account, appearance, keyboard, presets, team, ringtones, project, workspace - Add terminal settings page at /settings/terminal - Update navigation components to use router instead of app-state - Remove obsolete route pages that conflict with upstream _dashboard structure Still needs: - Full merge with origin/main - Complete app-state removal - Terminal backend conflict resolution --- .../docs/HANDOFF-terminal-cold-restore-bug.md | 192 +++++ .../_authenticated/_dashboard/layout.tsx | 83 ++ .../_authenticated/_dashboard/tasks/page.tsx | 10 + .../_dashboard/utils/workspace-navigation.ts | 25 + .../workspace/$workspaceId/page.tsx | 340 ++++++++ .../_dashboard/workspace/page.tsx | 49 ++ .../_dashboard/workspaces/page.tsx | 10 + .../renderer/routes/_authenticated/layout.tsx | 49 +- .../_authenticated/settings/account/page.tsx | 61 +- .../components/ThemeCard/ThemeCard.tsx | 95 +++ .../appearance/components/ThemeCard/index.ts | 1 + .../settings/appearance/page.tsx | 83 +- .../SettingsSidebar/SettingsSidebar.tsx | 25 + .../GeneralSettings/GeneralSettings.tsx | 102 +++ .../components/GeneralSettings/index.ts | 1 + .../ProjectsSettings/ProjectsSettings.tsx | 124 +++ .../components/ProjectsSettings/index.ts | 1 + .../components/SettingsSidebar/index.ts | 1 + .../_authenticated/settings/keyboard/page.tsx | 412 ++++++++- .../routes/_authenticated/settings/layout.tsx | 32 + .../CommandsEditor/CommandsEditor.tsx | 101 +++ .../components/CommandsEditor/index.ts | 1 + .../components/PresetRow/PresetRow.tsx | 142 ++++ .../presets/components/PresetRow/index.ts | 1 + .../_authenticated/settings/presets/page.tsx | 276 +++++- .../_authenticated/settings/presets/types.ts | 33 + .../settings/project/$projectId/page.tsx | 106 +++ .../settings/ringtones/page.tsx | 272 ++++++ .../InviteMemberButton/InviteMemberButton.tsx | 19 + .../components/InviteMemberButton/index.ts | 1 + .../MemberActions/MemberActions.tsx | 149 ++++ .../team/components/MemberActions/index.ts | 1 + .../_authenticated/settings/team/page.tsx | 179 ++++ .../_authenticated/settings/terminal/page.tsx | 524 ++++++++++++ .../settings/workspace/$workspaceId/page.tsx | 125 +++ .../settings/workspace/page.tsx | 14 - .../routes/_authenticated/tasks/page.tsx | 14 - .../routes/_authenticated/workspace/page.tsx | 10 - .../routes/_authenticated/workspaces/page.tsx | 14 - .../WorkspaceListItem/WorkspaceListItem.tsx | 20 +- .../OrganizationDropdown.tsx | 12 +- .../WorkspaceSidebarHeader.tsx | 37 +- .../WorkspacesListView/WorkspacesListView.tsx | 23 +- docs/CLOUD_WORKSPACE_PLAN.md | 785 ++++++++++++++++++ 44 files changed, 4443 insertions(+), 112 deletions(-) create mode 100644 apps/desktop/docs/HANDOFF-terminal-cold-restore-bug.md create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspaces/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/ThemeCard/ThemeCard.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/ThemeCard/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/GeneralSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/ProjectsSettings/ProjectsSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/ProjectsSettings/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/CommandsEditor/CommandsEditor.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/CommandsEditor/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/PresetRow/PresetRow.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/PresetRow/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/presets/types.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/team/components/InviteMemberButton/InviteMemberButton.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/team/components/InviteMemberButton/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/team/components/MemberActions/MemberActions.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/team/components/MemberActions/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/team/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/workspace/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/tasks/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/workspace/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/workspaces/page.tsx create mode 100644 docs/CLOUD_WORKSPACE_PLAN.md diff --git a/apps/desktop/docs/HANDOFF-terminal-cold-restore-bug.md b/apps/desktop/docs/HANDOFF-terminal-cold-restore-bug.md new file mode 100644 index 00000000000..8f64a5d5f2f --- /dev/null +++ b/apps/desktop/docs/HANDOFF-terminal-cold-restore-bug.md @@ -0,0 +1,192 @@ +# Terminal Cold Restore Bug - Handoff Document + +**Date:** 2026-01-09 +**Status:** LIKELY FIXED (pending verification) - see "Root Cause + Fix" +**Severity:** Critical - terminal input goes to tab name instead of terminal after cold restore + +## Problem Summary + +After daemon restart/session loss, clicking "Start Shell" creates a new terminal session, but: +1. Keyboard input goes to the tab name (visible as "Hello" or "sshi" in tab) +2. Nothing appears in the terminal +3. Writes ARE reaching the daemon (logs show `writeRef` being called) +4. Data IS coming back from daemon (logs show `TerminalHostClient` and `DaemonTerminalManager` receiving data) +5. BUT `listeners=0` means the tRPC subscription isn't receiving the data + +## Key Observation from Logs + +``` +[DaemonTerminalManager] Received data from daemon: paneId=..., bytes=211, listeners=1 # Working! +[DaemonTerminalManager] Received data from daemon: paneId=..., bytes=280, listeners=1 # Still working +[DaemonTerminalManager] Terminal error for pane-...: WRITE_FAILED: Session not found +[DaemonTerminalManager] Session pane-... lost - will trigger cold restore on next attach +[DaemonTerminalManager] Received data from daemon: paneId=..., bytes=4, listeners=0 # BROKEN! +[DaemonTerminalManager] Received data from daemon: paneId=..., bytes=388, listeners=0 # Still broken +``` + +**Critical:** Listeners go from 1 to 0 AFTER the "Session lost" event, BEFORE handleStartShell is called. + +## Root Cause + Fix + +### Root cause +The tRPC subscription was being **completed** when the terminal stream emitted an `exit` event. + +This can happen during cold restore / daemon session loss because `terminal.write` catches +`Terminal session not found or not alive` and emits `exit:${paneId}` (to avoid toast floods). + +Once the server completes the observable, **`trpc.useSubscription` does not auto-resubscribe** (since +the `paneId` input doesn't change). That leaves `listeners=0` permanently, so daemon output never reaches +the renderer even after a new session is created. + +### Fix +Keep the subscription open on `exit`: +- `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`: remove `emit.complete()` from `onExit` in `stream` subscription + +This preserves the data listener across exit/session-loss transitions, so when a new daemon session is created (same `paneId`) +output flows again. + +### Gotcha: terminal clears after clicking "Start Shell" +If the terminal clears and nothing renders after clicking "Start Shell", check for a stale queued `exit` event: +- In cold restore mode we intentionally pause streaming (`isStreamReady=false`), which queues stream events. +- If the user typed while the overlay was up, a write can fail and emit an `exit` event. +- When streaming resumes, that queued `exit` marks the terminal as exited and the next keypress triggers `restartTerminal()`, which calls `xterm.clear()`. + +Mitigations (implemented): +- Clear `pendingEventsRef.current` at the start of `handleStartShell` +- Ignore terminal input / keypress handling while `isRestoredMode` or `connectionError` overlays are visible + +### How to verify +1. Run with logging: `SUPERSET_TERMINAL_DEBUG=1 bun dev` +2. Repro: kill daemon (`pkill -f "terminal-host"`), get cold restore UI, click "Start Shell" +3. Confirm: + - `[DaemonTerminalManager] ... listeners=1` remains true after session loss and after starting shell + - terminal output appears again after "Start Shell" + +## Architecture Overview + +``` +Renderer (Terminal.tsx) + | + | trpc.terminal.stream.useSubscription(paneId) + v +tRPC Router (terminal.ts) - EventEmitter listeners attached here + | + | terminalManager.on(`data:${paneId}`, handler) + v +DaemonTerminalManager (daemon-manager.ts) - emits events + | + | this.emit(`data:${paneId}`, data) + v +TerminalHostClient (client.ts) - socket to daemon + | + v +Terminal Host Daemon (separate process) - actual PTY +``` + +## Failed Fix Attempts + +### 1. HMR-Stable Singletons (didn't work) +Made `DaemonTerminalManager` and `TerminalHostClient` singletons stored on `globalThis` to survive HMR module reloads. + +Files changed: +- `apps/desktop/src/main/lib/terminal/daemon-manager.ts` - added `__supersetDaemonTerminalManager` on globalThis +- `apps/desktop/src/main/lib/terminal-host/client.ts` - added `__supersetTerminalHostClient` on globalThis + +**Result:** Still `listeners=0` after session loss + +### 2. Fresh Manager Reference in Subscription (didn't work) +Changed terminal router to call `getActiveTerminalManager()` inside the subscription callback instead of capturing it at router creation time. + +File changed: +- `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` + +**Result:** Still `listeners=0` after session loss + +### 3. Move State Change to onSuccess (didn't work) +Moved `setIsRestoredMode(false)` from before `createOrAttachRef.current()` to inside `onSuccess` callback to prevent React re-render from tearing down subscription. + +File changed: +- `apps/desktop/src/renderer/.../Terminal/Terminal.tsx` - `handleStartShell` function + +**Result:** Still `listeners=0` after session loss + +## What I Don't Understand + +1. **Why do listeners drop to 0 after session loss?** The subscription should still be active - we're just getting an error event. + +2. **Was the subscription being torn down?** Yes — it was being completed on `exit` (via `emit.complete()`), which left `useSubscription` stuck without re-subscribing for the same `paneId`. + +3. **Is there a disconnect handler that tears things down?** The stream subscription has an `onDisconnect` handler but I don't see it completing the observable. + +4. **Is trpc-electron doing something?** The IPC transport might have its own subscription management. + +5. **Is React Query tearing down the subscription?** The `useSubscription` hook might be doing something on certain events. + +## Key Files to Investigate + +1. **`apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`** + - Lines 384-451: The `stream` subscription + - `onDisconnect` handler at line 419-421 - does this do something that breaks things? + - Cleanup function at lines 441-449 - when is this called? + +2. **`apps/desktop/src/main/lib/terminal/daemon-manager.ts`** + - Session loss handling around line 236: `this.coldRestoreInfo.set(paneId, ...)` + - Event emission: `this.emit(\`disconnect:${paneId}\`, reason)` + - The `listeners=0` log is at line ~196 + +3. **`apps/desktop/src/renderer/.../Terminal/Terminal.tsx`** + - `useSubscription` hook around line 895 + - How does it handle disconnect events? + - What causes the subscription to be recreated? + +4. **`node_modules/trpc-electron`** (or wherever the IPC transport is) + - How does it handle subscriptions? + - Is there automatic reconnection logic that breaks things? + +## Reproduction Steps + +1. Open desktop app with a terminal +2. Type something to verify it works +3. Kill the daemon: `pkill -f "terminal-host"` or delete `~/.superset-dev/terminal-host.sock` +4. The terminal shows cold restore UI +5. Click "Start Shell" +6. Type in the terminal +7. **EXPECTED:** Text appears in terminal +8. **ACTUAL:** Text appears in tab name, terminal is blank + +## Debugging Tips + +Enable debug logging: +```bash +SUPERSET_TERMINAL_DEBUG=1 bun dev +``` + +Key log patterns to watch: +- `[Terminal Stream] Subscribe` - when subscription is set up +- `[Terminal Stream] Unsubscribe` - when subscription is torn down +- `[DaemonTerminalManager] Received data from daemon: ... listeners=X` - the critical metric +- `[Terminal] Stream data received` - data reaching renderer (should appear but doesn't) + +## Hypotheses to Test + +1. **Subscription is being recreated on disconnect** - Add logging to see if `[Terminal Stream] Unsubscribe` is called after session loss + +2. **Event listeners are being removed** - Add logging in daemon-manager when `off()` is called + +3. **Different EventEmitter instances** - Even with globalThis, maybe something is creating a new instance + +4. **trpc-electron reconnection** - The IPC transport might reconnect and not re-establish subscriptions + +5. **React component unmounting** - Maybe the Terminal component is being unmounted and remounted + +## Current State of Code + +The codebase has debug logging added. Key changes from this session: +- HMR-stable singletons on globalThis (may or may not be necessary) +- `handleStartShell` moves state change to onSuccess (may or may not be necessary) +- Extensive console logging for debugging + +## Contact + +This handoff was created by a Claude session that got stuck. The session transcript is at: +`/Users/andreasasprou/.claude/projects/-Users-andreasasprou--superset-worktrees-superset-persistentterminals/e4e44094-f9c3-40c3-b817-d0e71fd52ecd.jsonl` diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx new file mode 100644 index 00000000000..67820b485c7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -0,0 +1,83 @@ +import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; +import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; +import { TopBar } from "renderer/screens/main/components/TopBar"; +import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar"; +import { useAppHotkey } from "renderer/stores/hotkeys"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { + COLLAPSED_WORKSPACE_SIDEBAR_WIDTH, + MAX_WORKSPACE_SIDEBAR_WIDTH, + useWorkspaceSidebarStore, +} from "renderer/stores/workspace-sidebar-state"; + +export const Route = createFileRoute("/_authenticated/_dashboard")({ + component: DashboardLayout, +}); + +function DashboardLayout() { + const navigate = useNavigate(); + const openNewWorkspaceModal = useOpenNewWorkspaceModal(); + + const { + isOpen: isWorkspaceSidebarOpen, + toggleCollapsed: toggleWorkspaceSidebarCollapsed, + setOpen: setWorkspaceSidebarOpen, + width: workspaceSidebarWidth, + setWidth: setWorkspaceSidebarWidth, + isResizing: isWorkspaceSidebarResizing, + setIsResizing: setWorkspaceSidebarIsResizing, + isCollapsed: isWorkspaceSidebarCollapsed, + } = useWorkspaceSidebarStore(); + + // Global hotkeys for dashboard + useAppHotkey( + "SHOW_HOTKEYS", + () => navigate({ to: "/settings/keyboard" }), + undefined, + [navigate], + ); + + useAppHotkey( + "TOGGLE_WORKSPACE_SIDEBAR", + () => { + if (!isWorkspaceSidebarOpen) { + setWorkspaceSidebarOpen(true); + } else { + toggleWorkspaceSidebarCollapsed(); + } + }, + undefined, + [ + isWorkspaceSidebarOpen, + setWorkspaceSidebarOpen, + toggleWorkspaceSidebarCollapsed, + ], + ); + + useAppHotkey("NEW_WORKSPACE", () => openNewWorkspaceModal(), undefined, [ + openNewWorkspaceModal, + ]); + + return ( +
+ +
+ {isWorkspaceSidebarOpen && ( + + + + )} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx new file mode 100644 index 00000000000..21012c51d70 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { TasksView } from "renderer/screens/main/components/TasksView"; + +export const Route = createFileRoute("/_authenticated/_dashboard/tasks/")({ + component: TasksPage, +}); + +function TasksPage() { + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts new file mode 100644 index 00000000000..9a43959ac4b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -0,0 +1,25 @@ +import type { + NavigateOptions, + UseNavigateResult, +} from "@tanstack/react-router"; + +/** + * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. + * This ensures the workspace will be restored when the app is reopened. + * + * @param workspaceId - The ID of the workspace to navigate to + * @param navigate - The navigate function from useNavigate() + * @param options - Optional navigation options (replace, resetScroll, etc.) + */ +export function navigateToWorkspace( + workspaceId: string, + navigate: UseNavigateResult, + options?: Omit, +): Promise { + localStorage.setItem("lastViewedWorkspaceId", workspaceId); + return navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + ...options, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx new file mode 100644 index 00000000000..261f37f6ea7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -0,0 +1,340 @@ +import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { NotFound } from "renderer/routes/not-found"; +import { ContentView } from "renderer/screens/main/components/WorkspaceView/ContentView"; +import { WorkspaceInitializingView } from "renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView"; +import { useAppHotkey } from "renderer/stores/hotkeys"; +import { useSidebarStore } from "renderer/stores/sidebar-state"; +import { getPaneDimensions } from "renderer/stores/tabs/pane-refs"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; +import { + findPanePath, + getFirstPaneId, + getNextPaneId, + getPreviousPaneId, +} from "renderer/stores/tabs/utils"; +import { + useHasWorkspaceFailed, + useIsWorkspaceInitializing, +} from "renderer/stores/workspace-init"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/workspace/$workspaceId/", +)({ + component: WorkspacePage, + notFoundComponent: NotFound, + loader: async ({ params, context }) => { + const queryKey = [ + ["workspaces", "get"], + { input: { id: params.workspaceId }, type: "query" }, + ]; + + try { + await context.queryClient.ensureQueryData({ + queryKey, + queryFn: () => + trpcClient.workspaces.get.query({ id: params.workspaceId }), + }); + } catch (error) { + // If workspace not found, throw notFound() to render 404 page + if (error instanceof Error && error.message.includes("not found")) { + throw notFound(); + } + // Re-throw other errors + throw error; + } + }, +}); + +function WorkspacePage() { + const { workspaceId } = Route.useParams(); + const { data: workspace } = trpc.workspaces.get.useQuery({ id: workspaceId }); + const navigate = useNavigate(); + + // Check if workspace is initializing or failed + const isInitializing = useIsWorkspaceInitializing(workspaceId); + const hasFailed = useHasWorkspaceFailed(workspaceId); + + // Check for incomplete init after app restart + const gitStatus = workspace?.worktree?.gitStatus; + const hasIncompleteInit = + workspace?.type === "worktree" && + (gitStatus === null || gitStatus === undefined); + + // Show full-screen initialization view for: + // - Actively initializing workspaces (shows progress) + // - Failed workspaces (shows error with retry) + // - Interrupted workspaces that aren't currently initializing (shows resume option) + const showInitView = isInitializing || hasFailed || hasIncompleteInit; + + const allTabs = useTabsStore((s) => s.tabs); + const activeTabIds = useTabsStore((s) => s.activeTabIds); + const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + const { addTab, splitPaneAuto, splitPaneVertical, splitPaneHorizontal } = + useTabsWithPresets(); + const setActiveTab = useTabsStore((s) => s.setActiveTab); + const removePane = useTabsStore((s) => s.removePane); + const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const toggleSidebar = useSidebarStore((s) => s.toggleSidebar); + + const tabs = useMemo( + () => allTabs.filter((tab) => tab.workspaceId === workspaceId), + [workspaceId, allTabs], + ); + + const activeTabId = activeTabIds[workspaceId] ?? null; + + const activeTab = useMemo( + () => (activeTabId ? tabs.find((t) => t.id === activeTabId) : null), + [activeTabId, tabs], + ); + + const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; + + // Tab management shortcuts + useAppHotkey( + "NEW_GROUP", + () => { + addTab(workspaceId); + }, + undefined, + [workspaceId, addTab], + ); + + useAppHotkey( + "CLOSE_TERMINAL", + () => { + if (focusedPaneId) { + removePane(focusedPaneId); + } + }, + undefined, + [focusedPaneId, removePane], + ); + + // Switch between tabs + useAppHotkey( + "PREV_TERMINAL", + () => { + if (!activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index > 0) { + setActiveTab(workspaceId, tabs[index - 1].id); + } + }, + undefined, + [workspaceId, activeTabId, tabs, setActiveTab], + ); + + useAppHotkey( + "NEXT_TERMINAL", + () => { + if (!activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index < tabs.length - 1) { + setActiveTab(workspaceId, tabs[index + 1].id); + } + }, + undefined, + [workspaceId, activeTabId, tabs, setActiveTab], + ); + + // Switch between panes within a tab + useAppHotkey( + "PREV_PANE", + () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); + if (prevPaneId) { + setFocusedPane(activeTabId, prevPaneId); + } + }, + undefined, + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], + ); + + useAppHotkey( + "NEXT_PANE", + () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); + if (nextPaneId) { + setFocusedPane(activeTabId, nextPaneId); + } + }, + undefined, + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], + ); + + // Open in last used app shortcut + const { data: lastUsedApp = "cursor" } = + trpc.settings.getLastUsedApp.useQuery(); + const openInApp = trpc.external.openInApp.useMutation(); + useAppHotkey( + "OPEN_IN_APP", + () => { + if (workspace?.worktreePath) { + openInApp.mutate({ + path: workspace.worktreePath, + app: lastUsedApp, + }); + } + }, + undefined, + [workspace?.worktreePath, lastUsedApp], + ); + + // Copy path shortcut + const copyPath = trpc.external.copyPath.useMutation(); + useAppHotkey( + "COPY_PATH", + () => { + if (workspace?.worktreePath) { + copyPath.mutate(workspace.worktreePath); + } + }, + undefined, + [workspace?.worktreePath], + ); + + // Toggle changes sidebar (⌘L) + useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), undefined, [ + toggleSidebar, + ]); + + // Pane splitting helper - resolves target pane for split operations + const resolveSplitTarget = useCallback( + (paneId: string, tabId: string, targetTab: Tab) => { + const path = findPanePath(targetTab.layout, paneId); + if (path !== null) return { path, paneId }; + + const firstPaneId = getFirstPaneId(targetTab.layout); + const firstPanePath = findPanePath(targetTab.layout, firstPaneId); + setFocusedPane(tabId, firstPaneId); + return { path: firstPanePath ?? [], paneId: firstPaneId }; + }, + [setFocusedPane], + ); + + // Pane splitting shortcuts + useAppHotkey( + "SPLIT_AUTO", + () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + const dimensions = getPaneDimensions(target.paneId); + if (dimensions) { + splitPaneAuto(activeTabId, target.paneId, dimensions, target.path); + } + } + }, + undefined, + [activeTabId, focusedPaneId, activeTab, splitPaneAuto, resolveSplitTarget], + ); + + useAppHotkey( + "SPLIT_RIGHT", + () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + splitPaneVertical(activeTabId, target.paneId, target.path); + } + }, + undefined, + [ + activeTabId, + focusedPaneId, + activeTab, + splitPaneVertical, + resolveSplitTarget, + ], + ); + + useAppHotkey( + "SPLIT_DOWN", + () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + splitPaneHorizontal(activeTabId, target.paneId, target.path); + } + }, + undefined, + [ + activeTabId, + focusedPaneId, + activeTab, + splitPaneHorizontal, + resolveSplitTarget, + ], + ); + + // Navigate to previous workspace (⌘↑) + const getPreviousWorkspace = trpc.workspaces.getPreviousWorkspace.useQuery( + { id: workspaceId }, + { enabled: !!workspaceId }, + ); + useAppHotkey( + "PREV_WORKSPACE", + () => { + const prevWorkspaceId = getPreviousWorkspace.data; + if (prevWorkspaceId) { + navigateToWorkspace(prevWorkspaceId, navigate); + } + }, + undefined, + [getPreviousWorkspace.data, navigate], + ); + + // Navigate to next workspace (⌘↓) + const getNextWorkspace = trpc.workspaces.getNextWorkspace.useQuery( + { id: workspaceId }, + { enabled: !!workspaceId }, + ); + useAppHotkey( + "NEXT_WORKSPACE", + () => { + const nextWorkspaceId = getNextWorkspace.data; + if (nextWorkspaceId) { + navigateToWorkspace(nextWorkspaceId, navigate); + } + }, + undefined, + [getNextWorkspace.data, navigate], + ); + + return ( +
+
+ {showInitView ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/page.tsx new file mode 100644 index 00000000000..64955567a72 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/page.tsx @@ -0,0 +1,49 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { StartView } from "renderer/screens/main/components/StartView"; + +export const Route = createFileRoute("/_authenticated/_dashboard/workspace/")({ + component: WorkspaceIndexPage, +}); + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function WorkspaceIndexPage() { + const navigate = useNavigate(); + const { data: workspaces, isLoading } = + trpc.workspaces.getAllGrouped.useQuery(); + + const allWorkspaces = workspaces?.flatMap((group) => group.workspaces) ?? []; + const hasNoWorkspaces = !isLoading && allWorkspaces.length === 0; + + useEffect(() => { + if (isLoading || !workspaces) return; + if (allWorkspaces.length === 0) return; // Show StartView instead + + // Try to restore last viewed workspace + const lastViewedId = localStorage.getItem("lastViewedWorkspaceId"); + const targetWorkspace = + allWorkspaces.find((w) => w.id === lastViewedId) ?? allWorkspaces[0]; + + if (targetWorkspace) { + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: targetWorkspace.id }, + replace: true, + }); + } + }, [workspaces, isLoading, navigate, allWorkspaces]); + + if (hasNoWorkspaces) { + return ; + } + + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspaces/page.tsx new file mode 100644 index 00000000000..3687139e93f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspaces/page.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { WorkspacesListView } from "renderer/screens/main/components/WorkspacesListView"; + +export const Route = createFileRoute("/_authenticated/_dashboard/workspaces/")({ + component: WorkspacesPage, +}); + +function WorkspacesPage() { + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 3f9d97800d2..dd01b09d6f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -1,7 +1,19 @@ -import { createFileRoute, Navigate, Outlet } from "@tanstack/react-router"; +import { + createFileRoute, + Navigate, + Outlet, + useNavigate, +} from "@tanstack/react-router"; import { DndProvider } from "react-dnd"; +import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; +import { useUpdateListener } from "renderer/components/UpdateToast"; import { dragDropManager } from "renderer/lib/dnd"; +import { trpc } from "renderer/lib/trpc"; import { useAuth } from "renderer/providers/AuthProvider"; +import { WorkspaceInitEffects } from "renderer/screens/main/components/WorkspaceInitEffects"; +import { useHotkeysSync } from "renderer/stores/hotkeys"; +import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; +import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { CollectionsProvider } from "./providers/CollectionsProvider"; import { OrganizationsProvider } from "./providers/OrganizationsProvider"; @@ -12,6 +24,39 @@ export const Route = createFileRoute("/_authenticated")({ function AuthenticatedLayout() { const { session, token } = useAuth(); const isSignedIn = !!token && !!session?.user; + const navigate = useNavigate(); + const utils = trpc.useUtils(); + + // Global hooks and subscriptions + useAgentHookListener(); + useUpdateListener(); + useHotkeysSync(); + + // Workspace initialization progress subscription + const updateInitProgress = useWorkspaceInitStore((s) => s.updateProgress); + trpc.workspaces.onInitProgress.useSubscription(undefined, { + onData: (progress) => { + updateInitProgress(progress); + if (progress.step === "ready" || progress.step === "failed") { + // Invalidate both the grouped list AND the specific workspace + utils.workspaces.getAllGrouped.invalidate(); + utils.workspaces.get.invalidate({ id: progress.workspaceId }); + } + }, + onError: (error) => { + console.error("[workspace-init-subscription] Subscription error:", error); + }, + }); + + // Menu navigation subscription + trpc.menu.subscribe.useSubscription(undefined, { + onData: (event) => { + if (event.type === "open-settings") { + const section = event.data.section || "account"; + navigate({ to: `/settings/${section}` as "/settings/account" }); + } + }, + }); if (!isSignedIn) { return ; @@ -22,6 +67,8 @@ function AuthenticatedLayout() { + + diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/account/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/account/page.tsx index da170ab173d..59ef6fc09c4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/account/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/account/page.tsx @@ -1,14 +1,69 @@ +import { Avatar } from "@superset/ui/atoms/Avatar"; +import { Button } from "@superset/ui/button"; +import { Skeleton } from "@superset/ui/skeleton"; +import { toast } from "@superset/ui/sonner"; import { createFileRoute } from "@tanstack/react-router"; +import { trpc } from "renderer/lib/trpc"; export const Route = createFileRoute("/_authenticated/settings/account/")({ component: AccountSettingsPage, }); function AccountSettingsPage() { + const { data: user, isLoading } = trpc.user.me.useQuery(); + const signOutMutation = trpc.auth.signOut.useMutation({ + onSuccess: () => toast.success("Signed out"), + }); + + const signOut = () => signOutMutation.mutate(); + return ( -
-

Account Settings

-

Account settings placeholder

+
+
+

Account

+

+ Manage your account settings +

+
+ +
+ {/* Profile Section */} +
+

Profile

+
+ {isLoading ? ( + <> + +
+ + +
+ + ) : user ? ( + <> + +
+

{user.name}

+

{user.email}

+
+ + ) : ( +

Unable to load user info

+ )} +
+
+ + {/* Sign Out Section */} +
+

Sign Out

+

+ Sign out of your Superset account on this device. +

+ +
+
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/ThemeCard/ThemeCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/ThemeCard/ThemeCard.tsx new file mode 100644 index 00000000000..9625c0bd228 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/ThemeCard/ThemeCard.tsx @@ -0,0 +1,95 @@ +import { cn } from "@superset/ui/utils"; +import { HiCheck } from "react-icons/hi2"; +import { getTerminalColors, type Theme } from "shared/themes"; + +interface ThemeCardProps { + theme: Theme; + isSelected: boolean; + onSelect: () => void; +} + +export function ThemeCard({ theme, isSelected, onSelect }: ThemeCardProps) { + const terminal = getTerminalColors(theme); + const bgColor = terminal.background; + const fgColor = terminal.foreground; + const accentColors = [ + terminal.red, + terminal.green, + terminal.yellow, + terminal.blue, + terminal.magenta, + terminal.cyan, + ]; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/ThemeCard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/ThemeCard/index.ts new file mode 100644 index 00000000000..0e84e8c03d6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/ThemeCard/index.ts @@ -0,0 +1 @@ +export * from "./ThemeCard"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/page.tsx index be99445115b..37933cb71b1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/page.tsx @@ -1,14 +1,91 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { createFileRoute } from "@tanstack/react-router"; +import { + type MarkdownStyle, + useMarkdownStyle, + useSetMarkdownStyle, + useSetTheme, + useThemeId, + useThemeStore, +} from "renderer/stores"; +import { builtInThemes } from "shared/themes"; +import { ThemeCard } from "./components/ThemeCard"; export const Route = createFileRoute("/_authenticated/settings/appearance/")({ component: AppearanceSettingsPage, }); function AppearanceSettingsPage() { + const activeThemeId = useThemeId(); + const setTheme = useSetTheme(); + const customThemes = useThemeStore((state) => state.customThemes); + const markdownStyle = useMarkdownStyle(); + const setMarkdownStyle = useSetMarkdownStyle(); + + const allThemes = [...builtInThemes, ...customThemes]; + return ( -
-

Appearance Settings

-

Appearance settings placeholder

+
+
+

Appearance

+

+ Customize how Superset looks on your device +

+
+ +
+ {/* Theme Section */} +
+

Theme

+
+ {allThemes.map((theme) => ( + setTheme(theme.id)} + /> + ))} +
+
+ +
+

Markdown Style

+

+ Rendering style for markdown files when viewing rendered content +

+ +

+ Tufte style uses elegant serif typography inspired by Edward Tufte's + books +

+
+ +
+

Custom Themes

+

+ Custom theme import coming soon. You'll be able to import JSON theme + files to create your own themes. +

+
+
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx new file mode 100644 index 00000000000..a9908e92d17 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx @@ -0,0 +1,25 @@ +import { Link } from "@tanstack/react-router"; +import { HiArrowLeft } from "react-icons/hi2"; +import { GeneralSettings } from "./components/GeneralSettings"; +import { ProjectsSettings } from "./components/ProjectsSettings"; + +export function SettingsSidebar() { + return ( +
+ + + Back + + +

Settings

+ +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/GeneralSettings.tsx new file mode 100644 index 00000000000..ad176a2e5fe --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/GeneralSettings.tsx @@ -0,0 +1,102 @@ +import { cn } from "@superset/ui/utils"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { + HiOutlineAdjustmentsHorizontal, + HiOutlineBell, + HiOutlineCog6Tooth, + HiOutlineCommandLine, + HiOutlineComputerDesktop, + HiOutlinePaintBrush, + HiOutlineUser, + HiOutlineUserGroup, +} from "react-icons/hi2"; + +type SettingsRoute = + | "/settings/account" + | "/settings/team" + | "/settings/appearance" + | "/settings/ringtones" + | "/settings/keyboard" + | "/settings/presets" + | "/settings/behavior" + | "/settings/terminal"; + +const GENERAL_SECTIONS: { + id: SettingsRoute; + label: string; + icon: React.ReactNode; +}[] = [ + { + id: "/settings/account", + label: "Account", + icon: , + }, + { + id: "/settings/team", + label: "Team", + icon: , + }, + { + id: "/settings/appearance", + label: "Appearance", + icon: , + }, + { + id: "/settings/ringtones", + label: "Ringtones", + icon: , + }, + { + id: "/settings/keyboard", + label: "Keyboard Shortcuts", + icon: , + }, + { + id: "/settings/presets", + label: "Presets", + icon: , + }, + { + id: "/settings/behavior", + label: "Behavior", + icon: , + }, + { + id: "/settings/terminal", + label: "Terminal", + icon: , + }, +]; + +export function GeneralSettings() { + const matchRoute = useMatchRoute(); + + return ( +
+

+ General +

+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/index.ts new file mode 100644 index 00000000000..2fca680417c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/index.ts @@ -0,0 +1 @@ +export * from "./GeneralSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/ProjectsSettings/ProjectsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/ProjectsSettings/ProjectsSettings.tsx new file mode 100644 index 00000000000..3ee7cffa8dc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/ProjectsSettings/ProjectsSettings.tsx @@ -0,0 +1,124 @@ +import { cn } from "@superset/ui/utils"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { HiChevronDown, HiChevronRight } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; + +export function ProjectsSettings() { + const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); + const matchRoute = useMatchRoute(); + const [expandedProjects, setExpandedProjects] = useState>( + new Set(), + ); + + // Expand all projects by default when groups are loaded + useEffect(() => { + if (groups.length > 0) { + setExpandedProjects(new Set(groups.map((g) => g.project.id))); + } + }, [groups]); + + const toggleProject = (projectId: string) => { + setExpandedProjects((prev) => { + const next = new Set(prev); + if (next.has(projectId)) { + next.delete(projectId); + } else { + next.add(projectId); + } + return next; + }); + }; + + if (groups.length === 0) { + return null; + } + + return ( +
+

+ Projects +

+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/ProjectsSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/ProjectsSettings/index.ts new file mode 100644 index 00000000000..6266320b20d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/ProjectsSettings/index.ts @@ -0,0 +1 @@ +export * from "./ProjectsSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/index.ts new file mode 100644 index 00000000000..602f8ffa712 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/index.ts @@ -0,0 +1 @@ +export * from "./SettingsSidebar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx index a96efd9cbf2..7ae403a81c9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx @@ -1,14 +1,416 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { toast } from "@superset/ui/sonner"; import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { HiMagnifyingGlass } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { + captureHotkeyFromEvent, + getHotkeyConflict, + useHotkeyDisplay, + useHotkeysByCategory, + useHotkeysStore, +} from "renderer/stores/hotkeys"; +import { + formatHotkeyText, + HOTKEYS, + type HotkeyCategory, + type HotkeyId, + type HotkeysState, + isOsReservedHotkey, + isTerminalReservedHotkey, +} from "shared/hotkeys"; + +const CATEGORY_ORDER: HotkeyCategory[] = [ + "Workspace", + "Terminal", + "Layout", + "Window", + "Help", +]; + +function HotkeyRow({ + id, + label, + description, + isRecording, + onStartRecording, + onReset, +}: { + id: HotkeyId; + label: string; + description?: string; + isRecording: boolean; + onStartRecording: () => void; + onReset: () => void; +}) { + const display = useHotkeyDisplay(id); + + return ( +
+
+ {label} + {description && ( + {description} + )} +
+
+ + +
+
+ ); +} export const Route = createFileRoute("/_authenticated/settings/keyboard/")({ - component: KeyboardSettingsPage, + component: KeyboardShortcutsPage, }); -function KeyboardSettingsPage() { +function KeyboardShortcutsPage() { + const [searchQuery, setSearchQuery] = useState(""); + const [recordingId, setRecordingId] = useState(null); + const [pendingConflict, setPendingConflict] = useState<{ + id: HotkeyId; + keys: string; + conflictId: HotkeyId; + } | null>(null); + const [pendingImport, setPendingImport] = useState<{ + path: string; + state: HotkeysState; + summary: { assigned: number; disabled: number }; + } | null>(null); + + const platform = useHotkeysStore((state) => state.platform); + const setHotkey = useHotkeysStore((state) => state.setHotkey); + const setHotkeysBatch = useHotkeysStore((state) => state.setHotkeysBatch); + const resetHotkey = useHotkeysStore((state) => state.resetHotkey); + const resetAllHotkeys = useHotkeysStore((state) => state.resetAllHotkeys); + const replaceHotkeysState = useHotkeysStore( + (state) => state.replaceHotkeysState, + ); + const hotkeysByCategory = useHotkeysByCategory(); + + const exportMutation = trpc.hotkeys.export.useMutation(); + const importMutation = trpc.hotkeys.import.useMutation(); + + const showHotkeysDisplay = useHotkeyDisplay("SHOW_HOTKEYS"); + + const allHotkeys = useMemo( + () => + CATEGORY_ORDER.flatMap((category) => hotkeysByCategory[category] ?? []), + [hotkeysByCategory], + ); + + const filteredHotkeys = useMemo(() => { + if (!searchQuery) return allHotkeys; + const lower = searchQuery.toLowerCase(); + return allHotkeys.filter((hotkey) => + hotkey.label.toLowerCase().includes(lower), + ); + }, [allHotkeys, searchQuery]); + + useEffect(() => { + if (!recordingId) return; + + const handleKeyDown = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + setRecordingId(null); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + setHotkey(recordingId, null); + setRecordingId(null); + return; + } + + const captured = captureHotkeyFromEvent(event, platform); + if (!captured) return; + + if (isTerminalReservedHotkey(captured)) { + toast.error("That shortcut is reserved by the terminal."); + setRecordingId(null); + return; + } + + const conflictId = getHotkeyConflict(captured, recordingId); + if (conflictId) { + setPendingConflict({ id: recordingId, keys: captured, conflictId }); + setRecordingId(null); + return; + } + + if (isOsReservedHotkey(captured, platform)) { + toast.warning("This shortcut may be reserved by your OS."); + } + + setHotkey(recordingId, captured); + setRecordingId(null); + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }; + }, [recordingId, platform, setHotkey]); + + const handleStartRecording = (id: HotkeyId) => { + setRecordingId((current) => (current === id ? null : id)); + }; + + const handleExport = async () => { + try { + const result = await exportMutation.mutateAsync(); + if ("canceled" in result && result.canceled) return; + if ("error" in result) { + toast.error("Failed to export shortcuts", { + description: result.error, + }); + return; + } + toast.success("Keyboard shortcuts exported", { + description: result.path, + }); + } catch (error) { + toast.error("Failed to export shortcuts", { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + const handleImport = async () => { + try { + const result = await importMutation.mutateAsync(); + if ("canceled" in result && result.canceled) return; + if ("error" in result) { + toast.error("Failed to import shortcuts", { + description: result.error, + }); + return; + } + setPendingImport({ + path: result.path, + state: result.state, + summary: result.summary, + }); + } catch (error) { + toast.error("Failed to import shortcuts", { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + const handleConfirmImport = () => { + if (!pendingImport) return; + replaceHotkeysState(pendingImport.state); + toast.success("Keyboard shortcuts imported"); + setPendingImport(null); + }; + + const handleConflictReassign = () => { + if (!pendingConflict) return; + setHotkeysBatch({ + [pendingConflict.conflictId]: null, + [pendingConflict.id]: pendingConflict.keys, + }); + if (isOsReservedHotkey(pendingConflict.keys, platform)) { + toast.warning("This shortcut may be reserved by your OS."); + } + setPendingConflict(null); + }; + return ( -
-

Keyboard Settings

-

Keyboard settings placeholder

+
+ {/* Header */} +
+
+

Keyboard Shortcuts

+

+ Customize keyboard shortcuts for your workflow. Press{" "} + + {showHotkeysDisplay.map((key) => ( + {key} + ))} + {" "} + to open this page anytime. +

+
+
+ + + +
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 bg-accent/30 border-transparent focus:border-accent" + /> +
+ + {/* Table */} +
+
+ + Command + + + Shortcut + +
+ +
+ {filteredHotkeys.length > 0 ? ( + filteredHotkeys.map((hotkey) => ( + handleStartRecording(hotkey.id)} + onReset={() => resetHotkey(hotkey.id)} + /> + )) + ) : ( +
+ No shortcuts found matching "{searchQuery}" +
+ )} +
+
+ + {/* Conflict dialog */} + setPendingConflict(null)} + > + + + + Shortcut already in use + + +
+ + {pendingConflict + ? `${formatHotkeyText( + pendingConflict.keys, + platform, + )} is already assigned to “${ + HOTKEYS[pendingConflict.conflictId].label + }”.` + : ""} + + Would you like to reassign it? +
+
+
+ + + + +
+
+ + {/* Import dialog */} + setPendingImport(null)} + > + + + + Import keyboard shortcuts? + + +
+ + This will replace your shortcuts on all platforms. + + {pendingImport && ( + + {pendingImport.summary.assigned} assigned,{" "} + {pendingImport.summary.disabled} disabled on {platform}. + + )} +
+
+
+ + + + +
+
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx new file mode 100644 index 00000000000..d5cfec26066 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -0,0 +1,32 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { trpc } from "renderer/lib/trpc"; +import { SettingsSidebar } from "./components/SettingsSidebar"; + +export const Route = createFileRoute("/_authenticated/settings")({ + component: SettingsLayout, +}); + +function SettingsLayout() { + const { data: platform } = trpc.window.getPlatform.useQuery(); + const isMac = platform === undefined || platform === "darwin"; + + return ( +
+ {/* Top bar with Mac spacing - invisible but reserves space */} +
+ + {/* Main content */} +
+ +
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/CommandsEditor/CommandsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/CommandsEditor/CommandsEditor.tsx new file mode 100644 index 00000000000..714a88af282 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/CommandsEditor/CommandsEditor.tsx @@ -0,0 +1,101 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { HiMiniXMark } from "react-icons/hi2"; + +interface CommandsEditorProps { + commands: string[]; + onChange: (commands: string[]) => void; + onBlur?: () => void; + placeholder?: string; +} + +export function CommandsEditor({ + commands, + onChange, + onBlur, + placeholder = "Command...", +}: CommandsEditorProps) { + const baseId = useId(); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + const [focusIndex, setFocusIndex] = useState(null); + + useEffect(() => { + if (focusIndex !== null && inputRefs.current[focusIndex]) { + inputRefs.current[focusIndex]?.focus(); + setFocusIndex(null); + } + }, [focusIndex]); + + const setInputRef = useCallback( + (index: number) => (el: HTMLInputElement | null) => { + inputRefs.current[index] = el; + }, + [], + ); + + const handleCommandChange = (index: number, value: string) => { + const updated = [...commands]; + updated[index] = value; + onChange(updated); + }; + + const handleCommandKeyDown = ( + e: React.KeyboardEvent, + index: number, + ) => { + if (e.key === "Enter") { + e.preventDefault(); + const updated = [...commands]; + updated.splice(index + 1, 0, ""); + onChange(updated); + setFocusIndex(index + 1); + } else if ( + e.key === "Backspace" && + commands[index] === "" && + commands.length > 1 + ) { + e.preventDefault(); + const updated = commands.filter((_, i) => i !== index); + onChange(updated); + } + }; + + const handleDeleteCommand = (index: number) => { + if (commands.length > 1) { + const updated = commands.filter((_, i) => i !== index); + onChange(updated); + } + }; + + return ( +
+ {commands.map((command, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: commands are ordered strings without stable IDs +
+ handleCommandChange(index, e.target.value)} + onKeyDown={(e) => handleCommandKeyDown(e, index)} + onBlur={onBlur} + className="h-7 px-2 text-sm font-mono flex-1 min-w-0" + placeholder={placeholder} + /> + {commands.length > 1 && ( + + )} +
+ ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/CommandsEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/CommandsEditor/index.ts new file mode 100644 index 00000000000..6d303dce24a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/CommandsEditor/index.ts @@ -0,0 +1 @@ +export * from "./CommandsEditor"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/PresetRow/PresetRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/PresetRow/PresetRow.tsx new file mode 100644 index 00000000000..14b466715ec --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/PresetRow/PresetRow.tsx @@ -0,0 +1,142 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { HiOutlineStar, HiStar } from "react-icons/hi2"; +import { LuTrash } from "react-icons/lu"; +import { + PRESET_COLUMNS, + type PresetColumnConfig, + type PresetColumnKey, + type TerminalPreset, +} from "../../types"; +import { CommandsEditor } from "../CommandsEditor"; + +interface PresetCellProps { + column: PresetColumnConfig; + preset: TerminalPreset; + rowIndex: number; + onChange: (rowIndex: number, column: PresetColumnKey, value: string) => void; + onBlur: (rowIndex: number, column: PresetColumnKey) => void; + onCommandsChange: (rowIndex: number, commands: string[]) => void; + onCommandsBlur: (rowIndex: number) => void; +} + +function PresetCell({ + column, + preset, + rowIndex, + onChange, + onBlur, + onCommandsChange, + onCommandsBlur, +}: PresetCellProps) { + const value = preset[column.key]; + + if (column.key === "commands") { + return ( + onCommandsChange(rowIndex, commands)} + onBlur={() => onCommandsBlur(rowIndex)} + placeholder={column.placeholder} + /> + ); + } + + return ( + onChange(rowIndex, column.key, e.target.value)} + onBlur={() => onBlur(rowIndex, column.key)} + className={`h-8 px-2 text-sm w-full min-w-0 truncate ${column.mono ? "font-mono" : ""}`} + placeholder={column.placeholder} + /> + ); +} + +interface PresetRowProps { + preset: TerminalPreset; + rowIndex: number; + isEven: boolean; + onChange: (rowIndex: number, column: PresetColumnKey, value: string) => void; + onBlur: (rowIndex: number, column: PresetColumnKey) => void; + onCommandsChange: (rowIndex: number, commands: string[]) => void; + onCommandsBlur: (rowIndex: number) => void; + onDelete: (rowIndex: number) => void; + onSetDefault: (presetId: string | null) => void; +} + +export function PresetRow({ + preset, + rowIndex, + isEven, + onChange, + onBlur, + onCommandsChange, + onCommandsBlur, + onDelete, + onSetDefault, +}: PresetRowProps) { + const handleToggleDefault = () => { + // If already default, clear it; otherwise set this preset as default + onSetDefault(preset.isDefault ? null : preset.id); + }; + + return ( +
+ {PRESET_COLUMNS.map((column) => ( +
+ +
+ ))} +
+ + + + + + {preset.isDefault + ? "Remove as default" + : "Set as default for new terminals"} + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/PresetRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/PresetRow/index.ts new file mode 100644 index 00000000000..605a8d9bc82 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/components/PresetRow/index.ts @@ -0,0 +1 @@ +export * from "./PresetRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/page.tsx index d3f2b4c77a5..c7352f5f3f6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/page.tsx @@ -1,14 +1,284 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { HiOutlineCheck, HiOutlinePlus } from "react-icons/hi2"; +import { + getPresetIcon, + useIsDarkTheme, +} from "renderer/assets/app-icons/preset-icons"; +import { usePresets } from "renderer/react-query/presets"; +import { PresetRow } from "./components/PresetRow"; +import { + PRESET_COLUMNS, + type PresetColumnKey, + type TerminalPreset, +} from "./types"; + +interface PresetTemplate { + name: string; + preset: { + name: string; + description: string; + cwd: string; + commands: string[]; + }; +} + +const PRESET_TEMPLATES: PresetTemplate[] = [ + { + name: "codex", + preset: { + name: "codex", + description: "Danger mode: All permissions auto-approved", + cwd: "", + commands: [ + 'codex -c model_reasoning_effort="high" --ask-for-approval never --sandbox danger-full-access -c model_reasoning_summary="detailed" -c model_supports_reasoning_summaries=true', + ], + }, + }, + { + name: "claude", + preset: { + name: "claude", + description: "Danger mode: All permissions auto-approved", + cwd: "", + commands: ["claude --dangerously-skip-permissions"], + }, + }, + { + name: "gemini", + preset: { + name: "gemini", + description: "Danger mode: All permissions auto-approved", + cwd: "", + commands: ["gemini --yolo"], + }, + }, + { + name: "cursor-agent", + preset: { + name: "cursor-agent", + description: "Cursor AI agent for terminal-based coding assistance", + cwd: "", + commands: ["cursor-agent"], + }, + }, + { + name: "opencode", + preset: { + name: "opencode", + description: "OpenCode: Open source AI coding agent", + cwd: "", + commands: ["opencode"], + }, + }, +]; export const Route = createFileRoute("/_authenticated/settings/presets/")({ component: PresetsSettingsPage, }); function PresetsSettingsPage() { + const { + presets: serverPresets, + isLoading, + createPreset, + updatePreset, + deletePreset, + setDefaultPreset, + } = usePresets(); + const [localPresets, setLocalPresets] = + useState(serverPresets); + const isDark = useIsDarkTheme(); + + useEffect(() => { + setLocalPresets(serverPresets); + }, [serverPresets]); + + const existingPresetNames = useMemo( + () => new Set(serverPresets.map((p) => p.name)), + [serverPresets], + ); + + const isTemplateAdded = (template: PresetTemplate) => + existingPresetNames.has(template.preset.name); + + const handleCellChange = ( + rowIndex: number, + column: PresetColumnKey, + value: string, + ) => { + setLocalPresets((prev) => + prev.map((p, i) => (i === rowIndex ? { ...p, [column]: value } : p)), + ); + }; + + const handleCellBlur = (rowIndex: number, column: PresetColumnKey) => { + const preset = localPresets[rowIndex]; + const serverPreset = serverPresets[rowIndex]; + if (!preset || !serverPreset) return; + if (preset[column] === serverPreset[column]) return; + + updatePreset.mutate({ + id: preset.id, + patch: { [column]: preset[column] }, + }); + }; + + const handleCommandsChange = (rowIndex: number, commands: string[]) => { + setLocalPresets((prev) => + prev.map((p, i) => (i === rowIndex ? { ...p, commands } : p)), + ); + }; + + const handleCommandsBlur = (rowIndex: number) => { + const preset = localPresets[rowIndex]; + const serverPreset = serverPresets[rowIndex]; + if (!preset || !serverPreset) return; + if ( + JSON.stringify(preset.commands) === JSON.stringify(serverPreset.commands) + ) + return; + + updatePreset.mutate({ + id: preset.id, + patch: { commands: preset.commands }, + }); + }; + + const handleAddRow = () => { + createPreset.mutate({ + name: "", + cwd: "", + commands: [""], + }); + }; + + const handleAddTemplate = (template: PresetTemplate) => { + if (isTemplateAdded(template)) return; + createPreset.mutate(template.preset); + }; + + const handleDeleteRow = (rowIndex: number) => { + const preset = localPresets[rowIndex]; + if (!preset) return; + + deletePreset.mutate({ id: preset.id }); + }; + + const handleSetDefault = (presetId: string | null) => { + setDefaultPreset.mutate({ id: presetId }); + }; + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + return ( -
-

Presets Settings

-

Presets settings placeholder

+
+
+
+

Terminal Presets

+ +
+

+ Presets let you quickly launch terminals with pre-configured commands. + Create a preset below, then use it from the "New Terminal" dropdown in + any workspace. +

+ +
+ + Quick add: + + {PRESET_TEMPLATES.map((template) => { + const alreadyAdded = isTemplateAdded(template); + const presetIcon = getPresetIcon(template.name, isDark); + return ( + + + + + + {alreadyAdded ? "Already added" : template.preset.description} + + + ); + })} +
+
+ +
+
+ {PRESET_COLUMNS.map((column) => ( +
+ {column.label} +
+ ))} +
+ Actions +
+
+ +
+ {localPresets.length > 0 ? ( + localPresets.map((preset, index) => ( + + )) + ) : ( +
+ No presets yet. Click "Add Preset" to create your first preset. +
+ )} +
+
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/types.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/types.ts new file mode 100644 index 00000000000..df42000bffd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/types.ts @@ -0,0 +1,33 @@ +import type { TerminalPreset } from "@superset/local-db"; + +export type { TerminalPreset }; + +export type PresetColumnKey = Exclude; + +export interface PresetColumnConfig { + key: PresetColumnKey; + label: string; + placeholder: string; + mono?: boolean; +} + +export const PRESET_COLUMNS: PresetColumnConfig[] = [ + { key: "name", label: "Name", placeholder: "e.g. Dev Server" }, + { + key: "description", + label: "Description", + placeholder: "e.g. Starts the dev server (optional)", + }, + { + key: "cwd", + label: "CWD", + placeholder: "e.g. ./src (optional)", + mono: true, + }, + { + key: "commands", + label: "Commands", + placeholder: "e.g. npm run dev", + mono: true, + }, +]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx new file mode 100644 index 00000000000..531d4844c8b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx @@ -0,0 +1,106 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { NotFound } from "renderer/routes/not-found"; + +export const Route = createFileRoute( + "/_authenticated/settings/project/$projectId/", +)({ + component: ProjectSettingsPage, + notFoundComponent: NotFound, + loader: async ({ params, context }) => { + const projectQueryKey = [ + ["projects", "get"], + { input: { id: params.projectId }, type: "query" }, + ]; + + const configQueryKey = [ + ["config", "getConfigFilePath"], + { input: { projectId: params.projectId }, type: "query" }, + ]; + + try { + await Promise.all([ + context.queryClient.ensureQueryData({ + queryKey: projectQueryKey, + queryFn: () => + trpcClient.projects.get.query({ id: params.projectId }), + }), + context.queryClient.ensureQueryData({ + queryKey: configQueryKey, + queryFn: () => + trpcClient.config.getConfigFilePath.query({ + projectId: params.projectId, + }), + }), + ]); + } catch (error) { + // If project not found, throw notFound() to render 404 page + if (error instanceof Error && error.message.includes("not found")) { + throw notFound(); + } + // Re-throw other errors + throw error; + } + }, +}); + +import { HiOutlineCog6Tooth, HiOutlineFolder } from "react-icons/hi2"; +import { ConfigFilePreview } from "renderer/components/ConfigFilePreview"; +import { trpc } from "renderer/lib/trpc"; + +function ProjectSettingsPage() { + const { projectId } = Route.useParams(); + const { data: project } = trpc.projects.get.useQuery({ + id: projectId, + }); + + const { data: configFilePath } = trpc.config.getConfigFilePath.useQuery({ + projectId, + }); + + // Project is guaranteed to exist here because loader handles 404s + if (!project) { + return null; + } + + return ( +
+
+

Project

+
+ +
+
+

Name

+

{project.name}

+
+ +
+

+ + Repository Path +

+

{project.mainRepoPath}

+
+ +
+
+

+ + Scripts +

+

+ Configure setup and teardown scripts that run when workspaces are + created or deleted. +

+
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/page.tsx new file mode 100644 index 00000000000..df4941687e5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/page.tsx @@ -0,0 +1,272 @@ +import { cn } from "@superset/ui/utils"; +import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { HiBellSlash, HiCheck, HiPlay, HiStop } from "react-icons/hi2"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { + AVAILABLE_RINGTONES, + type Ringtone, + useSelectedRingtoneId, + useSetRingtone, +} from "renderer/stores"; + +function formatDuration(seconds: number): string { + return `${seconds}s`; +} + +interface RingtoneCardProps { + ringtone: Ringtone; + isSelected: boolean; + isPlaying: boolean; + onSelect: () => void; + onTogglePlay: () => void; +} + +function RingtoneCard({ + ringtone, + isSelected, + isPlaying, + onSelect, + onTogglePlay, +}: RingtoneCardProps) { + const isSilent = ringtone.id === "none"; + + // Silent card has a distinct style + if (isSilent) { + return ( + + ); + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: Using div with role="button" to allow nested play/stop button +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + className={cn( + "relative flex flex-col rounded-lg border-2 overflow-hidden transition-all text-left cursor-pointer", + isSelected + ? "border-primary ring-2 ring-primary/20" + : "border-border hover:border-muted-foreground/50", + )} + > + {/* Preview area */} +
+ {/* Emoji */} + {ringtone.emoji} + + {/* Duration badge */} + {ringtone.duration && ( + + {formatDuration(ringtone.duration)} + + )} + + {/* Play/Stop button */} + +
+ + {/* Info */} +
+
+
{ringtone.name}
+
+ {ringtone.description} +
+
+ {isSelected && ( +
+ +
+ )} +
+
+ ); +} + +export const Route = createFileRoute("/_authenticated/settings/ringtones/")({ + component: RingtonesSettingsPage, +}); + +function RingtonesSettingsPage() { + const selectedRingtoneId = useSelectedRingtoneId(); + const setRingtone = useSetRingtone(); + const [playingId, setPlayingId] = useState(null); + const previewTimerRef = useRef | null>(null); + + // Clean up timer and stop any playing sound on unmount + useEffect(() => { + return () => { + if (previewTimerRef.current) { + clearTimeout(previewTimerRef.current); + } + // Stop any in-progress preview when navigating away + trpcClient.ringtone.stop.mutate().catch(() => { + // Ignore errors during cleanup + }); + }; + }, []); + + const handleTogglePlay = useCallback( + async (ringtone: Ringtone) => { + if (ringtone.id === "none" || !ringtone.filename) { + return; + } + + // Clear any pending timer + if (previewTimerRef.current) { + clearTimeout(previewTimerRef.current); + previewTimerRef.current = null; + } + + // If this ringtone is already playing, stop it + if (playingId === ringtone.id) { + try { + await trpcClient.ringtone.stop.mutate(); + } catch (error) { + console.error("Failed to stop ringtone:", error); + } + setPlayingId(null); + return; + } + + // Stop any currently playing sound first + try { + await trpcClient.ringtone.stop.mutate(); + } catch (error) { + console.error("Failed to stop ringtone:", error); + } + + // Play the new sound + setPlayingId(ringtone.id); + + try { + await trpcClient.ringtone.preview.mutate({ + filename: ringtone.filename, + }); + } catch (error) { + console.error("Failed to play ringtone:", error); + setPlayingId(null); + } + + // Auto-reset after the ringtone's actual duration (with 500ms buffer) + const durationMs = ((ringtone.duration ?? 5) + 0.5) * 1000; + previewTimerRef.current = setTimeout(() => { + setPlayingId((current) => (current === ringtone.id ? null : current)); + previewTimerRef.current = null; + }, durationMs); + }, + [playingId], + ); + + const handleSelect = useCallback( + (ringtoneId: string) => { + setRingtone(ringtoneId); + }, + [setRingtone], + ); + + return ( +
+
+

Ringtones

+

+ Choose the notification sound for completed tasks +

+
+ +
+ {/* Ringtone Section */} +
+

Notification Sound

+
+ {AVAILABLE_RINGTONES.map((ringtone) => ( + handleSelect(ringtone.id)} + onTogglePlay={() => handleTogglePlay(ringtone)} + /> + ))} +
+
+ + {/* Tip */} +
+

+ Click the play button to preview a sound. Click stop or play another + to stop the current sound. +

+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/InviteMemberButton/InviteMemberButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/InviteMemberButton/InviteMemberButton.tsx new file mode 100644 index 00000000000..d8460ab91a9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/InviteMemberButton/InviteMemberButton.tsx @@ -0,0 +1,19 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { HiOutlinePlus } from "react-icons/hi2"; + +export function InviteMemberButton() { + return ( + + + + + +

Coming soon - invitation system in development

+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/InviteMemberButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/InviteMemberButton/index.ts new file mode 100644 index 00000000000..1c7b31bdf66 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/InviteMemberButton/index.ts @@ -0,0 +1 @@ +export * from "./InviteMemberButton"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/MemberActions/MemberActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/MemberActions/MemberActions.tsx new file mode 100644 index 00000000000..526c67a0a7a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/MemberActions/MemberActions.tsx @@ -0,0 +1,149 @@ +import { authClient } from "@superset/auth/client"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { useState } from "react"; +import { HiEllipsisVertical, HiOutlineTrash } from "react-icons/hi2"; + +export interface MemberDetails { + memberId: string; + userId: string; + name: string | null; + email: string; + image: string | null; + role: string; + joinedAt: Date; + organizationId: string; +} + +interface MemberActionsProps { + member: MemberDetails; + isCurrentUser: boolean; + canRemove: boolean; +} + +export function MemberActions({ + member, + isCurrentUser, + canRemove, +}: MemberActionsProps) { + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + + const handleRemove = async () => { + setIsRemoving(true); + try { + if (isCurrentUser) { + await authClient.organization.leave({ + organizationId: member.organizationId, + }); + toast.success("Left organization"); + } else { + await authClient.organization.removeMember({ + organizationId: member.organizationId, + memberIdOrEmail: member.userId, + }); + toast.success("Member removed"); + } + setShowRemoveDialog(false); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : `Failed to ${isCurrentUser ? "leave" : "remove member from"} organization`, + ); + } finally { + setIsRemoving(false); + } + }; + + return ( + <> + + + + + + {isCurrentUser ? ( + setShowRemoveDialog(true)} + > + + Leave organization... + + ) : canRemove ? ( + setShowRemoveDialog(true)} + > + + Remove member + + ) : null} + + + + + + + + {isCurrentUser ? "Leave organization?" : "Remove team member?"} + + + {isCurrentUser ? ( + <> + Are you sure you want to leave this organization? You will + lose access immediately. + + ) : ( + <> + Are you sure you want to remove {member.name}{" "} + ({member.email}) from the organization? They will lose access + immediately. + + )} + + + + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/MemberActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/MemberActions/index.ts new file mode 100644 index 00000000000..65db8b7c421 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/team/components/MemberActions/index.ts @@ -0,0 +1 @@ +export * from "./MemberActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/team/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/team/page.tsx new file mode 100644 index 00000000000..8c2fc5ec966 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/team/page.tsx @@ -0,0 +1,179 @@ +import { Avatar } from "@superset/ui/atoms/Avatar"; +import { Badge } from "@superset/ui/badge"; +import { Skeleton } from "@superset/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@superset/ui/table"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { createFileRoute } from "@tanstack/react-router"; +import { useAuth } from "renderer/providers/AuthProvider"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { InviteMemberButton } from "./components/InviteMemberButton"; +import { MemberActions } from "./components/MemberActions"; + +export const Route = createFileRoute("/_authenticated/settings/team/")({ + component: TeamSettingsPage, +}); + +function TeamSettingsPage() { + const { session } = useAuth(); + const collections = useCollections(); + + const { data: membersData, isLoading } = useLiveQuery( + (q) => + q + .from({ members: collections.members }) + .leftJoin({ users: collections.users }, ({ members, users }) => + eq(members.userId, users.id), + ) + .select(({ members, users }) => ({ + memberId: members.id, + userId: members.userId, + name: users?.name ?? null, + email: users?.email ?? "", + image: users?.image ?? null, + role: members.role, + joinedAt: members.createdAt, + organizationId: members.organizationId, + })) + .orderBy(({ members }) => members.role, "asc") + .orderBy(({ members }) => members.createdAt, "asc"), + [collections], + ); + + const members = membersData ?? []; + + const currentUserId = session?.user?.id; + const currentMember = members.find((m) => m.userId === currentUserId); + const isOwner = currentMember?.role === "owner"; + + const formatDate = (date: Date | string) => { + const d = date instanceof Date ? date : new Date(date); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + }; + + return ( +
+
+
+

Team

+

+ Manage members in your organization +

+
+
+ +
+
+
+
+ +
+ + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + +
+ + +
+ ))} +
+ ) : members.length === 0 ? ( +
+ No team members yet +
+ ) : ( +
+ + + + Name + Email + Role + Joined + + + + + {members.map((member) => { + const isCurrentUserRow = member.userId === currentUserId; + + return ( + + +
+ +
+ + {member.name || "Unknown"} + + {isCurrentUserRow && ( + + You + + )} +
+
+
+ + {member.email} + + + + {member.role} + + + + {formatDate(member.joinedAt)} + + + + +
+ ); + })} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx new file mode 100644 index 00000000000..4e8981c7a8c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx @@ -0,0 +1,524 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { Switch } from "@superset/ui/switch"; +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { DEFAULT_TERMINAL_PERSISTENCE } from "shared/constants"; + +export const Route = createFileRoute("/_authenticated/settings/terminal/")({ + component: TerminalSettingsPage, +}); + +function TerminalSettingsPage() { + const utils = trpc.useUtils(); + const { data: terminalPersistence, isLoading } = + trpc.settings.getTerminalPersistence.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + + const { data: daemonSessions } = trpc.terminal.listDaemonSessions.useQuery(); + const daemonModeEnabled = daemonSessions?.daemonModeEnabled ?? false; + const sessions = daemonSessions?.sessions ?? []; + const sessionsSorted = useMemo(() => { + return [...sessions].sort((a, b) => { + // Attached sessions first, then newest attach time. + if (a.attachedClients !== b.attachedClients) { + return b.attachedClients - a.attachedClients; + } + const aTime = a.lastAttachedAt ? Date.parse(a.lastAttachedAt) : 0; + const bTime = b.lastAttachedAt ? Date.parse(b.lastAttachedAt) : 0; + return bTime - aTime; + }); + }, [sessions]); + const activeWorkspaceSessionCount = useMemo(() => { + if (!activeWorkspace?.id) return 0; + return sessions.filter((s) => s.workspaceId === activeWorkspace.id).length; + }, [sessions, activeWorkspace?.id]); + + const [confirmKillAllOpen, setConfirmKillAllOpen] = useState(false); + const [confirmKillWorkspaceOpen, setConfirmKillWorkspaceOpen] = + useState(false); + const [confirmClearHistoryOpen, setConfirmClearHistoryOpen] = useState(false); + const [showSessionList, setShowSessionList] = useState(false); + const [pendingKillSession, setPendingKillSession] = useState<{ + sessionId: string; + workspaceId: string; + } | null>(null); + const setTerminalPersistence = + trpc.settings.setTerminalPersistence.useMutation({ + onMutate: async ({ enabled }) => { + // Cancel outgoing fetches + await utils.settings.getTerminalPersistence.cancel(); + // Snapshot previous value + const previous = utils.settings.getTerminalPersistence.getData(); + // Optimistically update + utils.settings.getTerminalPersistence.setData(undefined, enabled); + return { previous }; + }, + onError: (_err, _vars, context) => { + // Rollback on error + if (context?.previous !== undefined) { + utils.settings.getTerminalPersistence.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + // Refetch to ensure sync with server + utils.settings.getTerminalPersistence.invalidate(); + }, + }); + + const handleToggle = (enabled: boolean) => { + setTerminalPersistence.mutate({ enabled }); + }; + + const killAllDaemonSessions = trpc.terminal.killAllDaemonSessions.useMutation( + { + onSuccess: (result) => { + if (result.daemonModeEnabled) { + toast.success("Killed all terminal sessions", { + description: `${result.killedCount} sessions terminated`, + }); + } else { + toast.error("Terminal persistence is not active", { + description: "Restart the app after enabling terminal persistence.", + }); + } + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to kill sessions", { + description: error.message, + }); + }, + }, + ); + + const killDaemonSessionsForWorkspace = + trpc.terminal.killDaemonSessionsForWorkspace.useMutation({ + onSuccess: (result) => { + if (result.daemonModeEnabled) { + toast.success("Killed workspace terminal sessions", { + description: `${result.killedCount} sessions terminated`, + }); + } else { + toast.error("Terminal persistence is not active", { + description: "Restart the app after enabling terminal persistence.", + }); + } + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to kill sessions", { + description: error.message, + }); + }, + }); + + const clearTerminalHistory = trpc.terminal.clearTerminalHistory.useMutation({ + onSuccess: () => { + toast.success("Cleared terminal history"); + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to clear terminal history", { + description: error.message, + }); + }, + }); + + const killDaemonSession = trpc.terminal.kill.useMutation({ + onSuccess: () => { + toast.success("Killed terminal session"); + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to kill session", { + description: error.message, + }); + }, + }); + + const formatTimestamp = (value?: string) => { + if (!value) return "—"; + return value.replace("T", " ").replace(/\.\d+Z$/, "Z"); + }; + + return ( +
+
+

Terminal

+

+ Configure terminal behavior and persistence +

+
+ +
+
+
+ +

+ Keep terminal sessions alive across app restarts and workspace + switches. TUI apps like Claude Code will resume exactly where you + left off. +

+

+ May use more memory with many terminals open. Disable if you + notice performance issues. +

+

+ Requires app restart to take effect. +

+
+ +
+ +
+
+
+ + +
+ {daemonModeEnabled ? ( + <> +

+ Daemon sessions running: {sessions.length} +

+ {sessions.length >= 20 && ( +

+ Large numbers of persistent terminals can increase + CPU/memory usage. Consider killing old sessions if you + notice slowdowns. +

+ )} + + ) : ( +

+ Enable terminal persistence and restart the app to manage daemon + sessions. +

+ )} +
+ +
+ + + + +
+ + {daemonModeEnabled && showSessionList && sessions.length > 0 && ( +
+
+ + + + + + + + + + + + + {sessionsSorted.map((session) => ( + + + + + + + + + ))} + +
+ Workspace + + Session + + Clients + PID + Last attached + + Action +
+ {session.workspaceId} + + {session.sessionId} + + {session.attachedClients} + + {session.pid ?? "—"} + + {formatTimestamp(session.lastAttachedAt)} + + +
+
+
+ )} +
+
+ + + + + + Kill all terminal sessions? + + +
+ + This will terminate all persistent terminal processes (builds, + tests, agents, etc.). + + + You can’t undo this action. Terminal panes will show “Process + exited” and can be restarted. + +
+
+
+ + + + +
+
+ + + + + + Kill active workspace terminal sessions? + + +
+ + This will terminate terminal processes for the currently + active workspace. + + + You can’t undo this action. Terminal panes will show “Process + exited” and can be restarted. + +
+
+
+ + + + +
+
+ + + + + + Clear terminal history? + + +
+ + This deletes the saved scrollback used for reboot/crash + recovery. + + + Running terminal processes continue, but older output may no + longer be available after restarting the app. + +
+
+
+ + + + +
+
+ + { + if (!open) setPendingKillSession(null); + }} + > + + + + Kill terminal session? + + +
+ + This will terminate the session and its underlying process. + + {pendingKillSession && ( + + {pendingKillSession.workspaceId} /{" "} + {pendingKillSession.sessionId} + + )} +
+
+
+ + + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx new file mode 100644 index 00000000000..39037373f79 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx @@ -0,0 +1,125 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { NotFound } from "renderer/routes/not-found"; + +export const Route = createFileRoute( + "/_authenticated/settings/workspace/$workspaceId/", +)({ + component: WorkspaceSettingsPage, + notFoundComponent: NotFound, + loader: async ({ params, context }) => { + const queryKey = [ + ["workspaces", "get"], + { input: { id: params.workspaceId }, type: "query" }, + ]; + + try { + await context.queryClient.ensureQueryData({ + queryKey, + queryFn: () => + trpcClient.workspaces.get.query({ id: params.workspaceId }), + }); + } catch (error) { + // If workspace not found, throw notFound() to render 404 page + if (error instanceof Error && error.message.includes("not found")) { + throw notFound(); + } + // Re-throw other errors + throw error; + } + }, +}); + +import { Input } from "@superset/ui/input"; +import { HiOutlineFolder, HiOutlinePencilSquare } from "react-icons/hi2"; +import { LuGitBranch } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; + +function WorkspaceSettingsPage() { + const { workspaceId } = Route.useParams(); + const { data: workspace } = trpc.workspaces.get.useQuery({ + id: workspaceId, + }); + + const rename = useWorkspaceRename(workspace?.id ?? "", workspace?.name ?? ""); + + // Workspace is guaranteed to exist here because loader handles 404s + if (!workspace) { + return null; + } + + return ( +
+
+

Workspace

+
+ +
+
+

+ Name +

+ {rename.isRenaming ? ( + rename.setRenameValue(e.target.value)} + onBlur={rename.submitRename} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } else { + rename.handleKeyDown(e); + } + }} + aria-labelledby="workspace-name-label" + className="text-base" + /> + ) : ( + + )} +
+ + {workspace.worktree && ( +
+

+ + Branch +

+
+

{workspace.worktree.branch}

+ {workspace.worktree.gitStatus?.needsRebase && ( + + Needs Rebase + + )} +
+
+ )} + +
+

+ + Path +

+

+ {workspace.worktreePath} +

+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/page.tsx deleted file mode 100644 index 4ecebbd064b..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/_authenticated/settings/workspace/")({ - component: WorkspaceSettingsPage, -}); - -function WorkspaceSettingsPage() { - return ( -
-

Workspace Settings

-

Workspace settings placeholder

-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/tasks/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/tasks/page.tsx deleted file mode 100644 index e14fce4ed55..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/tasks/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/_authenticated/tasks/")({ - component: TasksPage, -}); - -function TasksPage() { - return ( -
-

Tasks

-

Tasks page placeholder

-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/workspace/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/workspace/page.tsx deleted file mode 100644 index f6b57b8c1e9..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/workspace/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { MainScreen } from "renderer/screens/main"; - -export const Route = createFileRoute("/_authenticated/workspace/")({ - component: WorkspacePage, -}); - -function WorkspacePage() { - return ; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/workspaces/page.tsx deleted file mode 100644 index caf541db21f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/workspaces/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/_authenticated/workspaces/")({ - component: WorkspacesPage, -}); - -function WorkspacesPage() { - return ( -
-

Workspaces List

-

Workspaces list page placeholder

-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index f688b7979cb..c593b2bc607 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -15,6 +15,7 @@ import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; @@ -22,13 +23,12 @@ import { LuEye, LuEyeOff, LuFolder, LuFolderGit2 } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useReorderWorkspaces, - useSetActiveWorkspace, useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; -import { useCloseWorkspacesList } from "renderer/stores/app-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; import { getHighestPriorityStatus } from "shared/tabs-types"; @@ -56,7 +56,6 @@ interface WorkspaceListItemProps { name: string; branch: string; type: "worktree" | "branch"; - isActive: boolean; isUnread?: boolean; index: number; shortcutIndex?: number; @@ -71,16 +70,15 @@ export function WorkspaceListItem({ name, branch, type, - isActive, isUnread = false, index, shortcutIndex, isCollapsed = false, }: WorkspaceListItemProps) { const isBranchWorkspace = type === "branch"; - const setActiveWorkspace = useSetActiveWorkspace(); + const navigate = useNavigate(); + const matchRoute = useMatchRoute(); const reorderWorkspaces = useReorderWorkspaces(); - const closeWorkspacesList = useCloseWorkspacesList(); const [hasHovered, setHasHovered] = useState(false); const rename = useWorkspaceRename(id, name); const tabs = useTabsStore((s) => s.tabs); @@ -89,6 +87,12 @@ export function WorkspaceListItem({ (s) => s.clearWorkspaceAttentionStatus, ); const utils = trpc.useUtils(); + + // Derive isActive from route + const isActive = !!matchRoute({ + to: "/workspace/$workspaceId", + params: { workspaceId: id }, + }); const openInFinder = trpc.external.openInFinder.useMutation({ onError: (error) => toast.error(`Failed to open: ${error.message}`), }); @@ -134,10 +138,8 @@ export function WorkspaceListItem({ const handleClick = () => { if (!rename.isRenaming) { - setActiveWorkspace.mutate({ id }); clearWorkspaceAttentionStatus(id); - // Close workspaces list view if open, to show the workspace's terminal view - closeWorkspacesList(); + navigateToWorkspace(id, navigate); } }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/OrganizationDropdown.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/OrganizationDropdown.tsx index 6a8ea219c7b..82659eea054 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/OrganizationDropdown.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/OrganizationDropdown.tsx @@ -12,6 +12,7 @@ import { } from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate } from "@tanstack/react-router"; import { HiCheck, HiChevronUpDown, @@ -20,7 +21,6 @@ import { import { trpc } from "renderer/lib/trpc"; import { useAuth } from "renderer/providers/AuthProvider"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useOpenSettings } from "renderer/stores/app-state"; interface OrganizationDropdownProps { isCollapsed?: boolean; @@ -33,7 +33,7 @@ export function OrganizationDropdown({ const collections = useCollections(); const setActiveOrg = trpc.auth.setActiveOrganization.useMutation(); const signOut = trpc.auth.signOut.useMutation(); - const openSettings = useOpenSettings(); + const navigate = useNavigate(); const activeOrganizationId = session?.session?.activeOrganizationId; @@ -100,12 +100,16 @@ export function OrganizationDropdown({ {activeOrganization && ( <> {/* Settings */} - openSettings()}> + navigate({ to: "/settings/account" })} + > Settings {/* Team management */} - openSettings("team")}> + navigate({ to: "/settings/team" })} + > Invite and manage members diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx index ec8598c5c26..5fc833ec00c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx @@ -1,6 +1,7 @@ import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useState } from "react"; import { HiOutlineClipboardDocumentList } from "react-icons/hi2"; @@ -11,12 +12,6 @@ import { LuPanelLeftOpen, } from "react-icons/lu"; import { useWorkspaceSidebarStore } from "renderer/stores"; -import { - useCloseWorkspacesList, - useCurrentView, - useOpenTasks, - useOpenWorkspacesList, -} from "renderer/stores/app-state"; import { STROKE_WIDTH, STROKE_WIDTH_THIN } from "../constants"; import { NewWorkspaceButton } from "./NewWorkspaceButton"; import { OrganizationDropdown } from "./OrganizationDropdown"; @@ -28,27 +23,31 @@ interface WorkspaceSidebarHeaderProps { export function WorkspaceSidebarHeader({ isCollapsed = false, }: WorkspaceSidebarHeaderProps) { - const currentView = useCurrentView(); - const openWorkspacesList = useOpenWorkspacesList(); - const closeWorkspacesList = useCloseWorkspacesList(); - const openTasks = useOpenTasks(); + const navigate = useNavigate(); + const matchRoute = useMatchRoute(); const { toggleCollapsed } = useWorkspaceSidebarStore(); const [isHovering, setIsHovering] = useState(false); const hasTasksAccess = useFeatureFlagEnabled( FEATURE_FLAGS.ELECTRIC_TASKS_ACCESS, ); - const isWorkspacesListOpen = currentView === "workspaces-list"; - const isTasksOpen = currentView === "tasks"; + // Derive active state from route + const isWorkspacesListOpen = !!matchRoute({ to: "/workspaces" }); + const isTasksOpen = !!matchRoute({ to: "/tasks" }); - const handleClick = () => { + const handleWorkspacesClick = () => { if (isWorkspacesListOpen) { - closeWorkspacesList(); + // Navigate back to workspace view + navigate({ to: "/workspace" }); } else { - openWorkspacesList(); + navigate({ to: "/workspaces" }); } }; + const handleTasksClick = () => { + navigate({ to: "/tasks" }); + }; + const handleToggleSidebar = () => { toggleCollapsed(); }; @@ -92,7 +91,7 @@ export function WorkspaceSidebarHeader({
- {daemonModeEnabled && showSessionList && sessions.length > 0 && ( + {daemonModeEnabled && showSessionList && aliveSessions.length > 0 && (
@@ -359,6 +361,9 @@ function TerminalSettingsPage() { disabled={killAllDaemonSessions.isPending} onClick={() => { setConfirmKillAllOpen(false); + sessions.forEach((session) => + markTerminalKilledByUser(session.sessionId), + ); killAllDaemonSessions.mutate(); }} > @@ -454,6 +459,7 @@ function TerminalSettingsPage() { const sessionId = pendingKillSession?.sessionId; setPendingKillSession(null); if (!sessionId) return; + markTerminalKilledByUser(sessionId); killDaemonSession.mutate({ paneId: sessionId }); }} > diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index c5e03d6a815..c68f6537ed9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -7,6 +7,10 @@ import debounce from "lodash/debounce"; import { useCallback, useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; import { trpcClient } from "renderer/lib/trpc-client"; +import { + clearTerminalKilledByUser, + isTerminalKilledByUser, +} from "renderer/lib/terminal-kill-tracking"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; @@ -108,6 +112,10 @@ export const Terminal = ({ const searchAddonRef = useRef(null); const rendererRef = useRef(null); const isExitedRef = useRef(false); + const [exitStatus, setExitStatus] = useState<"killed" | "exited" | null>( + null, + ); + const wasKilledByUserRef = useRef(false); const pendingEventsRef = useRef([]); const commandBufferRef = useRef(""); const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -152,6 +160,8 @@ export const Terminal = ({ const initialThemeRef = useRef(terminalTheme); const isFocused = focusedPaneId === paneId; + const isTabVisibleRef = useRef(isTabVisible); + isTabVisibleRef.current = isTabVisible; // Gate streaming until initial state restoration is applied to avoid interleaving output. const isStreamReadyRef = useRef(false); @@ -161,6 +171,7 @@ export const Terminal = ({ const pendingInitialStateRef = useRef(null); const renderDisposableRef = useRef(null); const restoreSequenceRef = useRef(0); + const restartTerminalRef = useRef<() => void>(() => {}); // Track alternate screen mode ourselves (xterm.buffer.active.type is unreliable after HMR/recovery) // Updated from: snapshot.modes.alternateScreen on restore, escape sequences in stream @@ -361,6 +372,37 @@ export const Terminal = ({ const updateModesFromDataRef = useRef(updateModesFromData); updateModesFromDataRef.current = updateModesFromData; + const handleTerminalExit = useCallback( + (exitCode: number, xterm: XTerm) => { + isExitedRef.current = true; + isStreamReadyRef.current = false; + + const wasKilledByUser = isTerminalKilledByUser(paneId); + wasKilledByUserRef.current = wasKilledByUser; + setExitStatus(wasKilledByUser ? "killed" : "exited"); + + if (wasKilledByUser) { + xterm.writeln("\r\n\r\n[Session killed]"); + xterm.writeln("[Restart to start a new session]"); + } else { + xterm.writeln(`\r\n\r\n[Process exited with code ${exitCode}]`); + xterm.writeln("[Press any key to restart]"); + } + + // Clear transient pane status on terminal exit + // "working" and "permission" should clear (agent no longer active) + // "review" should persist (user needs to see completed work) + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + setPaneStatus(paneId, "idle"); + } + }, + [paneId, setPaneStatus], + ); + const flushPendingEvents = useCallback(() => { const xterm = xtermRef.current; if (!xterm) return; @@ -377,10 +419,7 @@ export const Terminal = ({ xterm.write(event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { - isExitedRef.current = true; - isStreamReadyRef.current = false; - xterm.writeln(`\r\n\r\n[Process exited with code ${event.exitCode}]`); - xterm.writeln("[Press any key to restart]"); + handleTerminalExit(event.exitCode, xterm); } else if (event.type === "disconnect") { setConnectionError( event.reason || "Connection to terminal daemon lost", @@ -430,7 +469,7 @@ export const Terminal = ({ } } } - }, [setConnectionError]); + }, [handleTerminalExit, setConnectionError]); // biome-ignore lint/correctness/useExhaustiveDependencies: refs (resizeRef, updateCwdRef, rendererRef) used intentionally to read latest values without recreating callback const maybeApplyInitialState = useCallback(() => { @@ -725,6 +764,14 @@ export const Terminal = ({ } }, onError: (error) => { + if (error.message?.includes("TERMINAL_SESSION_KILLED")) { + wasKilledByUserRef.current = true; + isExitedRef.current = true; + isStreamReadyRef.current = false; + setExitStatus("killed"); + setConnectionError(null); + return; + } setConnectionError(error.message || "Connection failed"); isStreamReadyRef.current = true; flushPendingEvents(); @@ -769,6 +816,9 @@ export const Terminal = ({ // Reset state for new session isStreamReadyRef.current = false; isExitedRef.current = false; // Critical: reset so handleTerminalInput writes to shell + wasKilledByUserRef.current = false; + setExitStatus(null); + clearTerminalKilledByUser(paneId); pendingInitialStateRef.current = null; isAlternateScreenRef.current = false; isBracketedPasteRef.current = false; @@ -784,6 +834,7 @@ export const Terminal = ({ rows: xterm.rows, cwd: restoredCwd || undefined, skipColdRestore: true, + allowKilled: true, }, { onSuccess: (result) => { @@ -851,23 +902,9 @@ export const Terminal = ({ xtermRef.current.write(event.data); updateCwdFromData(event.data); } else if (event.type === "exit") { - isExitedRef.current = true; - isStreamReadyRef.current = false; - xtermRef.current.writeln( - `\r\n\r\n[Process exited with code ${event.exitCode}]`, - ); - xtermRef.current.writeln("[Press any key to restart]"); - - // Clear transient pane status on terminal exit - // "working" and "permission" should clear (agent no longer active) - // "review" should persist (user needs to see completed work) - // Use store getter to get fresh pane status at event time (not stale closure) - const currentPane = useTabsStore.getState().panes[paneId]; - if ( - currentPane?.status === "working" || - currentPane?.status === "permission" - ) { - setPaneStatus(paneId, "idle"); + const xterm = xtermRef.current; + if (xterm) { + handleTerminalExit(event.exitCode, xterm); } } else if (event.type === "disconnect") { // Daemon connection lost - show error UI with retry option @@ -935,10 +972,16 @@ export const Terminal = ({ }, [isFocused]); useEffect(() => { - if (isFocused && xtermRef.current) { + if (isFocused && isTabVisible && xtermRef.current) { xtermRef.current.focus(); } - }, [isFocused]); + }, [isFocused, isTabVisible]); + + useEffect(() => { + if (!isTabVisible && xtermRef.current) { + xtermRef.current.blur(); + } + }, [isTabVisible]); useAppHotkey( "FIND_IN_TERMINAL", @@ -1042,6 +1085,9 @@ export const Terminal = ({ const restartTerminal = () => { isExitedRef.current = false; isStreamReadyRef.current = false; + wasKilledByUserRef.current = false; + setExitStatus(null); + clearTerminalKilledByUser(paneId); isAlternateScreenRef.current = false; // Reset for new shell isBracketedPasteRef.current = false; modeScanBufferRef.current = ""; @@ -1053,6 +1099,7 @@ export const Terminal = ({ workspaceId, cols: xterm.cols, rows: xterm.rows, + allowKilled: true, }, { onSuccess: (result) => { @@ -1068,6 +1115,7 @@ export const Terminal = ({ }, ); }; + restartTerminalRef.current = restartTerminal; const handleTerminalInput = (data: string) => { // When overlays are visible, ignore input completely: @@ -1076,7 +1124,13 @@ export const Terminal = ({ if (isRestoredModeRef.current || connectionErrorRef.current) { return; } + if (!isTabVisibleRef.current) { + return; + } if (isExitedRef.current) { + if (!isFocusedRef.current || wasKilledByUserRef.current) { + return; + } restartTerminal(); return; } @@ -1091,6 +1145,9 @@ export const Terminal = ({ if (isRestoredModeRef.current || connectionErrorRef.current) { return; } + if (!isTabVisibleRef.current) { + return; + } const { domEvent } = event; if (domEvent.key === "Enter") { // Don't auto-title from keyboard when in alternate screen (TUI apps like vim, codex) @@ -1142,6 +1199,14 @@ export const Terminal = ({ paneId, priority: isTabVisible ? (isFocusedRef.current ? 0 : 1) : 2, run: (done) => { + if (isTerminalKilledByUser(paneId)) { + wasKilledByUserRef.current = true; + isExitedRef.current = true; + isStreamReadyRef.current = false; + setExitStatus("killed"); + done(); + return; + } if (DEBUG_TERMINAL) { console.log(`[Terminal] createOrAttach start: ${paneId}`); } @@ -1225,6 +1290,14 @@ export const Terminal = ({ maybeApplyInitialState(); }, onError: (error) => { + if (error.message?.includes("TERMINAL_SESSION_KILLED")) { + wasKilledByUserRef.current = true; + isExitedRef.current = true; + isStreamReadyRef.current = false; + setExitStatus("killed"); + setConnectionError(null); + return; + } if (DEBUG_TERMINAL) { console.log( `[Terminal] createOrAttach error: ${paneId}`, @@ -1265,9 +1338,10 @@ export const Terminal = ({ }; const handleWrite = (data: string) => { - if (!isExitedRef.current) { - writeRef.current({ paneId, data }); + if (!isTabVisibleRef.current || isExitedRef.current) { + return; } + writeRef.current({ paneId, data }); }; const cleanupKeyboard = setupKeyboardHandler(xterm, { @@ -1403,6 +1477,10 @@ export const Terminal = ({ } }; + const handleRestartSession = useCallback(() => { + restartTerminalRef.current(); + }, []); + return (
setIsSearchOpen(false)} /> + {exitStatus === "killed" && !connectionError && !isRestoredMode && ( +
+
+

Session killed

+

+ This terminal was terminated by you. Restart to start a new + session. +

+
+ +
+ )} {connectionError && (
diff --git a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts index 25e36afa3ac..a60d2a732c5 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts @@ -1,9 +1,11 @@ import { trpcClient } from "../../../lib/trpc-client"; +import { markTerminalKilledByUser } from "../../../lib/terminal-kill-tracking"; /** * Uses standalone tRPC client to avoid React hook dependencies */ export const killTerminalForPane = (paneId: string): void => { + markTerminalKilledByUser(paneId); trpcClient.terminal.kill.mutate({ paneId }).catch((error) => { console.warn(`Failed to kill terminal for pane ${paneId}:`, error); }); From b2f8de2650d2bbef871afb2d98d327170db151f7 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Thu, 15 Jan 2026 10:35:39 +0200 Subject: [PATCH 57/62] fix(desktop): disable terminal session actions when none --- .../routes/_authenticated/settings/terminal/page.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx index 7ee431d831b..4f1062caf6d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx @@ -237,7 +237,11 @@ function TerminalSettingsPage() {