Skip to content

Conversation

@andreasasprou
Copy link
Collaborator

@andreasasprou andreasasprou commented Jan 6, 2026

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

  • No startup freeze: when persistence is enabled, the app no longer mounts/attaches every terminal tab on startup. Only the active tab plus a per-run MRU warm set (max 8 terminal tabs) stays mounted (hidden via CSS visibility: hidden) to keep common switching smooth.
  • Progressive attach: terminals attach progressively (max 3 concurrent attaches), prioritizing the focused pane to keep heavy split tabs responsive.
  • Cold restore: when the daemon does not have a session but on-disk scrollback exists from an unclean shutdown, the UI shows restored scrollback without spawning a new shell until the user clicks Start Shell.
  • Correctness-only exit signals: if a terminal exits while its pane isn't mounted (no stream subscription), we still clear stuck agent lifecycle indicators (working/permissionidle). No new background "terminal output" notifications/indicators are introduced in this PR.

Recovery / management

Settings → Terminal → Manage sessions:

  • View daemon session count + list sessions
  • Kill a session / kill sessions for active workspace / kill all sessions
  • Clear terminal history (cold-restore scrollback)

Dev-only:

  • Dev → Reset Terminal State menu action for fast local reproduction/iteration.

Architecture

Runtime Abstraction

┌─────────────────────┐
│   tRPC Routers      │
└─────────┬───────────┘
          │ registry.getForWorkspaceId(id)
          ▼
┌─────────────────────┐
│ WorkspaceRuntime    │
│ Registry            │ (process-scoped, capability-based selection)
└─────────┬───────────┘
          │
          ▼
┌─────────────────────┐
│ LocalTerminalRuntime│ (adapts backend to TerminalRuntime interface)
└─────────┬───────────┘
          │ delegates to
          ▼
┌─────────────────────┐
│ DaemonTerminalMgr   │ ◄── daemon mode (persistent)
│ or TerminalManager  │ ◄── in-process (non-persistent)
└─────────────────────┘

This abstraction enables future cloud workspace providers without spreading backend-specific branching throughout the codebase. Backend selection uses capability checks (terminal.management !== null) rather than instanceof checks.

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 manager
  • apps/desktop/src/main/lib/terminal/manager.ts - In-process terminal manager

Daemon Mode

┌─────────────────────┐
│   Electron Main     │
│  (DaemonManager)    │◄──── tRPC calls from renderer
└─────────┬───────────┘
          │ Unix socket (NDJSON; control + stream)
          ▼
┌─────────────────────┐
│  Terminal Host      │
│  Daemon Process     │ (detached, survives app restart)
└─────────┬───────────┘
          │ Binary framing (optimized for escape sequences)
          ▼
┌─────────────────────┐
│  PTY Subprocess 1   │ (one per terminal)
├─────────────────────┤
│  PTY Subprocess 2   │
└─────────────────────┘

Daemon components:

  • apps/desktop/src/main/terminal-host/ - Daemon process (runs as ELECTRON_RUN_AS_NODE)
  • apps/desktop/src/main/lib/terminal-host/client.ts - Client for Electron main → daemon

Key DX/Performance Changes (Hardening)

  • Renderer mount policy: bounded warm set (WARM_TERMINAL_TAB_LIMIT = 8) in apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx
  • Attach scheduling (renderer): concurrency cap (MAX_CONCURRENT_ATTACHES = 3) in apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-scheduler.ts
  • Main-process limiter: priority-based createOrAttach limiter (max 3) to prevent accidental attach fan-out
  • Daemon spawn limiter: limit concurrent new PTY spawns (max 3, permit held until ready/timeout)
  • Cold restore semantics: daemon session existence check (via listSessions) + skipColdRestore support to avoid spawning during cold restore
  • tRPC stream lifecycle: terminal.stream does not complete on exit so stable pane subscriptions survive session loss/restart (fixes the listeners=0 cold-restore regression)
  • Cold restore Start Shell guard: drop any queued pre-restore stream events and ignore terminal input while overlays are visible (prevents stale exit from clearing the terminal UI)
  • History writer init fix: avoid a buffer replay loop that could hang new terminal creation (RangeError: Invalid array length).
  • Byte-accurate scrollback caps: enforce caps using UTF-8 byte counts + safe suffix truncation (prevents multi-byte output from exceeding limits)
  • Worktree cwd guard: worktree terminals require a usable worktree path (no fallback to $HOME)
  • History file permissions: create terminal-history files with 0o600 (defense in depth)
  • Unattached exit signal: daemon broadcasts exit lifecycle 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

  • After daemon restart/session loss: cold restore overlay shows, clicking Start Shell creates a new session but terminal stays blank and input goes to the tab title.
  • Main-process logs show daemon output arriving but listeners=0 on data:${paneId}.

