diff --git a/package.json b/package.json index 4f85b102..2faf3940 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9f29e43..47a1fd79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 zustand: specifier: ^5.0.12 version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -6014,6 +6017,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.12: resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} engines: {node: '>=12.20.0'} @@ -12254,6 +12260,8 @@ snapshots: zod@3.25.76: {} + zod@4.3.6: {} + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts new file mode 100644 index 00000000..718bc457 --- /dev/null +++ b/src/main/daemon/daemon-init.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import type { SubprocessHandle } from './session' +import type * as DaemonInitModule from './daemon-init' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock, + getAppPath: () => process.cwd(), + isPackaged: false + } +})) + +// Why: we want the real DaemonServer + DaemonClient but not electron-based +// subprocess spawning. createTestDaemon() wires a mock subprocess harness +// compatible with daemon-spawner.test.ts. +function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 77777, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(), + signal: vi.fn(), + onData(_cb: (data: string) => void) {}, + onExit(cb: (code: number) => void) { + onExitCb = cb + } + } +} + +async function importFreshDaemonInit(): Promise { + vi.resetModules() + return import('./daemon-init') +} + +describe('cleanupOrphanedDaemon', () => { + let userDataDir: string + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'daemon-init-test-')) + getPathMock.mockImplementation(() => userDataDir) + }) + + afterEach(() => { + rmSync(userDataDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('returns cleaned=false when no daemon socket exists', async () => { + const { cleanupOrphanedDaemon } = await importFreshDaemonInit() + + const result = await cleanupOrphanedDaemon() + expect(result.cleaned).toBe(false) + expect(result.killedCount).toBe(0) + }) + + it('kills live sessions and shuts down a running daemon', async () => { + const { cleanupOrphanedDaemon } = await importFreshDaemonInit() + const { DaemonSpawner, getDaemonSocketPath } = await import('./daemon-spawner') + const { startDaemon } = await import('./daemon-main') + const { DaemonClient } = await import('./client') + + const runtimeDir = join(userDataDir, 'daemon') + const { mkdirSync } = await import('fs') + mkdirSync(runtimeDir, { recursive: true }) + + // Spin up a real daemon exactly where cleanupOrphanedDaemon will look. + const daemonHandles: { shutdown: () => Promise }[] = [] + const spawner = new DaemonSpawner({ + runtimeDir, + launcher: async (socketPath, tokenPath) => { + const handle = await startDaemon({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + daemonHandles.push(handle) + return { shutdown: () => handle.shutdown() } + } + }) + const info = await spawner.ensureRunning() + + // Create two sessions so killedCount is non-zero. + const client = new DaemonClient({ + socketPath: info.socketPath, + tokenPath: info.tokenPath + }) + await client.ensureConnected() + await client.request('createOrAttach', { sessionId: 'a', cols: 80, rows: 24 }) + await client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 }) + client.disconnect() + + // Now the daemon looks "orphaned" from cleanupOrphanedDaemon's POV. + const result = await cleanupOrphanedDaemon() + expect(result.cleaned).toBe(true) + expect(result.killedCount).toBeGreaterThanOrEqual(2) + + // Socket file should be gone so a later opt-in relaunch can bind cleanly. + if (process.platform !== 'win32') { + expect(existsSync(getDaemonSocketPath(runtimeDir))).toBe(false) + } + + // Best-effort teardown of any surviving handles from the spawner side. + for (const handle of daemonHandles) { + await handle.shutdown().catch(() => {}) + } + }) +}) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index f7ae53e7..eba9943f 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -3,8 +3,15 @@ import { app } from 'electron' import { mkdirSync, existsSync, unlinkSync } from 'fs' import { fork } from 'child_process' import { connect } from 'net' -import { DaemonSpawner, type DaemonLauncher } from './daemon-spawner' +import { + DaemonSpawner, + getDaemonSocketPath, + getDaemonTokenPath, + type DaemonLauncher +} from './daemon-spawner' import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonClient } from './client' +import type { ListSessionsResult } from './types' import { setLocalPtyProvider } from '../ipc/pty' let spawner: DaemonSpawner | null = null @@ -177,3 +184,75 @@ export async function shutdownDaemon(): Promise { await spawner?.shutdown() spawner = null } + +export type OrphanedDaemonCleanupResult = { + /** True when we detected a live daemon socket and connected to tear it down. + * False when no daemon was running (fresh install or clean previous quit). */ + cleaned: boolean + /** Number of live PTY sessions killed during cleanup. The caller surfaces this + * to the user so they know what background work was stopped. */ + killedCount: number +} + +/** Detect and tear down an orphaned daemon left behind by a previous app + * session (e.g. a user who had `experimentalTerminalDaemon` enabled on an + * older build and is now launching a build where the feature is disabled). + * + * Why it matters: the daemon is designed to outlive the Electron process. + * If we just skip `initDaemonPtyProvider()` on this launch, any live sessions + * from the previous session keep running invisibly — consuming CPU / holding + * files open / re-launching on every boot because nothing ever kills them. + * This helper connects to the existing socket, enumerates sessions, and asks + * the daemon to shut itself down (which terminates all PTYs). */ +export async function cleanupOrphanedDaemon(): Promise { + const runtimeDir = getRuntimeDir() + const socketPath = getDaemonSocketPath(runtimeDir) + const tokenPath = getDaemonTokenPath(runtimeDir) + + const alive = await probeSocket(socketPath) + if (!alive) { + // Why: still best-effort remove a stale socket file so a future opt-in + // launch doesn't hit EADDRINUSE when the daemon tries to bind. + if (process.platform !== 'win32' && existsSync(socketPath)) { + try { + unlinkSync(socketPath) + } catch { + // Best-effort + } + } + return { cleaned: false, killedCount: 0 } + } + + const client = new DaemonClient({ socketPath, tokenPath }) + let killedCount = 0 + try { + await client.ensureConnected() + const sessions = await client + .request('listSessions', undefined) + .catch(() => ({ sessions: [] })) + killedCount = sessions.sessions.filter((s) => s.isAlive).length + + // Why: the daemon exposes a single-shot `shutdown` RPC (daemon-server.ts:263) + // that kills every session and then terminates its own process. Using it + // avoids the race between per-session `kill` calls and the daemon exiting. + await client.request('shutdown', { killSessions: true }).catch(() => { + // Daemon exits immediately after handling the RPC — the socket may close + // before the reply round-trips. Treat that as success. + }) + } finally { + client.disconnect() + } + + // Why: after `shutdown`, the daemon unlinks its socket itself — but on some + // crash paths the file lingers. Clean up defensively so a later opt-in + // relaunch can bind cleanly. + if (process.platform !== 'win32' && existsSync(socketPath)) { + try { + unlinkSync(socketPath) + } catch { + // Best-effort + } + } + + return { cleaned: true, killedCount } +} diff --git a/src/main/index.ts b/src/main/index.ts index 0602e262..c93839e3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,12 @@ import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { killAllPty } from './ipc/pty' -import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' +import { + initDaemonPtyProvider, + disconnectDaemon, + cleanupOrphanedDaemon +} from './daemon/daemon-init' +import { recordPendingDaemonTransitionNotice, setAppRuntimeFlags } from './ipc/app' import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' import { triggerStartupNotificationRegistration } from './ipc/notifications' @@ -152,15 +157,39 @@ app.whenReady().then(async () => { userDataPath: app.getPath('userData') }) - // Why: daemon must start before openMainWindow because registerPtyHandlers - // (called inside) relies on the provider already being set. Starting it - // alongside the other parallel servers keeps cold-start latency flat. - // Why: catch so the app still opens even if the daemon fails. The local - // PTY provider remains as the fallback — terminals will still work, just - // without cross-restart persistence. - await initDaemonPtyProvider().catch((error) => { - console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) - }) + // Why: persistent terminal sessions (the out-of-process daemon) are gated + // behind an experimental setting that defaults to OFF. Users on v1.3.0 had + // the daemon on by default, so on upgrade we may need to clean up a live + // daemon from their previous session before continuing with the local + // provider. `registerPtyHandlers` (called inside openMainWindow) relies on + // the provider being set, so whichever branch runs must complete first. + const daemonEnabled = store.getSettings().experimentalTerminalDaemon === true + let daemonStarted = false + if (daemonEnabled) { + // Why: catch so the app still opens even if the daemon fails. The local + // PTY provider remains as the fallback — terminals will still work, just + // without cross-restart persistence. + try { + await initDaemonPtyProvider() + daemonStarted = true + } catch (error) { + console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) + } + } else { + // Why: stash the cleanup result so the renderer's one-shot transition + // toast can tell the user how many background sessions were stopped. Only + // record when `cleaned: true` — i.e. an orphan daemon was actually found. + // Fresh installs (no socket) skip the toast entirely. + try { + const result = await cleanupOrphanedDaemon() + if (result.cleaned) { + recordPendingDaemonTransitionNotice({ killedCount: result.killedCount }) + } + } catch (error) { + console.error('[daemon] Failed to clean up orphaned daemon:', error) + } + } + setAppRuntimeFlags({ daemonEnabledAtStartup: daemonStarted }) // Why: both server binds are independent and neither blocks window creation. // Parallelizing them with the window open shaves ~100-200ms off cold start. diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts new file mode 100644 index 00000000..59b672d1 --- /dev/null +++ b/src/main/ipc/app.ts @@ -0,0 +1,52 @@ +import { app, ipcMain } from 'electron' + +export type AppRuntimeFlags = { + /** Whether the persistent terminal daemon was actually started this session. + * The renderer compares this against the current setting to decide whether + * a "restart required" banner needs to be shown on the Experimental pane. */ + daemonEnabledAtStartup: boolean +} + +export type DaemonTransitionNotice = { + /** Number of live daemon PTY sessions that were killed when the app booted + * with `experimentalTerminalDaemon: false` but discovered a leftover daemon + * from a previous session. Non-zero values are surfaced in a one-shot + * toast so the user knows background work was stopped. */ + killedCount: number +} + +let runtimeFlags: AppRuntimeFlags = { daemonEnabledAtStartup: false } +let pendingDaemonTransitionNotice: DaemonTransitionNotice | null = null + +export function setAppRuntimeFlags(flags: AppRuntimeFlags): void { + runtimeFlags = flags +} + +export function recordPendingDaemonTransitionNotice(notice: DaemonTransitionNotice): void { + pendingDaemonTransitionNotice = notice +} + +export function registerAppHandlers(): void { + ipcMain.handle('app:getRuntimeFlags', (): AppRuntimeFlags => runtimeFlags) + + ipcMain.handle('app:consumeDaemonTransitionNotice', (): DaemonTransitionNotice | null => { + // Why: one-shot consumption — clear after reading so the renderer's + // post-hydration effect can't fire the same toast twice (e.g. after a + // window reload during dev). The persisted `experimentalTerminalDaemonNoticeShown` + // flag is the cross-session guard; this clear handles within-session races. + const notice = pendingDaemonTransitionNotice + pendingDaemonTransitionNotice = null + return notice + }) + + ipcMain.handle('app:relaunch', () => { + // Why: small delay lets the renderer finish painting any "Restarting…" + // UI state before the window tears down. `app.relaunch()` schedules a + // spawn; `app.exit(0)` triggers the actual quit without invoking + // before-quit handlers that could block on confirmation dialogs. + setTimeout(() => { + app.relaunch() + app.exit(0) + }, 150) + }) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index ad060ece..683f2398 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -20,7 +20,8 @@ const { registerRateLimitHandlersMock, registerBrowserHandlersMock, setTrustedBrowserRendererWebContentsIdMock, - registerFilesystemWatcherHandlersMock + registerFilesystemWatcherHandlersMock, + registerAppHandlersMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), @@ -41,7 +42,8 @@ const { registerRateLimitHandlersMock: vi.fn(), registerBrowserHandlersMock: vi.fn(), setTrustedBrowserRendererWebContentsIdMock: vi.fn(), - registerFilesystemWatcherHandlersMock: vi.fn() + registerFilesystemWatcherHandlersMock: vi.fn(), + registerAppHandlersMock: vi.fn() })) vi.mock('./cli', () => ({ @@ -118,6 +120,10 @@ vi.mock('./browser', () => ({ setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock })) +vi.mock('./app', () => ({ + registerAppHandlers: registerAppHandlersMock +})) + import { registerCoreHandlers } from './register-core-handlers' describe('registerCoreHandlers', () => { @@ -142,6 +148,7 @@ describe('registerCoreHandlers', () => { registerBrowserHandlersMock.mockReset() setTrustedBrowserRendererWebContentsIdMock.mockReset() registerFilesystemWatcherHandlersMock.mockReset() + registerAppHandlersMock.mockReset() }) it('passes the store through to handler registrars that need it', () => { diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 83689efc..34d1baa3 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -1,3 +1,4 @@ +import { registerAppHandlers } from './app' import { registerCliHandlers } from './cli' import { registerPreflightHandlers } from './preflight' import type { Store } from '../persistence' @@ -52,6 +53,7 @@ export function registerCoreHandlers( } registered = true + registerAppHandlers() registerCliHandlers() registerPreflightHandlers() registerClaudeUsageHandlers(claudeUsage) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 7d830b79..33134575 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -17,6 +17,7 @@ import { getDefaultRepoHookSettings, getDefaultWorkspaceSession } from '../shared/constants' +import { parseWorkspaceSession } from '../shared/workspace-session-schema' // Why: the data-file path must not be a module-level constant. Module-level // code runs at import time — before configureDevUserDataPath() redirects the @@ -101,7 +102,27 @@ export class Store { _sortBySmartMigrated: true } })(), - workspaceSession: { ...defaults.workspaceSession, ...parsed.workspaceSession }, + // Why: the workspace session is the most volatile persisted surface + // (schema evolves per release, daemon session IDs embedded in it). + // Zod-validate at the read boundary so a field-type flip from an + // older build — or a truncated write from a crash — gets rejected + // cleanly instead of poisoning Zustand state and crashing the + // renderer on mount. On validation failure, fall back to defaults + // and log; a corrupt session file shouldn't trap the user out. + workspaceSession: (() => { + if (parsed.workspaceSession === undefined) { + return defaults.workspaceSession + } + const result = parseWorkspaceSession(parsed.workspaceSession) + if (!result.ok) { + console.error( + '[persistence] Corrupt workspace session, using defaults:', + result.error + ) + return defaults.workspaceSession + } + return { ...defaults.workspaceSession, ...result.value } + })(), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget) } } diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 23a4f8ba..13861930 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -215,7 +215,32 @@ export type CodexUsageApi = { }) => Promise } +export type AppRuntimeFlags = { + daemonEnabledAtStartup: boolean +} + +export type DaemonTransitionNotice = { + killedCount: number +} + +export type AppApi = { + /** Returns flags about the main-process state that was set at startup + * (e.g. whether the persistent terminal daemon actually started). The + * renderer uses this to show a "restart required" banner when the user + * toggles a setting that only applies across a full relaunch. */ + getRuntimeFlags: () => Promise + /** Reads and clears any pending one-shot notice about a daemon cleanup + * that ran during startup (e.g. when upgrading from v1.3.0 where the + * daemon was on by default to a build where it's opt-in). Returns null + * when there is nothing to show. */ + consumeDaemonTransitionNotice: () => Promise + /** Relaunches the app via Electron's app.relaunch() + app.exit(0). Used + * by the "Restart now" button on the Experimental settings pane. */ + relaunch: () => Promise +} + export type PreloadApi = { + app: AppApi repos: { list: () => Promise add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 04219207..3bf1fb70 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -159,6 +159,14 @@ document.addEventListener( // Custom APIs for renderer const api = { + app: { + getRuntimeFlags: (): Promise<{ daemonEnabledAtStartup: boolean }> => + ipcRenderer.invoke('app:getRuntimeFlags'), + consumeDaemonTransitionNotice: (): Promise<{ killedCount: number } | null> => + ipcRenderer.invoke('app:consumeDaemonTransitionNotice'), + relaunch: (): Promise => ipcRenderer.invoke('app:relaunch') + }, + repos: { list: (): Promise => ipcRenderer.invoke('repos:list'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index decbbcc3..9695467a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -153,6 +153,12 @@ function App(): React.JSX.Element { await actions.fetchAllWorktrees() const persistedUI = await window.api.ui.get() const session = await window.api.session.get() + // Why: settings must be loaded before hydrateWorkspaceSession so that + // it can read experimentalTerminalDaemon to decide whether to stage + // pendingReconnectPtyIdByTabId. Without this, opted-in daemon users + // would silently lose session reattach on every launch because + // s.settings would still be null at hydration time. + await actions.fetchSettings() if (!cancelled) { actions.hydratePersistedUI(persistedUI) actions.hydrateWorkspaceSession(session) @@ -197,7 +203,6 @@ function App(): React.JSX.Element { await actions.reconnectPersistedTerminals() } } - void actions.fetchSettings() void actions.initGitHubCache() })() @@ -368,6 +373,65 @@ function App(): React.JSX.Element { return () => document.removeEventListener('visibilitychange', handler) }, [actions]) + // Why: v1.3.0 shipped the persistent-terminal daemon ON by default. v1.3.1+ + // defaults it OFF and gates it behind an Experimental toggle. On the first + // launch after that upgrade, main detects a still-running daemon, shuts it + // down (killing any surviving `sleep 9999`-style sessions), and stashes a + // one-shot notice. We consume that notice here and inform the user so their + // vanished sessions don't look like a bug. The renderer-side + // `experimentalTerminalDaemonNoticeShown` flag guarantees the toast fires at + // most once per install, even if main stashes a notice again on a later + // launch. + const transitionNoticeHandledRef = useRef(false) + useEffect(() => { + if (!settings || transitionNoticeHandledRef.current) { + return + } + if (settings.experimentalTerminalDaemonNoticeShown) { + transitionNoticeHandledRef.current = true + return + } + transitionNoticeHandledRef.current = true + void (async () => { + let notice: { killedCount: number } | null = null + try { + notice = await window.api.app.consumeDaemonTransitionNotice() + } catch { + // Informational only — if the IPC fails, don't fire the toast and + // don't flip the "shown" flag so we can retry on next launch. + return + } + if (!notice) { + return + } + const killedCount = notice.killedCount + const killedClause = + killedCount > 0 + ? ` Cleaned up ${killedCount} background session${killedCount === 1 ? '' : 's'} from the previous version.` + : '' + toast.info('Persistent terminal sessions are now opt-in.', { + description: `${killedClause} You can re-enable them in Settings → Experimental.`.trim(), + duration: 15000, + action: { + label: 'Open settings', + onClick: () => { + useAppStore.getState().openSettingsTarget({ + pane: 'experimental', + repoId: null + }) + useAppStore.getState().openSettingsPage() + } + } + }) + try { + await actions.updateSettings({ experimentalTerminalDaemonNoticeShown: true }) + } catch { + // If persistence fails, the toast may re-fire on a later launch — + // acceptable tradeoff vs. silently dropping the notification. + } + })() + }, [actions, settings]) + const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : [] const hasTabBar = tabs.length >= 2 const effectiveActiveTabId = activeTabId ?? tabs[0]?.id ?? null diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx new file mode 100644 index 00000000..4e3cdc6e --- /dev/null +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react' +import { RotateCw } from 'lucide-react' +import type { GlobalSettings } from '../../../../shared/types' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { useAppStore } from '../../store' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch } from './settings-search' +import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './experimental-search' + +export { EXPERIMENTAL_PANE_SEARCH_ENTRIES } + +type ExperimentalPaneProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function ExperimentalPane({ + settings, + updateSettings +}: ExperimentalPaneProps): React.JSX.Element { + const searchQuery = useAppStore((s) => s.settingsSearchQuery) + // Why: "daemon enabled at startup" is the effective runtime state, read + // directly from main once on mount. The banner compares the user's current + // setting against this snapshot to tell them a restart is still required. + // null = not yet fetched (banner stays hidden to avoid a flash). + const [daemonEnabledAtStartup, setDaemonEnabledAtStartup] = useState(null) + const [relaunching, setRelaunching] = useState(false) + + useEffect(() => { + let cancelled = false + void window.api.app + .getRuntimeFlags() + .then((flags) => { + if (!cancelled) { + setDaemonEnabledAtStartup(flags.daemonEnabledAtStartup) + } + }) + .catch(() => { + // Non-fatal; banner will just never show if the IPC is unavailable. + }) + return () => { + cancelled = true + } + }, []) + + const showDaemon = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[0]]) + const pendingRestart = + daemonEnabledAtStartup !== null && + settings.experimentalTerminalDaemon !== daemonEnabledAtStartup + + const handleRelaunch = async (): Promise => { + if (relaunching) { + return + } + setRelaunching(true) + try { + await window.api.app.relaunch() + } catch { + setRelaunching(false) + } + } + + return ( +
+ {showDaemon ? ( + +
+
+ +

+ Keeps terminals alive in a background daemon so they survive app restarts, with full + scrollback. Experimental — some sessions may become unresponsive after internal + state drift. Requires an app restart to take effect. +

+
+ +
+ + {pendingRestart ? ( +
+
+

+ Restart required +

+

+ {settings.experimentalTerminalDaemon + ? 'Restart Orca to start the background session daemon.' + : 'Restart Orca to stop the background session daemon. Any running background sessions will be closed.'} +

+
+ +
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2043937a..a7043fdf 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -4,6 +4,7 @@ import { BarChart3, Bell, Bot, + FlaskConical, GitBranch, Globe, Keyboard, @@ -28,6 +29,7 @@ import { getTerminalPaneSearchEntries } from './terminal-search' import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane' import { NotificationsPane, NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './NotificationsPane' import { SshPane, SSH_PANE_SEARCH_ENTRIES } from './SshPane' +import { ExperimentalPane, EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './ExperimentalPane' import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane' import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane' import { SettingsSidebar } from './SettingsSidebar' @@ -44,6 +46,7 @@ type SettingsNavTarget = | 'shortcuts' | 'stats' | 'ssh' + | 'experimental' | 'agents' | 'repo' @@ -302,6 +305,13 @@ function Settings(): React.JSX.Element { searchEntries: SSH_PANE_SEARCH_ENTRIES, badge: 'Beta' }, + { + id: 'experimental', + title: 'Experimental', + description: 'Features that are still being stabilized. Enable at your own risk.', + icon: FlaskConical, + searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES + }, ...repos.map((repo) => ({ id: `repo-${repo.id}`, title: repo.displayName, @@ -543,6 +553,15 @@ function Settings(): React.JSX.Element { + + + + {repos.map((repo) => { const repoSectionId = `repo-${repo.id}` const repoHooksState = repoHooksMap[repo.id] diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts new file mode 100644 index 00000000..821ed6ad --- /dev/null +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -0,0 +1,20 @@ +import type { SettingsSearchEntry } from './settings-search' + +export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Persistent terminal sessions', + description: + 'Keeps terminal sessions alive across app restarts via a background daemon. Experimental — some sessions may become unresponsive.', + keywords: [ + 'experimental', + 'terminal', + 'daemon', + 'persistent', + 'background', + 'sessions', + 'restart', + 'scrollback', + 'reattach' + ] + } +] diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index f8cd2bd9..ee978fcb 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -654,6 +654,22 @@ vi.mock('@/components/terminal-pane/pty-transport', () => ({ describe('reconnectPersistedTerminals', () => { let ptyIdCounter: number + // Why: reconnect-by-daemon-session-ID is an opt-in path (the experimental + // daemon toggle). These tests exercise that path, so each store created here + // must have the toggle set to true before hydrateWorkspaceSession runs — + // otherwise hydration clears pendingReconnectPtyIdByTabId and tab.ptyId + // never gets rehydrated. + function createDaemonEnabledStore(): ReturnType { + const store = createTestStore() + store.setState((prev) => ({ + settings: { + ...(prev.settings ?? ({} as AppState['settings'])), + experimentalTerminalDaemon: true + } as AppState['settings'] + })) + return store + } + beforeEach(() => { vi.clearAllMocks() ptyIdCounter = 0 @@ -666,7 +682,7 @@ describe('reconnectPersistedTerminals', () => { }) it('records daemon session IDs for deferred reattach and sets workspaceSessionReady', async () => { - const store = createTestStore() + const store = createDaemonEnabledStore() const wt1 = 'repo1::/path/wt1' const wt2 = 'repo1::/path/wt2' @@ -775,7 +791,7 @@ describe('reconnectPersistedTerminals', () => { }) it('falls back to tab ptyIds when activeWorktreeIdsOnShutdown is absent (upgrade)', async () => { - const store = createTestStore() + const store = createDaemonEnabledStore() const wt1 = 'repo1::/path/wt1' store.setState({ @@ -808,7 +824,7 @@ describe('reconnectPersistedTerminals', () => { }) it('reconnects the correct tab per worktree (not always tabs[0])', async () => { - const store = createTestStore() + const store = createDaemonEnabledStore() const wt1 = 'repo1::/path/wt1' store.setState({ @@ -843,7 +859,7 @@ describe('reconnectPersistedTerminals', () => { }) it('reconnects multiple live tabs in the same worktree', async () => { - const store = createTestStore() + const store = createDaemonEnabledStore() const wt1 = 'repo1::/path/wt1' store.setState({ @@ -908,7 +924,7 @@ describe('reconnectPersistedTerminals', () => { }) it('skips deleted worktrees in activeWorktreeIdsOnShutdown', async () => { - const store = createTestStore() + const store = createDaemonEnabledStore() const existing = 'repo1::/path/wt1' const deleted = 'repo1::/path/deleted' @@ -943,7 +959,7 @@ describe('reconnectPersistedTerminals', () => { }) it('preserves split-pane ptyIdsByLeafId for deferred reattach by connectPanePty', async () => { - const store = createTestStore() + const store = createDaemonEnabledStore() const wt1 = 'repo1::/path/wt1' store.setState({ diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index cd0efdf8..8c36c5e9 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1068,19 +1068,26 @@ export const createTerminalSlice: StateCreator // Why: preserve the previous session's ptyId for each tab so that // reconnectPersistedTerminals can pass it as sessionId to the daemon's // createOrAttach RPC, triggering reattach instead of a fresh spawn. + // When the experimental daemon is disabled, the LocalPtyProvider will + // ignore any sessionId we pass anyway — populating this map just + // persists stale daemon-era session IDs into the next session save, + // which confuses debugging and bloats the session file. Skip it. + const daemonEnabled = s.settings?.experimentalTerminalDaemon === true const pendingReconnectPtyIdByTabId: Record = {} - for (const worktreeId of pendingReconnectWorktreeIds) { - const worktree = Object.values(s.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? s.repos.find((entry) => entry.id === worktree.repoId) : null - if (repo?.connectionId) { - continue - } - const rawTabs = session.tabsByWorktree[worktreeId] ?? [] - for (const tab of rawTabs) { - if (tab.ptyId && validTabIds.has(tab.id)) { - pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId + if (daemonEnabled) { + for (const worktreeId of pendingReconnectWorktreeIds) { + const worktree = Object.values(s.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? s.repos.find((entry) => entry.id === worktree.repoId) : null + if (repo?.connectionId) { + continue + } + const rawTabs = session.tabsByWorktree[worktreeId] ?? [] + for (const tab of rawTabs) { + if (tab.ptyId && validTabIds.has(tab.id)) { + pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId + } } } } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index e5eaa387..cceb27df 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -84,7 +84,15 @@ export type UISlice = { openSettingsPage: () => void closeSettingsPage: () => void settingsNavigationTarget: { - pane: 'general' | 'browser' | 'appearance' | 'terminal' | 'shortcuts' | 'repo' | 'agents' + pane: + | 'general' + | 'browser' + | 'appearance' + | 'terminal' + | 'shortcuts' + | 'repo' + | 'agents' + | 'experimental' repoId: string | null sectionId?: string } | null diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 779ab448..938ee16d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -122,7 +122,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { defaultTuiAgent: null, defaultTaskViewPreset: 'all', agentCmdOverrides: {}, - terminalMacOptionAsAlt: 'true' + terminalMacOptionAsAlt: 'true', + experimentalTerminalDaemon: false, + experimentalTerminalDaemonNoticeShown: false } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 3fbf2e9f..252d34d3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -627,6 +627,16 @@ export type GlobalSettings = { * 'true' = full Meta on both Option keys; * 'left' / 'right' = only that Option key acts as Meta, the other composes. */ terminalMacOptionAsAlt: 'true' | 'false' | 'left' | 'right' + /** Experimental: persist terminal sessions across app restarts via an + * out-of-process daemon (src/main/daemon/**). Opt-in because the daemon + * protocol is still stabilizing — some sessions have been observed to go + * unresponsive after internal state drift. Disabled sessions fall back to + * the in-process LocalPtyProvider. Requires an app restart to apply. */ + experimentalTerminalDaemon: boolean + /** One-shot flag for the "persistent sessions are now opt-in" transition + * toast shown to users upgrading from v1.3.0 (where the daemon was on by + * default). Set to true the first time the toast fires so it never repeats. */ + experimentalTerminalDaemonNoticeShown: boolean } export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'test' diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts new file mode 100644 index 00000000..0b24fe5e --- /dev/null +++ b/src/shared/workspace-session-schema.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { parseWorkspaceSession } from './workspace-session-schema' + +describe('parseWorkspaceSession', () => { + it('accepts a minimal valid session', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + expect(result.ok).toBe(true) + }) + + it('accepts a fully populated session with optional fields', () => { + const result = parseWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: 'repo1::/path/wt1', + activeTabId: 'tab1', + tabsByWorktree: { + 'repo1::/path/wt1': [ + { + id: 'tab1', + ptyId: 'daemon-session-abc', + worktreeId: 'repo1::/path/wt1', + title: 'bash', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1_700_000_000_000 + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' } + }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'daemon-session-A' } + } + }, + activeWorktreeIdsOnShutdown: ['repo1::/path/wt1'] + }) + expect(result.ok).toBe(true) + }) + + it('rejects a session where ptyId is a number (schema drift)', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: 42, + worktreeId: 'wt', + title: 'bash', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + terminalLayoutsByTabId: {} + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('ptyId') + } + }) + + it('rejects a session with missing required top-level fields', () => { + const result = parseWorkspaceSession({ + activeRepoId: null + // missing activeWorktreeId, tabsByWorktree, etc. + }) + expect(result.ok).toBe(false) + }) + + it('rejects a truncated JSON object', () => { + const result = parseWorkspaceSession({}) + expect(result.ok).toBe(false) + }) + + it('rejects non-object input (e.g. corrupted file contents)', () => { + expect(parseWorkspaceSession(null).ok).toBe(false) + expect(parseWorkspaceSession('garbage').ok).toBe(false) + expect(parseWorkspaceSession(42).ok).toBe(false) + }) +}) diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts new file mode 100644 index 00000000..613ae125 --- /dev/null +++ b/src/shared/workspace-session-schema.ts @@ -0,0 +1,209 @@ +/* Why: the workspace session JSON is written to disk by older builds and read + * back by newer ones. A field type flip (e.g. ptyId going from string to an + * object) or a truncated write could poison Zustand state and crash the + * renderer on mount. Schema-validating at the read boundary gives us a single + * "reject and fall back to defaults" point so garbage never reaches React. + * + * Policy: be tolerant of extra fields (future builds may add more) but strict + * about the types of fields we actually read. Unknown enum values, wrong types, + * and wrong shapes all collapse to "use defaults" — never throw into main. + */ +import { z } from 'zod' +import type { + BrowserWorkspace, + TabGroupLayoutNode, + TerminalPaneLayoutNode, + WorkspaceSessionState +} from './types' + +// ─── Terminal pane layout (recursive) ─────────────────────────────── + +const terminalPaneSplitDirectionSchema = z.enum(['vertical', 'horizontal']) + +// Why: z.lazy + type annotation keeps the recursive inference working without +// forcing zod to resolve the whole tree at definition time. +const terminalPaneLayoutNodeSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ + type: z.literal('leaf'), + leafId: z.string() + }), + z.object({ + type: z.literal('split'), + direction: terminalPaneSplitDirectionSchema, + first: terminalPaneLayoutNodeSchema, + second: terminalPaneLayoutNodeSchema, + ratio: z.number().optional() + }) + ]) +) + +const terminalLayoutSnapshotSchema = z.object({ + root: terminalPaneLayoutNodeSchema.nullable(), + activeLeafId: z.string().nullable(), + expandedLeafId: z.string().nullable(), + ptyIdsByLeafId: z.record(z.string(), z.string()).optional(), + buffersByLeafId: z.record(z.string(), z.string()).optional(), + titlesByLeafId: z.record(z.string(), z.string()).optional() +}) + +// ─── Terminal tab (legacy) ────────────────────────────────────────── + +const terminalTabSchema = z.object({ + id: z.string(), + ptyId: z.string().nullable(), + worktreeId: z.string(), + title: z.string(), + defaultTitle: z.string().optional(), + customTitle: z.string().nullable(), + color: z.string().nullable(), + sortOrder: z.number(), + createdAt: z.number(), + generation: z.number().optional() +}) + +// ─── Unified tab model ────────────────────────────────────────────── + +const tabContentTypeSchema = z.enum(['terminal', 'editor', 'diff', 'conflict-review', 'browser']) + +const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser']) + +const tabSchema = z.object({ + id: z.string(), + entityId: z.string(), + groupId: z.string(), + worktreeId: z.string(), + contentType: tabContentTypeSchema, + label: z.string(), + customLabel: z.string().nullable(), + color: z.string().nullable(), + sortOrder: z.number(), + createdAt: z.number(), + isPreview: z.boolean().optional(), + isPinned: z.boolean().optional() +}) + +const tabGroupSchema = z.object({ + id: z.string(), + worktreeId: z.string(), + activeTabId: z.string().nullable(), + tabOrder: z.array(z.string()) +}) + +const tabGroupSplitDirectionSchema = z.enum(['horizontal', 'vertical']) + +const tabGroupLayoutNodeSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ + type: z.literal('leaf'), + groupId: z.string() + }), + z.object({ + type: z.literal('split'), + direction: tabGroupSplitDirectionSchema, + first: tabGroupLayoutNodeSchema, + second: tabGroupLayoutNodeSchema, + ratio: z.number().optional() + }) + ]) +) + +// ─── Editor ───────────────────────────────────────────────────────── + +const persistedOpenFileSchema = z.object({ + filePath: z.string(), + relativePath: z.string(), + worktreeId: z.string(), + language: z.string(), + isPreview: z.boolean().optional() +}) + +// ─── Browser ──────────────────────────────────────────────────────── + +const browserLoadErrorSchema = z.object({ + code: z.number(), + description: z.string(), + validatedUrl: z.string() +}) + +// Why: cast to WorkspaceSessionState's embedded BrowserWorkspace so future +// additive fields in the type flow through without requiring a schema edit. +const browserWorkspaceSchema: z.ZodType = z.object({ + id: z.string(), + worktreeId: z.string(), + label: z.string().optional(), + sessionProfileId: z.string().nullable().optional(), + activePageId: z.string().nullable().optional(), + pageIds: z.array(z.string()).optional(), + url: z.string(), + title: z.string(), + loading: z.boolean(), + faviconUrl: z.string().nullable(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + loadError: browserLoadErrorSchema.nullable(), + createdAt: z.number() +}) + +const browserPageSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + worktreeId: z.string(), + url: z.string(), + title: z.string(), + loading: z.boolean(), + faviconUrl: z.string().nullable(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + loadError: browserLoadErrorSchema.nullable(), + createdAt: z.number() +}) + +const browserHistoryEntrySchema = z.object({ + url: z.string(), + normalizedUrl: z.string(), + title: z.string(), + lastVisitedAt: z.number(), + visitCount: z.number() +}) + +// ─── Workspace session ────────────────────────────────────────────── + +export const workspaceSessionStateSchema: z.ZodType = z.object({ + activeRepoId: z.string().nullable(), + activeWorktreeId: z.string().nullable(), + activeTabId: z.string().nullable(), + tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)), + terminalLayoutsByTabId: z.record(z.string(), terminalLayoutSnapshotSchema), + activeWorktreeIdsOnShutdown: z.array(z.string()).optional(), + openFilesByWorktree: z.record(z.string(), z.array(persistedOpenFileSchema)).optional(), + activeFileIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), + browserTabsByWorktree: z.record(z.string(), z.array(browserWorkspaceSchema)).optional(), + browserPagesByWorkspace: z.record(z.string(), z.array(browserPageSchema)).optional(), + activeBrowserTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), + activeTabTypeByWorktree: z.record(z.string(), workspaceVisibleTabTypeSchema).optional(), + browserUrlHistory: z.array(browserHistoryEntrySchema).optional(), + activeTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), + unifiedTabs: z.record(z.string(), z.array(tabSchema)).optional(), + tabGroups: z.record(z.string(), z.array(tabGroupSchema)).optional(), + tabGroupLayouts: z.record(z.string(), tabGroupLayoutNodeSchema).optional(), + activeGroupIdByWorktree: z.record(z.string(), z.string()).optional() +}) + +export type ParsedWorkspaceSession = + | { ok: true; value: WorkspaceSessionState } + | { ok: false; error: string } + +/** Validate raw JSON as a WorkspaceSessionState. Returns a discriminated union + * so callers can fall back to defaults on failure without a try/catch. */ +export function parseWorkspaceSession(raw: unknown): ParsedWorkspaceSession { + const result = workspaceSessionStateSchema.safeParse(raw) + if (result.success) { + return { ok: true, value: result.data } + } + // Why: keep the error compact — a zod issue dump is noisy and most of the + // time only the first divergent field is actionable for debugging. + const firstIssue = result.error.issues[0] + const path = firstIssue?.path.join('.') || '' + return { ok: false, error: `${path}: ${firstIssue?.message ?? 'invalid session'}` } +}