-
Notifications
You must be signed in to change notification settings - Fork 57
feat(desktop): terminal persistence via daemon process #619
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
📝 WalkthroughWalkthroughAdds terminal persistence and a daemon-backed terminal host: new IPC protocol and daemon, headless emulator, PTY subprocess, history persistence, workspace runtime abstraction, renderer/UX changes for cold-restore and persistent terminal tabs, TRPC endpoints and settings, and many tests and migrations. Changes
Sequence Diagram(s)sequenceDiagram
participant Renderer as Renderer (Terminal UI)
participant TRPC as TRPC Router
participant Registry as Workspace Runtime Registry
participant Manager as Active Terminal Manager
participant Daemon as Terminal Host Daemon
participant PTYSub as PTY Subprocess
participant History as History Persistence
Renderer->>TRPC: createOrAttach(paneId, ...)
TRPC->>Registry: getForWorkspaceId(workspaceId)
Registry->>Manager: resolve terminal runtime
Manager->>Daemon: createOrAttach (IPC) / or spawn in-process
alt daemon mode
Daemon->>PTYSub: spawn or attach
PTYSub-->>Daemon: Ready / Spawned / Data / Exit
Daemon->>History: write metadata/scrollback
Daemon-->>TRPC: response (snapshot, isColdRestore)
else in-process
Manager->>History: init/write
Manager-->>TRPC: response (scrollback, snapshot)
end
TRPC-->>Renderer: snapshot + metadata
Renderer->>Renderer: rehydrate terminal UI
loop runtime
PTYSub->>Daemon: output
Daemon-->>TRPC: emit data event
TRPC-->>Renderer: stream data event
end
sequenceDiagram
participant App as Electron App
participant Registry as Workspace Runtime Registry
participant LocalRT as LocalWorkspaceRuntime
participant InProc as In-Process Manager
participant DaemonMgr as DaemonTerminalManager
App->>Registry: getWorkspaceRuntimeRegistry()
Registry->>Registry: getDefault()
Registry->>LocalRT: instantiate LocalWorkspaceRuntime
LocalRT->>InProc: use TerminalManager (if daemon off)
LocalRT->>DaemonMgr: use DaemonTerminalManager (if daemon on)
LocalRT-->>App: return runtime with capabilities
Estimated code review effort🎯 5 (Critical) | ⏱️ ~180 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 9
🤖 Fix all issues with AI Agents
In @apps/desktop/src/main/lib/terminal-host/headless-emulator.ts:
- Around line 93-97: The anonymous callback passed to terminal.onData is missing
an explicit parameter type; change the handler to declare the data parameter
type (e.g., update this.terminal.onData((data: string) => { ... })) and ensure
related members (pendingOutput array and onDataCallback) are typed accordingly
(e.g., pendingOutput: string[] and onDataCallback?: (data: string) => void) so
TypeScript no longer reports TS7006.
- Around line 10-11: The build is failing because @xterm/headless is imported
(Terminal in headless-emulator.ts) but not listed in apps/desktop/package.json;
add "@xterm/headless" with a matching version to the other @xterm packages in
the apps/desktop/package.json dependencies (or update workspace manifest if
using workspaces), run yarn/npm install to update lockfile, and ensure imports
like `Terminal` continue to resolve.
In @apps/desktop/src/main/lib/terminal/daemon-manager.ts:
- Around line 159-163: The session cleanup timer created with setTimeout (using
SESSION_CLEANUP_DELAY_MS) isn't tracked, so its callback may run after the
manager is disposed and mutate this.sessions; to fix, store the timeout ID when
scheduling the cleanup (e.g., a new map like sessionCleanupTimers keyed by
paneId) and replace the anonymous setTimeout with one that saves the ID; then
update cleanup() to iterate and clearTimeout for any pending timers and remove
their entries before disposing so sessions.delete(paneId) cannot run on a
disposed manager.
In @apps/desktop/src/main/terminal-host/daemon.test.ts:
- Around line 89-141: The startDaemon() helper can race between the
DAEMON_TIMEOUT timer and other resolve/reject paths; fix it by tracking the
timeoutId returned from setTimeout and a local settled flag, clearing the
timeout (clearTimeout(timeoutId)) and marking settled=true when resolving or
rejecting, and early-return from subsequent handlers if settled; also remove or
cleanup listeners on daemonProcess (stdout/stderr/error/exit) when settling to
avoid multiple callbacks. Ensure this logic is applied inside startDaemon(),
referencing the DAEMON_TIMEOUT, daemonProcess, output, and the resolve/reject
handlers so the timeout is cleared on success and duplicate settles are
prevented.
In @apps/desktop/src/main/terminal-host/index.ts:
- Around line 218-240: The createOrAttach handler is async but handleRequest
currently invokes it without awaiting, so post-await errors escape the
try/catch; update handleRequest to await the handler invocation (e.g., await
handler(socket, id, payload, clientState)) so thrown/rejected errors are caught,
and ensure any caller of handleRequest (the socket data event handler) is either
made async and awaits handleRequest or properly handles the returned promise to
propagate rejections into the existing try/catch; apply the same awaiting
pattern for other async handlers invoked by handleRequest.
In @apps/desktop/src/main/terminal-host/session-lifecycle.test.ts:
- Around line 91-139: The startDaemon() promise can call resolve/reject multiple
times because the DAEMON_TIMEOUT timer and process event listeners remain active
after settlement; update startDaemon to store the timeout ID,
clearTimeout(timeoutId) immediately after calling resolve() or reject(), and
remove or noop the process listeners (daemonProcess.stdout?.off/on or use once)
to prevent further callbacks; ensure the timeout is set to a variable (e.g.,
const timeoutId = setTimeout(...)) and is cleared inside the stdout 'Daemon
started' handler, the 'error' handler, and the 'exit' handler so the promise
cannot attempt to settle after it has already resolved via
startDaemon/daemonProcess/DAEMON_TIMEOUT.
- Around line 189-219: The sendRequest function creates a 5s timeout but never
clears it on successful response; modify sendRequest to store the timeout id
(from setTimeout) in a variable and call clearTimeout(timeoutId) immediately
before resolve(JSON.parse(line)) and also before rejecting on parse error, and
ensure the timeout handler removes the data listener (socket.off("data",
onData)) as it already does so when expiring.
In @apps/desktop/src/main/terminal-host/session.ts:
- Around line 611-644: The timeout cleanup in flushToSnapshotBoundary
incorrectly clears the entire snapshotBoundaryWaiters array; instead remove only
the specific waiter added for this call so concurrent flushToSnapshotBoundary
callers aren't affected. Change the boundaryPromise creation to capture the
resolve function (e.g. const resolveFn = resolve), push resolveFn into
snapshotBoundaryWaiters, and on timeout/filter cleanup remove only that
resolveFn from snapshotBoundaryWaiters (e.g. this.snapshotBoundaryWaiters =
this.snapshotBoundaryWaiters.filter(r => r !== resolveFn)); also update the
misleading comment to state we remove only our waiter; ensure this logic works
with processEmulatorWriteQueue, scheduleEmulatorWrite, and snapshotBoundaryIndex
as currently used.
🧹 Nitpick comments (26)
apps/desktop/src/renderer/stores/hotkeys/store.ts (1)
301-307: Refine the null check and resolve type inconsistency.The guard works but has two issues:
Imprecise check:
if (!state)uses truthiness, catching null, undefined, 0, false, and empty string. For an object type, prefer an explicit null check:if (state == null) { // catches both null and undefinedType mismatch: The callback is typed
(state: HotkeysState)but the guard implies the query can return null/undefined. Either:
- Update the type:
.then((state: HotkeysState | null | undefined) => {- Or fix the upstream query/storage to guarantee non-null returns
The defensive guard suggests data quality concerns in the storage layer that may warrant investigation.
🔎 Proposed refinement
trpcClient.uiState.hotkeys.get .query() - .then((state: HotkeysState) => { + .then((state: HotkeysState | null | undefined) => { // Guard against null/undefined state from storage - if (!state) { + if (state == null) { console.warn( "[hotkeys] Storage returned null/undefined state, skipping sync", ); return; }apps/desktop/src/main/lib/terminal/port-manager.ts (3)
333-336: Inconsistent handling of standalone\rcharacters.The newline index calculation treats
\ras a line terminator (viaMath.max), but the actual line splitting on line 371 uses/\r?\n/which only handles\r\nor\n, not standalone\r(classic Mac line endings).This could cause issues:
- If output contains
"foo\rbar\n", thelastNewlineIndexwould be 7 (the\n), which is correct.- But if output is
"foo\rbar"with no\n,lastNewlineIndexwould be 3 (the\r), buffering"bar"as incomplete, and"foo"would be processed as a complete line—which is likely fine.- However, if the pattern spans across the
\rboundary, it could be missed.Consider using a consistent regex for both operations, or simplifying to only track
\n:🔎 Suggested simplification
- const lastNewlineIndex = Math.max( - combined.lastIndexOf("\n"), - combined.lastIndexOf("\r"), - ); + const lastNewlineIndex = combined.lastIndexOf("\n");
358-368: Heuristic may miss some port patterns.The relevance check doesn't cover all keywords from
PORT_PATTERNS. Patterns like "bound to port", "binding to", "Serving HTTP on", and "Tomcat" might be missed if they don't co-occur with the sampled keywords.Consider aligning the heuristic with
PORT_PATTERNSfor completeness:🔎 Suggested enhancement
const looksRelevant = - /(?:localhost|127\.0\.0\.1|0\.0\.0\.0|https?:\/\/|listening|started|ready|running|\bon port\b)/i.test( + /(?:localhost|127\.0\.0\.1|0\.0\.0\.0|https?:\/\/|listening|started|ready|running|bound|binding|serving|\bon port\b)/i.test( sample, );
360-363: Extract magic numbers to named constants.The values
4096and2048for sampling thresholds should be extracted to named constants for clarity and consistency withMAX_LINE_BUFFER. Per coding guidelines, magic numbers should be extracted to module-level constants.🔎 Suggested refactor
// Max buffer size for incomplete lines (bytes) - prevents memory issues with pathological input const MAX_LINE_BUFFER = 4096; + +// Threshold for sampling output during relevance check (chars) +const RELEVANCE_SAMPLE_THRESHOLD = 4096; +const RELEVANCE_SAMPLE_SIZE = 2048;Then update the usage:
const sample = - completePart.length > 4096 - ? `${completePart.slice(0, 2048)}${completePart.slice(-2048)}` + completePart.length > RELEVANCE_SAMPLE_THRESHOLD + ? `${completePart.slice(0, RELEVANCE_SAMPLE_SIZE)}${completePart.slice(-RELEVANCE_SAMPLE_SIZE)}` : completePart;apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx (1)
16-28: Consider: Refactor conditionals to a lookup object.Per coding guidelines, lookup objects are preferred over repeated
if (type === ...)conditionals. This is optional since the current pattern is established across the file.🔎 Example refactor
const SETTINGS_COMPONENTS: Record<SettingsSection, React.ComponentType> = { account: AccountSettings, project: ProjectSettings, workspace: WorkspaceSettings, appearance: AppearanceSettings, ringtones: RingtonesSettings, keyboard: KeyboardShortcutsSettings, presets: PresetsSettings, behavior: BehaviorSettings, terminal: TerminalSettings, }; export function SettingsContent({ activeSection }: SettingsContentProps) { const Component = SETTINGS_COMPONENTS[activeSection]; return ( <div className="h-full overflow-y-auto flex justify-center"> {Component && <Component />} </div> ); }apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx (2)
52-71: Consider scoping mounted terminals to current workspace.The current implementation renders all tabs across all workspaces, keeping even inactive workspace terminals mounted. While this prevents remount issues, it's more aggressive than necessary and compounds the "increased memory with many terminals" limitation noted in the PR.
Suggestion: Filter to only render tabs from the current workspace:
-{allTabs.map((tab) => { +{currentWorkspaceTabs.map((tab) => {This would:
- Still prevent TUI white screen issues when switching tabs within a workspace
- Reduce memory footprint by unmounting terminals from inactive workspaces
- Make the visible/hidden logic simpler (only need to check
tab.id === activeTabId)If keeping all workspaces mounted is intentional, please add a comment explaining why tabs from inactive workspaces must remain mounted.
10-11: Consider handling loading state explicitly to prevent potential flicker.The
terminalPersistencequery has no loading state check. During initial load,terminalPersistencewill beundefined, causing the component to render non-persistence mode (line 78-90), then potentially switch to persistence mode once loaded.If the setting loads quickly this may not be noticeable, but with slower loads it could cause a visual flicker or re-mount of terminals.
🔍 Optional: Add explicit loading check
const { data: terminalPersistence } = trpc.settings.getTerminalPersistence.useQuery(); +const { isLoading: isLoadingPersistence } = + trpc.settings.getTerminalPersistence.useQuery(); // ... -if (terminalPersistence) { +if (isLoadingPersistence) { + return null; // or loading skeleton +} + +if (terminalPersistence) {Alternatively, if the setting is expected to load synchronously from local SQLite, this may not be necessary.
Also applies to: 36-36
apps/desktop/src/main/terminal-host/pty-subprocess.ts (2)
281-291: Sensitive environment variables may be logged.The spawn debug logging includes
ZDOTDIR,SUPERSET_ORIG_ZDOTDIR, and a prefix ofPATH. While useful for debugging, this could inadvertently log sensitive environment variables in production. Consider gating this behindDEBUG_OUTPUT_BATCHINGor a separate debug flag.🔎 Proposed fix
- // Debug: Log spawn parameters - console.error("[pty-subprocess] Spawning PTY:", { - shell: msg.shell, - args: msg.args, - cwd: msg.cwd, - cols: msg.cols, - rows: msg.rows, - ZDOTDIR: msg.env.ZDOTDIR, - SUPERSET_ORIG_ZDOTDIR: msg.env.SUPERSET_ORIG_ZDOTDIR, - PATH_start: msg.env.PATH?.substring(0, 100), - }); + if (DEBUG_OUTPUT_BATCHING) { + console.error("[pty-subprocess] Spawning PTY:", { + shell: msg.shell, + args: msg.args, + cwd: msg.cwd, + cols: msg.cols, + rows: msg.rows, + }); + }
410-431: handleDispose skips graceful shutdown.Unlike
handleKill,handleDisposeimmediately sendsSIGKILLwithout first attemptingSIGTERM. This is acceptable for disposal semantics (immediate cleanup), but consider if a brief graceful window would be beneficial for processes that can save state.apps/desktop/src/main/terminal-host/daemon.test.ts (1)
45-222: Significant code duplication with session-lifecycle.test.ts.The
cleanup(),startDaemon(),stopDaemon(),connectToDaemon(), andsendRequest()functions are nearly identical between this file andsession-lifecycle.test.ts. Consider extracting these into a shared test utilities module.🔎 Proposed approach
Create a shared test utilities file:
// apps/desktop/src/main/terminal-host/__tests__/daemon-test-utils.ts export const SUPERSET_DIR_NAME = ".superset-dev"; export const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); // ... other constants export function cleanup(): void { /* ... */ } export function startDaemon(): Promise<void> { /* ... */ } export function stopDaemon(daemonProcess: ChildProcess | null): Promise<void> { /* ... */ } export function connectToDaemon(): Promise<Socket> { /* ... */ } export function sendRequest(socket: Socket, request: IpcRequest): Promise<IpcResponse> { /* ... */ }Then import in both test files:
import { cleanup, startDaemon, stopDaemon, connectToDaemon, sendRequest } from "./__tests__/daemon-test-utils";apps/desktop/src/main/lib/terminal/manager.ts (1)
413-426: Write return value ignored in refreshPromptsForWorkspace.
writeQueue.write()returnsfalsewhen the queue is full, but this return value is ignored. While unlikely for a single newline to exceed the queue, the inconsistency with the strict error-throwing inwrite()at line 176 is worth noting.🔎 Proposed fix for consistency
refreshPromptsForWorkspace(workspaceId: string): void { for (const [paneId, session] of this.sessions.entries()) { if (session.workspaceId === workspaceId && session.isAlive) { try { - session.writeQueue.write("\n"); + if (!session.writeQueue.write("\n")) { + console.warn( + `[TerminalManager] Queue full when refreshing prompt for pane ${paneId}`, + ); + } } catch (error) { console.warn( `[TerminalManager] Failed to refresh prompt for pane ${paneId}:`, error, ); } } } }apps/desktop/src/main/lib/terminal/daemon-manager.ts (2)
263-287: Async operation inwriteToHistoryinitiated without awaiting or error boundary.Lines 273-280 call
initHistoryWriterwithout awaiting and only attach a.catch(() => {})which silently swallows errors. While this is fire-and-forget, consider at minimum logging the error for debuggability.🔎 Suggested improvement
this.initHistoryWriter( paneId, session.workspaceId, session.cwd, 80, 24, contentAfterClear || undefined, - ).catch(() => {}); + ).catch((error) => { + console.error( + `[DaemonTerminalManager] Failed to reinit history after clear for ${paneId}:`, + error, + ); + });
685-693: Hardcoded dimensions (80x24) when reinitializing history after clearScrollback.These magic numbers should ideally come from the current session dimensions rather than hardcoded defaults.
🔎 Suggested improvement
Consider storing current dimensions in SessionInfo or querying the daemon for current terminal size:
+ // Get current dimensions from daemon if available + // Fallback to standard defaults + const currentCols = 80; // TODO: track actual dimensions + const currentRows = 24; await this.initHistoryWriter( paneId, session.workspaceId, session.cwd, - 80, - 24, + currentCols, + currentRows, undefined, );apps/desktop/src/main/terminal-host/index.ts (4)
51-61: Duplicate SUPERSET_DIR_NAME/SUPERSET_HOME_DIR definitions.These constants are defined in both this file and
app-environment.ts. Consider importing from a shared location to avoid drift.Based on the relevant code snippets,
SUPERSET_HOME_DIRis already exported fromapps/desktop/src/main/lib/app-environment.ts. Consider importing it:-const SUPERSET_DIR_NAME = - process.env.NODE_ENV === "development" ? ".superset-dev" : ".superset"; -const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); +import { SUPERSET_HOME_DIR } from "../lib/app-environment";Note: The daemon runs as a separate process, so verify the import path works in the bundled daemon script.
85-102: Auth token generation and validation are secure.
- 32 bytes (256 bits) of randomness is cryptographically strong
- Token file permissions (0o600) restrict access to owner only
- Timing-safe comparison should be used for token validation to prevent timing attacks
🔎 Use timing-safe comparison for token validation
+import { timingSafeEqual } from "node:crypto"; function validateToken(token: string): boolean { - return token === authToken; + if (token.length !== authToken.length) { + return false; + } + try { + return timingSafeEqual(Buffer.from(token), Buffer.from(authToken)); + } catch { + return false; + } }
353-376: Shutdown handler sends response before exit but uses fixed delay.The 100ms delay is arbitrary. If the response write is slow, the process may exit before the client receives it.
Consider using a callback on the socket write to ensure the response is flushed:
// Send success response before shutting down - sendSuccess(socket, id, { success: true }); + socket.write(`${JSON.stringify({ id, ok: true, payload: { success: true } })}\n`, () => { + // Kill sessions if requested + if (request.killSessions) { + terminalHost.killAll({ deleteHistory: false }); + } + stopServer().then(() => process.exit(0)); + }); - // Kill sessions if requested - if (request.killSessions) { - terminalHost.killAll({ deleteHistory: false }); - } - - // Schedule shutdown after a brief delay to allow response to be sent - setTimeout(() => { - stopServer().then(() => process.exit(0)); - }, 100);
445-471:isSocketLiveusesrequireinstead of import.Line 452 uses dynamic
require("node:net")whenSocketis already imported at the top of the file.🔎 Proposed fix
- const testSocket = new (require("node:net").Socket)(); + const { Socket: NetSocket } = await import("node:net"); + const testSocket = new NetSocket();Or since
Socketis already imported, use it directly (though you'd need theSocketclass, not the type):+import { createServer, type Server, Socket } from "node:net"; // ... - const testSocket = new (require("node:net").Socket)(); + const testSocket = new Socket();apps/desktop/src/main/lib/terminal/index.ts (1)
74-83: Console log on everygetActiveTerminalManagercall may be noisy.Line 78 logs every time this function is called, which could happen frequently. Consider moving the log to only fire when the cached value is first determined.
🔎 Reduce logging noise
export function getActiveTerminalManager(): | TerminalManager | DaemonTerminalManager { const daemonEnabled = isDaemonModeEnabled(); - console.log("[getActiveTerminalManager] Daemon mode enabled:", daemonEnabled); if (daemonEnabled) { return getDaemonTerminalManager(); } return terminalManager; }The mode is already logged in
isDaemonModeEnabled()when the cached value is set.apps/desktop/src/main/lib/terminal-host/client.ts (2)
63-79: Duplicate SUPERSET path constants across files.Same issue as in the daemon index.ts - these paths are defined in multiple places. As per coding guidelines, consider extracting to a shared constants module.
591-627: Daemon log file helps debugging but file descriptor may leak.Line 595 opens a file descriptor for logging but it's never explicitly closed. The
spawnwithdetached: trueshould handle this, but consider using a helper that manages the fd lifecycle.The fd is passed to the child and the child is unref'd, so the parent doesn't need to manage it. However, explicitly closing after spawn would be cleaner:
child.unref(); + + // Close log fd - child process has its own reference + if (logFd >= 0) { + try { + require("node:fs").closeSync(logFd); + } catch { + // Best effort + } + }apps/desktop/src/main/terminal-host/terminal-host.ts (1)
321-343: Recursive cleanup scheduling could run indefinitely.If clients never detach from a dead session,
scheduleSessionCleanupwill reschedule itself forever every 5 seconds. Consider adding a maximum retry count.🔎 Add cleanup retry limit
- private scheduleSessionCleanup(sessionId: string): void { + private scheduleSessionCleanup(sessionId: string, retryCount = 0): void { + const MAX_CLEANUP_RETRIES = 12; // 1 minute max + setTimeout(() => { const session = this.sessions.get(sessionId); if (!session || session.isAlive) { return; } if (session.clientCount === 0) { session.dispose(); this.sessions.delete(sessionId); + } else if (retryCount >= MAX_CLEANUP_RETRIES) { + console.warn( + `[TerminalHost] Force disposing session ${sessionId} after max retries (clients still attached: ${session.clientCount})`, + ); + session.dispose(); + this.sessions.delete(sessionId); } else { - this.scheduleSessionCleanup(sessionId); + this.scheduleSessionCleanup(sessionId, retryCount + 1); } }, 5000); }apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx (2)
48-77: Consider extracting theCreateOrAttachResulttype to a shared location.This inline type duplicates structure that likely exists in the backend types (
apps/desktop/src/main/lib/terminal-host/types.ts). Consider importing from there or from a shared types file to maintain a single source of truth.
657-660: Verify!xtermRef.currentcondition doesn't cause silent failures.In
handleStreamData, when!xtermRef.currentor!isStreamReadyRef.current, events are queued. However, ifxtermRef.currentbecomes null permanently (e.g., after cleanup), events will accumulate inpendingEventsRefindefinitely without being processed or cleared.Consider adding a guard in the cleanup to clear pending events, or add a size limit to prevent unbounded growth.
🔎 Suggested addition to cleanup
return () => { isUnmounted = true; + // Clear pending events to prevent memory leak if component never remounts + pendingEventsRef.current = []; // ... rest of cleanupapps/desktop/src/main/terminal-host/session.ts (2)
161-252: Subprocess spawn logic has a potential issue with env filtering.The
processEnvis built by filteringprocess.env, but thenspawn()uses{ ...process.env, ELECTRON_RUN_AS_NODE: "1" }directly (line 195-198), ignoring the carefully filteredprocessEnv. The filtered env is only used inpendingSpawnwhich is sent to the subprocess later.This appears intentional (subprocess needs ELECTRON_RUN_AS_NODE to run as Node, while the PTY process spawned inside gets the filtered env), but the code flow is confusing. Consider adding a clarifying comment.
🔎 Suggested clarifying comment
+ // Note: We use process.env + ELECTRON_RUN_AS_NODE for the subprocess itself + // (it needs to run as Node.js). The filtered processEnv is passed via IPC + // to the PTY spawn inside the subprocess. this.subprocess = spawn(electronPath, [subprocessPath], { stdio: ["pipe", "pipe", "inherit"], // pipe stdin/stdout, inherit stderr env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", }, });
808-848: Dispose method is thorough but has a minor issue.The
killTimer.unref()prevents the daemon from staying alive just for this timer, which is good. However, afterthis.subprocess = nullon line 825, thesubprocessreference captured in the closure (line 814) is still valid, so the SIGKILL fallback works correctly.One issue: if
sendDisposeToSubprocess()returns false (queue full), the subprocess might not receive the dispose command, but we still setthis.subprocess = null. The fallback SIGKILL after 1s should handle this, but consider logging a warning.🔎 Suggested warning
if (this.subprocess) { // Capture reference before nullifying - the timeout needs it const subprocess = this.subprocess; - this.sendDisposeToSubprocess(); + const disposeSent = this.sendDisposeToSubprocess(); + if (!disposeSent) { + console.warn(`[Session ${this.sessionId}] Failed to send dispose, will force kill`); + }apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts (1)
318-431: Paste handler with chunking and bracketed paste support is comprehensive.The implementation handles:
- Small pastes synchronously for performance
- Large pastes chunked with delays to prevent PTY overwhelm
- Bracketed paste mode wrapping per-chunk (correct for TUIs that defer repainting)
- Cancellation of in-flight pastes
- Newline normalization (
\r?\n→\r)One concern: the
CHUNK_DELAY_MS = 0in the direct write path (line 395) means chunks are processed viasetTimeout(..., 0)which still yields to the event loop but may be very fast. This might still overwhelm slow PTYs. Consider using a small non-zero delay like the fallback path's 5ms.🔎 Consider non-zero delay for direct write path
let offset = 0; const CHUNK_CHARS = 16_384; - const CHUNK_DELAY_MS = 0; + const CHUNK_DELAY_MS = 1; // Small delay to allow PTY to process
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
andreasasprou
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please search for all logs introduced in this pr and make sure they are appropriately retained
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx`:
- Around line 252-263: The Clear terminal history button is currently disabled
when there are no live sessions; remove the aliveSessions.length === 0 check so
the button is enabled whenever daemonModeEnabled is true (still respecting
clearTerminalHistory.isPending). Update the disabled expression used on the
Button rendered in page.tsx (the Button component with onClick={() =>
setConfirmClearHistoryOpen(true)}) to only consider !daemonModeEnabled and
clearTerminalHistory.isPending so users can clear disk-based scrollback even
with no live sessions.
- Around line 374-380: The click handler currently iterates over sessions and
marks every session as killed by user; change that loop to iterate over
aliveSessions instead so only sessions that will be terminated are marked via
markTerminalKilledByUser(session.sessionId). Keep the existing calls to
setConfirmKillAllOpen(false) and killAllDaemonSessions.mutate(), but replace the
for (const session of sessions) { ... } loop with for (const session of
aliveSessions) { ... } (or equivalent filtering of sessions to only alive ones)
to ensure only live terminals are flagged.
♻️ Duplicate comments (1)
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx (1)
31-46: Unstable array reference causes unnecessary memo recalculations.The
?? []fallback creates a new array reference each render whendaemonSessions?.sessionsis undefined, defeating the memoization ofaliveSessionsandsessionsSorted.♻️ Proposed fix
+const EMPTY_SESSIONS: typeof daemonSessions.sessions = []; + function TerminalSettingsPage() { const utils = electronTrpc.useUtils(); // ... - const sessions = daemonSessions?.sessions ?? []; + const sessions = daemonSessions?.sessions ?? EMPTY_SESSIONS;Alternatively, fold the filtering into a single memo:
- const sessions = daemonSessions?.sessions ?? []; - const aliveSessions = useMemo( - () => sessions.filter((session) => session.isAlive), - [sessions], - ); - const sessionsSorted = useMemo(() => { - return [...aliveSessions].sort((a, b) => { + const sessionsSorted = useMemo(() => { + const alive = (daemonSessions?.sessions ?? []).filter((s) => s.isAlive); + return alive.sort((a, b) => { // ... }); - }, [aliveSessions]); + }, [daemonSessions?.sessions]);Note: This is a refinement of the prior review comment about the useMemo dependency issue.
🧹 Nitpick comments (2)
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx (2)
128-134: Extract magic number and consider race condition.The 300ms delay is a hardcoded value that may not be sufficient if the daemon cleanup takes longer. Consider extracting to a named constant and documenting the assumption.
♻️ Proposed fix
+// Delay before refetching to allow daemon cleanup to complete +const DAEMON_CLEANUP_DELAY_MS = 300; + // ... inside mutation config onSettled: () => { // Always refetch to get actual state after mutation settles - // Small delay to allow daemon to finish cleanup setTimeout(() => { utils.terminal.listDaemonSessions.invalidate(); - }, 300); + }, DAEMON_CLEANUP_DELAY_MS); },
162-165: Consider moving pure utility outside the component.
formatTimestampis recreated each render. Since it has no dependencies on component state, extract it to module scope.♻️ Proposed fix
+const formatTimestamp = (value?: string) => { + if (!value) return "—"; + return value.replace("T", " ").replace(/\.\d+Z$/, "Z"); +}; + function TerminalSettingsPage() { // ... - const formatTimestamp = (value?: string) => { - if (!value) return "—"; - return value.replace("T", " ").replace(/\.\d+Z$/, "Z"); - };
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx
🧰 Additional context used
📓 Path-based instructions (6)
apps/desktop/**/*.{ts,tsx}
📄 CodeRabbit inference engine (apps/desktop/AGENTS.md)
apps/desktop/**/*.{ts,tsx}: For Electron interprocess communication, ALWAYS use tRPC as defined insrc/lib/trpc
Use alias as defined intsconfig.jsonwhen possible
Prefer zustand for state management if it makes sense. Do not use effect unless absolutely necessary.
For tRPC subscriptions with trpc-electron, ALWAYS use the observable pattern from@trpc/server/observableinstead of async generators, as the library explicitly checksisObservable(result)and throws an error otherwise
Files:
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: Use object parameters for functions with 2+ parameters instead of positional arguments
Functions with 2+ parameters should accept a single params object with named properties for self-documentation and extensibility
Use prefixed console logging with context pattern: [domain/operation] message
Extract magic numbers and hardcoded values to named constants at module top
Use lookup objects/maps instead of repeated if (type === ...) conditionals
Avoid usinganytype - maintain type safety in TypeScript code
Never swallow errors silently - at minimum log them with context
Import from concrete files directly when possible - avoid barrel file abuse that creates circular dependencies
Avoid deep nesting (4+ levels) - use early returns, extract functions, and invert conditions
Use named properties in options objects instead of boolean parameters to avoid boolean blindness
Files:
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx
apps/desktop/src/renderer/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
Never import Node.js modules (fs, path, os, net) in renderer process or shared code - they are externalized for browser compatibility
Files:
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx
**/*.tsx
📄 CodeRabbit inference engine (AGENTS.md)
One component per file - do not create multi-component files
Files:
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx
apps/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use Drizzle ORM for all database operations - never use raw SQL
Files:
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use Biome for formatting and linting - run at root level with
bun run lint:fixorbiome check --write
Files:
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
- GitHub Check: Deploy API
- GitHub Check: Deploy Docs
- GitHub Check: Deploy Marketing
- GitHub Check: Deploy Web
- GitHub Check: Build
🔇 Additional comments (2)
apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx (2)
1-21: LGTM!Imports and route definition follow project conventions. Proper use of aliases and no Node.js modules in renderer code.
433-482: LGTM!The per-session kill dialog handles null state defensively with proper early return. Dialog structure follows accessibility patterns.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
| <Button | ||
| variant="secondary" | ||
| size="sm" | ||
| disabled={ | ||
| !daemonModeEnabled || | ||
| aliveSessions.length === 0 || | ||
| clearTerminalHistory.isPending | ||
| } | ||
| onClick={() => setConfirmClearHistoryOpen(true)} | ||
| > | ||
| Clear terminal history | ||
| </Button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Clear terminal history" should be available when daemon is enabled, regardless of live sessions.
Clearing history removes disk-based scrollback files used for cold restore. Users may want to clear old history even when no sessions are currently alive (e.g., after all sessions exited but before app restart).
🐛 Proposed fix
<Button
variant="secondary"
size="sm"
disabled={
!daemonModeEnabled ||
- aliveSessions.length === 0 ||
clearTerminalHistory.isPending
}
onClick={() => setConfirmClearHistoryOpen(true)}
>
Clear terminal history
</Button>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Button | |
| variant="secondary" | |
| size="sm" | |
| disabled={ | |
| !daemonModeEnabled || | |
| aliveSessions.length === 0 || | |
| clearTerminalHistory.isPending | |
| } | |
| onClick={() => setConfirmClearHistoryOpen(true)} | |
| > | |
| Clear terminal history | |
| </Button> | |
| <Button | |
| variant="secondary" | |
| size="sm" | |
| disabled={ | |
| !daemonModeEnabled || | |
| clearTerminalHistory.isPending | |
| } | |
| onClick={() => setConfirmClearHistoryOpen(true)} | |
| > | |
| Clear terminal history | |
| </Button> |
🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx`
around lines 252 - 263, The Clear terminal history button is currently disabled
when there are no live sessions; remove the aliveSessions.length === 0 check so
the button is enabled whenever daemonModeEnabled is true (still respecting
clearTerminalHistory.isPending). Update the disabled expression used on the
Button rendered in page.tsx (the Button component with onClick={() =>
setConfirmClearHistoryOpen(true)}) to only consider !daemonModeEnabled and
clearTerminalHistory.isPending so users can clear disk-based scrollback even
with no live sessions.
| onClick={() => { | ||
| setConfirmKillAllOpen(false); | ||
| for (const session of sessions) { | ||
| markTerminalKilledByUser(session.sessionId); | ||
| } | ||
| killAllDaemonSessions.mutate(); | ||
| }} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Iterate over aliveSessions when marking sessions as killed.
The loop marks all sessions (including already-dead ones) as killed by user, but only alive sessions are actually terminated.
🐛 Proposed fix
onClick={() => {
setConfirmKillAllOpen(false);
- for (const session of sessions) {
+ for (const session of aliveSessions) {
markTerminalKilledByUser(session.sessionId);
}
killAllDaemonSessions.mutate();
}}🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/routes/_authenticated/settings/terminal/page.tsx`
around lines 374 - 380, The click handler currently iterates over sessions and
marks every session as killed by user; change that loop to iterate over
aliveSessions instead so only sessions that will be terminated are marked via
markTerminalKilledByUser(session.sessionId). Keep the existing calls to
setConfirmKillAllOpen(false) and killAllDaemonSessions.mutate(), but replace the
for (const session of sessions) { ... } loop with for (const session of
aliveSessions) { ... } (or equivalent filtering of sessions to only alive ones)
to ensure only live terminals are flagged.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts (1)
185-191: InconsistentSAFE_IDvalidation onpaneIdacross procedures.
createOrAttachvalidatespaneIdwithSAFE_ID(preventing path traversal), butwrite,resize,signal,kill,detach,clearScrollback,ackColdRestore,getSession, andstreamall accept plainz.string().If
paneIdis used to construct file paths (e.g., for history persistence in daemon mode), this inconsistency could expose path traversal vulnerabilities. ApplySAFE_IDconsistently to all paneId inputs.🔒 Example fix for write mutation
write: publicProcedure .input( z.object({ - paneId: z.string(), + paneId: SAFE_ID, data: z.string(), }), )
🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`:
- Around line 303-317: The code adds session IDs to userKilledSessions before
calling terminal.management.killAllSessions(), causing createOrAttach to treat
sessions as dead if killAll fails; change the logic so you only add IDs to
userKilledSessions after killAllSessions() completes successfully (or in a loop
that confirms each id was killed), or wrap the killAllSessions() call in
try/catch and on failure remove any previously added IDs from
userKilledSessions; reference terminal.management.killAllSessions(),
userKilledSessions, and createOrAttach to locate and update the flow so session
IDs are added only after confirmed kill (or rolled back on error).
♻️ Duplicate comments (1)
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts (1)
43-52: Terminal reference captured once may become stale on daemon restart or mode toggle.The
terminalobject is resolved once at router creation. If the daemon restarts or terminal persistence mode is toggled, procedures will continue using the stale reference.Consider resolving the terminal runtime inside each procedure or introducing a getter helper.
🧹 Nitpick comments (2)
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts (2)
18-19: Unbounded growth ofuserKilledSessionsSet.This Set accumulates paneIds across the app lifetime and is never pruned. Over extended usage (especially with many workspace/terminal operations), this could grow unboundedly.
Consider periodic cleanup (e.g., removing entries when the corresponding workspace is deleted or on a TTL basis), or using a bounded LRU cache.
368-371: Sequential session kills could be parallelized.The loop awaits each
terminal.kill()sequentially. If multiple sessions need killing, this adds latency. Consider parallel execution.♻️ Proposed refactor
-for (const session of toKill) { - userKilledSessions.add(session.sessionId); - await terminal.kill({ paneId: session.sessionId }); -} +await Promise.all( + toKill.map((session) => { + userKilledSessions.add(session.sessionId); + return terminal.kill({ paneId: session.sessionId }); + }), +);
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/desktop/src/lib/trpc/routers/terminal/terminal.tsapps/desktop/src/main/lib/terminal-host/client.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/desktop/src/main/lib/terminal-host/client.ts
🧰 Additional context used
📓 Path-based instructions (4)
apps/desktop/**/*.{ts,tsx}
📄 CodeRabbit inference engine (apps/desktop/AGENTS.md)
apps/desktop/**/*.{ts,tsx}: For Electron interprocess communication, ALWAYS use tRPC as defined insrc/lib/trpc
Use alias as defined intsconfig.jsonwhen possible
Prefer zustand for state management if it makes sense. Do not use effect unless absolutely necessary.
For tRPC subscriptions with trpc-electron, ALWAYS use the observable pattern from@trpc/server/observableinstead of async generators, as the library explicitly checksisObservable(result)and throws an error otherwise
Files:
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: Use object parameters for functions with 2+ parameters instead of positional arguments
Functions with 2+ parameters should accept a single params object with named properties for self-documentation and extensibility
Use prefixed console logging with context pattern: [domain/operation] message
Extract magic numbers and hardcoded values to named constants at module top
Use lookup objects/maps instead of repeated if (type === ...) conditionals
Avoid usinganytype - maintain type safety in TypeScript code
Never swallow errors silently - at minimum log them with context
Import from concrete files directly when possible - avoid barrel file abuse that creates circular dependencies
Avoid deep nesting (4+ levels) - use early returns, extract functions, and invert conditions
Use named properties in options objects instead of boolean parameters to avoid boolean blindness
Files:
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
apps/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use Drizzle ORM for all database operations - never use raw SQL
Files:
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use Biome for formatting and linting - run at root level with
bun run lint:fixorbiome check --write
Files:
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
🧠 Learnings (2)
📚 Learning: 2025-12-21T04:39:28.543Z
Learnt from: CR
Repo: superset-sh/superset PR: 0
File: apps/desktop/AGENTS.md:0-0
Timestamp: 2025-12-21T04:39:28.543Z
Learning: Applies to apps/desktop/**/*.{ts,tsx} : For tRPC subscriptions with trpc-electron, ALWAYS use the observable pattern from `trpc/server/observable` instead of async generators, as the library explicitly checks `isObservable(result)` and throws an error otherwise
Applied to files:
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
📚 Learning: 2025-12-21T04:39:28.543Z
Learnt from: CR
Repo: superset-sh/superset PR: 0
File: apps/desktop/AGENTS.md:0-0
Timestamp: 2025-12-21T04:39:28.543Z
Learning: Applies to apps/desktop/**/*.{ts,tsx} : For Electron interprocess communication, ALWAYS use tRPC as defined in `src/lib/trpc`
Applied to files:
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
- GitHub Check: Deploy Marketing
- GitHub Check: Deploy Docs
- GitHub Check: Deploy Admin
- GitHub Check: Deploy API
- GitHub Check: Deploy Web
- GitHub Check: Build
🔇 Additional comments (2)
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts (2)
54-183: Well-structured mutation with proper validation and error handling.Good use of
SAFE_IDvalidation, debug telemetry withcallIdtracking, and theallowKilledescape hatch for intentional session restarts.
469-531: LGTM!The observable pattern is correctly used for the tRPC subscription (per trpc-electron requirements). Event handlers are properly registered and cleaned up, and the comment explaining why
emit.complete()is avoided on exit is valuable documentation. Based on learnings about trpc-electron observable requirements.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
| // Get sessions before kill for accurate count | ||
| 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, | ||
| "sessions", | ||
| beforeIds, | ||
| ); | ||
|
|
||
| // Request kill of all sessions | ||
| await terminal.management.killAllSessions(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sessions marked as killed before killAllSessions() completes—may block createOrAttach incorrectly if kill fails.
If terminal.management.killAllSessions() throws or partially fails, the sessions are already in userKilledSessions, causing createOrAttach to reject them even though they're still alive.
Consider adding sessions to userKilledSessions only after confirming the kill succeeded, or wrapping in a try/catch that removes the entries on failure.
🐛 Proposed fix
-const beforeIds = before.sessions.map((s) => s.sessionId);
-for (const id of beforeIds) {
- userKilledSessions.add(id);
-}
-console.log(
- "[killAllDaemonSessions] Before kill:",
- beforeIds.length,
- "sessions",
- beforeIds,
-);
-
-// Request kill of all sessions
-await terminal.management.killAllSessions();
+const beforeIds = before.sessions.map((s) => s.sessionId);
+console.log(
+ "[killAllDaemonSessions] Before kill:",
+ beforeIds.length,
+ "sessions",
+ beforeIds,
+);
+
+// Request kill of all sessions
+await terminal.management.killAllSessions();
+
+// Mark as killed only after successful kill request
+for (const id of beforeIds) {
+ userKilledSessions.add(id);
+}🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` around lines 303 -
317, The code adds session IDs to userKilledSessions before calling
terminal.management.killAllSessions(), causing createOrAttach to treat sessions
as dead if killAll fails; change the logic so you only add IDs to
userKilledSessions after killAllSessions() completes successfully (or in a loop
that confirms each id was killed), or wrap the killAllSessions() call in
try/catch and on failure remove any previously added IDs from
userKilledSessions; reference terminal.management.killAllSessions(),
userKilledSessions, and createOrAttach to locate and update the flow so session
IDs are added only after confirmed kill (or rolled back on error).
feat(desktop): terminal persistence via daemon process
AviPeltz
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks good :)
Links
Summary
This PR implements terminal session persistence via a background daemon process that survives app restarts, and hardens the feature for real-world usage (many accumulated terminal panes).
Happy path: https://www.loom.com/share/d84c42fdb4c24952ad112f9e6be4c82e?from_recorder=1&focus_title=1
User-visible behavior
visibility: hidden) to keep common switching smooth.working/permission→idle). No new background "terminal output" notifications/indicators are introduced in this PR.Recovery / management
Settings → Terminal → Manage sessions:
Dev-only:
Architecture
Runtime Abstraction
This abstraction enables future cloud workspace providers without spreading backend-specific branching throughout the codebase. Backend selection uses capability checks (
terminal.management !== null) rather thaninstanceofchecks.Key modules:
apps/desktop/src/main/lib/workspace-runtime/- Runtime abstraction layer (types, registry, local adapter)apps/desktop/src/main/lib/terminal/daemon-manager.ts- Daemon-backed terminal managerapps/desktop/src/main/lib/terminal/manager.ts- In-process terminal managerDaemon Mode
Daemon components:
apps/desktop/src/main/terminal-host/- Daemon process (runs asELECTRON_RUN_AS_NODE)apps/desktop/src/main/lib/terminal-host/client.ts- Client for Electron main → daemonKey DX/Performance Changes (Hardening)
WARM_TERMINAL_TAB_LIMIT = 8) inapps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsxMAX_CONCURRENT_ATTACHES = 3) inapps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.tscreateOrAttachlimiter (max 3) to prevent accidental attach fan-outlistSessions) +skipColdRestoresupport to avoid spawning during cold restoreterminal.streamdoes not complete onexitso stable pane subscriptions survive session loss/restart (fixes thelisteners=0cold-restore regression)exitfrom clearing the terminal UI)$HOME)0o600(defense in depth)exitlifecycle event even when no client is attached; main forwards via notifications subscription (terminal-exit)Cold Restore Regression Fix (listeners=0 / blank terminal after Start Shell)
Symptom
listeners=0ondata:${paneId}.Root cause
terminal.streamcompleted the observable onexit(emit.complete()).paneIdkey;@trpc/react-querydoes not auto-resubscribe after completion unless the input changes.paneIdacross restarts/cold restore → the pane becomes permanently detached from output.Fix
terminal.streamsubscription open acrossexit(treat exit as a state transition).exittriggering an unintended restart/clear).apps/desktop/docs/TERMINAL_HOST_EVENTS.mdand covered by regression testapps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts.Verification Matrix
Optional debug signals:
SUPERSET_TERMINAL_DEBUG=1localStorage.setItem('SUPERSET_TERMINAL_DEBUG', '1')Non-daemon (default / main parity)
exitshows restart prompt; any key restarts and output resumesDaemon mode (warm attach)
working/permission→idleDaemon mode (cold restore)
listeners=0stateFailure & recovery
isExitedstateBackpressure / correctness
yes | head -n 50000): UI stays responsive; no crash/OOMValidation
bun run typecheckbun run lintNODE_ENV=test bun testKnown Limitations / Follow-ups
pnpm testfinishes" or general background terminal output indicators (out of scope; evaluate in follow-up PR based on feedback).Summary by CodeRabbit
New Features
Improvements
Documentation
Tests
✏️ Tip: You can customize this high-level summary in your review settings.