Root cause

  • Server-side terminal.stream completed the observable on exit (emit.complete()).
  • The renderer subscribes with a stable paneId key; @trpc/react-query does not auto-resubscribe after completion unless the input changes.
  • We reuse the same paneId across restarts/cold restore → the pane becomes permanently detached from output.

Fix

  • Keep the terminal.stream subscription open across exit (treat exit as a state transition).
  • In cold restore UI, drop queued pre-restore stream events before starting a new shell and ignore terminal input while overlays are visible (prevents stale queued exit triggering an unintended restart/clear).
  • Documented in apps/desktop/docs/TERMINAL_HOST_EVENTS.md and covered by regression test apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts.

Verification Matrix

Optional debug signals:

  • Main/Electron: SUPERSET_TERMINAL_DEBUG=1
  • Renderer DevTools: localStorage.setItem('SUPERSET_TERMINAL_DEBUG', '1')

Non-daemon (default / main parity)

  • Tab switching: terminal persists across tab/workspace switches (no remount flicker)
  • Resize: correct cols/rows; no render glitches
  • Exit + restart: exit shows restart prompt; any key restarts and output resumes
  • Clear scrollback: clears visible buffer; subsequent output renders normally
  • TUI / alt-screen (vim/codex): no keyboard-driven auto-title while in alt screen; escape-based titles still work
  • Large paste: bracketed paste envelope preserved; no dropped chars

Daemon mode (warm attach)

  • Many panes + restart app: UI interactive quickly (no startup freeze)
  • Active pane attaches first; warm set attaches progressively; others stay detached
  • Heavy split tab: panes attach progressively; app remains responsive (max 3 concurrent attaches)
  • Status correctness: exit while pane not mounted clears working/permissionidle
  • Settings → Terminal → Manage sessions: list sessions, kill session(s), clear history

Daemon mode (cold restore)

  • Daemon absent + history on disk: cold restore scrollback shows without spawning a shell
  • Start Shell: output resumes; input goes to terminal (not tab title); no listeners=0 state
  • Overlay typing safety: typing while overlay is visible does not clear the terminal after Start Shell

Failure & recovery

  • Kill daemon while attached: connection error shown; app stays stable
  • Retry/reconnect: session reattaches or cold restore flow works; no stuck isExited state

Backpressure / correctness

  • Flood output (e.g. yes | head -n 50000): UI stays responsive; no crash/OOM
  • Workspace deletion with active terminals: no error toast flood; sessions exit/cleanup cleanly

Validation

  • bun run typecheck
  • bun run lint
  • NODE_ENV=test bun test

Known Limitations / Follow-ups

  • No "notify me when pnpm test finishes" or general background terminal output indicators (out of scope; evaluate in follow-up PR based on feedback).
  • Warm set sizing is a tradeoff; currently fixed at 8 (per-run, not persisted).

