diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index 44624e0645a..1294af276d8 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -10,6 +10,7 @@ import { publicProcedure, router } from ".."; type TerminalExitNotification = NotificationIds & { exitCode: number; signal?: number; + reason?: "killed" | "exited" | "error"; }; type NotificationEvent = diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index ea2166ba14e..b4f9a44cbaa 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -13,7 +13,6 @@ import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET, 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"; @@ -295,26 +294,6 @@ export const createSettingsRouter = () => { return { success: true }; }), - getTerminalPersistence: publicProcedure.query(() => { - const row = getSettings(); - return row.terminalPersistence ?? DEFAULT_TERMINAL_PERSISTENCE; - }), - - 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 }; - }), - getAutoApplyDefaultPreset: publicProcedure.query(() => { const row = getSettings(); return row.autoApplyDefaultPreset ?? DEFAULT_AUTO_APPLY_DEFAULT_PRESET; 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 490cd1d0c37..579574de501 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,4 +1,4 @@ -import { describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; import { EventEmitter } from "node:events"; interface MockManagement { @@ -19,8 +19,22 @@ interface MockManagement { * Extends EventEmitter and provides the minimal TerminalRuntime interface. */ class MockTerminalRuntime extends EventEmitter { - management: MockManagement | null = null; // non-daemon mode - capabilities = { persistent: false, coldRestore: false }; + management: MockManagement; + capabilities = { persistent: true, coldRestore: true }; + killCalls: Array<{ paneId: string }> = []; + + constructor() { + super(); + this.management = { + listSessions: async () => ({ sessions: [] }), + killAllSessions: async () => {}, + resetHistoryPersistence: async () => {}, + }; + } + + async kill(params: { paneId: string }) { + this.killCalls.push(params); + } detachAllListeners() { for (const event of this.eventNames()) { @@ -39,6 +53,21 @@ class MockTerminalRuntime extends EventEmitter { } let mockTerminal: MockTerminalRuntime = new MockTerminalRuntime(); +let mockListSessionsCallCount = 0; +let mockDaemonSessions: Array<{ + sessionId: string; + paneId: string; + workspaceId: string; + isAlive: boolean; +}> = []; +let mockListSessions: () => Promise<{ sessions: typeof mockDaemonSessions }> = + async () => ({ sessions: mockDaemonSessions }); + +beforeEach(() => { + mockListSessionsCallCount = 0; + mockDaemonSessions = []; + mockListSessions = async () => ({ sessions: mockDaemonSessions }); +}); // Mock the workspace-runtime module mock.module("main/lib/workspace-runtime", () => ({ @@ -70,22 +99,7 @@ mock.module("main/lib/local-db", () => ({ })); // Mock terminal module to avoid Electron imports from terminal-host/client -// The mock checks mockTerminal.management to determine daemon mode mock.module("main/lib/terminal", () => ({ - tryListExistingDaemonSessions: async () => { - // Check if mockTerminal.management is set to simulate daemon mode - if (mockTerminal.management) { - const result = await mockTerminal.management.listSessions(); - return { - daemonRunning: true, - sessions: result.sessions, - }; - } - return { - daemonRunning: false, - sessions: [], - }; - }, getDaemonTerminalManager: () => ({ reset: () => {}, }), @@ -95,10 +109,11 @@ mock.module("main/lib/terminal", () => ({ mock.module("main/lib/terminal-host/client", () => ({ getTerminalHostClient: () => ({ tryConnectAndAuthenticate: async () => false, - listSessions: async () => ({ sessions: [] }), + listSessions: async () => mockListSessions(), killAll: async () => ({}), kill: async () => ({}), }), + disposeTerminalHostClient: () => {}, })); const { createTerminalRouter } = await import("./terminal"); @@ -201,43 +216,98 @@ describe("terminal.stream", () => { }); }); -describe("terminal.management capability", () => { - it("returns daemonModeEnabled: false when management is null", async () => { +describe("terminal.listDaemonSessions", () => { + it("returns sessions from management list", async () => { mockTerminal = new MockTerminalRuntime(); - mockTerminal.management = null; // non-daemon mode + mockDaemonSessions = [ + { + sessionId: "pane-1", + paneId: "pane-1", + workspaceId: "ws-1", + isAlive: true, + }, + ]; + mockTerminal.management.listSessions = async () => ({ + sessions: mockDaemonSessions, + }); const router = createTerminalRouter(); const caller = router.createCaller({} as never); const result = await caller.listDaemonSessions(); - expect(result.daemonModeEnabled).toBe(false); - expect(result.sessions).toEqual([]); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].sessionId).toBe("pane-1"); }); +}); - it("returns daemonModeEnabled: true when management is present", async () => { +describe("terminal daemon kill helpers", () => { + it("killAllDaemonSessions forwards kills for each daemon session", 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 () => {}, + mockDaemonSessions = [ + { + sessionId: "pane-1", + paneId: "pane-1", + workspaceId: "ws-1", + isAlive: true, + }, + { + sessionId: "pane-2", + paneId: "pane-2", + workspaceId: "ws-2", + isAlive: true, + }, + ]; + mockListSessionsCallCount = 0; + mockTerminal.management.listSessions = async () => ({ + sessions: mockDaemonSessions, + }); + mockListSessions = async () => { + mockListSessionsCallCount++; + if (mockListSessionsCallCount === 1) { + return { sessions: mockDaemonSessions }; + } + return { sessions: [] }; }; const router = createTerminalRouter(); const caller = router.createCaller({} as never); - const result = await caller.listDaemonSessions(); + const result = await caller.killAllDaemonSessions(); - expect(result.daemonModeEnabled).toBe(true); - expect(result.sessions.length).toBe(1); - expect(result.sessions[0].sessionId).toBe("pane-1"); + expect(result.killedCount).toBe(2); + expect(mockTerminal.killCalls).toEqual([ + { paneId: "pane-1" }, + { paneId: "pane-2" }, + ]); + }); + + it("killDaemonSessionsForWorkspace only kills matching workspace sessions", async () => { + mockTerminal = new MockTerminalRuntime(); + mockDaemonSessions = [ + { + sessionId: "pane-1", + paneId: "pane-1", + workspaceId: "ws-1", + isAlive: true, + }, + { + sessionId: "pane-2", + paneId: "pane-2", + workspaceId: "ws-2", + isAlive: true, + }, + ]; + mockTerminal.management.listSessions = async () => ({ + sessions: mockDaemonSessions, + }); + mockListSessions = async () => ({ sessions: mockDaemonSessions }); + + const router = createTerminalRouter(); + const caller = router.createCaller({} as never); + const result = await caller.killDaemonSessionsForWorkspace({ + workspaceId: "ws-1", + }); + + expect(result.killedCount).toBe(1); + expect(mockTerminal.killCalls).toEqual([{ paneId: "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 3e7f8de79e3..360e7bbfe35 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -5,10 +5,11 @@ import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; +import { getDaemonTerminalManager } from "main/lib/terminal"; import { - getDaemonTerminalManager, - tryListExistingDaemonSessions, -} from "main/lib/terminal"; + TERMINAL_SESSION_KILLED_MESSAGE, + TerminalKilledError, +} from "main/lib/terminal/errors"; import { getTerminalHostClient } from "main/lib/terminal-host/client"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; @@ -18,10 +19,9 @@ import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveCwd } from "./utils"; const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; +const logger = console; let createOrAttachCallCounter = 0; -const TERMINAL_SESSION_KILLED_MESSAGE = "TERMINAL_SESSION_KILLED"; -const userKilledSessions = new Set(); const SAFE_ID = z .string() .min(1) @@ -32,7 +32,7 @@ const SAFE_ID = z ); /** - * Terminal router using TerminalManager with node-pty + * Terminal router using daemon-backed terminal runtime * Sessions are keyed by paneId and linked to workspaces for cwd resolution * * Environment variables set for terminal sessions: @@ -85,21 +85,6 @@ export const createTerminalRouter = () => { allowKilled, } = input; - if (allowKilled) { - userKilledSessions.delete(paneId); - } else if (userKilledSessions.has(paneId)) { - if (DEBUG_TERMINAL) { - console.warn("[Terminal Router] createOrAttach blocked (killed):", { - paneId, - workspaceId, - }); - } - throw new TRPCError({ - code: "BAD_REQUEST", - message: TERMINAL_SESSION_KILLED_MESSAGE, - }); - } - const workspace = localDb .select() .from(workspaces) @@ -146,6 +131,7 @@ export const createTerminalRouter = () => { rows, initialCommands, skipColdRestore, + allowKilled, }); if (DEBUG_TERMINAL) { @@ -170,6 +156,25 @@ export const createTerminalRouter = () => { snapshot: result.snapshot, }; } catch (error) { + const isKilledError = + error instanceof TerminalKilledError || + (error instanceof Error && + error.message === TERMINAL_SESSION_KILLED_MESSAGE); + if (isKilledError) { + if (DEBUG_TERMINAL) { + console.warn( + "[Terminal Router] createOrAttach blocked (killed):", + { + paneId, + workspaceId, + }, + ); + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: TERMINAL_SESSION_KILLED_MESSAGE, + }); + } if (DEBUG_TERMINAL) { console.warn("[Terminal Router] createOrAttach failed:", { callId, @@ -247,7 +252,6 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { - userKilledSessions.add(input.paneId); await terminal.kill(input); }), @@ -272,22 +276,14 @@ export const createTerminalRouter = () => { }), listDaemonSessions: publicProcedure.query(async () => { - const { daemonRunning, sessions } = await tryListExistingDaemonSessions(); - return { daemonModeEnabled: daemonRunning, sessions }; + const { sessions } = await terminal.management.listSessions(); + return { sessions }; }), killAllDaemonSessions: publicProcedure.mutation(async () => { const client = getTerminalHostClient(); - const connected = await client.tryConnectAndAuthenticate(); - if (!connected) { - return { daemonModeEnabled: false, killedCount: 0, remainingCount: 0 }; - } - - const before = await client.listSessions(); + const before = await terminal.management.listSessions(); const beforeIds = before.sessions.map((s) => s.sessionId); - for (const id of beforeIds) { - userKilledSessions.add(id); - } console.log( "[killAllDaemonSessions] Before kill:", beforeIds.length, @@ -295,7 +291,23 @@ export const createTerminalRouter = () => { beforeIds, ); - await client.killAll({}); + if (beforeIds.length > 0) { + const results = await Promise.allSettled( + beforeIds.map((paneId) => terminal.kill({ paneId })), + ); + for (const [index, result] of results.entries()) { + if (result.status === "rejected") { + const paneId = beforeIds[index]; + logger.error( + `[killAllDaemonSessions] terminal.kill failed for paneId=${paneId}`, + { + paneId, + reason: result.reason, + }, + ); + } + } + } // Poll until sessions are actually dead const MAX_RETRIES = 10; @@ -329,35 +341,42 @@ export const createTerminalRouter = () => { remainingCount > 0 ? afterIds : [], ); - return { daemonModeEnabled: true, killedCount, remainingCount }; + return { killedCount, remainingCount }; }), killDaemonSessionsForWorkspace: publicProcedure .input(z.object({ workspaceId: z.string() })) .mutation(async ({ input }) => { - const client = getTerminalHostClient(); - const connected = await client.tryConnectAndAuthenticate(); - if (!connected) { - return { daemonModeEnabled: false, killedCount: 0 }; - } - - const { sessions } = await client.listSessions(); + const { sessions } = await terminal.management.listSessions(); const toKill = sessions.filter( (session) => session.workspaceId === input.workspaceId, ); - for (const session of toKill) { - userKilledSessions.add(session.sessionId); - await client.kill({ sessionId: session.sessionId }); + if (toKill.length > 0) { + const paneIds = toKill.map((session) => session.sessionId); + const results = await Promise.allSettled( + paneIds.map((paneId) => terminal.kill({ paneId })), + ); + for (const [index, result] of results.entries()) { + if (result.status === "rejected") { + const paneId = paneIds[index]; + logger.error( + `[killDaemonSessionsForWorkspace] terminal.kill failed for paneId=${paneId}`, + { + paneId, + workspaceId: input.workspaceId, + reason: result.reason, + }, + ); + } + } } - return { daemonModeEnabled: true, killedCount: toKill.length }; + return { killedCount: toKill.length }; }), clearTerminalHistory: publicProcedure.mutation(async () => { - if (terminal.management) { - await terminal.management.resetHistoryPersistence(); - } + await terminal.management.resetHistoryPersistence(); return { success: true }; }), @@ -377,7 +396,12 @@ export const createTerminalRouter = () => { ); for (const session of sessions) { - userKilledSessions.add(session.sessionId); + void terminal.kill({ paneId: session.sessionId }).catch((error) => { + console.warn( + "[restartDaemon] Failed to mark session killed:", + error, + ); + }); } await client.shutdownIfRunning({ killSessions: true }); @@ -479,7 +503,12 @@ export const createTerminalRouter = () => { .subscription(({ input: paneId }) => { return observable< | { type: "data"; data: string } - | { type: "exit"; exitCode: number; signal?: number } + | { + type: "exit"; + exitCode: number; + signal?: number; + reason?: "killed" | "exited" | "error"; + } | { type: "disconnect"; reason: string } | { type: "error"; error: string; code?: string } >((emit) => { @@ -499,9 +528,13 @@ export const createTerminalRouter = () => { emit.next({ type: "data", data }); }; - const onExit = (exitCode: number, signal?: number) => { + const onExit = ( + exitCode: number, + signal?: number, + reason?: "killed" | "exited" | "error", + ) => { // Don't emit.complete() - paneId is reused across restarts, completion would strand listeners - emit.next({ type: "exit", exitCode, signal }); + emit.next({ type: "exit", exitCode, signal, reason }); }; const onDisconnect = (reason: string) => { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2a46c20f4e5..09c0d42fc90 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -12,10 +12,7 @@ import { initAppState } from "./lib/app-state"; import { setupAutoUpdater } from "./lib/auto-updater"; import { localDb } from "./lib/local-db"; import { initSentry } from "./lib/sentry"; -import { - reconcileDaemonSessions, - shutdownOrphanedDaemon, -} from "./lib/terminal"; +import { reconcileDaemonSessions } from "./lib/terminal"; import { disposeTray, initTray } from "./lib/tray"; import { MainWindow } from "./windows/main"; @@ -240,9 +237,6 @@ if (!gotTheLock) { // Must happen BEFORE renderer restore runs await reconcileDaemonSessions(); - // Shutdown orphaned daemon if persistence is disabled - await shutdownOrphanedDaemon(); - try { setupAgentHooks(); } catch (error) { diff --git a/apps/desktop/src/main/lib/terminal/daemon/constants.ts b/apps/desktop/src/main/lib/terminal/daemon/constants.ts index 1d912dc2b4c..2d94871072f 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/constants.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/constants.ts @@ -3,3 +3,4 @@ export const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; export const CREATE_OR_ATTACH_CONCURRENCY = 3; export const MAX_SCROLLBACK_BYTES = 500_000; export const MAX_HISTORY_SCROLLBACK_BYTES = 512 * 1024; +export const MAX_KILLED_SESSION_TOMBSTONES = 1000; diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts new file mode 100644 index 00000000000..23ac8963cad --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { SessionInfo } from "./types"; + +class MockTerminalHostClient extends EventEmitter { + killCalls: Array<{ sessionId: string; deleteHistory?: boolean }> = []; + + async kill(params: { sessionId: string; deleteHistory?: boolean }) { + this.killCalls.push(params); + } + + async listSessions() { + return { sessions: [] }; + } + + writeNoAck() {} + resize() { + return Promise.resolve(); + } + signal() { + return Promise.resolve(); + } + detach() { + return Promise.resolve(); + } + clearScrollback() { + return Promise.resolve(); + } +} + +let mockClient = new MockTerminalHostClient(); + +mock.module("../../terminal-host/client", () => ({ + getTerminalHostClient: () => mockClient, + disposeTerminalHostClient: () => {}, +})); + +mock.module("main/lib/analytics", () => ({ + track: () => {}, +})); + +mock.module("main/lib/app-state", () => ({ + appState: { data: null }, +})); + +mock.module("main/lib/local-db", () => ({ + localDb: { + select: () => ({ + from: () => ({ + all: () => [], + get: () => undefined, + }), + }), + }, +})); + +mock.module("@superset/local-db", () => ({ + workspaces: { id: "id" }, +})); + +const { DaemonTerminalManager } = await import("./daemon-manager"); + +describe("DaemonTerminalManager kill tracking", () => { + beforeEach(() => { + mockClient = new MockTerminalHostClient(); + }); + + it("waits for daemon exit and labels killed sessions", async () => { + const manager = new DaemonTerminalManager(); + const paneId = "pane-kill-1"; + const sessions = ( + manager as unknown as { sessions: Map } + ).sessions; + sessions.set(paneId, { + paneId, + workspaceId: "ws-1", + isAlive: true, + lastActive: Date.now(), + cwd: "", + pid: 123, + cols: 80, + rows: 24, + }); + + let exitReason: string | undefined; + manager.on(`exit:${paneId}`, (_exitCode, _signal, reason) => { + exitReason = reason; + }); + + await manager.kill({ paneId }); + expect(exitReason).toBeUndefined(); + + mockClient.emit("exit", paneId, 0, 15); + expect(exitReason).toBe("killed"); + expect(mockClient.killCalls.length).toBe(1); + }); + + it("labels exit as killed even if session is missing", async () => { + const manager = new DaemonTerminalManager(); + const paneId = "pane-kill-2"; + + let exitReason: string | undefined; + manager.on(`exit:${paneId}`, (_exitCode, _signal, reason) => { + exitReason = reason; + }); + + await manager.kill({ paneId }); + mockClient.emit("exit", paneId, 0, 15); + expect(exitReason).toBe("killed"); + }); + + it("defaults exit reason to exited when no kill tombstone exists", () => { + const manager = new DaemonTerminalManager(); + const paneId = "pane-exit-1"; + + let exitReason: string | undefined; + manager.on(`exit:${paneId}`, (_exitCode, _signal, reason) => { + exitReason = reason; + }); + + mockClient.emit("exit", paneId, 0, 15); + expect(exitReason).toBe("exited"); + }); +}); diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index 4571174be69..1ef22a1456a 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -11,11 +11,13 @@ import { } from "../../terminal-host/client"; import type { ListSessionsResponse } from "../../terminal-host/types"; import { buildTerminalEnv, getDefaultShell } from "../env"; +import { TerminalKilledError } from "../errors"; import { portManager } from "../port-manager"; import type { CreateSessionParams, SessionResult } from "../types"; import { CREATE_OR_ATTACH_CONCURRENCY, DEBUG_TERMINAL, + MAX_KILLED_SESSION_TOMBSTONES, MAX_SCROLLBACK_BYTES, SESSION_CLEANUP_DELAY_MS, } from "./constants"; @@ -27,6 +29,7 @@ export class DaemonTerminalManager extends EventEmitter { private client!: TerminalHostClient; private sessions = new Map(); private pendingSessions = new Map>(); + private killedSessionTombstones = new Map(); private createOrAttachLimiter = new PrioritySemaphore( CREATE_OR_ATTACH_CONCURRENCY, ); @@ -43,6 +46,31 @@ export class DaemonTerminalManager extends EventEmitter { this.initializeClient(); } + private recordKilledSession(paneId: string): void { + this.killedSessionTombstones.delete(paneId); + this.killedSessionTombstones.set(paneId, Date.now()); + if (this.killedSessionTombstones.size > MAX_KILLED_SESSION_TOMBSTONES) { + const oldest = this.killedSessionTombstones.keys().next().value; + if (oldest) { + this.killedSessionTombstones.delete(oldest); + } + } + + const session = this.sessions.get(paneId); + if (session) { + session.exitReason = "killed"; + session.killedByUserAt = Date.now(); + } + } + + private isSessionKilled(paneId: string): boolean { + return this.killedSessionTombstones.has(paneId); + } + + private clearKilledSession(paneId: string): void { + this.killedSessionTombstones.delete(paneId); + } + private initializeClient(): void { this.client = getTerminalHostClient(); this.setupClientEventHandlers(); @@ -168,8 +196,14 @@ export class DaemonTerminalManager extends EventEmitter { portManager.unregisterDaemonSession(paneId); this.historyManager.closeHistoryWriter(paneId, exitCode); - this.emit(`exit:${paneId}`, exitCode, signal); - this.emit("terminalExit", { paneId, exitCode, signal }); + const reason = + session?.exitReason ?? + (this.isSessionKilled(paneId) ? "killed" : "exited"); + if (session) { + session.exitReason = reason; + } + this.emit(`exit:${paneId}`, exitCode, signal, reason); + this.emit("terminalExit", { paneId, exitCode, signal, reason }); const timeoutId = setTimeout(() => { this.sessions.delete(paneId); @@ -238,6 +272,14 @@ export class DaemonTerminalManager extends EventEmitter { async createOrAttach(params: CreateSessionParams): Promise { const { paneId } = params; + if (this.isSessionKilled(paneId)) { + if (params.allowKilled) { + this.clearKilledSession(paneId); + } else { + throw new TerminalKilledError(); + } + } + const pending = this.pendingSessions.get(paneId); if (pending) { return pending; @@ -602,13 +644,12 @@ export class DaemonTerminalManager extends EventEmitter { }): Promise { const { paneId, deleteHistory = false } = params; this.daemonAliveSessionIds.delete(paneId); + this.recordKilledSession(paneId); const session = this.sessions.get(paneId); if (session?.isAlive) { session.isAlive = false; session.pid = null; - this.emit(`exit:${paneId}`, 0, 15); - this.emit("terminalExit", { paneId, exitCode: 0, signal: 15 }); } portManager.unregisterDaemonSession(paneId); @@ -731,12 +772,12 @@ export class DaemonTerminalManager extends EventEmitter { for (const paneId of paneIdsToKill) { try { + this.recordKilledSession(paneId); + const session = this.sessions.get(paneId); if (session?.isAlive) { session.isAlive = false; session.pid = null; - this.emit(`exit:${paneId}`, 0, 15); - this.emit("terminalExit", { paneId, exitCode: 0, signal: 15 }); } portManager.unregisterDaemonSession(paneId); @@ -822,6 +863,7 @@ export class DaemonTerminalManager extends EventEmitter { this.daemonAliveSessionIds.clear(); this.daemonSessionIdsHydrated = false; this.coldRestoreInfo.clear(); + this.killedSessionTombstones.clear(); this.removeAllListeners(); disposeTerminalHostClient(); } @@ -832,6 +874,17 @@ export class DaemonTerminalManager extends EventEmitter { })); const sessionIds = response.sessions.map((s) => s.sessionId); + for (const session of response.sessions) { + if (!session.isAlive) continue; + this.recordKilledSession(session.sessionId); + + const localSession = this.sessions.get(session.sessionId); + if (localSession?.isAlive) { + localSession.isAlive = false; + localSession.pid = null; + } + } + for (const timeout of this.cleanupTimeouts.values()) { clearTimeout(timeout); } @@ -863,6 +916,7 @@ export class DaemonTerminalManager extends EventEmitter { this.daemonAliveSessionIds.clear(); this.daemonSessionIdsHydrated = false; this.coldRestoreInfo.clear(); + this.killedSessionTombstones.clear(); this.historyManager.closeAllSync(); this.createOrAttachLimiter.reset(); diff --git a/apps/desktop/src/main/lib/terminal/daemon/types.ts b/apps/desktop/src/main/lib/terminal/daemon/types.ts index 82ef913f357..1edc05b5bea 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/types.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/types.ts @@ -7,6 +7,8 @@ export interface SessionInfo { pid: number | null; cols: number; rows: number; + exitReason?: "killed" | "exited" | "error"; + killedByUserAt?: number; } export interface ColdRestoreInfo { diff --git a/apps/desktop/src/main/lib/terminal/errors.ts b/apps/desktop/src/main/lib/terminal/errors.ts new file mode 100644 index 00000000000..c8b2da1c638 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/errors.ts @@ -0,0 +1,8 @@ +export const TERMINAL_SESSION_KILLED_MESSAGE = "TERMINAL_SESSION_KILLED"; + +export class TerminalKilledError extends Error { + constructor() { + super(TERMINAL_SESSION_KILLED_MESSAGE); + this.name = "TerminalKilledError"; + } +} diff --git a/apps/desktop/src/main/lib/terminal/index.ts b/apps/desktop/src/main/lib/terminal/index.ts index 6dc557e7295..4f4b3c34b58 100644 --- a/apps/desktop/src/main/lib/terminal/index.ts +++ b/apps/desktop/src/main/lib/terminal/index.ts @@ -1,15 +1,7 @@ -import { settings } from "@superset/local-db"; -import { localDb } from "main/lib/local-db"; -import { - disposeTerminalHostClient, - getTerminalHostClient, -} from "main/lib/terminal-host/client"; +import { getTerminalHostClient } from "main/lib/terminal-host/client"; import type { ListSessionsResponse } from "main/lib/terminal-host/types"; -import { DEFAULT_TERMINAL_PERSISTENCE } from "shared/constants"; import { DaemonTerminalManager, getDaemonTerminalManager } from "./daemon"; -import { TerminalManager, terminalManager } from "./manager"; -export { TerminalManager, terminalManager }; export { DaemonTerminalManager, getDaemonTerminalManager }; export type { CreateSessionParams, @@ -19,81 +11,14 @@ export type { TerminalExitEvent, } from "./types"; -// ============================================================================= -// Terminal Manager Selection -// ============================================================================= - const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; -/** - * Check if daemon mode is enabled. - * Reads from user settings (terminalPersistence) or falls back to env var. - */ -export function isDaemonModeEnabled(): boolean { - // First check environment variable override (for development/testing) - if (process.env.SUPERSET_TERMINAL_DAEMON === "1") { - if (DEBUG_TERMINAL) { - console.log( - "[TerminalManager] Daemon mode: ENABLED (via SUPERSET_TERMINAL_DAEMON env var)", - ); - } - return true; - } - - // Read from user settings - try { - const row = localDb.select().from(settings).get(); - const enabled = row?.terminalPersistence ?? DEFAULT_TERMINAL_PERSISTENCE; - if (DEBUG_TERMINAL) { - console.log( - `[TerminalManager] Daemon mode: ${enabled ? "ENABLED" : "DISABLED"} (via settings.terminalPersistence)`, - ); - } - return enabled; - } catch (error) { - console.warn( - "[TerminalManager] Failed to read settings, defaulting to disabled:", - error, - ); - return DEFAULT_TERMINAL_PERSISTENCE; - } -} - -/** - * 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(); - if (DEBUG_TERMINAL) { - 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 survives app restarts. - * Reconciliation removes sessions that no longer map to existing workspaces and - * restores state for sessions that can be retained. + * Cleans up stale sessions from previous app runs and preserves sessions + * that can be retained. */ export async function reconcileDaemonSessions(): Promise { - if (!isDaemonModeEnabled()) { - // Not in daemon mode, nothing to reconcile - return; - } - try { const manager = getDaemonTerminalManager(); await manager.reconcileOnStartup(); @@ -105,56 +30,24 @@ export async function reconcileDaemonSessions(): Promise { } } -/** - * Shutdown any orphaned daemon process. - * Called on app startup when daemon mode is disabled to clean up - * any daemon left running from a previous session with persistence enabled. - */ -export async function shutdownOrphanedDaemon(): Promise { - if (isDaemonModeEnabled()) { - return; - } - - try { - const client = getTerminalHostClient(); - 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) { - console.warn( - "[TerminalManager] Error during orphan daemon cleanup:", - error, - ); - } finally { - disposeTerminalHostClient(); - } -} - export async function tryListExistingDaemonSessions(): Promise<{ - daemonRunning: boolean; sessions: ListSessionsResponse["sessions"]; }> { try { const client = getTerminalHostClient(); - const connected = await client.tryConnectAndAuthenticate(); - if (!connected) { - return { daemonRunning: false, sessions: [] }; - } - const result = await client.listSessions(); - return { daemonRunning: true, sessions: result.sessions }; + return { sessions: result.sessions }; } catch (error) { + console.warn( + "[TerminalManager] Failed to list existing daemon sessions (getTerminalHostClient/client.listSessions):", + error, + ); if (DEBUG_TERMINAL) { console.log( "[TerminalManager] Failed to list existing daemon sessions:", error, ); } - return { daemonRunning: false, sessions: [] }; + return { sessions: [] }; } } diff --git a/apps/desktop/src/main/lib/terminal/manager.test.ts b/apps/desktop/src/main/lib/terminal/manager.test.ts deleted file mode 100644 index 064ad3f13e0..00000000000 --- a/apps/desktop/src/main/lib/terminal/manager.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import * as pty from "node-pty"; - -// Mock node-pty -mock.module("node-pty", () => ({ - spawn: mock(() => {}), -})); - -// Mock analytics to avoid electron imports (analytics → api-client → auth → electron.shell) -mock.module("main/lib/analytics", () => ({ - track: mock(() => {}), -})); - -// Mock treeKillWithEscalation to avoid actual process killing in tests -const mockTreeKillWithEscalation = mock(() => - Promise.resolve({ success: true }), -); -mock.module("../tree-kill-with-escalation", () => ({ - treeKillWithEscalation: mockTreeKillWithEscalation, -})); - -// Import manager after mocks are set up -const { TerminalManager } = await import("./manager"); - -describe("TerminalManager", () => { - let manager: InstanceType; - let mockPty: { - pid: number; - write: ReturnType; - resize: ReturnType; - kill: ReturnType; - onData: ReturnType; - onExit: ReturnType; - }; - - beforeEach(() => { - manager = new TerminalManager(); - - // Reset the treeKillWithEscalation mock and make it trigger onExit - mockTreeKillWithEscalation.mockReset(); - mockTreeKillWithEscalation.mockImplementation(() => { - // Trigger onExit when tree-kill is called to simulate process death - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - setImmediate(async () => { - await onExitCallback({ exitCode: 0, signal: undefined }); - }); - } - return Promise.resolve({ success: true }); - }); - - // Setup mock pty - mockPty = { - pid: 12345, // Mock PID for treeKillWithEscalation - write: mock(() => {}), - resize: mock(() => {}), - // kill is still used by signal() method - kill: mock(() => {}), - onData: mock((callback: (data: string) => void) => { - // Store callback for testing - mockPty.onData.mockImplementation(() => callback); - return callback; - }), - onExit: mock( - (callback: (event: { exitCode: number; signal?: number }) => void) => { - mockPty.onExit.mockImplementation(() => callback); - return callback; - }, - ), - }; - - (pty.spawn as ReturnType).mockReturnValue( - mockPty as unknown as pty.IPty, - ); - }); - - afterEach(async () => { - await manager.cleanup(); - mock.restore(); - }); - - describe("createOrAttach", () => { - it("should create a new terminal session", async () => { - const result = await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cwd: "/test/path", - cols: 80, - rows: 24, - }); - - expect(result.isNew).toBe(true); - expect(result.scrollback).toBe(""); - expect(result.wasRecovered).toBe(false); - expect(pty.spawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - cwd: "/test/path", - cols: 80, - rows: 24, - }), - ); - }); - - it("should reuse existing terminal session", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cwd: "/test/path", - }); - - const spawnCallCount = (pty.spawn as ReturnType).mock.calls - .length; - - const result = await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - expect(result.isNew).toBe(false); - // Should not have spawned again - expect((pty.spawn as ReturnType).mock.calls.length).toBe( - spawnCallCount, - ); - }); - - it("should update size when reattaching with new dimensions", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cols: 80, - rows: 24, - }); - - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cols: 100, - rows: 30, - }); - - expect(mockPty.resize).toHaveBeenCalledWith(100, 30); - }); - }); - - describe("write", () => { - it("should write data to terminal", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.write({ - paneId: "pane-1", - 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"); - }); - - it("should throw error for non-existent session", () => { - expect(() => { - manager.write({ - paneId: "non-existent", - data: "test", - }); - }).toThrow("Terminal session non-existent not found or not alive"); - }); - }); - - describe("resize", () => { - it("should resize terminal", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.resize({ - paneId: "pane-1", - cols: 120, - rows: 40, - }); - - expect(mockPty.resize).toHaveBeenCalledWith(120, 40); - }); - - it("should handle resize of non-existent session gracefully", () => { - // Mock console.warn to suppress the warning in test output - const warnSpy = mock(() => {}); - const originalWarn = console.warn; - console.warn = warnSpy; - - // Should not throw - expect(() => { - manager.resize({ - paneId: "non-existent", - cols: 80, - rows: 24, - }); - }).not.toThrow(); - - // Verify warning was called - expect(warnSpy).toHaveBeenCalledWith( - "Cannot resize terminal non-existent: session not found or not alive", - ); - - console.warn = originalWarn; - }); - }); - - describe("signal", () => { - it("should send signal to terminal", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.signal({ - paneId: "pane-1", - signal: "SIGINT", - }); - - expect(mockPty.kill).toHaveBeenCalledWith("SIGINT"); - }); - - it("should use SIGTERM by default", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.signal({ - paneId: "pane-1", - }); - - expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); - }); - }); - - describe("kill", () => { - it("should kill the terminal session using tree-kill", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - const exitPromise = new Promise((resolve) => { - manager.once("exit:pane-1", () => resolve()); - }); - - await manager.kill({ paneId: "pane-1" }); - - // Should use treeKillWithEscalation to kill the process tree - expect(mockTreeKillWithEscalation).toHaveBeenCalledWith({ - pid: mockPty.pid, - }); - - // The mock triggers onExit automatically, wait for it - await exitPromise; - }); - }); - - describe("detach", () => { - it("should keep session alive after detach", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.detach({ paneId: "pane-1" }); - - const session = manager.getSession("pane-1"); - expect(session).not.toBeNull(); - expect(session?.isAlive).toBe(true); - }); - }); - - describe("getSession", () => { - it("should return session metadata", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cwd: "/test/path", - }); - - const session = manager.getSession("pane-1"); - - expect(session).toMatchObject({ - isAlive: true, - cwd: "/test/path", - }); - expect(session?.lastActive).toBeGreaterThan(0); - }); - - it("should return null for non-existent session", () => { - const session = manager.getSession("non-existent"); - expect(session).toBeNull(); - }); - }); - - describe("cleanup", () => { - it("should kill all sessions using tree-kill and wait for exit handlers", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - await manager.createOrAttach({ - paneId: "pane-2", - tabId: "tab-2", - workspaceId: "workspace-1", - }); - - // The mock triggers onExit automatically when treeKillWithEscalation is called - await manager.cleanup(); - - // Should use treeKillWithEscalation for each session - expect(mockTreeKillWithEscalation).toHaveBeenCalledTimes(2); - }); - }); - - describe("event handling", () => { - it("should emit data events", async () => { - const dataHandler = mock(() => {}); - - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.on("data:pane-1", dataHandler); - - const onDataCallback = mockPty.onData.mock.results[0]?.value; - if (onDataCallback) { - onDataCallback("test output\n"); - } - - // Wait for DataBatcher to flush (16ms batching interval) - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(dataHandler).toHaveBeenCalledWith("test output\n"); - }); - - it("should pass through raw data including escape sequences", async () => { - const dataHandler = mock(() => {}); - - await manager.createOrAttach({ - paneId: "pane-raw", - tabId: "tab-raw", - workspaceId: "workspace-1", - }); - - manager.on("data:pane-raw", dataHandler); - - const onDataCallback = mockPty.onData.mock.results[0]?.value; - const dataWithEscapes = - "hello\x1b[2;1R\x1b[?1;0cworld\x1b]10;rgb:ffff/ffff/ffff\x07\n"; - if (onDataCallback) { - onDataCallback(dataWithEscapes); - } - - // Wait for DataBatcher to flush (16ms batching interval) - await new Promise((resolve) => setTimeout(resolve, 30)); - - // Raw data passed through unchanged - expect(dataHandler).toHaveBeenCalledWith(dataWithEscapes); - }); - - it("should emit exit events", async () => { - const exitHandler = mock(() => {}); - - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - // Listen for exit event - const exitPromise = new Promise((resolve) => { - manager.once("exit:pane-1", () => resolve()); - }); - - manager.on("exit:pane-1", exitHandler); - - const onExitCallback = mockPty.onExit.mock.results[0]?.value; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - await exitPromise; - - expect(exitHandler).toHaveBeenCalledWith(0, undefined); - }); - }); - - describe("killByWorkspaceId", () => { - it("should kill session for a workspace and return count", async () => { - await manager.createOrAttach({ - paneId: "pane-kill-single", - tabId: "tab-kill-single", - workspaceId: "workspace-kill-single", - }); - - const result = await manager.killByWorkspaceId("workspace-kill-single"); - - // With the mock, the session exits cleanly via the kill mock's setImmediate - expect(result.killed + result.failed).toBe(1); - }); - - it("should not kill sessions from other workspaces", async () => { - await manager.createOrAttach({ - paneId: "pane-other", - tabId: "tab-other", - workspaceId: "workspace-other", - }); - - await manager.killByWorkspaceId("workspace-different"); - - // Session should still exist - expect(manager.getSession("pane-other")).not.toBeNull(); - expect(manager.getSession("pane-other")?.isAlive).toBe(true); - }); - - it("should return zero counts for non-existent workspace", async () => { - const result = await manager.killByWorkspaceId("non-existent"); - - expect(result.killed).toBe(0); - expect(result.failed).toBe(0); - }); - - it("should clean up already-dead sessions", async () => { - await manager.createOrAttach({ - paneId: "pane-dead", - tabId: "tab-dead", - workspaceId: "workspace-dead", - }); - - // Simulate the session dying naturally - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - // Wait for the dead session to be kept in map (5s timeout in onExit) - await new Promise((resolve) => setTimeout(resolve, 100)); - - const result = await manager.killByWorkspaceId("workspace-dead"); - - expect(result.killed).toBe(1); - expect(result.failed).toBe(0); - }); - }); - - describe("getSessionCountByWorkspaceId", () => { - it("should return count of active sessions for workspace", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-count", - }); - - await manager.createOrAttach({ - paneId: "pane-2", - tabId: "tab-2", - workspaceId: "workspace-count", - }); - - await manager.createOrAttach({ - paneId: "pane-3", - tabId: "tab-3", - workspaceId: "other-workspace", - }); - - expect( - await manager.getSessionCountByWorkspaceId("workspace-count"), - ).toBe(2); - expect( - await manager.getSessionCountByWorkspaceId("other-workspace"), - ).toBe(1); - }); - - it("should return zero for non-existent workspace", async () => { - expect(await manager.getSessionCountByWorkspaceId("non-existent")).toBe( - 0, - ); - }); - - it("should not count dead sessions", async () => { - await manager.createOrAttach({ - paneId: "pane-alive", - tabId: "tab-alive", - workspaceId: "workspace-mixed", - }); - - await manager.createOrAttach({ - paneId: "pane-dead", - tabId: "tab-dead", - workspaceId: "workspace-mixed", - }); - - // Simulate the second session dying - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - // Wait for state to update - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect( - await manager.getSessionCountByWorkspaceId("workspace-mixed"), - ).toBe(1); - }); - }); - - describe("clearScrollback", () => { - it("should clear in-memory scrollback", async () => { - await manager.createOrAttach({ - paneId: "pane-clear", - tabId: "tab-clear", - workspaceId: "workspace-1", - }); - - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("some output\n"); - } - - manager.clearScrollback({ paneId: "pane-clear" }); - - const result = await manager.createOrAttach({ - paneId: "pane-clear", - tabId: "tab-clear", - workspaceId: "workspace-1", - }); - - expect(result.scrollback).toBe(""); - }); - - it("should handle non-existent session gracefully", () => { - const warnSpy = mock(() => {}); - const originalWarn = console.warn; - console.warn = warnSpy; - - expect(() => - manager.clearScrollback({ paneId: "non-existent" }), - ).not.toThrow(); - - expect(warnSpy).toHaveBeenCalledWith( - "Cannot clear scrollback for terminal non-existent: session not found", - ); - - console.warn = originalWarn; - }); - - it("should clear scrollback when shell sends clear sequence", async () => { - await manager.createOrAttach({ - paneId: "pane-shell-clear", - tabId: "tab-shell-clear", - workspaceId: "workspace-1", - }); - - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("some output\n"); - // ED3 sequence clears scrollback, then output after the sequence is stored - onDataCallback("\x1b[3Jnew content after clear"); - } - - // Wait for headless terminal to process async writes - await new Promise((resolve) => setTimeout(resolve, 50)); - - const result = await manager.createOrAttach({ - paneId: "pane-shell-clear", - tabId: "tab-shell-clear", - workspaceId: "workspace-1", - }); - - // Only content after the clear sequence should remain - expect(result.scrollback).not.toContain("some output"); - expect(result.scrollback).toContain("new content after clear"); - // ED3 sequence itself should NOT be in scrollback - expect(result.scrollback).not.toContain("\x1b[3J"); - }); - - it("should not persist content before clear sequence", async () => { - await manager.createOrAttach({ - paneId: "pane-clear-before", - tabId: "tab-clear-before", - workspaceId: "workspace-1", - }); - - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - // Content before and after clear in same chunk - onDataCallback("old content\x1b[3Jnew content"); - } - - // Wait for headless terminal to process async writes - await new Promise((resolve) => setTimeout(resolve, 50)); - - const result = await manager.createOrAttach({ - paneId: "pane-clear-before", - tabId: "tab-clear-before", - workspaceId: "workspace-1", - }); - - // Old content should be gone, only new content remains - expect(result.scrollback).not.toContain("old content"); - expect(result.scrollback).toContain("new content"); - expect(result.scrollback).not.toContain("\x1b[3J"); - }); - }); -}); diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts deleted file mode 100644 index 4259c2a5522..00000000000 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ /dev/null @@ -1,472 +0,0 @@ -import { EventEmitter } from "node:events"; -import { track } from "main/lib/analytics"; -import { ensureAgentHooks } from "../agent-setup/ensure-agent-hooks"; -import { treeKillWithEscalation } from "../tree-kill-with-escalation"; -import { FALLBACK_SHELL, SHELL_CRASH_THRESHOLD_MS } from "./env"; -import { portManager } from "./port-manager"; -import { - createHeadlessTerminal, - createSession, - flushSession, - getSerializedScrollback, - setupDataHandler, -} from "./session"; -import type { - CreateSessionParams, - InternalCreateSessionParams, - SessionResult, - TerminalSession, -} from "./types"; - -const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; - -export class TerminalManager extends EventEmitter { - private sessions = new Map(); - private pendingSessions = new Map>(); - - async createOrAttach(params: CreateSessionParams): Promise { - const { paneId, cols, rows } = params; - - // Deduplicate concurrent calls (prevents race in React Strict Mode) - const pending = this.pendingSessions.get(paneId); - if (pending) { - return pending; - } - - // Return existing session if alive - const existing = this.sessions.get(paneId); - if (existing?.isAlive) { - existing.lastActive = Date.now(); - if (cols !== undefined && rows !== undefined) { - this.resize({ paneId, cols, rows }); - } - return { - isNew: false, - scrollback: getSerializedScrollback(existing), - wasRecovered: existing.wasRecovered, - }; - } - - // Create new session - const creationPromise = this.doCreateSession({ - ...params, - existingScrollback: existing ? getSerializedScrollback(existing) : null, - }); - this.pendingSessions.set(paneId, creationPromise); - - try { - return await creationPromise; - } finally { - this.pendingSessions.delete(paneId); - } - } - - private async doCreateSession( - params: InternalCreateSessionParams, - ): Promise { - const { paneId, workspaceId, initialCommands } = params; - - const agentHooksReady = ensureAgentHooks().catch((error): void => { - console.warn("[TerminalManager] Agent hook ensure failed:", error); - }); - - // Create the session - const session = await createSession(params, (id, data) => { - this.emit(`data:${id}`, data); - }); - - // Match agent commands anywhere in the string (handles "cd repo && claude ...") - const agentCommandPattern = /\b(claude|codex|opencode)\b/; - const shouldAwaitAgentHooks = - initialCommands?.some((command) => agentCommandPattern.test(command)) ?? - false; - - // Set up data handler - setupDataHandler( - session, - initialCommands, - session.wasRecovered, - shouldAwaitAgentHooks ? agentHooksReady : undefined, - ); - - // Set up exit handler with fallback logic - this.setupExitHandler(session, params); - - this.sessions.set(paneId, session); - - portManager.registerSession(session, workspaceId); - - // Track terminal opened (only fires once per session creation) - track("terminal_opened", { workspace_id: workspaceId, pane_id: paneId }); - - return { - isNew: true, - scrollback: getSerializedScrollback(session), - wasRecovered: session.wasRecovered, - }; - } - - private setupExitHandler( - session: TerminalSession, - params: InternalCreateSessionParams, - ): void { - const { paneId } = params; - - session.pty.onExit(async ({ exitCode, signal }) => { - const sessionDuration = Date.now() - session.startTime; - const logPayload: Record = { - paneId, - shell: session.shell, - exitCode, - signal, - sessionDuration, - }; - if (DEBUG_TERMINAL) { - logPayload.cwd = session.cwd; - } - console.log("[TerminalManager] Shell exited:", logPayload); - - 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 crashedQuickly = - sessionDuration < SHELL_CRASH_THRESHOLD_MS && exitCode !== 0; - - if (crashedQuickly && !session.usedFallback) { - console.warn( - `[TerminalManager] Shell "${session.shell}" exited with code ${exitCode} after ${sessionDuration}ms, retrying with fallback shell "${FALLBACK_SHELL}"`, - ); - - this.sessions.delete(paneId); - - try { - await this.doCreateSession({ - ...params, - existingScrollback, - useFallbackShell: true, - }); - return; // Recovered - don't emit exit - } catch (fallbackError) { - console.error( - "[TerminalManager] Fallback shell also failed:", - fallbackError, - ); - } - } - - // Unregister from port manager (also removes detected ports) - portManager.unregisterSession(paneId); - - this.emit(`exit:${paneId}`, exitCode, signal); - this.emit("terminalExit", { paneId, exitCode, signal }); - - // Clean up session after delay - const timeout = setTimeout(() => { - this.sessions.delete(paneId); - }, 5000); - timeout.unref(); - }); - } - - 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`); - } - - 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; - - // Validate geometry: cols and rows must be positive integers - if ( - !Number.isInteger(cols) || - !Number.isInteger(rows) || - cols <= 0 || - rows <= 0 - ) { - console.warn( - `[TerminalManager] Invalid resize geometry for ${paneId}: cols=${cols}, rows=${rows}. Must be positive integers.`, - ); - return; - } - - const session = this.sessions.get(paneId); - - if (!session || !session.isAlive) { - console.warn( - `Cannot resize terminal ${paneId}: session not found or not alive`, - ); - return; - } - - try { - session.pty.resize(cols, rows); - session.headless.resize(cols, rows); - session.cols = cols; - session.rows = rows; - session.lastActive = Date.now(); - } catch (error) { - console.error( - `[TerminalManager] Failed to resize terminal ${paneId} (cols=${cols}, rows=${rows}):`, - error, - ); - } - } - - 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; - } - - session.pty.kill(signal); - session.lastActive = Date.now(); - } - - async kill(params: { paneId: string }): Promise { - const { paneId } = params; - const session = this.sessions.get(paneId); - - if (!session) { - console.warn(`Cannot kill terminal ${paneId}: session not found`); - return; - } - - if (session.isAlive) { - // Kill the entire process tree, not just the shell - await treeKillWithEscalation({ pid: session.pty.pid }); - } else { - this.sessions.delete(paneId); - } - } - - detach(params: { paneId: string }): void { - const { paneId } = params; - const session = this.sessions.get(paneId); - - if (!session) { - console.warn(`Cannot detach terminal ${paneId}: session not found`); - return; - } - - session.lastActive = Date.now(); - } - - clearScrollback(params: { paneId: string }): void { - const { paneId } = params; - const session = this.sessions.get(paneId); - - if (!session) { - console.warn( - `Cannot clear scrollback for terminal ${paneId}: session not found`, - ); - return; - } - - // Recreate headless (xterm writes are async, so clear() alone is unreliable) - session.headless.dispose(); - const { headless, serializer } = createHeadlessTerminal({ - cols: session.cols, - rows: session.rows, - }); - session.headless = headless; - session.serializer = serializer; - session.lastActive = Date.now(); - } - - 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 }> { - const sessionsToKill = Array.from(this.sessions.entries()).filter( - ([, session]) => session.workspaceId === workspaceId, - ); - - if (sessionsToKill.length === 0) { - return { killed: 0, failed: 0 }; - } - - const results = await Promise.all( - sessionsToKill.map(([paneId, session]) => - this.killSessionWithTimeout(paneId, session), - ), - ); - - const killed = results.filter(Boolean).length; - return { killed, failed: results.length - killed }; - } - - private killSessionWithTimeout( - paneId: string, - 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 forceCleanupTimeout: ReturnType | undefined; - - const finish = (success: boolean) => { - if (resolved) return; - resolved = true; - this.off(`exit:${paneId}`, onExit); - if (forceCleanupTimeout) clearTimeout(forceCleanupTimeout); - resolve(success); - }; - - const onExit = () => finish(true); - this.once(`exit:${paneId}`, onExit); - - treeKillWithEscalation({ pid: session.pty.pid }).then((result) => { - if (resolved) return; - - if (!result.success) { - console.error(`Terminal ${paneId} tree-kill failed:`, result.error); - } - - // node-pty's onExit may not fire reliably; force cleanup after delay - forceCleanupTimeout = setTimeout(() => { - if (resolved) return; - if (session.isAlive) { - console.error( - `Terminal ${paneId} did not emit exit after kill, forcing cleanup`, - ); - session.isAlive = false; - this.sessions.delete(paneId); - } - finish(!!result.success); - }, 500); - forceCleanupTimeout.unref(); - }); - }); - } - - async getSessionCountByWorkspaceId(workspaceId: string): Promise { - 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. - * Useful after switching branches to update the branch name in prompts. - */ - refreshPromptsForWorkspace(workspaceId: string): void { - for (const [paneId, session] of this.sessions.entries()) { - if (session.workspaceId === workspaceId && session.isAlive) { - try { - session.writeQueue.write("\n"); - } catch (error) { - console.warn( - `[TerminalManager] Failed to refresh prompt for pane ${paneId}:`, - error, - ); - } - } - } - } - - 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:") || - name === "terminalExit" - ) { - this.removeAllListeners(event); - } - } - } - - async cleanup(): Promise { - const exitPromises: Promise[] = []; - - for (const [paneId, session] of this.sessions.entries()) { - session.writeQueue.dispose(); - if (session.isAlive) { - const exitPromise = new Promise((resolve) => { - let timeoutId: ReturnType | undefined; - const onExit = () => { - this.off(`exit:${paneId}`, onExit); - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - } - resolve(); - }; - this.once(`exit:${paneId}`, onExit); - - // 2.5s allows for tree-kill escalation (2s) + buffer - timeoutId = setTimeout(() => { - this.off(`exit:${paneId}`, onExit); - resolve(); - }, 2500); - timeoutId.unref(); - }); - - exitPromises.push(exitPromise); - - treeKillWithEscalation({ pid: session.pty.pid }).then((result) => { - if (!result.success) { - console.error(`Terminal ${paneId} cleanup failed:`, result.error); - } - }); - } - } - - await Promise.all(exitPromises); - this.sessions.clear(); - this.removeAllListeners(); - } -} - -/** Singleton terminal manager instance */ -export const terminalManager = new TerminalManager(); diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index 5235f02e72b..87b49f52938 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -22,8 +22,12 @@ export interface TerminalSession { shell: string; startTime: number; usedFallback: boolean; + exitReason?: TerminalExitReason; + killedByUserAt?: number; } +export type TerminalExitReason = "killed" | "exited" | "error"; + export interface TerminalDataEvent { type: "data"; data: string; @@ -33,6 +37,7 @@ export interface TerminalExitEvent { type: "exit"; exitCode: number; signal?: number; + reason?: TerminalExitReason; } export type TerminalEvent = TerminalDataEvent | TerminalExitEvent; @@ -97,6 +102,8 @@ export interface CreateSessionParams { initialCommands?: string[]; /** Skip cold restore detection (used when auto-resuming after cold restore) */ skipColdRestore?: boolean; + /** Allow restarting a session that was explicitly killed */ + allowKilled?: boolean; } export interface InternalCreateSessionParams extends CreateSessionParams { diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 2b051829309..caeb4bf2323 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -169,7 +169,6 @@ function formatSessionLabel( function buildSessionsSubmenu( sessions: ListSessionsResponse["sessions"], - daemonRunning: boolean, ): MenuItemConstructorOptions[] { const aliveSessions = sessions.filter((s) => s.isAlive); const menuItems: MenuItemConstructorOptions[] = []; @@ -229,7 +228,6 @@ function buildSessionsSubmenu( }); menuItems.push({ label: "Restart Daemon", - enabled: daemonRunning, click: restartDaemon, }); @@ -279,10 +277,10 @@ async function restartDaemon(): Promise { async function updateTrayMenu(): Promise { if (!tray) return; - const { daemonRunning, sessions } = await tryListExistingDaemonSessions(); + const { sessions } = await tryListExistingDaemonSessions(); const sessionCount = sessions.filter((s) => s.isAlive).length; - const sessionsSubmenu = buildSessionsSubmenu(sessions, daemonRunning); + const sessionsSubmenu = buildSessionsSubmenu(sessions); const sessionsLabel = sessionCount > 0 ? `Background Sessions (${sessionCount})` diff --git a/apps/desktop/src/main/lib/workspace-runtime/local.ts b/apps/desktop/src/main/lib/workspace-runtime/local.ts index 85f15b350da..c7d3e8e9ac1 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/local.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/local.ts @@ -2,19 +2,16 @@ * Local Workspace Runtime * * This is the local implementation of WorkspaceRuntime that wraps - * either TerminalManager (in-process) or DaemonTerminalManager (daemon mode). + * DaemonTerminalManager (persistent terminals). * - * Backend selection is done once at construction time based on settings. + * Backend selection is fixed to the daemon-based manager. * The runtime caches the backend and exposes it through the provider-neutral * TerminalRuntime interface. */ import { - DaemonTerminalManager, + type DaemonTerminalManager, getDaemonTerminalManager, - isDaemonModeEnabled, - type TerminalManager, - terminalManager, } from "../terminal"; import type { TerminalCapabilities, @@ -29,7 +26,7 @@ import type { // ============================================================================= /** - * Adapts TerminalManager or DaemonTerminalManager to the TerminalRuntime interface. + * Adapts DaemonTerminalManager to the TerminalRuntime interface. * * This adapter: * 1. Wraps the underlying manager with the common interface @@ -37,33 +34,25 @@ import type { * 3. Provides capability flags for UI feature detection */ class LocalTerminalRuntime implements TerminalRuntime { - private readonly backend: TerminalManager | DaemonTerminalManager; - private readonly isDaemon: boolean; + private readonly backend: DaemonTerminalManager; - readonly management: TerminalManagement | null; + readonly management: TerminalManagement; readonly capabilities: TerminalCapabilities; - constructor(backend: TerminalManager | DaemonTerminalManager) { + constructor(backend: DaemonTerminalManager) { this.backend = backend; - this.isDaemon = backend instanceof DaemonTerminalManager; - // Set up capabilities based on backend type + // Capabilities are always daemon-backed this.capabilities = { - persistent: this.isDaemon, - coldRestore: this.isDaemon, + persistent: true, + coldRestore: true, }; - // 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; - } + this.management = { + listSessions: () => backend.listDaemonSessions(), + killAllSessions: () => backend.forceKillAll(), + resetHistoryPersistence: () => backend.resetHistoryPersistence(), + }; } // =========================================================================== @@ -238,7 +227,7 @@ class LocalTerminalRuntime implements TerminalRuntime { * Local workspace runtime implementation. * * This provides the WorkspaceRuntime interface for local workspaces, - * wrapping the terminal manager (either in-process or daemon-based). + * wrapping the daemon-based terminal manager. */ export class LocalWorkspaceRuntime implements WorkspaceRuntime { readonly id: WorkspaceRuntimeId; @@ -248,10 +237,7 @@ export class LocalWorkspaceRuntime implements WorkspaceRuntime { constructor() { this.id = "local"; - // Select backend based on daemon mode setting - const backend = isDaemonModeEnabled() - ? getDaemonTerminalManager() - : terminalManager; + const backend = getDaemonTerminalManager(); // Create terminal runtime adapter this.terminal = new LocalTerminalRuntime(backend); diff --git a/apps/desktop/src/main/lib/workspace-runtime/types.ts b/apps/desktop/src/main/lib/workspace-runtime/types.ts index 135d7bdd471..33f22782a0c 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/types.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/types.ts @@ -3,7 +3,7 @@ * * 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). + * (local daemon today, or cloud/SSH in the future). * * Key invariants: * 1. Stream subscriptions MUST NOT complete on session exit (exit is a state transition) @@ -49,11 +49,7 @@ export interface TerminalCapabilities { /** * 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"). + * These are available for daemon-backed runtimes. */ export interface TerminalManagement { /** List all sessions in the daemon */ @@ -99,10 +95,7 @@ export interface TerminalSessionOperations { /** Clear the scrollback buffer */ clearScrollback(params: { paneId: string }): void | Promise; - /** - * Acknowledge cold restore - clears sticky cold restore info. - * No-op in non-daemon mode. - */ + /** Acknowledge cold restore - clears sticky cold restore info. */ ackColdRestore(paneId: string): void; /** Get session info */ @@ -163,22 +156,15 @@ export interface TerminalEventSource extends EventEmitter { * 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) + * Implementation: + * - Daemon: DaemonTerminalManager (persistent, management available) */ 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; + /** Session management capabilities (daemon-backed). */ + management: TerminalManagement; /** Terminal capabilities for this backend */ capabilities: TerminalCapabilities; diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 9aa14f064f2..a5f2fb7aaaf 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -189,11 +189,17 @@ export async function MainWindow() { .getDefault() .terminal.on( "terminalExit", - (event: { paneId: string; exitCode: number; signal?: number }) => { + (event: { + paneId: string; + exitCode: number; + signal?: number; + reason?: "killed" | "exited" | "error"; + }) => { notificationsEmitter.emit(NOTIFICATION_EVENTS.TERMINAL_EXIT, { paneId: event.paneId, exitCode: event.exitCode, signal: event.signal, + reason: event.reason, }); }, ); diff --git a/apps/desktop/src/renderer/lib/terminal-kill-tracking.ts b/apps/desktop/src/renderer/lib/terminal-kill-tracking.ts deleted file mode 100644 index a268b97a86c..00000000000 --- a/apps/desktop/src/renderer/lib/terminal-kill-tracking.ts +++ /dev/null @@ -1,12 +0,0 @@ -const killedByUserSessions = new Set(); - -export const markTerminalKilledByUser = (paneId: string): void => { - killedByUserSessions.add(paneId); -}; - -export const isTerminalKilledByUser = (paneId: string): boolean => - killedByUserSessions.has(paneId); - -export const clearTerminalKilledByUser = (paneId: string): void => { - killedByUserSessions.delete(paneId); -}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx index 58176e3fb59..8800cdab342 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx @@ -34,16 +34,12 @@ import { useIsDarkTheme, } from "renderer/assets/app-icons/preset-icons"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { markTerminalKilledByUser } from "renderer/lib/terminal-kill-tracking"; import { usePresets } from "renderer/react-query/presets"; import { PRESET_COLUMNS, type PresetColumnKey, } from "renderer/routes/_authenticated/settings/presets/types"; -import { - DEFAULT_AUTO_APPLY_DEFAULT_PRESET, - DEFAULT_TERMINAL_PERSISTENCE, -} from "shared/constants"; +import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET } from "shared/constants"; import { isItemVisible, SETTING_ITEM_ID, @@ -128,10 +124,6 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { SETTING_ITEM_ID.TERMINAL_AUTO_APPLY_PRESET, visibleItems, ); - const showPersistence = isItemVisible( - SETTING_ITEM_ID.TERMINAL_PERSISTENCE, - visibleItems, - ); const showSessions = isItemVisible( SETTING_ITEM_ID.TERMINAL_SESSIONS, visibleItems, @@ -339,12 +331,8 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { [reorderPresets], ); - const { data: terminalPersistence, isLoading } = - electronTrpc.settings.getTerminalPersistence.useQuery(); - const { data: daemonSessions } = electronTrpc.terminal.listDaemonSessions.useQuery(); - const daemonModeEnabled = daemonSessions?.daemonModeEnabled ?? false; const sessions = daemonSessions?.sessions ?? []; const aliveSessions = useMemo( () => sessions.filter((session) => session.isAlive), @@ -372,31 +360,6 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { workspaceId: string; } | null>(null); - const setTerminalPersistence = - electronTrpc.settings.setTerminalPersistence.useMutation({ - onMutate: async ({ enabled }) => { - await utils.settings.getTerminalPersistence.cancel(); - const previous = utils.settings.getTerminalPersistence.getData(); - utils.settings.getTerminalPersistence.setData(undefined, enabled); - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous !== undefined) { - utils.settings.getTerminalPersistence.setData( - undefined, - context.previous, - ); - } - }, - onSettled: () => { - utils.settings.getTerminalPersistence.invalidate(); - }, - }); - - const handleToggle = (enabled: boolean) => { - setTerminalPersistence.mutate({ enabled }); - }; - // Terminal link behavior setting const { data: terminalLinkBehavior, isLoading: isLoadingLinkBehavior } = electronTrpc.settings.getTerminalLinkBehavior.useQuery(); @@ -463,25 +426,18 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { await utils.terminal.listDaemonSessions.cancel(); const previous = utils.terminal.listDaemonSessions.getData(); utils.terminal.listDaemonSessions.setData(undefined, { - daemonModeEnabled: true, sessions: [], }); return { previous }; }, onSuccess: (result) => { - if (result.daemonModeEnabled) { - if (result.remainingCount > 0) { - toast.warning("Some sessions could not be killed", { - description: `${result.killedCount} terminated, ${result.remainingCount} remaining`, - }); - } else { - toast.success("Killed all terminal sessions", { - description: `${result.killedCount} sessions terminated`, - }); - } + if (result.remainingCount > 0) { + toast.warning("Some sessions could not be killed", { + description: `${result.killedCount} terminated, ${result.remainingCount} remaining`, + }); } else { - toast.error("Terminal persistence is not active", { - description: "Restart the app after enabling terminal persistence.", + toast.success("Killed all terminal sessions", { + description: `${result.killedCount} sessions terminated`, }); } }, @@ -553,7 +509,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) {

Terminal

- Configure terminal behavior, presets, and persistence + Configure terminal behavior and presets

@@ -730,50 +686,10 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { )} - {showPersistence && ( -
-
- -

- 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. -

-
- -
- )} - {showLinkBehavior && (
+
@@ -826,23 +736,13 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { Refresh
- {daemonModeEnabled ? ( - <> -

- Daemon sessions running: {aliveSessions.length} -

- {aliveSessions.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. +

+ Daemon sessions running: {aliveSessions.length} +

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

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

)}
@@ -852,9 +752,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { variant="destructive" size="sm" disabled={ - !daemonModeEnabled || - aliveSessions.length === 0 || - killAllDaemonSessions.isPending + aliveSessions.length === 0 || killAllDaemonSessions.isPending } onClick={() => setConfirmKillAllOpen(true)} > @@ -864,9 +762,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { variant="secondary" size="sm" disabled={ - !daemonModeEnabled || - aliveSessions.length === 0 || - clearTerminalHistory.isPending + aliveSessions.length === 0 || clearTerminalHistory.isPending } onClick={() => setConfirmClearHistoryOpen(true)} > @@ -875,7 +771,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) {
- {daemonModeEnabled && - showSessionList && - aliveSessions.length > 0 && ( -
-
- - - - - - - - - + {showSessionList && aliveSessions.length > 0 && ( +
+
+
- Workspace - - Session - - Clients - - PID - - Last attached - - Action -
+ + + + + + + + + + + + {sessionsSorted.map((session) => ( + + + + + + + - - - {sessionsSorted.map((session) => ( - - - - - - - - - ))} - -
+ Workspace + + Session + + Clients + + PID + + Last attached + + Action +
+ {session.workspaceId} + + {session.sessionId} + + {session.attachedClients} + + {session.pid ?? "—"} + + {formatTimestamp(session.lastAttachedAt)} + + +
- {session.workspaceId} - - {session.sessionId} - - {session.attachedClients} - - {session.pid ?? "—"} - - {formatTimestamp(session.lastAttachedAt)} - - -
-
+ ))} + +
- )} +
+ )} )} @@ -1000,9 +894,6 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { disabled={killAllDaemonSessions.isPending} onClick={() => { setConfirmKillAllOpen(false); - for (const session of sessions) { - markTerminalKilledByUser(session.sessionId); - } killAllDaemonSessions.mutate(); }} > @@ -1098,7 +989,6 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { const sessionId = pendingKillSession?.sessionId; setPendingKillSession(null); if (!sessionId) return; - markTerminalKilledByUser(sessionId); killDaemonSession.mutate({ paneId: sessionId }); }} > @@ -1145,13 +1035,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { disabled={restartDaemon.isPending} onClick={() => { setConfirmRestartDaemonOpen(false); - restartDaemon.mutate(undefined, { - onSuccess: () => { - for (const session of sessions) { - markTerminalKilledByUser(session.sessionId); - } - }, - }); + restartDaemon.mutate(undefined, {}); }} > Restart daemon diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 568eeaa9f60..e7ce68243c0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -26,7 +26,6 @@ export const SETTING_ITEM_ID = { TERMINAL_PRESETS: "terminal-presets", TERMINAL_QUICK_ADD: "terminal-quick-add", TERMINAL_AUTO_APPLY_PRESET: "terminal-auto-apply-preset", - TERMINAL_PERSISTENCE: "terminal-persistence", TERMINAL_SESSIONS: "terminal-sessions", TERMINAL_LINK_BEHAVIOR: "terminal-link-behavior", @@ -383,24 +382,6 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "launch", ], }, - { - id: SETTING_ITEM_ID.TERMINAL_PERSISTENCE, - section: "terminal", - title: "Terminal Persistence", - description: "Keep terminal sessions running in background", - keywords: [ - "terminal", - "persistence", - "background", - "daemon", - "session", - "keep alive", - "restore", - "resume", - "reconnect", - "restart", - ], - }, { id: SETTING_ITEM_ID.TERMINAL_SESSIONS, section: "terminal", 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 480afe2a633..f27e06fbc20 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 @@ -22,7 +22,11 @@ import { } from "./hooks"; import { ScrollToBottomButton } from "./ScrollToBottomButton"; import { TerminalSearch } from "./TerminalSearch"; -import type { TerminalProps, TerminalStreamEvent } from "./types"; +import type { + TerminalExitReason, + TerminalProps, + TerminalStreamEvent, +} from "./types"; import { shellEscapePaths } from "./utils"; export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { @@ -88,7 +92,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { // Refs for stream event handlers (populated after useTerminalStream) // These allow flushPendingEvents to call the handlers via refs const handleTerminalExitRef = useRef< - (exitCode: number, xterm: XTerm) => void + (exitCode: number, xterm: XTerm, reason?: TerminalExitReason) => void >(() => {}); const handleStreamErrorRef = useRef< ( @@ -143,8 +147,8 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { modeScanBufferRef, updateCwdFromData, updateModesFromData, - onExitEvent: (exitCode, xterm) => - handleTerminalExitRef.current(exitCode, xterm), + onExitEvent: (exitCode, xterm, reason) => + handleTerminalExitRef.current(exitCode, xterm, reason), onErrorEvent: (event, xterm) => handleStreamErrorRef.current(event, xterm), onDisconnectEvent: (reason) => setConnectionError(reason || "Connection to terminal daemon lost"), @@ -213,12 +217,10 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { isFocused, xtermRef, }); - useEffect(() => { if (!isRestoredMode) return; handleStartShell(); }, [isRestoredMode, handleStartShell]); - const { xtermInstance, restartTerminal } = useTerminalLifecycle({ paneId, tabIdRef, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts index 42f0c246b81..6fa0ca50bc6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts @@ -1,7 +1,6 @@ import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef, useState } from "react"; -import { clearTerminalKilledByUser } from "renderer/lib/terminal-kill-tracking"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { coldRestoreState } from "../state"; import type { @@ -191,7 +190,6 @@ export function useTerminalColdRestore({ isExitedRef.current = false; wasKilledByUserRef.current = false; setExitStatus(null); - clearTerminalKilledByUser(paneId); pendingInitialStateRef.current = null; resetModes(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index 52c66e4f32e..a5e15ebc5ee 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -3,11 +3,8 @@ import type { SearchAddon } from "@xterm/addon-search"; import type { IDisposable, ITheme, Terminal as XTerm } from "@xterm/xterm"; import type { MutableRefObject, RefObject } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - clearTerminalKilledByUser, - isTerminalKilledByUser, -} from "renderer/lib/terminal-kill-tracking"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { killTerminalForPane } from "renderer/stores/tabs/utils/terminal-cleanup"; import { scheduleTerminalAttach } from "../attach-scheduler"; import { sanitizeForTitle } from "../commandBuffer"; import { DEBUG_TERMINAL, FIRST_RENDER_RESTORE_FALLBACK_MS } from "../config"; @@ -21,6 +18,7 @@ import { setupResizeHandlers, type TerminalRendererRef, } from "../helpers"; +import { isPaneDestroyed } from "../pane-guards"; import { coldRestoreState, pendingDetaches } from "../state"; import type { CreateOrAttachMutate, @@ -272,7 +270,6 @@ export function useTerminalLifecycle({ isStreamReadyRef.current = false; wasKilledByUserRef.current = false; setExitStatus(null); - clearTerminalKilledByUser(paneId); resetModes(); xterm.clear(); createOrAttachRef.current( @@ -382,14 +379,6 @@ export function useTerminalLifecycle({ done(); }; - if (isTerminalKilledByUser(paneId)) { - wasKilledByUserRef.current = true; - isExitedRef.current = true; - isStreamReadyRef.current = false; - setExitStatus("killed"); - finishAttach(); - return; - } if (DEBUG_TERMINAL) { console.log(`[Terminal] createOrAttach start: ${paneId}`); } @@ -540,6 +529,9 @@ export function useTerminalLifecycle({ }; document.addEventListener("visibilitychange", handleVisibilityChange); + const isPaneDestroyedInStore = () => + isPaneDestroyed(useTabsStore.getState().panes, paneId); + return () => { if (DEBUG_TERMINAL) { console.log(`[Terminal] Unmount: ${paneId}`); @@ -570,12 +562,19 @@ export function useTerminalLifecycle({ unregisterScrollToBottomCallbackRef.current(paneId); debouncedSetTabAutoTitleRef.current?.cancel?.(); - const detachTimeout = setTimeout(() => { - detachRef.current({ paneId }); - pendingDetaches.delete(paneId); + if (isPaneDestroyedInStore()) { + // Pane was explicitly destroyed, so kill the session. + killTerminalForPane(paneId); coldRestoreState.delete(paneId); - }, 50); - pendingDetaches.set(paneId, detachTimeout); + pendingDetaches.delete(paneId); + } else { + const detachTimeout = setTimeout(() => { + detachRef.current({ paneId }); + pendingDetaches.delete(paneId); + coldRestoreState.delete(paneId); + }, 50); + pendingDetaches.set(paneId, detachTimeout); + } isStreamReadyRef.current = false; didFirstRenderRef.current = false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts index f7225a5c1f8..41e1296d53f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts @@ -2,7 +2,11 @@ import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef } from "react"; import { DEBUG_TERMINAL } from "../config"; -import type { CreateOrAttachResult, TerminalStreamEvent } from "../types"; +import type { + CreateOrAttachResult, + TerminalExitReason, + TerminalStreamEvent, +} from "../types"; import { scrollToBottom } from "../utils"; export interface UseTerminalRestoreOptions { @@ -15,7 +19,11 @@ export interface UseTerminalRestoreOptions { modeScanBufferRef: React.MutableRefObject; updateCwdFromData: (data: string) => void; updateModesFromData: (data: string) => void; - onExitEvent: (exitCode: number, xterm: XTerm) => void; + onExitEvent: ( + exitCode: number, + xterm: XTerm, + reason?: TerminalExitReason, + ) => void; onErrorEvent: ( event: Extract, xterm: XTerm, @@ -89,7 +97,7 @@ export function useTerminalRestore({ xterm.write(event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { - onExitEventRef.current(event.exitCode, xterm); + onExitEventRef.current(event.exitCode, xterm, event.reason); } else if (event.type === "error") { onErrorEventRef.current(event, xterm); } else if (event.type === "disconnect") { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts index 6a160efdc9e..aeef315124b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts @@ -1,10 +1,9 @@ import { toast } from "@superset/ui/sonner"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef } from "react"; -import { isTerminalKilledByUser } from "renderer/lib/terminal-kill-tracking"; import { useTabsStore } from "renderer/stores/tabs/store"; import { DEBUG_TERMINAL } from "../config"; -import type { TerminalStreamEvent } from "../types"; +import type { TerminalExitReason, TerminalStreamEvent } from "../types"; export interface UseTerminalStreamOptions { paneId: string; @@ -20,7 +19,11 @@ export interface UseTerminalStreamOptions { } export interface UseTerminalStreamReturn { - handleTerminalExit: (exitCode: number, xterm: XTerm) => void; + handleTerminalExit: ( + exitCode: number, + xterm: XTerm, + reason?: TerminalExitReason, + ) => void; handleStreamError: ( event: Extract, xterm: XTerm, @@ -53,11 +56,11 @@ export function useTerminalStream({ updateCwdRef.current = updateCwdFromData; const handleTerminalExit = useCallback( - (exitCode: number, xterm: XTerm) => { + (exitCode: number, xterm: XTerm, reason?: TerminalExitReason) => { isExitedRef.current = true; isStreamReadyRef.current = false; - const wasKilledByUser = isTerminalKilledByUser(paneId); + const wasKilledByUser = reason === "killed"; wasKilledByUserRef.current = wasKilledByUser; setExitStatus(wasKilledByUser ? "killed" : "exited"); @@ -150,7 +153,7 @@ export function useTerminalStream({ xterm.write(event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { - handleTerminalExit(event.exitCode, xterm); + handleTerminalExit(event.exitCode, xterm, event.reason); } else if (event.type === "disconnect") { setConnectionError( event.reason || "Connection to terminal daemon lost", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/pane-guards.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/pane-guards.test.ts new file mode 100644 index 00000000000..9a78e830919 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/pane-guards.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import type { Pane } from "shared/tabs-types"; +import { isPaneDestroyed } from "./pane-guards"; + +describe("isPaneDestroyed", () => { + it("returns false when pane exists", () => { + const panes = { + "pane-1": { id: "pane-1" } as Pane, + }; + + expect(isPaneDestroyed(panes, "pane-1")).toBe(false); + }); + + it("returns true when pane is missing", () => { + const panes = { + "pane-1": { id: "pane-1" } as Pane, + }; + + expect(isPaneDestroyed(panes, "pane-2")).toBe(true); + }); + + it("returns true when panes map is undefined", () => { + expect(isPaneDestroyed(undefined, "pane-1")).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/pane-guards.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/pane-guards.ts new file mode 100644 index 00000000000..ff674a0d1f0 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/pane-guards.ts @@ -0,0 +1,6 @@ +import type { Pane } from "shared/tabs-types"; + +export const isPaneDestroyed = ( + panes: Record | undefined, + paneId: string, +): boolean => !panes?.[paneId]; 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 2e70298cc44..6f8cb4c2322 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 @@ -4,9 +4,16 @@ export interface TerminalProps { workspaceId: string; } +export type TerminalExitReason = "killed" | "exited" | "error"; + export type TerminalStreamEvent = | { type: "data"; data: string } - | { type: "exit"; exitCode: number; signal?: number } + | { + type: "exit"; + exitCode: number; + signal?: number; + reason?: TerminalExitReason; + } | { type: "disconnect"; reason: string } | { type: "error"; error: string; code?: string }; 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 833796a0cec..741943df81b 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts @@ -1,11 +1,9 @@ -import { markTerminalKilledByUser } from "../../../lib/terminal-kill-tracking"; import { electronTrpcClient } from "../../../lib/trpc-client"; /** * Uses standalone tRPC client to avoid React hook dependencies */ export const killTerminalForPane = (paneId: string): void => { - markTerminalKilledByUser(paneId); electronTrpcClient.terminal.kill.mutate({ paneId }).catch((error) => { console.warn(`Failed to kill terminal for pane ${paneId}:`, error); }); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 7d4d80c53b9..d211bd10e40 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -52,7 +52,6 @@ export const MOCK_ORG_ID = "mock-org-id"; // 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 = true; export const DEFAULT_AUTO_APPLY_DEFAULT_PRESET = true; // External links (documentation, help resources, etc.)