Commit 213cc65
authored
feat(desktop): terminal persistence via daemon process (#619)
* feat(desktop): terminal persistence via daemon process
Add terminal session persistence using a background daemon process that
survives app restarts. Terminals can now be resumed with full scrollback
and TUI state (like Claude Code) exactly where they left off.
Key changes:
- New terminal host daemon with per-session PTY subprocesses
- DaemonTerminalManager as drop-in replacement for TerminalManager
- Settings toggle for terminal persistence (requires app restart)
- Schema migration for terminal_persistence setting
- Smooth workspace/tab switching via CSS visibility (avoids remount)
* fix(desktop): add @xterm/headless dependency and fix type error
- Add missing @xterm/headless package to desktop dependencies
- Use @ts-expect-error for known xterm addon type mismatch
(SerializeAddon types expect @xterm/xterm but works with @xterm/headless)
* fix(desktop): address CodeRabbit review feedback
- Await async handler in terminal-host/index.ts to prevent unhandled promise rejections
- Track session cleanup timeouts in daemon-manager.ts and clear on dispose to prevent memory leaks
- Move headless-emulator.test.ts to co-locate with implementation (from __tests__/ subfolder)
- Fix timeout race conditions in test files by tracking settlement state and clearing timeouts
* fix(desktop): fix CI errors after test file move
- Update relative imports in headless-emulator.test.ts after moving from __tests__/
- Externalize @xterm/* packages in Vite config (incorrect package exports for bundlers)
* refactor(desktop): centralize DEFAULT_TERMINAL_PERSISTENCE constant
Extract the terminal persistence default value (false) into a shared
constant to avoid duplicating magic values across the codebase.
* refactor(desktop): address PR review comments
- Add SUPERSET_TERMINAL_DEBUG env var for conditional debug logging
- Wrap verbose console.log statements in debug checks (terminal router
and terminal host client)
- Make sendRequest<T> generic to eliminate type casts in public API
- Use switch statement for event payload narrowing
- Remove unused imports (IpcSuccessResponse, IpcErrorResponse)
* fix(desktop): align @xterm/headless version with @xterm/xterm
Downgrade @xterm/headless from ^6.0.0 to ^5.5.0 to match @xterm/xterm
version. Mismatched major versions can cause runtime failures.
Also removes now-unnecessary @ts-expect-error directive since matching
versions resolve the type compatibility issue.
* fix(desktop): address code review feedback for terminal persistence
P0 Fixes:
- Use getActiveTerminalManager() instead of direct terminalManager import
in workspaces.ts, projects.ts, and main/windows/main.ts
- Add missing await for async getSessionCountByWorkspaceId() call
- Update reconcileOnStartup() to preserve sessions for true app restart
persistence, only killing orphaned sessions (deleted workspaces)
P1 Fixes:
- Call shutdownOrphanedDaemon() on startup when persistence is disabled
- Fix error code mismatch: QUEUE_FULL -> WRITE_QUEUE_FULL in client.ts
P2 Fixes:
- Only keep terminal-containing tabs mounted when persistence enabled,
non-terminal tabs use normal unmount behavior to save memory
Additional Fixes (from runtime testing):
- Fix resize race condition: forward resize to daemon regardless of
local session cache state (handles startup race)
- Add safety checks in initHistoryWriter: validate scrollback is string
and cap at 512KB to prevent RangeError: Invalid array length
* fix(desktop): guard pane updates against deleted panes
When async processes like Claude Code still hold pane references
after the terminal is closed, calling setNeedsAttention (or similar)
would create an undefined entry in the panes record. This caused
GroupStrip to crash when iterating Object.values(panes).
Now all pane update functions check if the pane exists first and
return early (no-op) if it doesn't, preventing undefined entries.
* fix(desktop): split terminal host control/stream sockets
Prevents createOrAttach timeouts by removing head-of-line blocking when terminal output backpressures.
Adds protocol v2 hello with clientId/role, pairs control+stream sockets, and adds a backpressure isolation integration test.
* chore(desktop): biome format + archive execplan
Formats a few files to satisfy biome format checks and moves the terminal host dual-socket ExecPlan to apps/desktop/plans/done/.
* fix(desktop): spawn daemon when token missing
Read terminal-host auth token after ensuring a daemon exists; if token is missing with a live socket, restart the daemon to re-create a coherent socket+token pair.
* fix(desktop): harden terminal persistence data perms
Ensure ~/.superset* is created/repair-chmodded to 0700 and history/log files are written with 0600 where applicable.
Also closes the daemon.log fd in the parent after spawning the daemon.
* fix(desktop): address terminal persistence review feedback
- Keep terminal tabs mounted even when switching to empty workspaces
- Gate noisy logs behind SUPERSET_TERMINAL_DEBUG
- Use TRPCError for settings validation
- Fix history reinit sizing + avoid silent catch blocks
- Gate PTY subprocess spawn logging
* fix(desktop): bundle @XTerm packages for terminal-host daemon
The daemon runs as standalone Node.js outside app.asar and needs
@xterm/headless and @xterm/addon-serialize bundled to function.
* wip: dx hardening plan
* fix(desktop): remove obsolete setNeedsAttention after rebase
PR #588 replaced needsAttention with PaneStatus (idle/working/permission/review).
Remove the obsolete setNeedsAttention method and update plan doc references.
* docs(desktop): update terminal persistence exec plan
* fix(desktop): harden terminal persistence DX
* fix(desktop): prevent history init buffer loop
* fix(desktop): enable terminal stream when snapshot empty
* fix(desktop): prevent scheduler deadlock on React StrictMode unmount
React StrictMode simulates mount → unmount → mount cycles. The terminal
attach scheduler was deadlocking because:
1. First mount starts a task (inFlight++)
2. Unmount cancels the task but it's still executing
3. Second mount queues a new task
4. tRPC callbacks for unmounted components don't fire reliably
5. done() never gets called → inFlight stays stuck at MAX_CONCURRENT
Fix: Track running tasks per paneId and immediately decrement inFlight
when canceling a running task. Also add optional debug logging (enable
via localStorage.setItem('SUPERSET_TERMINAL_DEBUG', '1')).
* fix(desktop): address PR review blocking issues
- P0: Fix attach-scheduler race condition where inFlight counter could be
double-decremented when cancel() and done() both fire for the same task.
Added `released` flag to ensure idempotent completion.
- P1: Fix sendRequestOnStream NDJSON parsing bug that dropped messages
arriving in the same TCP read as the hello response. Now feeds remainder
data to streamParser after parsing first response.
- P1: Fix maybeApplyInitialState catch block that would wedge terminal on
restoration error. Now fail-open by setting isStreamReady and flushing
pending events even on error.
- P2: Fix coldRestoreState memory leak by cleaning up on unmount. Previously
scrollback (potentially MBs per pane) was only cleared on "Start Shell"
click, not on component unmount.
* fix(desktop): address oracle feedback on PR fixes
- Move coldRestoreState cleanup into detachTimeout to preserve StrictMode
unmount/remount semantics. The module-level Map is specifically designed
to survive quick remounts, so deleting immediately on unmount was wrong.
- Harden attach-scheduler cancel path: when a running task is canceled,
re-queue any waiting task for the same paneId. This mirrors the done()
behavior and protects against the "done never fires" scenario.
* docs(desktop): document ordering assumption in sendRequestOnStream
Per oracle review suggestion: add JSDoc comment explaining that the daemon's
hello handler guarantees response is first frame. Documents when this assumption
would need to change (if daemon ever emits events before hello response).
* fix(desktop): resolve type errors after rebase onto main
- Add stub terminal-history module (real impl in Phase 4)
- Fix port-manager: add checkOutputForHint method, await async calls
- Fix TabsContent: add panes store selector
- Fix TabView: add Pane type import
- Fix daemon-manager: handle null scrollback from stub reader
* fix(desktop): implement daemon signal() support for SIGINT/SIGTERM
The daemon's signal() method was a no-op, which meant Ctrl+C through
the daemon pathway would silently fail. This adds full signal support:
- Add SignalRequest type and signal to RequestTypeMap
- Add Signal IPC frame type (6) distinct from Kill
- Implement handleSignal in pty-subprocess without kill escalation
- Add sendSignal chain through session → terminal-host → daemon → client
- Update daemon-manager to use client.signal() instead of no-op
Unlike kill(), signal() does not mark the session as terminating and
does not escalate to SIGKILL, allowing the process to continue running.
* docs(desktop): add terminal host event semantics documentation
Documents the event delivery model for the daemon protocol:
- Event types (data, exit, error)
- Dual-socket model (control vs stream)
- At-most-once delivery semantics (no durability/retries)
- In-order guarantees within sessions
- Multi-level backpressure handling
- Error codes and race condition handling
* feat(desktop): implement cold restore terminal history persistence
Replaces the stub terminal-history.ts with a working implementation
for Phase 4 of terminal persistence. Enables terminal recovery after
app/system restarts when the daemon is not running.
Storage format:
- scrollback.bin: Raw PTY output (append-only)
- meta.json: Session metadata (cols, rows, cwd, timestamps)
Cold restore detection:
- meta.json without endedAt → unclean shutdown → can restore
- meta.json with endedAt → clean shutdown → no restore
HistoryWriter API:
- init(initialScrollback?) - create directory and files
- write(data) - append PTY output
- flush() - flush pending writes
- close(exitCode?) - write endedAt to meta.json
- reinitialize() - reset for clear scrollback
- deleteHistory() - remove all files
HistoryReader API:
- exists() - check if history available
- readMetadata() - get cols/rows/cwd/endedAt
- readScrollback() - get terminal content
- cleanup() - delete history files
* feat(desktop): add telemetry for terminal persistence events
Adds tracking for key terminal persistence lifecycle events:
- terminal_cold_restored: Triggered when recovering terminal after reboot
with scrollback_bytes to measure restoration payload size
- terminal_warm_attached: Triggered when reconnecting to existing daemon
session with snapshot_bytes for payload metrics
- terminal_daemon_disconnected: Triggered on daemon connection loss with
active_session_count for impact assessment
* fix(desktop): trigger cold restore on daemon session loss
When daemon restarts and loses sessions, "Session not found" errors
were treated as non-fatal (just showed toast). This prevented the
retry UI from appearing and cold restore from triggering.
Changes:
- Promote "Session not found" WRITE_FAILED errors to connection error
so retry UI appears instead of endless toast spam
- Handle isColdRestore in handleRetryConnection so clicking retry
can trigger cold restore if disk history is available
- Update both event handler locations for consistency
Now when daemon dies and user clicks retry, cold restore kicks in
if history exists on disk (meta.json without endedAt).
* fix(desktop): suppress toast when showing retry UI for session loss
When daemon restarts and loses terminal sessions, show only the retry UI
without also showing a toast notification. This prevents confusing UX
where both a toast and the retry overlay appear simultaneously.
* fix(desktop): suppress toast for transient PTY not spawned errors
During daemon recovery, writes may arrive before the PTY subprocess
is fully initialized. Treat "PTY not spawned" as a transient error
that doesn't need a toast notification - just log to terminal.
* fix(desktop): clear connection error on successful initial attach
When daemon restarts and component remounts, the background
createOrAttach may succeed while the error overlay is still visible.
Clear connectionError on success to dismiss the overlay automatically.
* fix(desktop): trigger cold restore on daemon session loss
When "Session not found" error occurs, clear the stale cache entry
so the next createOrAttach properly checks disk history and triggers
cold restore instead of creating a new session.
* fix(desktop): re-focus terminal after successful retry connection
After clicking "Retry Connection" and the connection succeeds,
re-focus the terminal so keyboard input works immediately.
Skip focus for cold restore since user needs to click overlay button.
* fix(desktop): cold restore for TUI apps with empty scrollback
Two fixes:
1. Check rawScrollback === null instead of !rawScrollback. TUI apps
in alternate screen may have empty normal buffer, which is still
valid for cold restore (empty string is truthy check fix).
2. Use fresh xterm ref in handleRetryConnection onSuccess callback
to handle potential component remount during async operation.
* fix(desktop): focus terminal after clicking Start Shell
After cold restore, clicking "Start Shell" creates a new session but
wasn't focusing the terminal, causing keystrokes to go elsewhere.
* fix(desktop): keep terminal stream alive on exit
* chore(desktop): fix biome check
* chore(desktop): fix lint warnings
* fix(desktop): address persistence review blockers
* fix(desktop): harden terminal history caps
* docs(desktop): add terminal runtime abstraction plan
* docs(desktop): expand plan for Terminal.tsx decomposition
* docs(desktop): add target architecture snippets to plan
* docs(desktop): refine terminal runtime abstraction plan
* docs(desktop): add remote runner notes to plan
* docs(desktop): align terminal runtime plan with cloud provider direction
* docs(desktop): add terminal runtime architecture review packet
* docs(desktop): narrow changes router reference list
* docs(desktop): incorporate architecture feedback into runtime rewrite plan
* refactor(desktop): introduce WorkspaceRuntime abstraction
Adds a provider-neutral runtime layer that abstracts terminal backend selection:
- WorkspaceRuntimeRegistry: process-scoped registry for runtime selection
- LocalTerminalRuntime: adapts TerminalManager/DaemonTerminalManager
- Capability-based checks: uses `terminal.management !== null` instead of
`instanceof DaemonTerminalManager`
Key changes:
- New workspace-runtime module with types, registry, and local implementation
- tRPC terminal router migrated to use registry pattern
- All call sites updated to use getForWorkspaceId when workspaceId is in-hand
- Regression tests for capability presence and stream contract
This foundation enables future cloud workspace providers without spreading
backend-specific branching throughout the codebase.
* WIP: route-based settings/dashboard structure alignment with upstream
- Add _dashboard route group with workspace/tasks pages
- Rebuild settings routing/layout/sidebar under routes
- Port settings pages: account, appearance, keyboard, presets, team, ringtones, project, workspace
- Add terminal settings page at /settings/terminal
- Update navigation components to use router instead of app-state
- Remove obsolete route pages that conflict with upstream _dashboard structure
Still needs:
- Full merge with origin/main
- Complete app-state removal
- Terminal backend conflict resolution
* fix(desktop): improve terminal kill-all reliability and UI feedback
Fixes two bugs in the terminal settings page:
1. UI not refreshing after kill: Added optimistic UI clearing
- Clear session list immediately on click for instant feedback
- Rollback on error, delayed refetch on success
2. Sessions surviving kill: Added wait/verify loop in backend
- Poll daemon up to 10x100ms until sessions are actually dead
- Return accurate killedCount and remainingCount
- Show warning toast if some sessions survive
Also adds diagnostic logging for troubleshooting kill operations.
* fix(desktop): keep killed terminals dead
* fix(desktop): disable terminal session actions when none
* fix(desktop): memoize terminal session lists
* remove old code
* fix(desktop): log best-effort failures in terminal utils
* fix: harden terminal persistence paths and logs
* chore: format terminal router and client1 parent b2be236 commit 213cc65
65 files changed
Lines changed: 16173 additions & 249 deletions
File tree
- apps/desktop
- docs
- plans
- done
- src
- lib/trpc/routers
- projects
- settings
- terminal
- workspaces/procedures
- main
- lib
- local-db
- terminal-host
- terminal
- workspace-runtime
- terminal-host
- windows
- renderer
- lib
- routes/_authenticated/settings
- components/SettingsSidebar/components/GeneralSettings
- terminal
- screens/main/components/WorkspaceView/ContentView/TabsContent
- TabView
- Terminal
- hooks
- stores
- hotkeys
- tabs
- utils
- packages/local-db
- drizzle
- meta
- src/schema
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
67 | 71 | | |
68 | 72 | | |
69 | 73 | | |
| |||
0 commit comments