Summary by CodeRabbit

  • New Features

    • Terminal persistence with cold-restore (scrollback/snapshots) and a Terminal Settings page to manage persistence, sessions, clear history, and kill sessions
    • Background terminal host for persistent sessions and improved session listing/management
  • Improvements

    • More reliable reconnect, backpressure handling, and smoother terminal restoration UX
    • Session lifecycle overlays (restored, killed, connection errors) and better observability
  • Documentation

    • Terminal host and runtime docs added
  • Tests

    • Extensive integration/unit tests for host, emulator, and streaming behavior

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Daemon & IPC
apps/desktop/src/main/terminal-host/index.ts, .../client.ts, .../types.ts, .../pty-subprocess.ts, .../pty-subprocess-ipc.ts, .../terminal-host.ts, .../session.ts, .../session-lifecycle.test.ts, .../daemon.test.ts
New Terminal Host daemon, two-socket control/stream protocol, NDJSON framing + auth, PTY subprocess framing, request/response and event contracts, daemon lifecycle and tests.
Headless Emulator & Session
apps/desktop/src/main/lib/terminal-host/headless-emulator.ts, .../headless-emulator.test.ts, apps/desktop/src/main/terminal-host/session.ts, apps/desktop/src/main/terminal-host/session-lifecycle.test.ts
Headless xterm wrapper with snapshot/rehydration, OSC-7 CWD tracking, buffering, query support, and session lifecycle integrating PTY subprocess and emulator; tests included.
History & Write Queue
apps/desktop/src/main/lib/terminal-history.ts, apps/desktop/src/main/lib/terminal/pty-write-queue.ts
Disk-backed terminal history (HistoryWriter/HistoryReader) and PtyWriteQueue for backpressure-aware PTY writes.
Daemon Manager & Host Integration
apps/desktop/src/main/lib/terminal/daemon-manager.ts, apps/desktop/src/main/lib/terminal/terminal-host/*
DaemonTerminalManager delegating to daemon, reconciliation, session lifecycle, history wiring, public manager API and singleton helpers.
Workspace Runtime Abstraction
apps/desktop/src/main/lib/workspace-runtime/{types,registry,local,index}.ts, apps/desktop/src/main/lib/terminal/index.ts
New workspace runtime registry and LocalWorkspaceRuntime adapting in-process vs daemon backends; isDaemonModeEnabled and runtime selection APIs.
TRPC Router & Terminal API
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts, .../settings/index.ts, .../notifications.ts
createOrAttach enhanced (skipColdRestore, allowKilled), ackColdRestore, stream emits disconnect/error, new daemon management endpoints, and settings endpoints for terminalPersistence.
Renderer: Terminal & Helpers
apps/desktop/src/renderer/.../Terminal/Terminal.tsx, helpers.ts, types.ts, attach-scheduler.ts, hooks/useTerminalConnection.ts
Terminal refactor: isTabVisible prop, attach scheduler, cold-restore overlays and flows, renderer selection, chunked paste, and stable useTerminalConnection hook.
Tabs / Persistence UI
apps/desktop/src/renderer/.../TabsContent/index.tsx, TabView/{index.tsx,TabPane.tsx}, routes/_authenticated/settings/terminal/page.tsx, .../GeneralSettings.tsx
Bounded multi-mount terminal persistence rendering, settings page to toggle persistence and manage daemon sessions, and settings sidebar entry.
Port Manager & Integration
apps/desktop/src/main/lib/terminal/port-manager.ts, apps/desktop/src/lib/trpc/routers/workspaces/procedures/{branch,delete}.ts, apps/desktop/src/lib/trpc/routers/projects/projects.ts
Port hint detection, daemon session tracking in PortManager, and wiring of workspace-scoped runtime for workspace/project flows.
App Startup, Env & Dev Utilities
apps/desktop/src/main/index.ts, .../app-environment.ts, .../local-db/index.ts, .../terminal/dev-reset.ts, .../menu.ts, .../windows/main.ts
Ensure superset home dir permissions, pre-auth reconcile/shutdown orphaned daemon on startup, DB permission handling, dev reset utility, and notifications wiring for terminal-exit.
Renderer State & Tracking
apps/desktop/src/renderer/lib/terminal-kill-tracking.ts, apps/desktop/src/renderer/stores/tabs/..., apps/desktop/src/renderer/stores/hotkeys/store.ts, apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts, apps/desktop/src/renderer/screens/.../TabView/*
In-memory kill-tracking, minor store imports/guards, and agent handling for TERMINAL_EXIT.
Schema & Migrations
packages/local-db/drizzle/0011_add_terminal_persistence.sql, packages/local-db/src/schema/schema.ts, packages/local-db/drizzle/meta/_journal.json, packages/local-db/drizzle/meta/0011_snapshot.json
Adds terminal_persistence boolean column and migration/meta updates.
Build & Constants
apps/desktop/electron.vite.config.ts, apps/desktop/src/shared/constants.ts
Rollup entries for terminal-host and pty-subprocess, new DEFAULT_TERMINAL_PERSISTENCE and TERMINAL_EXIT notification constant.
Docs & Plans
apps/desktop/docs/*, apps/desktop/plans/*
New documentation and planning artifacts for terminal host events, runtime architecture, and DX hardening.
Tests
many new/updated tests under apps/desktop/src/main/... and apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts
Integration and unit tests covering daemon protocol, headless emulator, session lifecycle, stream behaviors, and router streaming semantics.

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

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~180 minutes

Possibly related PRs

Suggested reviewers

  • AviPeltz

Poem

🐰 I dug a socket in the ground, a cozy, secret lair,

A token, frames, and headless blooms—terminals sleep there.
Scrollback tucked like carrots, snapshots snug and bright,
Daemon hums a lullaby through every quiet night.
Hop, reboot, restore — the shells wake up with cheer.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat(desktop): terminal persistence via daemon process' clearly and concisely describes the main change: implementing terminal persistence using a background daemon process.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes a clear summary, user-visible behavior, architecture diagrams, key DX/performance changes, cold-restore regression fix explanation, verification matrix, and validation steps. All major sections from the template are covered with detailed content.
Linked Issues check ✅ Passed The PR addresses the core objectives from linked issue #518: terminal session persistence with CWD restore, scrollback restoration, safe lifecycle management, cold-restore semantics, and many hardening improvements. While #518 specified a tmux-based approach, this PR implements a Superset-owned daemon alternative that achieves the same user-visible goals and satisfies the stated requirements.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing terminal persistence via daemon process and hardening the feature. File additions span the daemon implementation (terminal-host/), runtime abstraction (workspace-runtime/), enhanced managers, renderer scheduling/persistence logic, database schema for settings, documentation, tests, and supporting infrastructure—all in scope for the stated objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a 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:

  1. 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 undefined
  2. Type 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 \r characters.

The newline index calculation treats \r as a line terminator (via Math.max), but the actual line splitting on line 371 uses /\r?\n/ which only handles \r\n or \n, not standalone \r (classic Mac line endings).

This could cause issues:

  • If output contains "foo\rbar\n", the lastNewlineIndex would be 7 (the \n), which is correct.
  • But if output is "foo\rbar" with no \n, lastNewlineIndex would 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 \r boundary, 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_PATTERNS for 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 4096 and 2048 for sampling thresholds should be extracted to named constants for clarity and consistency with MAX_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 terminalPersistence query has no loading state check. During initial load, terminalPersistence will be undefined, 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 of PATH. While useful for debugging, this could inadvertently log sensitive environment variables in production. Consider gating this behind DEBUG_OUTPUT_BATCHING or 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, handleDispose immediately sends SIGKILL without first attempting SIGTERM. 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(), and sendRequest() functions are nearly identical between this file and session-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() returns false when 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 in write() 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 in writeToHistory initiated without awaiting or error boundary.

Lines 273-280 call initHistoryWriter without 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_DIR is already exported from apps/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: isSocketLive uses require instead of import.

Line 452 uses dynamic require("node:net") when Socket is 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 Socket is already imported, use it directly (though you'd need the Socket class, 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 every getActiveTerminalManager call 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 spawn with detached: true should 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, scheduleSessionCleanup will 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 the CreateOrAttachResult type 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.current condition doesn't cause silent failures.

In handleStreamData, when !xtermRef.current or !isStreamReadyRef.current, events are queued. However, if xtermRef.current becomes null permanently (e.g., after cleanup), events will accumulate in pendingEventsRef indefinitely 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 cleanup
apps/desktop/src/main/terminal-host/session.ts (2)

161-252: Subprocess spawn logic has a potential issue with env filtering.

The processEnv is built by filtering process.env, but then spawn() uses { ...process.env, ELECTRON_RUN_AS_NODE: "1" } directly (line 195-198), ignoring the carefully filtered processEnv. The filtered env is only used in pendingSpawn which 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, after this.subprocess = null on line 825, the subprocess reference 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 set this.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 = 0 in the direct write path (line 395) means chunks are processed via setTimeout(..., 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

@github-actions
Copy link

github-actions bot commented Jan 6, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch
  • ✅ Electric Fly.io app

Thank you for your contribution! 🎉

Copy link
Collaborator Author

@andreasasprou andreasasprou left a 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

Copy link

@coderabbitai coderabbitai bot left a 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 when daemonSessions?.sessions is undefined, defeating the memoization of aliveSessions and sessionsSorted.

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

formatTimestamp is 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

📥 Commits

Reviewing files that changed from the base of the PR and between b2f8de2 and e0a1b02.

📒 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 in src/lib/trpc
Use alias as defined in tsconfig.json when 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/observable instead of async generators, as the library explicitly checks isObservable(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 using any type - 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:fix or biome 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.

Comment on lines +252 to +263
<Button
variant="secondary"
size="sm"
disabled={
!daemonModeEnabled ||
aliveSessions.length === 0 ||
clearTerminalHistory.isPending
}
onClick={() => setConfirmClearHistoryOpen(true)}
>
Clear terminal history
</Button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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

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

Comment on lines +374 to +380
onClick={() => {
setConfirmKillAllOpen(false);
for (const session of sessions) {
markTerminalKilledByUser(session.sessionId);
}
killAllDaemonSessions.mutate();
}}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Copy link

@coderabbitai coderabbitai bot left a 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: Inconsistent SAFE_ID validation on paneId across procedures.

createOrAttach validates paneId with SAFE_ID (preventing path traversal), but write, resize, signal, kill, detach, clearScrollback, ackColdRestore, getSession, and stream all accept plain z.string().

If paneId is used to construct file paths (e.g., for history persistence in daemon mode), this inconsistency could expose path traversal vulnerabilities. Apply SAFE_ID consistently 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 terminal object 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 of userKilledSessions Set.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 81b435e and c212b86.

📒 Files selected for processing (2)
  • apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
  • apps/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 in src/lib/trpc
Use alias as defined in tsconfig.json when 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/observable instead of async generators, as the library explicitly checks isObservable(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 using any type - 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:fix or biome 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_ID validation, debug telemetry with callId tracking, and the allowKilled escape 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.

Comment on lines +303 to +317
// 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();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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

andreasasprou added a commit to andreasasprou/superset that referenced this pull request Jan 15, 2026
feat(desktop): terminal persistence via daemon process
Copy link
Collaborator

@AviPeltz AviPeltz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants