/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ import { app } from 'electron' import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync, copyFileSync, statSync, realpathSync } from 'node:fs' import { rename, mkdir, rm, copyFile, open, stat, access, writeFile } from 'node:fs/promises' import { durableWriteTempPath, removeStaleDurableWriteTempFiles, renameDurable, writeFileDurableSync } from './durable-file-write' import { join, dirname, isAbsolute, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { createHash, randomUUID } from 'node:crypto' import type { Automation, AutomationCreateInput, AutomationDispatchResult, AutomationPrecheckResult, AutomationRunOutputSnapshot, AutomationRun, AutomationSchedulerOwner, AutomationRunTrigger, AutomationUpdateInput } from '../shared/automations-types' import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../shared/automation-schedules' import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import { normalizeProxyUrl } from '../shared/network-proxy' import { normalizeKagiSessionLink } from '../shared/browser-url' import type { PersistedState, Project, ProjectUpdateArgs, ProjectHostSetup, ProjectHostSetupCreateArgs, ProjectHostSetupCreateResult, ProjectHostSetupDeleteArgs, ProjectHostSetupDeleteResult, ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult, RepoProjectHostSetupMethod, Repo, ProjectGroup, FolderWorkspace, SparsePreset, PersistedMobileClientTabSelections, WorktreeMeta, WorktreeLineage, WorkspaceLineage, WorkspaceKey, GlobalSettings, OrcaWorkspaceLayout, NotificationSettings, OnboardingChecklistState, OnboardingOutcome, OnboardingState, LegacyPaneKeyAliasEntry, TerminalPaneLayoutNode, TerminalLayoutSnapshot, TerminalTab, WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings, normalizeProjectRuntimePreference } from '../shared/project-execution-runtime' import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' import { isPluginPanelTabKey } from '../shared/plugins/plugin-manifest' import type { GitRemoteIdentity } from '../shared/git-remote-identity' import { areTaskSourceContextsEqual, buildTaskSourceContextFromRepo, buildWorkspaceRunContext, normalizeStoredTaskSourceContext } from '../shared/task-source-context' import { areWorkspaceLinkedItemsEqual, normalizeWorkspaceLinkedItem } from '../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-linked-item-source-context' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' import { removeRepoFromHostWorkspaceSessions, removeRepoFromWorkspaceSession } from './orca-profiles/profile-project-session-state' import { hardenExistingSecureFile } from '../shared/secure-file' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type RemovedSshTargetTombstone, type SshPtyConsumerRecovery, type SshRemotePtyLease, type SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getRepoExecutionHostId, parseExecutionHostId, LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, normalizeExecutionHostId, normalizeVisibleExecutionHostIds, toSshExecutionHostId, type ExecutionHostId } from '../shared/execution-host' import { getDefaultPersistedState, getDefaultNotificationSettings, getDefaultOnboardingState, getDefaultVoiceSettings, getDefaultUIState, getDefaultRepoHookSettings, getDefaultWorkspaceSession, getWorktreeCardModeProperties, isDefaultedCompactWorktreeCardProperties, normalizeAgentActivityDisplayMode, normalizeWorktreeCardProperties, ONBOARDING_FLOW_VERSION, ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides' import { toRelaySshPtyId } from './providers/ssh-pty-id' import { migrateUiHostScopeSshTargetId, migrateWorkspaceSessionSshTargetId } from './ssh/ssh-target-id-migration' import { isWslUncPath } from '../shared/wsl-paths' import { isTerminalLeafId, makePaneKey, parseLegacyNumericPaneKey, parsePaneKey } from '../shared/stable-pane-id' import { setMigrationUnsupportedPty, setMigrationUnsupportedPtyPersistenceListener } from './agent-hooks/migration-unsupported-pty-state' import { agentHookServer } from './agent-hooks/server' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { backfillAutomationRunNumbers, nextAutomationRunNumber, pruneAutomationRuns } from '../shared/automation-run-retention' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id' import { isPathInsideOrEqual, isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' import { normalizeTaskProviderSettings } from '../shared/task-providers' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings' import { addMobilePairingCustomAddress, normalizeMobilePairingCustomAddress, normalizeMobilePairingCustomAddresses } from '../shared/mobile-pairing-custom-address' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' import { legacyTerminalScrollbackBytesToRows, normalizeDesktopTerminalScrollbackRows } from '../shared/terminal-scrollback-policy' import { compareFeatureInteractionUsageBuckets, getFeatureInteractionCategory, getFeatureInteractionUsageBucket, normalizeFeatureInteractions, normalizeFeatureInteractionTelemetryBuckets, type FeatureInteractionId } from '../shared/feature-interactions' import { normalizeContextualTourIds } from '../shared/contextual-tours' import { normalizeFeatureTipIds } from '../shared/feature-tips' import { parseCodexResetCreditAttemptLedger, type CodexResetCreditAttemptLedger } from '../shared/codex-reset-credit-attempt-ledger' import { normalizeManualRepoOrder } from '../shared/manual-repo-order' import { DEFAULT_WORKSPACE_STATUS_ID, clampWorkspaceBoardColumnWidth, clampWorkspaceBoardOpacity, normalizePersistedWorkspaceStatuses, normalizeWorkspaceStatuses } from '../shared/workspace-statuses' import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width' import { clampCombinedDiffFileTreeWidth } from '../shared/combined-diff-file-tree-width' import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership' import { sanitizeRepoIcon } from '../shared/repo-icon' import { normalizeRepoBadgeColor } from '../shared/repo-badge-color' import { buildProjectGroupOwnerIndex, clearMissingProjectGroupMemberships, createProjectGroup, getNextProjectGroupOrder, getProjectGroupOwnerHostId, getProjectGroupOwnerIdentity, getProjectGroupOwnerSubtreeIdentities, normalizeProjectGroupName, normalizeProjectGroups, resolveProjectGroupMembership, resolveProjectGroupOwner } from '../shared/project-groups' import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../shared/folder-workspace-project-group-resolution' import { createNestedProjectGroupResolver } from './project-groups/nested-repo-import' import { mergeLegacyCommitMessageAiIntoSourceControlAi, normalizeRepoSourceControlAiOverrides, normalizeSourceControlAiSettings, projectSourceControlAiToLegacyCommitMessageAi, sourceControlAiSettingsFromLegacy } from '../shared/source-control-ai' import { DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES, SOURCE_CONTROL_TEXT_ACTION_IDS } from '../shared/source-control-ai-actions' import { normalizeDisabledTuiAgents } from '../shared/tui-agent-selection' import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV, hasUnsupportedTuiAgentArgs, normalizeTuiAgentArgsRecord, normalizeTuiAgentEnvRecord } from '../shared/tui-agent-launch-defaults' import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings' import { normalizeOsc52ClipboardDefaultOn, osc52ClipboardDefaultOnOverridesPersistedOff } from '../shared/osc52-clipboard-settings' import { normalizeTerminalLineHeight } from '../shared/terminal-line-height-settings' import { normalizeUiLanguage } from '../shared/ui-language' import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom' import { persistedUIValuesEqual } from '../shared/persisted-ui-equality' import { ActiveViewPreference } from './active-view-preference' import { resolveFolderWorkspaceCatalogOwnerHostId, normalizeFolderWorkspaceName, normalizeFolderWorkspaces } from '../shared/folder-workspaces' import { folderWorkspaceKey, isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import { collectTerminalScrollbackSnapshotRefs, deleteTerminalScrollbackSnapshotSync, getProfileTerminalScrollbackSnapshotRoot, migrateWorkspaceSessionTerminalScrollbackSnapshots, readTerminalScrollbackSnapshotSync, type TerminalScrollbackSnapshotStorage } from './terminal-scrollback-snapshots' import { deleteRemovedTerminalScrollbackSnapshotsAsync, migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync } from './terminal-scrollback-snapshot-async-migration' import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' import { PROTECTED_SECRET_SLOT, ProtectedSecretPersistence, sshPtyOwnerLeaseSecretSlot, type ProtectedSecretRetentionUpdate } from './protected-secret-persistence' function isLegacyOpenCodeSessionCookie(value: string): boolean { const trimmed = value.trim() return ( trimmed.startsWith('Fe26.2**') || trimmed.split(';').some((pair) => /^(?:auth|__Host-auth)=\S+$/i.test(pair.trim())) ) } function isLegacySshPtyOwnerLease(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } function retireLegacyInstructionsForClearedTextActionRecipes( sourceControlAi: GlobalSettings['sourceControlAi'], previousSettings: GlobalSettings ): GlobalSettings['sourceControlAi'] { if (!sourceControlAi?.actions) { return sourceControlAi } const previousSourceControlAi = normalizeSourceControlAiSettings( previousSettings.sourceControlAi, previousSettings.commitMessageAi ) let instructionsByOperation = sourceControlAi.instructionsByOperation let changed = false for (const actionId of SOURCE_CONTROL_TEXT_ACTION_IDS) { if ( sourceControlAi.actions[actionId]?.commandInputTemplate !== DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] ) { continue } if ( previousSourceControlAi.actions?.[actionId]?.commandInputTemplate === DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] || instructionsByOperation?.[actionId] !== previousSourceControlAi.instructionsByOperation[actionId] ) { continue } if (instructionsByOperation?.[actionId] === '') { continue } // Why: {basePrompt} is the explicit clear state; an empty instruction shadows rollback commitMessageAi.customPrompt on normalize/project. instructionsByOperation = { ...instructionsByOperation, [actionId]: '' } changed = true } return changed ? { ...sourceControlAi, instructionsByOperation } : sourceControlAi } // Why capture once (not a module const, not per-call): a const resolves before configureDevUserDataPath() redirects userData (dev/prod collide); // per-call resolves after app.setName('Orca') flips path case and loses data on case-sensitive FS. index.ts calls initDataPath() at the right moment. let _dataFile: string | null = null let _userDataDir: string | null = null export function initDataPath(): void { const userDataDir = app.getPath('userData') _userDataDir = userDataDir _dataFile = join(userDataDir, 'orca-data.json') } function getDataFile(): string { if (!_dataFile) { // Safety fallback — should not be hit in normal startup. const userDataDir = app.getPath('userData') _userDataDir = userDataDir _dataFile = join(userDataDir, 'orca-data.json') } return _dataFile } // Why a sidecar: githubCache refreshes every poll and would rewrite the whole multi-MB orca-data.json each cycle. // Snapshotted best-effort at quit for instant badges next launch; safe to lose. function getGithubCacheFile(dataFile = getDataFile()): string { return join(dirname(dataFile), 'orca-github-cache.json') } // Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). // GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 function gcStaleWorktreeMeta(state: PersistedState): number { // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. state.worktreeMeta ??= {} const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) const projectIds = new Set((state.projects ?? []).map((project) => project.id)) const now = Date.now() let removed = 0 for (const key of Object.keys(state.worktreeMeta)) { // Why: folder-project workspace instances (keyed repoId::path::workspace:) ARE the workspace record, not a checkout row; skip them. if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) { continue } const separator = key.indexOf('::') if (separator === -1) { continue } const ownerId = key.slice(0, separator) const worktreePath = key.slice(separator + 2) const meta = state.worktreeMeta[key] const repo = repoById.get(ownerId) if (repo) { if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { continue } } else if (projectIds.has(ownerId)) { // Project-owned metas keep their own project/host lifecycle; leave them alone. continue } // Unowned entries (repo removed before metas were pruned) fall through to the same missing-path + idle-grace gate. if (meta?.hostId && meta.hostId !== LOCAL_EXECUTION_HOST_ID) { continue } if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) { continue } // Why: WSL worktrees on Windows carry Linux-style paths that Windows existsSync can't probe and would falsely condemn. if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) { continue } // Why keep timestamp-less entries: without timestamps we can't prove the 30-day grace elapsed (measured dead entries all had them). // Grace is checked before existsSync so active entries skip the stat fan-out (and its slow-NFS tail). const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0) if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) { continue } if (existsSync(worktreePath)) { continue } delete state.worktreeMeta[key] delete state.worktreeLineageById[key] delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] removed++ } return removed } function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { let changed = false const rawWorktreeMeta = state.worktreeMeta as unknown if ( typeof rawWorktreeMeta !== 'object' || rawWorktreeMeta === null || Array.isArray(rawWorktreeMeta) ) { state.worktreeMeta = {} changed = rawWorktreeMeta !== undefined } for (const [key, meta] of Object.entries(state.worktreeMeta)) { // Why: hand-corrupted non-object entries are a real input class; drop them here because gcStaleWorktreeMeta // keeps timestamp-less keys forever and every downstream consumer trusts the Record type. if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { delete state.worktreeMeta[key] // Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would // otherwise re-attach to a worktree recreated at the same repoId::path. delete state.worktreeLineageById[key] delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] changed = true continue } const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( linkedWorkItem, sourceContext ) ? sourceContext : null if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) { meta.linkedWorkItem = linkedWorkItem changed = true } if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) { meta.linkedTaskSourceContext = linkedTaskSourceContext changed = true } } return changed } function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null { try { const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown const isPlainRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) if ( isPlainRecord(parsed) && isPlainRecord((parsed as { pr?: unknown }).pr) && isPlainRecord((parsed as { issue?: unknown }).issue) ) { return parsed as PersistedState['githubCache'] } } catch { // Missing or corrupt snapshot: start with an empty cache and refetch. } return null } /** * Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves. * * Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices. */ export function getCanonicalUserDataPath(): string { if (!_userDataDir) { // Safety fallback — should not be hit in normal startup. _userDataDir = app.getPath('userData') } return _userDataDir } /** * Copy legacy mobile pairing credentials into the canonical userData directory. * * Copies the registry and E2EE keypair forward as a pair so an update doesn't force a re-pair or mix devices with the wrong key. */ export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void { const targetUserDataDir = getCanonicalUserDataPath() if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) { return } const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({ sourcePath: join(sourceUserDataDir, fileName), targetPath: join(targetUserDataDir, fileName) })) if (migrations.some(({ sourcePath }) => !existsSync(sourcePath))) { return } if (migrations.some(({ targetPath }) => existsSync(targetPath))) { return } mkdirSync(targetUserDataDir, { recursive: true }) for (const { sourcePath, targetPath } of migrations) { copyFileSync(sourcePath, targetPath) // Why: copyFileSync drops Windows ACLs, so re-assert current-user-only on these credential copies (device tokens, E2EE key). hardenExistingSecureFile(targetPath) } } // Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. const BACKUP_COUNT = 5 const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ 'tabsByWorktree', 'terminalLayoutsByTabId' ]) function logPersistenceStartupMilestone( event: string, details: Record = {} ): void { if (isStartupDiagnosticsEnabled()) { logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) } } function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { return Object.keys(patch).some((key) => WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) ) } function workspaceSessionSalvageLogDetails(result: { droppedCount: number droppedPaths: string[] }): { count: number; fields: string[]; detailsTruncated: boolean } { return { count: result.droppedCount, fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], detailsTruncated: result.droppedCount > result.droppedPaths.length } } /** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ function parseWorkspaceSessionsByHostId( raw: unknown, defaults: WorkspaceSessionState ): { partitions: Partial>; repaired: boolean } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { partitions: {}, repaired: raw !== undefined } } let repaired = false const partitions: Partial> = {} for (const [key, value] of Object.entries(raw as Record)) { const hostId = normalizeExecutionHostId(key) // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { continue } const result = parseWorkspaceSessionSalvaging(value) if (!result.ok) { repaired = true console.error( `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, result.error ) continue } if (result.droppedCount > 0) { console.warn( `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, workspaceSessionSalvageLogDetails(result) ) repaired = true } partitions[hostId] = { ...defaults, ...result.value } } return { partitions, repaired } } function backupPath(dataFile: string, index: number): string { return `${dataFile}.bak.${index}` } /** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ async function exists(path: string): Promise { return access(path).then( () => true, () => false ) } function buildWorkspaceDirHistoryForUpdate( current: GlobalSettings, updates: Partial ): OrcaWorkspaceLayout[] | null { if (!('workspaceDir' in updates) && !('nestWorkspaces' in updates)) { return null } const nextPath = updates.workspaceDir ?? current.workspaceDir const nextNestWorkspaces = updates.nestWorkspaces ?? current.nestWorkspaces if ( normalizeRuntimePathForComparison(nextPath) === normalizeRuntimePathForComparison(current.workspaceDir) && nextNestWorkspaces === current.nestWorkspaces ) { return null } const previousLayout = { path: current.workspaceDir, nestWorkspaces: current.nestWorkspaces } const existing = current.workspaceDirHistory ?? [] const next = [...existing] const previousKey = getWorkspaceLayoutHistoryKey(previousLayout) if (!next.some((layout) => getWorkspaceLayoutHistoryKey(layout) === previousKey)) { next.push(previousLayout) } return next } type LegacyTerminalScrollbackSettings = { terminalScrollbackRows?: unknown terminalScrollbackBytes?: unknown } const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3 function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings { return settings && typeof settings === 'object' ? (settings as LegacyTerminalScrollbackSettings) : {} } function stripLegacyTerminalScrollbackBytes( settings: Partial | undefined ): Partial { const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? {}) as Partial & { terminalScrollbackBytes?: unknown } void _legacyScrollbackBytes return rest } function migrateTerminalScrollbackRows(settings: unknown): { rows: number needsSave: boolean } { const legacySettings = readLegacyTerminalScrollbackSettings(settings) const hasRows = Object.prototype.hasOwnProperty.call(legacySettings, 'terminalScrollbackRows') const hasLegacyBytes = Object.prototype.hasOwnProperty.call( legacySettings, 'terminalScrollbackBytes' ) const rows = hasRows ? normalizeDesktopTerminalScrollbackRows(legacySettings.terminalScrollbackRows) : legacyTerminalScrollbackBytesToRows(legacySettings.terminalScrollbackBytes) return { rows, needsSave: !hasRows || hasLegacyBytes || legacySettings.terminalScrollbackRows !== rows } } function migrateTerminalTuiScrollSensitivityDefault(settings: GlobalSettings | undefined): { settings: Pick< GlobalSettings, 'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne' > needsSave: boolean } { const alreadyDefaultedToOne = settings?.terminalTuiScrollSensitivityDefaultedToOne === true const current = settings?.terminalTuiScrollSensitivity const shouldMoveInheritedDefault = !alreadyDefaultedToOne && (current === undefined || current === LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT) const terminalTuiScrollSensitivity = shouldMoveInheritedDefault ? 1 : (current ?? 1) return { settings: { terminalTuiScrollSensitivity, terminalTuiScrollSensitivityDefaultedToOne: true }, needsSave: !alreadyDefaultedToOne || current === undefined } } function getWorkspaceLayoutHistoryKey(layout: OrcaWorkspaceLayout): string { return `${normalizeRuntimePathForComparison(layout.path)}:${layout.nestWorkspaces}` } function migrateAgentYoloDefaults( settings: GlobalSettings | undefined ): Pick { const existingArgs = normalizeTuiAgentArgsRecord(settings?.agentDefaultArgs) const existingEnv = normalizeTuiAgentEnvRecord(settings?.agentDefaultEnv) if (settings?.agentYoloDefaultsMigrated === true) { return { agentDefaultArgs: existingArgs, agentDefaultEnv: existingEnv, agentYoloDefaultsMigrated: true } } const commandOverrides = settings?.agentCmdOverrides ?? {} const migratedArgs = { ...existingArgs } for (const [agent, args] of Object.entries(DEFAULT_TUI_AGENT_ARGS)) { if (agent in migratedArgs) { continue } if (agent in commandOverrides) { migratedArgs[agent as keyof typeof DEFAULT_TUI_AGENT_ARGS] = '' continue } migratedArgs[agent as keyof typeof DEFAULT_TUI_AGENT_ARGS] = args } const migratedEnv = { ...existingEnv } for (const [agent, env] of Object.entries(DEFAULT_TUI_AGENT_ENV)) { if (agent in migratedEnv) { continue } if (agent in commandOverrides) { migratedEnv[agent as keyof typeof DEFAULT_TUI_AGENT_ENV] = {} continue } migratedEnv[agent as keyof typeof DEFAULT_TUI_AGENT_ENV] = { ...env } } return { // Why: legacy users could only customize launch defaults via command overrides, so those agents count as already user-owned. agentDefaultArgs: migratedArgs, agentDefaultEnv: migratedEnv, agentYoloDefaultsMigrated: true } } function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] { if ( groupBy === 'none' || groupBy === 'workspace-status' || groupBy === 'repo' || groupBy === 'pr-status' ) { return groupBy } if (groupBy === 'flat') { return 'none' } return getDefaultUIState().groupBy } function normalizeShowDotfilesByWorktree(value: unknown): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) { return {} } const out: Record = {} for (const [worktreeId, showDotfiles] of Object.entries(value as Record)) { if ( !worktreeId || worktreeId === '__proto__' || worktreeId === 'constructor' || worktreeId === 'prototype' || typeof showDotfiles !== 'boolean' ) { continue } out[worktreeId] = showDotfiles } return out } function mergeFeatureInteractions( current: PersistedState['ui']['featureInteractions'], incoming: PersistedState['ui']['featureInteractions'] ): PersistedState['ui']['featureInteractions'] { const currentNormalized = normalizeFeatureInteractions(current) const incomingNormalized = normalizeFeatureInteractions(incoming) const merged = { ...currentNormalized } for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { const currentRecord = currentNormalized[id as keyof typeof currentNormalized] merged[id as keyof typeof merged] = currentRecord ? { firstInteractedAt: Math.min( currentRecord.firstInteractedAt, incomingRecord.firstInteractedAt ), interactionCount: Math.max( currentRecord.interactionCount, incomingRecord.interactionCount ) } : incomingRecord } return merged } function mergeContextualTourSeenIds( current: PersistedState['ui']['contextualToursSeenIds'], incoming: PersistedState['ui']['contextualToursSeenIds'] ): PersistedState['ui']['contextualToursSeenIds'] { const merged = new Set(normalizeContextualTourIds(current)) for (const id of normalizeContextualTourIds(incoming)) { merged.add(id) } return [...merged] } function stripMainOwnedTelemetryMarkerFromUI( value: Partial | undefined ): Partial { if (!value || typeof value !== 'object') { return {} } const { featureInteractionTelemetryBuckets: _reserved, ...ui } = value as Partial< PersistedState['ui'] > & { featureInteractionTelemetryBuckets?: unknown } void _reserved return ui } function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { if ( sortBy === 'smart' || sortBy === 'recent' || sortBy === 'repo' || sortBy === 'name' || sortBy === 'manual' ) { return sortBy } return getDefaultUIState().sortBy } function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui']['projectOrderBy'] { if (projectOrderBy === 'manual' || projectOrderBy === 'recent') { return projectOrderBy } return getDefaultUIState().projectOrderBy } export function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { if ( tab === 'explorer' || tab === 'search' || tab === 'vault' || tab === 'workspaces' || tab === 'pr-checks' || tab === 'source-control' || tab === 'checks' || tab === 'ports' ) { return tab } // Why: plugin tabs are open-ended `plugin:./` keys; validate the // shape so a persisted plugin tab doesn't reset to Explorer on restart. if (typeof tab === 'string' && isPluginPanelTabKey(tab)) { return tab } return getDefaultUIState().rightSidebarTab } function normalizeWorkspaceLineageByChildKey( value: unknown ): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {} } const normalized: Record = {} for (const [key, entry] of Object.entries(value)) { if (!isWorkspaceKey(key) || !entry || typeof entry !== 'object') { continue } const lineage = entry as Partial const childWorkspaceKey = typeof lineage.childWorkspaceKey === 'string' && isWorkspaceKey(lineage.childWorkspaceKey) ? lineage.childWorkspaceKey : key const parentWorkspaceKey = lineage.parentWorkspaceKey if ( !isWorkspaceKey(childWorkspaceKey) || typeof parentWorkspaceKey !== 'string' || !isWorkspaceKey(parentWorkspaceKey) || childWorkspaceKey !== key || childWorkspaceKey === parentWorkspaceKey ) { continue } normalized[childWorkspaceKey] = { childWorkspaceKey, childInstanceId: lineage.childInstanceId ?? null, parentWorkspaceKey, parentInstanceId: lineage.parentInstanceId ?? null, origin: lineage.origin ?? 'cli', capture: lineage.capture ?? { source: 'manual-action', confidence: 'inferred' }, ...(lineage.taskId ? { taskId: lineage.taskId } : {}), ...(lineage.orchestrationRunId ? { orchestrationRunId: lineage.orchestrationRunId } : {}), ...(lineage.coordinatorHandle ? { coordinatorHandle: lineage.coordinatorHandle } : {}), ...(lineage.createdByTerminalHandle ? { createdByTerminalHandle: lineage.createdByTerminalHandle } : {}), createdAt: Number.isFinite(lineage.createdAt) ? Number(lineage.createdAt) : Date.now() } } return normalized } function normalizeRightSidebarExplorerView( view: unknown, tab?: unknown ): PersistedState['ui']['rightSidebarExplorerView'] { // Why: older builds persisted Search as a standalone activity tab. if (tab === 'search') { return 'search' } if (view === 'files' || view === 'search') { return view } return getDefaultUIState().rightSidebarExplorerView } function normalizeNotificationSettings(value: unknown): NotificationSettings { const defaults = getDefaultNotificationSettings() const candidate = value && typeof value === 'object' ? (value as Partial) : {} const rawSoundId = (candidate as { customSoundId?: unknown }).customSoundId const customSoundId = rawSoundId === 'system' || rawSoundId === 'two-tone' || rawSoundId === 'bong' || rawSoundId === 'thump' || rawSoundId === 'blip' || rawSoundId === 'sonar' || rawSoundId === 'blop' || rawSoundId === 'ding' || rawSoundId === 'clack' || rawSoundId === 'beep' || rawSoundId === 'custom' ? rawSoundId : rawSoundId === 'orca' || rawSoundId === 'chime' ? 'two-tone' : rawSoundId === 'pop' ? 'blop' : typeof candidate.customSoundPath === 'string' ? 'custom' : defaults.customSoundId const rawVolume = candidate.customSoundVolume const customSoundVolume = typeof rawVolume === 'number' && Number.isFinite(rawVolume) ? Math.min(100, Math.max(0, rawVolume)) : defaults.customSoundVolume return { ...defaults, ...candidate, customSoundId, customSoundVolume } } function normalizeAutomationRunWorkspaceDisplayName(value: string | null): string | null { const trimmed = value?.trim() return trimmed ? trimmed : null } function normalizeAutomationRunTerminalPaneKey(value: string | null | undefined): string | null { const trimmed = typeof value === 'string' ? value.trim() : '' return trimmed && parsePaneKey(trimmed) ? trimmed : null } function normalizeAutomationRunTerminalPtyId(value: string | null | undefined): string | null { const trimmed = typeof value === 'string' ? value.trim() : '' return trimmed || null } function normalizeAutomationRunOutputSnapshot( value: AutomationRunOutputSnapshot | null | undefined ): AutomationRunOutputSnapshot | null { if (!value || value.format !== 'plain_text') { return null } const content = typeof value.content === 'string' ? value.content : '' if (!content.trim()) { return null } return { format: 'plain_text', content, capturedAt: typeof value.capturedAt === 'number' && Number.isFinite(value.capturedAt) ? value.capturedAt : Date.now(), truncated: value.truncated === true } } function normalizeAutomationPrecheckResult( value: AutomationPrecheckResult | null | undefined ): AutomationPrecheckResult | null { if (!value || typeof value.command !== 'string' || !value.command.trim()) { return null } const startedAt = typeof value.startedAt === 'number' && Number.isFinite(value.startedAt) ? value.startedAt : Date.now() const completedAt = typeof value.completedAt === 'number' && Number.isFinite(value.completedAt) ? value.completedAt : startedAt return { command: value.command.trim(), exitCode: typeof value.exitCode === 'number' && Number.isFinite(value.exitCode) ? value.exitCode : null, timedOut: value.timedOut === true, durationMs: typeof value.durationMs === 'number' && Number.isFinite(value.durationMs) ? Math.max(0, value.durationMs) : Math.max(0, completedAt - startedAt), stdout: typeof value.stdout === 'string' ? value.stdout : '', stderr: typeof value.stderr === 'string' ? value.stderr : '', stdoutTruncated: value.stdoutTruncated === true, stderrTruncated: value.stderrTruncated === true, error: typeof value.error === 'string' && value.error.trim() ? value.error : null, startedAt, completedAt } } function normalizeAutomationSessionReuse(automation: Automation): Automation { const setupDecision = normalizeAutomationSetupDecisionForWorkspaceMode( automation.workspaceMode, automation.setupDecision ) return { ...automation, precheck: normalizeAutomationPrecheck(automation.precheck), setupDecision, reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true } } function normalizeAutomationSetupDecisionForWorkspaceMode( workspaceMode: Automation['workspaceMode'], setupDecision: unknown ): Automation['setupDecision'] { return workspaceMode === 'new_per_run' && (setupDecision === 'run' || setupDecision === 'skip') ? setupDecision : undefined } function getAutomationContextsForRepo( repo: Repo | undefined, projectHostSetups: readonly ProjectHostSetup[] ): Pick { if (!repo) { return { runContext: null, sourceContext: null } } const projection = projectHostSetupProjectionFromRepos([repo]) const projectedProject = projection.projects[0] const projectedSetup = projection.setups[0] const setup = projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? projectedSetup const runContext = setup ? buildWorkspaceRunContext({ projectId: setup.projectId, hostId: setup.hostId, projectHostSetupId: setup.id, repoId: repo.id, path: setup.path }) : null const providerIdentity = projectedProject?.providerIdentity const sourceContext = providerIdentity ? buildTaskSourceContextFromRepo({ provider: providerIdentity.provider, projectId: providerIdentity.provider === 'github' ? (setup?.projectId ?? repo.id) : repo.id, repo, projectHostSetupId: setup?.id, providerIdentity }) : null return { runContext, sourceContext } } function getAutomationSchedulerOwner(repo: Repo | undefined): AutomationSchedulerOwner { if (!repo) { return 'local_host_service' } const host = parseExecutionHostId(getRepoExecutionHostId(repo)) if (host?.kind === 'ssh') { return 'ssh_bridge' } if (host?.kind === 'runtime') { return 'remote_host_service' } return 'local_host_service' } function backfillLegacyAutomationContexts( state: Pick ): { state: Pick changed: boolean } { let changed = false const contextsByAutomationId = new Map>() const automations = (state.automations ?? []).map((automation) => { const contexts = getAutomationContextsForRepo( state.repos.find((repo) => repo.id === getAutomationLegacyRepoId(automation)), state.projectHostSetups ?? [] ) const next: Automation = { ...automation } if (!Object.hasOwn(next, 'runContext')) { // Why: pre-host-context automations only stored a repo id; backfill the run target once so dispatch/precheck stop inferring it. next.runContext = contexts.runContext changed = true } if (!Object.hasOwn(next, 'sourceContext')) { next.sourceContext = contexts.sourceContext changed = true } contextsByAutomationId.set(next.id, { runContext: next.runContext ?? null, sourceContext: next.sourceContext ?? null }) return next }) const automationRuns = (state.automationRuns ?? []).map((run) => { const automationContexts = contextsByAutomationId.get(run.automationId) const next: AutomationRun = { ...run } if (!Object.hasOwn(next, 'runContext')) { next.runContext = automationContexts?.runContext ?? null changed = true } if (!Object.hasOwn(next, 'sourceContext')) { next.sourceContext = automationContexts?.sourceContext ?? null changed = true } if (!Object.hasOwn(next, 'terminalPaneKey')) { next.terminalPaneKey = null changed = true } if (!Object.hasOwn(next, 'terminalPtyId')) { next.terminalPtyId = null changed = true } return next }) if (!changed) { return { state, changed: false } } return { state: { ...state, automations, automationRuns }, changed: true } } type LegacySshTarget = SshTarget & { remoteWorkspaceSyncEnabled?: unknown remoteWorkspaceSyncGracePeriodSeconds?: unknown experimentalPtySourceCreditV1?: unknown } // Why: old targets predate configHost; default to label-based lookup so imported SSH aliases still resolve via ssh -G. function normalizeSshTarget(t: SshTarget): SshTarget { const target = { ...(t as LegacySshTarget) } const legacySyncEnabled = target.remoteWorkspaceSyncEnabled const currentGracePeriodSeconds = target.relayGracePeriodSeconds const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds const systemSshConnectionReuse = target.systemSshConnectionReuse // Why: remote sync now follows the SSH relay lifecycle, so retired per-target sync/grace fields are dropped at disk load. delete target.remoteWorkspaceSyncEnabled delete target.remoteWorkspaceSyncGracePeriodSeconds delete target.relayGracePeriodSeconds delete target.systemSshConnectionReuse delete target.experimentalPtySourceCreditV1 // Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration. const relayGracePeriodSeconds = legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number' ? legacyGracePeriodSeconds : currentGracePeriodSeconds const normalized: SshTarget = { ...target, configHost: target.configHost ?? target.label ?? target.host } // Why: old SSH form persisted 10800 even without a user choice; treat that legacy default as the new implicit default. if ( relayGracePeriodSeconds !== undefined && relayGracePeriodSeconds !== LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS ) { normalized.relayGracePeriodSeconds = relayGracePeriodSeconds } if (systemSshConnectionReuse === false) { normalized.systemSshConnectionReuse = false } return normalized } // Why: strict whitelist rejects unknown/bad-typed keys; returns Partial so partial updates don't clobber valid persisted state. type SanitizeOnboardingUpdateOptions = { migrateLegacyProgress?: boolean } function remapLegacyOnboardingLastCompletedStep( lastCompletedStep: number, raw: Record ): number { if (raw.outcome === 'completed' && lastCompletedStep >= 4) { return ONBOARDING_FINAL_STEP } // Why: v3 (pre-Windows-terminal-page) step 4 already meant notifications, so resume there, not the inserted Windows step. if (raw.flowVersion === 3) { return Math.min(4, lastCompletedStep) } // Why: v2's five-step flow had step 4 = removed agent setup, not completed integrations. if (raw.flowVersion === 2) { if (lastCompletedStep === 3) { return 2 } if (lastCompletedStep >= 4) { return 3 } return lastCompletedStep } if (lastCompletedStep === 3) { return 2 } if (lastCompletedStep === 4) { return 2 } if (lastCompletedStep >= 5) { return 3 } return lastCompletedStep } export function sanitizeOnboardingUpdate( input: unknown, options: SanitizeOnboardingUpdateOptions = {} ): Partial> & { checklist?: Partial } { if (!input || typeof input !== 'object' || Array.isArray(input)) { return {} } const raw = input as Record const out: Partial> & { checklist?: Partial } = {} if ('closedAt' in raw) { // Why: NaN/Infinity serialize to null on save, reverting closedAt and reopening the wizard; require a finite timestamp. if (typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) && raw.closedAt >= 0) { out.closedAt = raw.closedAt } else if (raw.closedAt === null) { out.closedAt = null } // else: omit — preserve existing persisted value on merge. } if ('outcome' in raw) { const v = raw.outcome if (v === 'completed' || v === 'dismissed') { out.outcome = v as OnboardingOutcome } else if (v === null) { out.outcome = null } // else: omit. } if ('flowVersion' in raw) { const v = raw.flowVersion if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= ONBOARDING_FLOW_VERSION) { out.flowVersion = v } // else: omit. } if ('lastCompletedStep' in raw) { const v = raw.lastCompletedStep if (typeof v === 'number' && Number.isInteger(v) && v >= -1) { const isLegacyFlow = options.migrateLegacyProgress && raw.flowVersion !== ONBOARDING_FLOW_VERSION // Why: removing two wizard pages changed step numbering; migrate legacy values before the final-step bound drops them. const normalized = isLegacyFlow ? remapLegacyOnboardingLastCompletedStep(v, raw) : v if (normalized <= ONBOARDING_FINAL_STEP) { out.lastCompletedStep = normalized } } // else: omit. } if ('checklist' in raw) { const rawChecklist = raw.checklist if (rawChecklist && typeof rawChecklist === 'object' && !Array.isArray(rawChecklist)) { // Why: copy ONLY caller-sent boolean keys so partial updates don't reset other checklist items to false. const defaults = getDefaultOnboardingState().checklist const rc = rawChecklist as Record const checklist: Partial = {} for (const key of Object.keys(defaults) as (keyof OnboardingChecklistState)[]) { if (key in rc && typeof rc[key] === 'boolean') { checklist[key] = rc[key] as boolean } } out.checklist = checklist } } if (options.migrateLegacyProgress) { out.flowVersion = ONBOARDING_FLOW_VERSION } return out } function normalizeLoadedOnboardingState( input: unknown, defaults: OnboardingState ): OnboardingState { // Why: an existing file with no onboarding block is an upgrade user; backfill as completed so they skip the wizard. if (!input) { return { ...defaults, closedAt: Date.now(), outcome: 'completed', lastCompletedStep: ONBOARDING_FINAL_STEP } } // Why: sanitize persisted onboarding keys so a type-flipped field on disk can't poison in-memory state. const sanitized = sanitizeOnboardingUpdate(input, { migrateLegacyProgress: true }) // Why: a completed/dismissed outcome means the user left; recover a bad closedAt instead of reopening the checklist. const recoveredClosedAt = typeof sanitized.closedAt === 'number' ? sanitized.closedAt : sanitized.outcome !== null && sanitized.outcome !== undefined ? Date.now() : sanitized.closedAt return { ...defaults, ...sanitized, closedAt: recoveredClosedAt ?? defaults.closedAt, checklist: { ...defaults.checklist, ...sanitized.checklist } } } function resolveSetupGuideSidebarDismissedOnLoad( persistedDismissed: unknown, onboarding: OnboardingState ): boolean { // Why: once onboarding is closed, persisted false is just the old default, not a user opt-in to the sidebar checklist. return onboarding.closedAt !== null || persistedDismissed === true } // Why: read a settings field removed from GlobalSettings but still on disk; one-shot for the inline-agents migration. function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean { return ( (parsed?.settings as { experimentalAgentDashboard?: boolean } | undefined) ?.experimentalAgentDashboard === true ) } function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | undefined { return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick } function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { if (value === undefined) { return undefined } if (value === null) { return null } if (!value || typeof value !== 'object') { return undefined } const candidate = value as { owner?: unknown; repo?: unknown; host?: unknown } const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : '' const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : '' if (!owner || !repo) { return undefined } // Why: an `upstream` remote may live on a different server than `origin`, so // dropping the host forced consumers to re-infer it from origin and could bind // a GHES parent to a same-named github.com repo. Absent host stays absent so // records written before this survive unchanged. const host = typeof candidate.host === 'string' ? candidate.host.trim() : '' return host ? { owner, repo, host } : { owner, repo } } function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | null | undefined { // Why: `null` is a resolved "no usable remote" marker; dropping it would make // a settled repo indistinguishable from one whose identity probe is pending. if (value === null) { return null } if (!value || typeof value !== 'object') { return undefined } const candidate = value as { canonicalKey?: unknown remoteName?: unknown remoteUrl?: unknown } const canonicalKey = typeof candidate.canonicalKey === 'string' ? candidate.canonicalKey.trim() : '' const remoteName = typeof candidate.remoteName === 'string' ? candidate.remoteName.trim() : '' const remoteUrl = typeof candidate.remoteUrl === 'string' ? candidate.remoteUrl.trim() : '' return canonicalKey && remoteName && remoteUrl ? { canonicalKey, remoteName, remoteUrl } : undefined } function sanitizeRepoProjectHostSetupMethod( value: unknown ): RepoProjectHostSetupMethod | undefined { return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined } function sanitizeForkSyncMode(value: unknown): Repo['forkSyncMode'] | undefined { return value === 'ask' || value === 'safe-auto' || value === 'off' ? value : undefined } function sanitizeRepoUpdatesForPersistence< T extends Partial< Pick< Repo, | 'badgeColor' | 'repoIcon' | 'upstream' | 'gitRemoteIdentity' | 'worktreeBasePath' | 'projectHostSetupMethod' | 'forkSyncMode' > > >(updates: T): T { const sanitized = { ...updates } if ('badgeColor' in sanitized) { const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) if (!badgeColor) { delete sanitized.badgeColor } else { sanitized.badgeColor = badgeColor } } if ('repoIcon' in sanitized) { const repoIcon = sanitizeRepoIcon(sanitized.repoIcon) if (repoIcon === undefined) { delete sanitized.repoIcon } else { sanitized.repoIcon = repoIcon } } // Why: `null` is a valid "not a fork" / "no usable remote" marker; only drop malformed shapes. if ('upstream' in sanitized) { const upstream = sanitizeRepoUpstream(sanitized.upstream) if (upstream === undefined) { delete sanitized.upstream } else { sanitized.upstream = upstream } } if ('gitRemoteIdentity' in sanitized) { const gitRemoteIdentity = sanitizeGitRemoteIdentity(sanitized.gitRemoteIdentity) if (gitRemoteIdentity === undefined) { delete sanitized.gitRemoteIdentity } else { sanitized.gitRemoteIdentity = gitRemoteIdentity } } if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { if (typeof sanitized.worktreeBasePath === 'string') { sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined } else { delete sanitized.worktreeBasePath } } if ('projectHostSetupMethod' in sanitized) { const setupMethod = sanitizeRepoProjectHostSetupMethod(sanitized.projectHostSetupMethod) if (setupMethod === undefined) { delete sanitized.projectHostSetupMethod } else { sanitized.projectHostSetupMethod = setupMethod } } if ('forkSyncMode' in sanitized) { const forkSyncMode = sanitizeForkSyncMode(sanitized.forkSyncMode) if (forkSyncMode === undefined) { delete sanitized.forkSyncMode } else { sanitized.forkSyncMode = forkSyncMode } } return sanitized } function expandFloatingWorkspaceHomePath(input: string, home: string): string { if (input === '~') { return home } if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) { return join(home, input.slice(2)) } return input } function resolveFloatingWorkspacePath(input: string, home: string): string { const expanded = expandFloatingWorkspaceHomePath(input, home) return isAbsolute(expanded) ? resolve(expanded) : resolve(home, expanded) } function canonicalizePersistedFloatingWorkspaceDirectory( input: string, home: string ): string | null { const trimmed = input.trim() if (!trimmed) { return null } try { const canonicalPath = resolve(realpathSync(resolveFloatingWorkspacePath(trimmed, home))) return statSync(canonicalPath).isDirectory() ? canonicalPath : null } catch { return null } } function normalizeFloatingWorkspaceTrustedCwds( input: unknown, home: string ): { trustedCwds: string[]; changed: boolean } { const rawTrustedCwds = Array.isArray(input) ? input : [] const trustedCwds: string[] = [] const seen = new Set() let changed = input !== undefined && !Array.isArray(input) for (const rawTrustedCwd of rawTrustedCwds) { if (typeof rawTrustedCwd !== 'string') { changed = true continue } const trimmedTrustedCwd = rawTrustedCwd.trim() if (!trimmedTrustedCwd) { changed = true continue } const canonicalPath = canonicalizePersistedFloatingWorkspaceDirectory(trimmedTrustedCwd, home) const normalizedPath = canonicalPath ?? resolveFloatingWorkspacePath(trimmedTrustedCwd, home) if (!normalizedPath) { changed = true continue } if (seen.has(normalizedPath)) { changed = true continue } seen.add(normalizedPath) trustedCwds.push(normalizedPath) if (rawTrustedCwd !== normalizedPath) { changed = true } } return { trustedCwds, changed } } function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { if (!value || typeof value !== 'object') { return null } const raw = value as Partial if (typeof raw.targetId !== 'string' || typeof raw.ptyId !== 'string') { return null } const state = raw.state ?? 'detached' if (!['attached', 'detached', 'terminated', 'expired'].includes(state)) { return null } const now = Date.now() return { targetId: raw.targetId, ptyId: raw.ptyId, ...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}), ...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}), ...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}), state, createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now, updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : now, ...(typeof raw.lastAttachedAt === 'number' ? { lastAttachedAt: raw.lastAttachedAt } : {}), ...(typeof raw.lastDetachedAt === 'number' ? { lastDetachedAt: raw.lastDetachedAt } : {}) } } const SSH_PTY_OWNER_LEASE_MAX_LENGTH = 512 const ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH = 4096 function normalizeSshPtyConsumerRecovery( value: unknown, ownerLeaseMaxLength = SSH_PTY_OWNER_LEASE_MAX_LENGTH ): SshPtyConsumerRecovery | null { if (!value || typeof value !== 'object') { return null } const raw = value as Partial const clientGeneration = raw.clientGeneration const ownerGeneration = raw.ownerGeneration if ( typeof raw.targetId !== 'string' || raw.targetId.length === 0 || raw.targetId.length > 512 || typeof raw.clientInstanceId !== 'string' || raw.clientInstanceId.length === 0 || raw.clientInstanceId.length > 512 || typeof raw.serverBuildId !== 'string' || raw.serverBuildId.length === 0 || raw.serverBuildId.length > 512 || typeof clientGeneration !== 'number' || !Number.isSafeInteger(clientGeneration) || clientGeneration <= 0 || typeof ownerGeneration !== 'number' || !Number.isSafeInteger(ownerGeneration) || ownerGeneration <= 0 || typeof raw.ownerLease !== 'string' || raw.ownerLease.length === 0 || raw.ownerLease.length > ownerLeaseMaxLength ) { return null } const flow = raw.outputFlowControl const outputFlowControl = flow?.version === 1 && Number.isSafeInteger(flow.windowSu) && flow.windowSu > 0 ? { version: 1 as const, windowSu: flow.windowSu } : undefined return { targetId: raw.targetId, clientInstanceId: raw.clientInstanceId, serverBuildId: raw.serverBuildId, clientGeneration, ownerGeneration, ownerLease: raw.ownerLease, ...(outputFlowControl ? { outputFlowControl } : {}) } } type LayoutLeafNormalization = { snapshot: TerminalLayoutSnapshot changed: boolean leafIdByInputLeafId: Map } function collectLayoutLeafCounts( node: TerminalPaneLayoutNode, counts: Map = new Map() ): Map { if (node.type === 'leaf') { counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1) return counts } collectLayoutLeafCounts(node.first, counts) collectLayoutLeafCounts(node.second, counts) return counts } function collectLayoutLeafIdsInOrder(node: TerminalPaneLayoutNode | null | undefined): string[] { if (!node) { return [] } if (node.type === 'leaf') { return [node.leafId] } return [...collectLayoutLeafIdsInOrder(node.first), ...collectLayoutLeafIdsInOrder(node.second)] } function firstLayoutLeafId(node: TerminalPaneLayoutNode | null): string | null { if (!node) { return null } return node.type === 'leaf' ? node.leafId : firstLayoutLeafId(node.first) } function layoutContainsLeafId(node: TerminalPaneLayoutNode | null, leafId: string): boolean { if (!node) { return false } if (node.type === 'leaf') { return node.leafId === leafId } return layoutContainsLeafId(node.first, leafId) || layoutContainsLeafId(node.second, leafId) } function cloneLayoutNode(node: TerminalPaneLayoutNode): TerminalPaneLayoutNode { if (node.type === 'leaf') { return { type: 'leaf', leafId: node.leafId } } return { ...node, first: cloneLayoutNode(node.first), second: cloneLayoutNode(node.second) } } function cloneLayoutWithLeafIds( node: TerminalPaneLayoutNode, leafIdByInputLeafId: Map, duplicatedInputLeafIds: Set ): TerminalPaneLayoutNode { if (node.type === 'leaf') { return { type: 'leaf', leafId: duplicatedInputLeafIds.has(node.leafId) ? randomUUID() : (leafIdByInputLeafId.get(node.leafId) ?? randomUUID()) } } return { ...node, first: cloneLayoutWithLeafIds(node.first, leafIdByInputLeafId, duplicatedInputLeafIds), second: cloneLayoutWithLeafIds(node.second, leafIdByInputLeafId, duplicatedInputLeafIds) } } function remapLeafRecordForPersistence( source: Record | undefined, leafIdByInputLeafId: Map, duplicatedInputLeafIds: Set ): Record | undefined { if (!source) { return undefined } const next: Record = {} for (const [leafId, value] of Object.entries(source)) { if (duplicatedInputLeafIds.has(leafId)) { continue } const nextLeafId = leafIdByInputLeafId.get(leafId) if (nextLeafId) { next[nextLeafId] = value } } return Object.keys(next).length > 0 ? next : undefined } function leafRecordEquivalent( left: Record | undefined, right: Record | undefined ): boolean { const leftEntries = Object.entries(left ?? {}) const rightRecord = right ?? {} if (leftEntries.length !== Object.keys(rightRecord).length) { return false } return leftEntries.every(([key, value]) => rightRecord[key] === value) } function preserveMissingLeafRecordEntries( priorRecord: Record | undefined, incomingRecord: Record | undefined, liveLeafIds: Set ): Record | undefined { const preserved = Object.fromEntries( Object.entries(priorRecord ?? {}).filter( ([leafId]) => liveLeafIds.has(leafId) && incomingRecord?.[leafId] === undefined ) ) const next = { ...preserved, ...incomingRecord } return Object.keys(next).length > 0 ? next : undefined } function findWorktreeIdForTab(session: WorkspaceSessionState, tabId: string): string | undefined { for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { if (tabs.some((tab) => tab.id === tabId)) { return worktreeId } } return undefined } type PaneIdentityMigrationEntries = { migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } function collectMigrationUnsupportedPtyEntries(args: { session: WorkspaceSessionState tabId: string inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map }): PaneIdentityMigrationEntries { const worktreeId = findWorktreeIdForTab(args.session, args.tabId) const tab = worktreeId ? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId) : undefined const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const registeredLegacyPaneKeys = new Set() const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0 const fallbackPtyId = !hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => { if (!isTerminalLeafId(leafId)) { return false } let paneKey: string try { paneKey = makePaneKey(args.tabId, leafId) } catch { return false } const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1] if (!numeric) { return false } // Why: PaneManager ids are 1-based; a zero-based alias in split layouts makes tab:1 ambiguous and misroutes panes. const legacyPaneKey = `${args.tabId}:${numeric}` agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId) registeredLegacyPaneKeys.add(legacyPaneKey) if (ptyId) { legacyPaneKeyAliasEntries.push({ ptyId, legacyPaneKey, stablePaneKey: paneKey, updatedAt: Date.now() }) return true } return false } const inputLeafIds = new Set([ ...collectLayoutLeafIdsInOrder(args.inputLayout.root), ...Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}) ]) for (const inputLeafId of inputLeafIds) { if (isTerminalLeafId(inputLeafId)) { continue } const leafId = args.leafIdByInputLeafId.get(inputLeafId) if (leafId) { registerLegacyAlias( inputLeafId, leafId, args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId ) } } if (tab?.ptyId && !hasLeafPtyBindings) { const fallbackLeafId = args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root) if (fallbackLeafId && isTerminalLeafId(fallbackLeafId)) { const paneKey = makePaneKey(args.tabId, fallbackLeafId) for (const legacyPaneKey of [`${args.tabId}:0`, `${args.tabId}:1`]) { if (registeredLegacyPaneKeys.has(legacyPaneKey)) { continue } agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId) legacyPaneKeyAliasEntries.push({ ptyId: tab.ptyId, legacyPaneKey, stablePaneKey: paneKey, updatedAt: Date.now() }) } } } // Why: legacy numeric pane keys are now bridged by aliases, not persisted as restart-required rows. return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } } function legacyMigrationUnsupportedRowsToAliasEntries( entries: MigrationUnsupportedPtyEntry[] ): LegacyPaneKeyAliasEntry[] { const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter( (entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey) ) const entriesByTabId = new Map() for (const entry of normalizedEntries) { const tabId = entry.tabId if (!tabId) { continue } entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry]) } const aliasEntries: LegacyPaneKeyAliasEntry[] = [] for (const [tabId, tabEntries] of entriesByTabId) { if (tabEntries.length !== 1) { continue } const [entry] = tabEntries if (!entry.paneKey) { continue } // Why: pre-stable rows lack the old numeric key; only synthesize single-pane aliases when the row is unambiguous. for (const legacyPaneKey of [`${tabId}:0`, `${tabId}:1`]) { aliasEntries.push({ ptyId: entry.ptyId, legacyPaneKey, stablePaneKey: entry.paneKey, updatedAt: entry.updatedAt }) } } return aliasEntries } function normalizeTerminalLayoutSnapshotForPersistence( snapshot: TerminalLayoutSnapshot, preferredLayout?: TerminalLayoutSnapshot ): LayoutLeafNormalization { let inputSnapshot = snapshot let changed = false if (!inputSnapshot.root) { if (!preferredLayout?.root) { return { snapshot, changed: false, leafIdByInputLeafId: new Map() } } const root = cloneLayoutNode(preferredLayout.root) const rootLeafIds = new Set(collectLayoutLeafIdsInOrder(root)) const activeLeafId = (inputSnapshot.activeLeafId && rootLeafIds.has(inputSnapshot.activeLeafId) ? inputSnapshot.activeLeafId : null) ?? (preferredLayout.activeLeafId && rootLeafIds.has(preferredLayout.activeLeafId) ? preferredLayout.activeLeafId : null) ?? firstLayoutLeafId(root) const expandedLeafId = (inputSnapshot.expandedLeafId && rootLeafIds.has(inputSnapshot.expandedLeafId) ? inputSnapshot.expandedLeafId : null) ?? (preferredLayout.expandedLeafId && rootLeafIds.has(preferredLayout.expandedLeafId) ? preferredLayout.expandedLeafId : null) inputSnapshot = { ...inputSnapshot, root, activeLeafId, expandedLeafId } // Why: a debounced renderer writer can still hold the createTab-era empty layout after the UUID root was sync-flushed. changed = true } const inputRoot = inputSnapshot.root if (!inputRoot) { return { snapshot, changed: false, leafIdByInputLeafId: new Map() } } const counts = collectLayoutLeafCounts(inputRoot) const duplicatedInputLeafIds = new Set( Array.from(counts.entries()) .filter(([, count]) => count > 1) .map(([leafId]) => leafId) ) const inputLeafIdsInOrder = collectLayoutLeafIdsInOrder(inputRoot) const preferredLeafIdsInOrder = collectLayoutLeafIdsInOrder(preferredLayout?.root) const usePreferredLeafIds = preferredLeafIdsInOrder.length === inputLeafIdsInOrder.length const leafIdByInputLeafId = new Map() for (const [index, leafId] of inputLeafIdsInOrder.entries()) { const count = counts.get(leafId) ?? 0 if (count !== 1 || leafIdByInputLeafId.has(leafId)) { changed = true continue } if (isTerminalLeafId(leafId)) { leafIdByInputLeafId.set(leafId, leafId) continue } changed = true const preferredLeafId = usePreferredLeafIds ? preferredLeafIdsInOrder[index] : undefined leafIdByInputLeafId.set( leafId, preferredLeafId && isTerminalLeafId(preferredLeafId) ? preferredLeafId : randomUUID() ) } const root = changed ? cloneLayoutWithLeafIds(inputRoot, leafIdByInputLeafId, duplicatedInputLeafIds) : inputRoot const activeLeafId = inputSnapshot.activeLeafId && !duplicatedInputLeafIds.has(inputSnapshot.activeLeafId) ? (leafIdByInputLeafId.get(inputSnapshot.activeLeafId) ?? firstLayoutLeafId(root)) : inputSnapshot.activeLeafId === null ? null : firstLayoutLeafId(root) const expandedLeafId = inputSnapshot.expandedLeafId && !duplicatedInputLeafIds.has(inputSnapshot.expandedLeafId) ? (leafIdByInputLeafId.get(inputSnapshot.expandedLeafId) ?? null) : null const ptyIdsByLeafId = remapLeafRecordForPersistence( inputSnapshot.ptyIdsByLeafId, leafIdByInputLeafId, duplicatedInputLeafIds ) const buffersByLeafId = remapLeafRecordForPersistence( inputSnapshot.buffersByLeafId, leafIdByInputLeafId, duplicatedInputLeafIds ) const scrollbackRefsByLeafId = remapLeafRecordForPersistence( inputSnapshot.scrollbackRefsByLeafId, leafIdByInputLeafId, duplicatedInputLeafIds ) const titlesByLeafId = remapLeafRecordForPersistence( inputSnapshot.titlesByLeafId, leafIdByInputLeafId, duplicatedInputLeafIds ) const recordsChanged = !leafRecordEquivalent(inputSnapshot.ptyIdsByLeafId, ptyIdsByLeafId) || !leafRecordEquivalent(inputSnapshot.buffersByLeafId, buffersByLeafId) || !leafRecordEquivalent(inputSnapshot.scrollbackRefsByLeafId, scrollbackRefsByLeafId) || !leafRecordEquivalent(inputSnapshot.titlesByLeafId, titlesByLeafId) const metadataChanged = activeLeafId !== inputSnapshot.activeLeafId || expandedLeafId !== inputSnapshot.expandedLeafId if (!changed && !recordsChanged && !metadataChanged) { return { snapshot, changed: false, leafIdByInputLeafId } } const { ptyIdsByLeafId: _oldPtyIdsByLeafId, buffersByLeafId: _oldBuffersByLeafId, scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, titlesByLeafId: _oldTitlesByLeafId, ...snapshotWithoutLeafRecords } = inputSnapshot return { snapshot: { ...snapshotWithoutLeafRecords, root, activeLeafId, expandedLeafId, ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), ...(titlesByLeafId ? { titlesByLeafId } : {}) }, changed: true, leafIdByInputLeafId } } function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, priorLayoutsByTabId: Record = {} ): { session: WorkspaceSessionState changed: boolean leafIdByInputLeafIdByTabId: Map> leafIdByPtyIdByTabId: Map> migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { let changed = false const leafIdByInputLeafIdByTabId = new Map>() const leafIdByPtyIdByTabId = new Map>() const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { const normalized = normalizeTerminalLayoutSnapshotForPersistence( layout, priorLayoutsByTabId[tabId] ) terminalLayoutsByTabId[tabId] = normalized.snapshot leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) const migrationEntries = collectMigrationUnsupportedPtyEntries({ session, tabId, inputLayout: layout, normalizedLayout: normalized.snapshot, leafIdByInputLeafId: normalized.leafIdByInputLeafId }) // Why: old split layouts can generate enough alias rows to exceed V8's argument limit if spread into push(). for (const entry of migrationEntries.migrationUnsupportedEntries) { migrationUnsupportedEntries.push(entry) } for (const entry of migrationEntries.legacyPaneKeyAliasEntries) { legacyPaneKeyAliasEntries.push(entry) } const leafIdByPtyId = new Map() const duplicatePtyIds = new Set() for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) { if (duplicatePtyIds.has(ptyId)) { continue } if (leafIdByPtyId.has(ptyId)) { leafIdByPtyId.delete(ptyId) duplicatePtyIds.add(ptyId) continue } leafIdByPtyId.set(ptyId, leafId) } leafIdByPtyIdByTabId.set(tabId, leafIdByPtyId) changed ||= normalized.changed } return { session: changed ? { ...session, terminalLayoutsByTabId } : session, changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, migrationUnsupportedEntries, legacyPaneKeyAliasEntries } } function remapSshRemotePtyLeaseLeafIds( leases: SshRemotePtyLease[], leafIdByInputLeafIdByTabId: Map>, leafIdByPtyIdByTabId: Map> ): { leases: SshRemotePtyLease[]; changed: boolean } { let changed = false const nextLeases = leases.map((lease) => { if (lease.leafId === undefined || isTerminalLeafId(lease.leafId)) { return lease } const remappedLeafId = lease.tabId ? leafIdByInputLeafIdByTabId.get(lease.tabId)?.get(lease.leafId) : undefined const leafIdForPty = lease.tabId ? leafIdByPtyIdByTabId.get(lease.tabId)?.get(lease.ptyId) : undefined changed = true const nextLeafId = remappedLeafId ?? leafIdForPty if (nextLeafId) { return { ...lease, leafId: nextLeafId } } const next = { ...lease } // Why: unmatched legacy leaf ids are ambiguous after migration; don't re-persist them as durable pane identity. delete next.leafId return next }) return { leases: nextLeases, changed } } function normalizePersistedPaneIdentityState(state: PersistedState): { state: PersistedState changed: boolean migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {}) const remappedLeases = remapSshRemotePtyLeaseLeafIds( state.sshRemotePtyLeases ?? [], normalizedSession.leafIdByInputLeafIdByTabId, normalizedSession.leafIdByPtyIdByTabId ) const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([ ...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries), ...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []), ...normalizedSession.legacyPaneKeyAliasEntries ]) const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( state.ui?.acknowledgedAgentsByPaneKey, normalizedSession.leafIdByInputLeafIdByTabId ) const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual( state.migrationUnsupportedPtyEntries ?? [], mergedMigrationUnsupportedEntries ) const legacyAliasesChanged = !legacyPaneKeyAliasEntriesEqual( state.legacyPaneKeyAliasEntries ?? [], mergedLegacyPaneKeyAliasEntries ) if ( !normalizedSession.changed && !remappedLeases.changed && !migrationUnsupportedChanged && !legacyAliasesChanged && !remappedAcknowledgements.changed ) { return { state, changed: false, migrationUnsupportedEntries: mergedMigrationUnsupportedEntries, legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } return { state: { ...state, workspaceSession: normalizedSession.session, sshRemotePtyLeases: remappedLeases.leases, migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries, legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries, ...(remappedAcknowledgements.changed ? { ui: { ...state.ui, acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements } } : {}) }, changed: true, migrationUnsupportedEntries: mergedMigrationUnsupportedEntries, legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } function remapAcknowledgedAgentPaneKeys( acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'], leafIdByInputLeafIdByTabId: Map> ): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } { if (!acknowledgements || Object.keys(acknowledgements).length === 0) { return { acknowledgements, changed: false } } let changed = false const next: NonNullable = {} const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => { const existing = next[paneKey] next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt) } for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) { const parsed = parsePaneKey(paneKey) if (parsed) { setAcknowledgement(paneKey, acknowledgedAt) continue } const delimiter = paneKey.indexOf(':') if (delimiter <= 0 || delimiter === paneKey.length - 1) { setAcknowledgement(paneKey, acknowledgedAt) continue } const tabId = paneKey.slice(0, delimiter) const legacyLeafId = paneKey.slice(delimiter + 1) const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId) if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) { setAcknowledgement(paneKey, acknowledgedAt) continue } try { // Why: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread. setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt) changed = true } catch { setAcknowledgement(paneKey, acknowledgedAt) } } return { acknowledgements: next, changed } } // Why: bounds a corrupt/bloated persisted list — the gate only needs the few Claude sessions a daemon can keep alive. const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 // Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded. const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 function normalizeClaudeLivePtySessionIds(value: unknown): string[] { if (!Array.isArray(value)) { return [] } // Why: scan newest-first so the cap keeps the most recent ids, matching addClaudeLivePtySessionId's eviction policy. const ids: string[] = [] for (let index = value.length - 1; index >= 0; index -= 1) { const entry = value[index] if (typeof entry !== 'string' || entry.length === 0 || entry.length > 512) { continue } if (!ids.includes(entry)) { ids.push(entry) } if (ids.length >= MAX_CLAUDE_LIVE_PTY_SESSION_IDS) { break } } return ids.toReversed() } function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsupportedPtyEntry[] { if (!Array.isArray(value)) { return [] } return value.filter((entry): entry is MigrationUnsupportedPtyEntry => { if (!entry || typeof entry !== 'object') { return false } const candidate = entry as Partial return ( typeof candidate.ptyId === 'string' && candidate.ptyId.length > 0 && (candidate.worktreeId === undefined || typeof candidate.worktreeId === 'string') && (candidate.tabId === undefined || typeof candidate.tabId === 'string') && (candidate.leafId === undefined || isTerminalLeafId(candidate.leafId)) && (candidate.paneKey === undefined || typeof candidate.paneKey === 'string') && candidate.reason === 'legacy-numeric-pane-key' && (candidate.source === 'local' || candidate.source === 'ssh') && Number.isFinite(candidate.updatedAt) ) }) } function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasEntry[] { if (!Array.isArray(value)) { return [] } return value.filter((entry): entry is LegacyPaneKeyAliasEntry => { if (!entry || typeof entry !== 'object') { return false } const candidate = entry as Partial if ( typeof candidate.ptyId !== 'string' || candidate.ptyId.trim().length === 0 || typeof candidate.legacyPaneKey !== 'string' || typeof candidate.stablePaneKey !== 'string' || !Number.isFinite(candidate.updatedAt) ) { return false } const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey) const relocatedSource = parsePaneKey(candidate.legacyPaneKey) const stable = parsePaneKey(candidate.stablePaneKey) return Boolean(stable && ((legacy && legacy.tabId === stable.tabId) || relocatedSource)) }) } function registerPersistedPaneKeyAlias(entry: LegacyPaneKeyAliasEntry): void { if (parseLegacyNumericPaneKey(entry.legacyPaneKey)) { agentHookServer.registerPaneKeyAlias( entry.legacyPaneKey, entry.stablePaneKey, entry.ptyId, entry.updatedAt, { overwriteExisting: false } ) return } // Why: detached agents keep their UUID pane key across restarts; restore the physical-to-owner mapping before hook replay. agentHookServer.transferPaneAuthority( entry.legacyPaneKey, entry.stablePaneKey, entry.ptyId, entry.updatedAt, { authorityVerified: false } ) } function mergeLegacyPaneKeyAliasEntries( entries: LegacyPaneKeyAliasEntry[] ): LegacyPaneKeyAliasEntry[] { const byLegacyPaneKey = new Map() for (const entry of normalizeLegacyPaneKeyAliasEntries(entries)) { const existing = byLegacyPaneKey.get(entry.legacyPaneKey) if (!existing || existing.updatedAt <= entry.updatedAt) { byLegacyPaneKey.set(entry.legacyPaneKey, entry) } } return [...byLegacyPaneKey.values()] } function legacyPaneKeyAliasEntriesEqual( left: LegacyPaneKeyAliasEntry[], right: LegacyPaneKeyAliasEntry[] ): boolean { if (left.length !== right.length) { return false } const rightByLegacyPaneKey = new Map(right.map((entry) => [entry.legacyPaneKey, entry])) return left.every((entry) => { const other = rightByLegacyPaneKey.get(entry.legacyPaneKey) return other ? JSON.stringify(entry) === JSON.stringify(other) : false }) } function migrationUnsupportedEntriesEqual( left: MigrationUnsupportedPtyEntry[], right: MigrationUnsupportedPtyEntry[] ): boolean { if (left.length !== right.length) { return false } const rightByPtyId = new Map(right.map((entry) => [entry.ptyId, entry])) return left.every((entry) => { const other = rightByPtyId.get(entry.ptyId) return other ? JSON.stringify(entry) === JSON.stringify(other) : false }) } function projectHostSetupCompatibilityStateEqual( state: Pick, nextState: Pick ): boolean { return ( JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) && JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups) ) } function isRepoBackedProjectHostSetup( setup: ProjectHostSetup, currentRepoIds: ReadonlySet ): boolean { const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) } function mergeProjectHostSetupCompatibilityState( state: Pick, repos: readonly Repo[] ): Pick { const projection = projectHostSetupProjectionFromRepos(repos) const existingProjectsById = new Map( (state.projects ?? []).map((project) => [project.id, project]) ) const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) // Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts. const independentSetups = (state.projectHostSetups ?? []).filter((setup) => { if (projectedSetupIds.has(setup.id)) { return false } return !isRepoBackedProjectHostSetup(setup, currentRepoIds) }) const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) const independentProjects = (state.projects ?? []) .filter( (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) ) .map((project) => ({ ...project, sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) })) const projectedProjects = projection.projects.map((project) => { const existingProject = existingProjectsById.get(project.id) return existingProject?.localWindowsRuntimePreference ? { ...project, localWindowsRuntimePreference: existingProject.localWindowsRuntimePreference, updatedAt: Math.max(project.updatedAt, existingProject.updatedAt) } : project }) return { projects: [...projectedProjects, ...independentProjects], projectHostSetups: [...projection.setups, ...independentSetups] } } function makeProjectHostSetupId( projectId: string, hostId: ExecutionHostId, existingIds: ReadonlySet, requestedId?: string ): string { const baseId = requestedId?.trim() || `${projectId}::${hostId}` if (!existingIds.has(baseId)) { return baseId } let suffix = 2 let candidate = `${baseId}::${suffix}` while (existingIds.has(candidate)) { suffix++ candidate = `${baseId}::${suffix}` } return candidate } function createMinimalPersistedTerminalTab(args: { worktreeId: string tabId: string ptyId: string existingTabCount: number startupCwd?: string }): TerminalTab { const ordinal = args.existingTabCount + 1 const defaultTitle = `Terminal ${ordinal}` return { id: args.tabId, ptyId: args.ptyId, worktreeId: args.worktreeId, title: defaultTitle, defaultTitle, customTitle: null, color: null, sortOrder: args.existingTabCount, createdAt: Date.now(), ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), pendingActivationSpawn: true } } function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSessionState { return structuredClone(session) } // Deletes the O(1) owner-keyed fields for `ownerKey` from an already-cloned // session in place, recording removed tab ids into `removedTabIds`. The // pane-key-scanned maps (pty incarnations, surface tombstones, sleeping agents) // and the shutdown list are handled by deleteScannedSessionFieldsForOwners so a // batch prune scans each collection once instead of once per owner. function deleteOwnerKeyedSessionFields( next: WorkspaceSessionState, ownerKey: string, removedTabIds: Set, options: { advanceTerminalTopologyRevision?: boolean } = {} ): void { const removedTerminalTabs = next.tabsByWorktree?.[ownerKey] ?? [] if (next.tabsByWorktree) { delete next.tabsByWorktree[ownerKey] } for (const tab of removedTerminalTabs) { removedTabIds.add(tab.id) delete next.terminalLayoutsByTabId[tab.id] if (next.activeTabId === tab.id) { next.activeTabId = null } } if (options.advanceTerminalTopologyRevision) { const repoId = getRepoIdFromWorktreeId(ownerKey) const previousTopologyRevision = next.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 next.terminalTopologyRevisionByRepoId = { ...next.terminalTopologyRevisionByRepoId, [repoId]: previousTopologyRevision + 1 } } if (next.openFilesByWorktree) { delete next.openFilesByWorktree[ownerKey] } if (next.activeFileIdByWorktree) { delete next.activeFileIdByWorktree[ownerKey] } const browserWorkspaces = next.browserTabsByWorktree?.[ownerKey] ?? [] if (next.browserTabsByWorktree) { delete next.browserTabsByWorktree[ownerKey] } if (next.browserPagesByWorkspace) { for (const workspace of browserWorkspaces) { delete next.browserPagesByWorkspace[workspace.id] } } if (next.activeBrowserTabIdByWorktree) { delete next.activeBrowserTabIdByWorktree[ownerKey] } if (next.activeTabTypeByWorktree) { delete next.activeTabTypeByWorktree[ownerKey] } if (next.activeTabIdByWorktree) { delete next.activeTabIdByWorktree[ownerKey] } if (next.unifiedTabs) { delete next.unifiedTabs[ownerKey] } if (next.tabGroups) { delete next.tabGroups[ownerKey] } if (next.tabGroupLayouts) { delete next.tabGroupLayouts[ownerKey] } if (next.activeGroupIdByWorktree) { delete next.activeGroupIdByWorktree[ownerKey] } if (next.lastVisitedAtByWorktreeId) { delete next.lastVisitedAtByWorktreeId[ownerKey] } if (next.defaultTerminalTabsAppliedByWorktreeId) { delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey] } if (next.activeWorkspaceKey === ownerKey) { next.activeWorkspaceKey = null } if (next.activeWorktreeId === ownerKey) { next.activeWorktreeId = null } } // Scans the pane-key-keyed maps and the shutdown list once, removing every entry // owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose tab // was removed). Kept separate from the O(1) deletes so a batch prune scans each // collection a single time regardless of how many owners are being removed. function deleteScannedSessionFieldsForOwners( next: WorkspaceSessionState, removedTabIds: ReadonlySet, isRemovedOwner: (worktreeId: string) => boolean ): void { if (next.terminalPtyIncarnationsByPaneKey) { next.terminalPtyIncarnationsByPaneKey = Object.fromEntries( Object.entries(next.terminalPtyIncarnationsByPaneKey).filter(([paneKey]) => { const separator = paneKey.lastIndexOf(':') return separator < 1 || !removedTabIds.has(paneKey.slice(0, separator)) }) ) } if (next.terminalSurfaceTombstonesByPaneKey) { next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( ([, tombstone]) => !isRemovedOwner(tombstone.worktreeId) ) ) } if (next.sleepingAgentSessionsByPaneKey) { for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey)) { if (isRemovedOwner(record.worktreeId)) { delete next.sleepingAgentSessionsByPaneKey[paneKey] } } } next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( (worktreeId) => !isRemovedOwner(worktreeId) ) } // Remote (ssh:/runtime:) workspace state can exist in both the renderer's local blob and main's host // partition, because the renderer falls back to 'local' whenever worktree ownership is unresolved. function workspaceSessionPartitionIdsForHost(hostId: string | null | undefined): ExecutionHostId[] { const parsed = parseExecutionHostId(hostId) return parsed && parsed.id !== LOCAL_EXECUTION_HOST_ID ? [LOCAL_EXECUTION_HOST_ID, parsed.id] : [LOCAL_EXECUTION_HOST_ID] } /** The partition the host actually owns; the others are only spill surfaces for it. */ function workspaceSessionOwnerPartitionForHost(hostId: string | null | undefined): ExecutionHostId { return parseExecutionHostId(hostId)?.id ?? LOCAL_EXECUTION_HOST_ID } function removeWorkspaceSessionOwner( session: WorkspaceSessionState | undefined, ownerKey: string, options: { advanceTerminalTopologyRevision?: boolean } = {} ): WorkspaceSessionState | undefined { if (!session) { return session } const next = cloneWorkspaceSessionState(session) const removedTabIds = new Set() deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds, options) deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => worktreeId === ownerKey) return next } // Batch variant of removeWorkspaceSessionOwner: prunes every owner in `ownerKeys` // with a single structuredClone and a single scan of each collection, instead of // one clone+scan per owner. Project removal can touch many worktrees across many // host partitions, so the per-owner clones added up to O(worktrees × hosts). function removeWorkspaceSessionOwners( session: WorkspaceSessionState | undefined, ownerKeys: ReadonlySet ): WorkspaceSessionState | undefined { if (!session || ownerKeys.size === 0) { return session } const next = cloneWorkspaceSessionState(session) const removedTabIds = new Set() for (const ownerKey of ownerKeys) { deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds) } deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => ownerKeys.has(worktreeId) ) return next } function inferFolderScopeConnectionIdForMigration(args: { folderPath: string projectGroup: ProjectGroup projectGroups: readonly ProjectGroup[] projectGroupIndex: ReturnType repos: readonly Repo[] }): string | null { if (args.projectGroupIndex.byId.get(args.projectGroup.id)?.length !== 1) { return null } const groupIdentities = getProjectGroupOwnerSubtreeIdentities( args.projectGroups, args.projectGroup ) const groupRepos = args.repos.filter( (repo) => typeof repo.projectGroupId === 'string' && (() => { const exactGroup = resolveProjectGroupMembership( args.projectGroupIndex, repo.projectGroupId, getRepoExecutionHostId(repo) ) const group = exactGroup ?? resolveProjectGroupOwner(args.projectGroupIndex, repo.projectGroupId) return group !== null && groupIdentities.has(getProjectGroupOwnerIdentity(group)) })() ) const candidateRepos = groupRepos.length > 0 ? groupRepos : args.repos.filter((repo) => isPathInsideOrEqual(args.folderPath, repo.path)) if (candidateRepos.length === 0) { return null } let hasLocalRepo = false const connectionIds = new Set() for (const repo of candidateRepos) { if (repo.connectionId) { connectionIds.add(repo.connectionId) } else { hasLocalRepo = true } } if (hasLocalRepo || connectionIds.size !== 1) { return null } return [...connectionIds][0] } function backfillFolderScopeConnectionIds(state: PersistedState): { state: PersistedState changed: boolean } { const groups = state.projectGroups ?? [] const repos = state.repos ?? [] const originalProjectGroupIndex = buildProjectGroupOwnerIndex(groups) let changed = false const projectGroups = groups.map((group) => { if (getProjectGroupOwnerHostId(group) !== LOCAL_EXECUTION_HOST_ID || !group.parentPath) { return group } const connectionId = inferFolderScopeConnectionIdForMigration({ folderPath: group.parentPath, projectGroup: group, projectGroups: groups, projectGroupIndex: originalProjectGroupIndex, repos }) if (!connectionId) { return group } changed = true return { ...group, connectionId } }) const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups) const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => { if (workspace.connectionId || normalizeExecutionHostId(workspace.executionHostId)) { return workspace } const group = resolveProjectGroupOwner(projectGroupIndex, workspace.projectGroupId) const groupConnectionId = group?.connectionId ?? null const connectionId = groupConnectionId ?? (group ? inferFolderScopeConnectionIdForMigration({ folderPath: workspace.folderPath, projectGroup: group, projectGroups, projectGroupIndex, repos }) : null) if (!connectionId) { return workspace } changed = true return { ...workspace, connectionId } }) return { changed, state: changed ? { ...state, projectGroups, folderWorkspaces } : state } } function deleteRemovedTerminalScrollbackSnapshots( prior: WorkspaceSessionState | undefined, next: WorkspaceSessionState, storage?: TerminalScrollbackSnapshotStorage ): void { if (!prior) { return } const nextRefs = collectTerminalScrollbackSnapshotRefs(next) for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { if (!nextRefs.has(ref)) { deleteTerminalScrollbackSnapshotSync(ref, storage) } } } export type StoreOptions = { dataFile?: string } export class Store { private state: PersistedState private readonly dataFile: string private readonly activeViewPreference: ActiveViewPreference private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null private pendingSnapshotFileWork: Promise | null = null private readonly staleTempCleanup: Promise private writeGeneration = 0 private inFlightAsyncTmpFile: string | null = null // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. private backupRotationInFlight = false // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. private writesFrozen = false /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ private quitFlushStarted = false private quitFlushPromise: Promise | null = null // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. private lastWrittenStateHash: string | null = null private lastDurableWriteGeneration = -1 private firstPendingSaveAt: number | null = null private githubCacheDirty = false private githubCacheGeneration = 0 private pendingGithubCacheWrite: Promise | null = null private readonly staleGithubCacheTempCleanup: Promise private gitUsernameCache = new Map() private readonly protectedSecrets = new ProtectedSecretPersistence() private loadNeedsSave = false private settingsChangeListeners = new Set< ( updates: Partial, settings: GlobalSettings, originWebContentsId?: number ) => void >() private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() constructor(options: StoreOptions = {}) { // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. this.dataFile = options.dataFile ?? getDataFile() this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS }) this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( getGithubCacheFile(this.dataFile), { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } ) const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) this.terminalScrollbackSnapshotStorage = { snapshotRoot: profileSnapshotRoot, fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot } const loaded = this.load() const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state // Why: activeView is a frequent, tiny preference; keeping it beside the // profile avoids serializing the multi-MB recovery store on navigation. this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView) const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } for (const entry of normalized.legacyPaneKeyAliasEntries) { registerPersistedPaneKeyAlias(entry) } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries this.scheduleSave() }) agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { this.state.legacyPaneKeyAliasEntries = entries this.scheduleSave() }) if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { // Why: rewrite legacy pane:1 leaves so older renderer writes can't revive them; other migrations also set loadNeedsSave. this.scheduleSave() } } private adaptFlatFolderScanProjectGroups(): boolean { // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. const groups = this.state.projectGroups ?? [] const repos = this.state.repos if (groups.length === 0 || repos.length === 0) { return false } let changed = false let maxOrder = -1 for (const group of groups) { maxOrder = Math.max(maxOrder, group.tabOrder) } const childGroupIds = new Set( groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) ) const initialGroupCount = groups.length for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { const rootGroup = groups[groupIndex] if (!rootGroup) { continue } if ( rootGroup.createdFrom !== 'folder-scan' || !rootGroup.parentPath || rootGroup.parentGroupId || childGroupIds.has(rootGroup.id) ) { continue } const rootPath = rootGroup.parentPath const repoCandidates = repos.filter( (repo) => !isFolderRepo(repo) && repo.projectGroupId === rootGroup.id && isPathInsideOrEqual(rootPath, repo.path) ) if (repoCandidates.length < 2) { continue } const resolver = createNestedProjectGroupResolver({ parentPath: rootPath, groupName: rootGroup.name, mode: 'group', repoPaths: repoCandidates.map((repo) => repo.path), createGroup: (input) => { if (!input.parentGroupId) { return rootGroup } maxOrder += 1 const group = createProjectGroup({ ...input, tabOrder: maxOrder }) groups.push(group) changed = true return group } }) const nextOrderByGroupId = new Map() for (const repo of repoCandidates) { const group = resolver.getGroupForRepo(repo.path) if (!group) { continue } const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 nextOrderByGroupId.set(group.id, nextOrder + 1) if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { repo.projectGroupId = group.id repo.projectGroupOrder = nextOrder changed = true } } } return changed } // Why (#1158): debounced writes fire ~every 300ms; throttle backups to distinct moments, not near-identical snapshots. private shouldRotateBackups(now: number, dataFile: string): boolean { try { const mtime = statSync(backupPath(dataFile, 0)).mtimeMs return now - mtime >= BACKUP_MIN_INTERVAL_MS } catch { return true } } // Why separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. private async shouldRotateBackupsAsync(dataFile: string): Promise { try { const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS } catch { return true } } // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. private async rotateBackupsAsync(dataFile: string): Promise { if (this.backupRotationInFlight) { return } this.backupRotationInFlight = true try { if (!(await this.shouldRotateBackupsAsync(dataFile))) { return } if (!(await exists(dataFile))) { return } await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('[persistence] Failed to remove oldest backup:', err) } }) for (let i = BACKUP_COUNT - 2; i >= 0; i--) { const src = backupPath(dataFile, i) const dst = backupPath(dataFile, i + 1) // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. if (await exists(src)) { await rename(src, dst).catch((err) => { console.error('[persistence] Failed to rotate backup', src, '->', dst, err) }) } } await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { console.error('[persistence] Failed to snapshot current file to .bak.0:', err) }) } finally { this.backupRotationInFlight = false } } private rotateBackupsSync(dataFile: string): void { if (!existsSync(dataFile)) { return } try { unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) } catch (err) { if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('[persistence] Failed to remove oldest backup:', err) } } for (let i = BACKUP_COUNT - 2; i >= 0; i--) { const src = backupPath(dataFile, i) const dst = backupPath(dataFile, i + 1) if (existsSync(src)) { try { renameSync(src, dst) } catch (err) { console.error('[persistence] Failed to rotate backup', src, '->', dst, err) } } } try { copyFileSync(dataFile, backupPath(dataFile, 0)) } catch (err) { console.error('[persistence] Failed to snapshot current file to .bak.0:', err) } } private restoreFromBackup(dataFile: string): boolean { for (let i = 0; i < BACKUP_COUNT; i++) { const path = backupPath(dataFile, i) if (!existsSync(path)) { continue } try { const raw = readFileSync(path, 'utf-8') JSON.parse(raw) mkdirSync(dirname(dataFile), { recursive: true }) writeFileSync(dataFile, raw, 'utf-8') console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) return true } catch (err) { console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) } } return false } private load(allowBackupRecovery = true): PersistedState { // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. const dataFile = this.dataFile const fileExistedOnLoad = existsSync(dataFile) logPersistenceStartupMilestone('persistence-load-start', { fileExists: fileExistedOnLoad }) let result: PersistedState | null = null try { if (fileExistedOnLoad) { const readStartedAt = performance.now() const raw = readFileSync(dataFile, 'utf-8') logPersistenceStartupMilestone('persistence-read-done', { bytes: Buffer.byteLength(raw), durationMs: Math.round(performance.now() - readStartedAt) }) logPersistenceStartupMilestone('persistence-json-parse-start') const parsed = JSON.parse(raw) as PersistedState logPersistenceStartupMilestone('persistence-json-parse-done') // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( PROTECTED_SECRET_SLOT.opencodeSessionCookie, parsed.settings.opencodeSessionCookie, isLegacyOpenCodeSessionCookie ) } if (parsed.settings?.httpProxyUrl) { const decryptedProxy = this.protectedSecrets.decryptWithStatus( PROTECTED_SECRET_SLOT.httpProxyUrl, parsed.settings.httpProxyUrl, (value) => normalizeProxyUrl(value).ok ) // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL // value must not masquerade as a configured proxy (silent DIRECT fallback) or // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. if ( decryptedProxy.status === 'unavailable' || (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) ) { parsed.settings.httpProxyUrl = '' } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { parsed.settings.httpProxyUrl = decryptedProxy.plaintext } else { console.warn( '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' ) parsed.settings.httpProxyUrl = '' this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) this.loadNeedsSave = true } } if (parsed.ui?.browserKagiSessionLink) { parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( PROTECTED_SECRET_SLOT.browserKagiSessionLink, parsed.ui.browserKagiSessionLink, (value) => normalizeKagiSessionLink(value) !== null ) } parsed.sshPtyConsumerRecoveries = ( Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] ) .map((record) => normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) ) .filter((record): record is SshPtyConsumerRecovery => record !== null) .map((record) => { const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) const decrypted = this.protectedSecrets.decryptWithStatus( slot, record.ownerLease, isLegacySshPtyOwnerLease ) const normalized = decrypted.status === 'unavailable' || (decrypted.status === 'failed' && !decrypted.plaintext) ? record : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) if (!normalized) { this.protectedSecrets.removeRetainedBlob(slot) } return normalized }) .filter((record): record is SshPtyConsumerRecovery => record !== null) // Merge with defaults in case new fields were added const homeDir = homedir() const defaults = getDefaultPersistedState(homeDir) const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) if (migratedTerminalScrollback.needsSave) { this.loadNeedsSave = true } const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( parsed.settings ) if (migratedTerminalTuiScrollSensitivity.needsSave) { this.loadNeedsSave = true } const rawSourceControlAi = parsed.settings?.sourceControlAi const rawSourceControlAiMissing = rawSourceControlAi === undefined const rawSourceControlAiActionsMissing = rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { this.loadNeedsSave = true } const legacyCommitMessageAi = parsed.settings?.commitMessageAi const migratedSourceControlAi = rawSourceControlAiMissing ? sourceControlAiSettingsFromLegacy( legacyCommitMessageAi ?? defaults.settings.commitMessageAi ) : mergeLegacyCommitMessageAiIntoSourceControlAi( parsed.settings?.sourceControlAi, legacyCommitMessageAi ) // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated ? (rawOptionAsAlt ?? 'auto') : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' ? 'auto' : rawOptionAsAlt const floatingTerminalDefaultedForAllUsers = parsed.settings?.floatingTerminalDefaultedForAllUsers === true // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers ? (parsed.settings?.floatingTerminalEnabled ?? true) : true // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) const osc52ClipboardNoticePending = osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || parsed.ui?.osc52ClipboardDefaultOnNoticePending === true if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { this.loadNeedsSave = true } const floatingTerminalCwdMigrated = parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated ? !parsed.settings?.floatingTerminalCwd ? defaults.settings.floatingTerminalCwd : parsed.settings.floatingTerminalCwd : parsed.settings?.floatingTerminalCwd === undefined ? defaults.settings.floatingTerminalCwd : parsed.settings.floatingTerminalCwd const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( parsed.settings?.floatingTerminalTrustedCwds, homeDir ) const migratedFloatingTerminalTrustedCwds = [ ...normalizedFloatingTerminalTrustedCwds.trustedCwds ] const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd const shouldTrustLegacyFloatingTerminalCwd = !floatingTerminalCwdMigrated && typeof rawLegacyFloatingTerminalCwd === 'string' && rawLegacyFloatingTerminalCwd.trim().length > 0 && rawLegacyFloatingTerminalCwd.trim() !== '~' if (!floatingTerminalCwdMigrated) { this.loadNeedsSave = true } if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( rawLegacyFloatingTerminalCwd, homeDir ) if ( canonicalLegacyCwd && !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd) ) { // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) normalizedFloatingTerminalTrustedCwds.changed = true } } if (normalizedFloatingTerminalTrustedCwds.changed) { this.loadNeedsSave = true } const experimentalActivityDefaultedOffForAllUsers = parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers ? (parsed.settings?.experimentalActivity ?? false) : false const autoRenameBranchFromWorkDefaultedOn = parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( parsed.settings ) const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) const migratedTerminalLineHeight = normalizeTerminalLineHeight( parsed.settings?.terminalLineHeight ) const terminalRightClickToPasteDefaultedForPlatform = parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true if (!terminalRightClickToPasteDefaultedForPlatform) { this.loadNeedsSave = true } if ( parsed.settings?.terminalLineHeight !== undefined && parsed.settings.terminalLineHeight !== migratedTerminalLineHeight ) { this.loadNeedsSave = true } const rawTaskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: parsed.settings?.visibleTaskProviders, defaultTaskSource: parsed.settings?.defaultTaskSource }) const visibleTaskProvidersDefaultedForJira = parsed.settings?.visibleTaskProvidersDefaultedForJira === true const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira ? rawTaskProviderSettings.visibleTaskProviders : rawTaskProviderSettings.visibleTaskProviders.includes('jira') ? rawTaskProviderSettings.visibleTaskProviders : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] const taskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: migratedVisibleTaskProviders, defaultTaskSource: rawTaskProviderSettings.defaultTaskSource }) const primarySelectionDefaultedForLinux = parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true const primarySelectionDefaultedForTerminalDefaults = parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true const primarySelectionPlatformDefaultEnabled = defaults.settings.primarySelectionMiddleClickPaste === true const primarySelectionAlreadyDefaultedForPlatform = primarySelectionDefaultedForTerminalDefaults || (process.platform === 'linux' && primarySelectionDefaultedForLinux) const migratePrimarySelectionPlatformDefault = primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform const stampPrimarySelectionTerminalDefaults = primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { this.loadNeedsSave = true } if (!visibleTaskProvidersDefaultedForJira) { this.loadNeedsSave = true } const claudeAgentTeamsDefaultDisabledMigrated = parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true if (!claudeAgentTeamsDefaultDisabledMigrated) { this.loadNeedsSave = true } const migratedDisabledTuiAgents = normalizeDisabledTuiAgents( parsed.settings?.disabledTuiAgents ) const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) if ( parsed.settings?.agentYoloDefaultsMigrated !== true || hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) ) { this.loadNeedsSave = true } if ( !claudeAgentTeamsDefaultDisabledMigrated && !migratedDisabledTuiAgents.includes('claude-agent-teams') ) { migratedDisabledTuiAgents.push('claude-agent-teams') } const migratedWindowsRuntimeDefault = parsed.settings?.localWindowsRuntimeDefault === undefined ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime : parsed.settings.localWindowsRuntimeDefault if ( parsed.settings?.localWindowsRuntimeDefault === undefined && migratedWindowsRuntimeDefault.kind === 'wsl' ) { this.loadNeedsSave = true } // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. const localAccountRuntimeAlreadyMigrated = parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = localAccountRuntimeAlreadyMigrated ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) : parsed.settings?.localAccountRuntime === 'wsl' ? 'wsl' : 'auto' if (!localAccountRuntimeAlreadyMigrated) { this.loadNeedsSave = true } if (!autoRenameBranchFromWorkDefaultedOn) { this.loadNeedsSave = true } const normalizedOnboarding = normalizeLoadedOnboardingState( parsed.onboarding, defaults.onboarding ) if (!parsed.onboarding) { this.loadNeedsSave = true } const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) const loadedCompactWorktreeCards = parsed.settings?.compactWorktreeCards ?? parsed.settings?.experimentalCompactWorktreeCards ?? defaults.settings.compactWorktreeCards const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( parsed.settings?.mobilePairingCustomAddress ) const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses const mobilePairingCustomAddresses = mobilePairingCustomAddress ? addMobilePairingCustomAddress( normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), mobilePairingCustomAddress ) : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) if ( parsed.settings?.mobilePairingCustomAddress !== undefined && parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress ) { this.loadNeedsSave = true } const customAddressesMatch = Array.isArray(rawMobilePairingCustomAddresses) && rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && rawMobilePairingCustomAddresses.every( (address, index) => address === mobilePairingCustomAddresses[index] ) if ( (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && !customAddressesMatch ) { this.loadNeedsSave = true } const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( parsed.settings?.sourceControlGroupOrder ) if ( parsed.settings?.sourceControlGroupOrder !== undefined && parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder ) { this.loadNeedsSave = true } result = { ...defaults, ...parsed, featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( parsed.featureInteractionTelemetryBuckets ), projectGroups: normalizedProjectGroups, folderWorkspaces: normalizeFolderWorkspaces( parsed.folderWorkspaces, normalizedProjectGroups ), worktreeLineageById: parsed.worktreeLineageById ?? {}, mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( parsed.mobileClientTabSelectionsByDeviceId ), workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( parsed.workspaceLineageByChildKey ), settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. ...stripLegacyTerminalScrollbackBytes(parsed.settings), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. experimentalPet: parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault ? true : (parsed.settings?.primarySelectionMiddleClickPaste ?? defaults.settings.primarySelectionMiddleClickPaste), primarySelectionMiddleClickPasteDefaultedForLinux: primarySelectionDefaultedForLinux || (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, ...migratedAutoRenameBranchFromWork, ...migratedTerminalCursorStyle, terminalLineHeight: migratedTerminalLineHeight, // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform ? (parsed.settings?.terminalRightClickToPaste ?? defaults.settings.terminalRightClickToPaste) : parsed.settings?.terminalRightClickToPaste === false ? false : defaults.settings.terminalRightClickToPaste, terminalRightClickToPasteDefaultedForPlatform: true, ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. compactWorktreeCards: loadedCompactWorktreeCards, experimentalCompactWorktreeCards: undefined, terminalMacOptionAsAlt: migratedOptionAsAlt, terminalMacOptionAsAltMigrated: true, localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, localAccountRuntime: migratedLocalAccountRuntime, localAccountRuntimeDefaultedToAutoForAllUsers: true, ...migratedOsc52Clipboard, floatingTerminalEnabled: migratedFloatingTerminalEnabled, floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: migratedFloatingTerminalCwd, floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, floatingTerminalCwdMigratedToAppWorkspace: true, terminalScrollbackRows: migratedTerminalScrollback.rows, terminalQuickCommands: normalizeTerminalQuickCommands( parsed.settings?.terminalQuickCommands ), terminalCustomThemes: normalizeTerminalCustomThemes( parsed.settings?.terminalCustomThemes ), appIcon: normalizeAppIconId(parsed.settings?.appIcon), mobilePairingCustomAddress, mobilePairingCustomAddresses, // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, visibleTaskProvidersDefaultedForJira: true, terminalShortcutPolicy: normalizeTerminalShortcutPolicy( parsed.settings?.terminalShortcutPolicy ), disabledTuiAgents: migratedDisabledTuiAgents, ...migratedAgentYoloDefaults, claudeAgentTeamsDefaultDisabledMigrated: true, openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { seedDefaults: true }), notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, sourceControlGroupOrder: normalizedSourceControlGroupOrder, // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( migratedSourceControlAi, parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi ), voice: { ...getDefaultVoiceSettings(), ...parsed.settings?.voice } }, // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. ui: (() => { const rawSort = parsed.ui?.sortBy const sort = normalizeSortBy(rawSort) const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' const rightSidebarOpen = typeof parsed.ui?.rightSidebarOpen === 'boolean' ? parsed.ui.rightSidebarOpen : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' ? parsed.settings.rightSidebarOpenByDefault : defaults.ui.rightSidebarOpen if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { this.loadNeedsSave = true } const workspaceStatusesDefaultOrderMigrated = parsed.ui?._workspaceStatusesDefaultOrderMigrated === true // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. const workspaceStatusesReorderedDefaultRepaired = parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. const workspaceStatusesDefaultWorkflowMigrated = parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. const workspaceStatusesDefaultVisualsMigrated = parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true const workspaceStatuses = normalizePersistedWorkspaceStatuses( parsed.ui?.workspaceStatuses, { migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated } ) if ( !workspaceStatusesDefaultOrderMigrated || !workspaceStatusesReorderedDefaultRepaired || !workspaceStatusesDefaultWorkflowMigrated || !workspaceStatusesDefaultVisualsMigrated ) { this.loadNeedsSave = true } const rawCardProps = parsed.ui?.worktreeCardProperties const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true const jiraIssueCardPropDefaulted = parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true const hadExperimentOn = readDeprecatedExperimentFlag(parsed) const deliberateUncheck = hadExperimentOn && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents') const needsInlineAgentsMigration = !inlineAgentsMigrated && !deliberateUncheck && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents') const needsLegacyDefaultedCompactMigration = loadedCompactWorktreeCards && parsed.ui?._worktreeCardModeDefaulted === true && isDefaultedCompactWorktreeCardProperties(rawCardProps) const migratedCardProps = (() => { if (!Array.isArray(rawCardProps)) { return undefined } if (needsLegacyDefaultedCompactMigration) { return getWorktreeCardModeProperties('Compact') } const candidate = needsInlineAgentsMigration ? [...rawCardProps, 'inline-agents' as const] : rawCardProps const expandedCandidate = (() => { if (expandedCardPropsMigrated) { return candidate } const next = [...candidate] // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. if (candidate.includes('issue') && !candidate.includes('linear-issue')) { next.push('linear-issue' as const) } if (!candidate.includes('ports')) { next.push('ports' as const) } return next })() // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. const jiraCandidate = jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') ? expandedCandidate : [...expandedCandidate, 'jira-issue' as const] const normalized = normalizeWorktreeCardProperties(jiraCandidate) const changed = normalized.length !== rawCardProps.length || normalized.some((property, index) => property !== rawCardProps[index]) return changed ? normalized : undefined })() if ( migratedCardProps !== undefined || !inlineAgentsMigrated || !expandedCardPropsMigrated || !jiraIssueCardPropDefaulted ) { this.loadNeedsSave = true } const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( parsed.ui?.setupGuideSidebarDismissed, normalizedOnboarding ) if ( parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) ) { this.loadNeedsSave = true } // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. const usagePercentageDisplayChangeNoticeDismissed = resolveUsagePercentageDisplayChangeNoticeDismissed({ rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, isExistingProfile: isExistingPersistedProfile({ repoCount: parsed.repos?.length ?? 0, onboardingClosedAt: normalizedOnboarding.closedAt, ui: parsed.ui }) }) if ( parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== usagePercentageDisplayChangeNoticeDismissed ) { this.loadNeedsSave = true } return { ...defaults.ui, // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. worktreeCardProperties: getWorktreeCardModeProperties( loadedCompactWorktreeCards ? 'Compact' : 'Default' ), ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, usagePercentageDisplayChangeNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated : false, setupGuideBrowserMilestoneLegacyComplete: parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, // Why persist rather than notify inline: the flip lands during load, before any // window exists, and it must survive a crash before the user ever sees the notice. osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, sortBy: migrate ? ('smart' as const) : sort, showDotfilesByWorktree: normalizeShowDotfilesByWorktree( parsed.ui?.showDotfilesByWorktree ), workspaceStatuses, _workspaceStatusesDefaultOrderMigrated: true, _workspaceStatusesReorderedDefaultRepaired: true, _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true, _sortBySmartMigrated: true, ...(migratedCardProps !== undefined ? { worktreeCardProperties: migratedCardProps } : {}), // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. _inlineAgentsDefaultedForExperiment: true, _inlineAgentsDefaultedForAllUsers: true, _expandedWorktreeCardPropertiesDefaulted: true, _jiraIssueWorktreeCardPropertyDefaulted: true } })(), // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. workspaceSession: (() => { if (parsed.workspaceSession === undefined) { return defaults.workspaceSession } const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) if (!result.ok) { console.error( '[persistence] Corrupt workspace session, using defaults:', result.error ) return defaults.workspaceSession } if (result.droppedCount > 0) { console.warn( '[persistence] Salvaged workspace session; dropped corrupt entries:', workspaceSessionSalvageLogDetails(result) ) // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. this.loadNeedsSave = true } return { ...defaults.workspaceSession, ...result.value } })(), // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. workspaceSessionsByHostId: (() => { const { partitions, repaired } = parseWorkspaceSessionsByHostId( parsed.workspaceSessionsByHostId, defaults.workspaceSession ) if (repaired) { // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. this.loadNeedsSave = true } return partitions })(), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) ? parsed.deletedSshConfigAliases.filter( (alias): alias is string => typeof alias === 'string' ) : [], sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries ), legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( parsed.legacyPaneKeyAliasEntries ), automations: Array.isArray(parsed.automations) ? parsed.automations : [], automationRuns: (() => { if (!Array.isArray(parsed.automationRuns)) { return [] } const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. if (runs.length !== parsed.automationRuns.length) { this.loadNeedsSave = true } return runs })(), onboarding: normalizedOnboarding } } } catch (err) { console.error('[persistence] Failed to load primary state, trying backups:', err) } // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. if (result === null && allowBackupRecovery) { let hasBackup = false for (let i = 0; i < BACKUP_COUNT; i++) { if (existsSync(backupPath(dataFile, i))) { hasBackup = true break } } if (fileExistedOnLoad || hasBackup) { if (this.restoreFromBackup(dataFile)) { return this.load(false) } console.error('[persistence] No usable state file or backup found, using defaults') } } if (result === null) { result = getDefaultPersistedState(homedir()) } const workspaceSession = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) ) const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( workspaceSession, this.terminalScrollbackSnapshotStorage ) if (migratedScrollback.changed) { this.loadNeedsSave = true } // Why: legacy folder groups derive SSH ownership from their assigned repos; stamp that owner before exact-owner membership validation. const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ ...result, workspaceSession: migratedScrollback.session }) if (folderScopeConnectionMigration.changed) { this.loadNeedsSave = true } result = folderScopeConnectionMigration.state const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { this.loadNeedsSave = true } const automationContextMigration = backfillLegacyAutomationContexts({ ...result, repos, ...projectHostSetupCompatibility }) if (automationContextMigration.changed) { this.loadNeedsSave = true } result = { ...result, repos, ...projectHostSetupCompatibility, automations: automationContextMigration.state.automations, automationRuns: automationContextMigration.state.automationRuns } if (normalizeWorktreeLinkedItemMetadata(result)) { this.loadNeedsSave = true } if (gcStaleWorktreeMeta(result) > 0) { this.loadNeedsSave = true } const migrated = this.migrateTabSwitchKeybindings( this.migrateTelemetry(result, fileExistedOnLoad), fileExistedOnLoad ) // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. const legacyCache = migrated.githubCache const hasLegacyCache = Object.keys(legacyCache?.pr ?? {}).length > 0 || Object.keys(legacyCache?.issue ?? {}).length > 0 if (hasLegacyCache) { this.loadNeedsSave = true // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. this.githubCacheDirty = true } else { migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache } logPersistenceStartupMilestone('persistence-load-done', { repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) }) return migrated } // One-shot telemetry cohort migration: seeds existedBeforeTelemetryRelease, optedIn, and installId (no-op once set). // One-shot tab-switch cohort freeze: fileExistedOnLoad tells existing vs fresh only on the first launch, so persist now. private migrateTabSwitchKeybindings( state: PersistedState, fileExistedOnLoad: boolean ): PersistedState { const existing = state.settings?.tabSwitchKeybindingSeed if (existing === 'pending' || existing === 'done') { return state } // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. this.loadNeedsSave = true return { ...state, settings: { ...state.settings, // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' } } } private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { const existing = state.settings?.telemetry // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. if ( typeof existing?.existedBeforeTelemetryRelease === 'boolean' && typeof existing.installId === 'string' && existing.installId.length > 0 && (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) ) { return state } // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. const resolvedExistedBefore = typeof existing?.existedBeforeTelemetryRelease === 'boolean' ? existing.existedBeforeTelemetryRelease : fileExistedOnLoad return { ...state, settings: { ...state.settings, telemetry: { ...existing, existedBeforeTelemetryRelease: resolvedExistedBefore, // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. optedIn: existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null ? existing.optedIn : resolvedExistedBefore ? null : true, installId: typeof existing?.installId === 'string' && existing.installId.length > 0 ? existing.installId : randomUUID() } } } } // Why 1s trailing + 5s max-wait (was 300ms unbounded): coalesce mutation bursts; max-wait bounds crash staleness at 5s. private static SAVE_DEBOUNCE_MS = 1_000 private static SAVE_MAX_WAIT_MS = 5_000 private scheduleSave(): void { // Why: once the quit flush has snapshotted, a newly debounced write would fire during // teardown with nothing awaiting it, and the process can exit mid-rename. The quit // flush is the last write by construction. if (this.quitFlushStarted) { return } this.writeGeneration += 1 const now = Date.now() this.firstPendingSaveAt ??= now if (this.writeTimer) { clearTimeout(this.writeTimer) } const untilMaxWait = Math.max(0, this.firstPendingSaveAt + Store.SAVE_MAX_WAIT_MS - now) const delay = Math.min(Store.SAVE_DEBOUNCE_MS, untilMaxWait) this.writeTimer = setTimeout(() => { this.writeTimer = null this.firstPendingSaveAt = null void this.enqueueWrite() }, delay) } private enqueueWrite(): Promise { const previousWrite = Promise.all([ this.pendingWrite ?? this.staleTempCleanup, this.pendingSnapshotFileWork ?? Promise.resolve() ]).then(() => {}) const write = previousWrite.then(() => this.writeToDiskAsync()) const trackedWrite = write .catch((err) => { console.error('[persistence] Failed to write state:', err) }) .finally(() => { if (this.pendingWrite === trackedWrite) { this.pendingWrite = null } }) this.pendingWrite = trackedWrite return write } /** Wait for any in-flight async disk write to complete. Used in tests. */ async waitForPendingWrite(): Promise { await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()]) } // Why githubCache is omitted: memory-only this session (see getGithubCacheFile), so refreshes never touch the durable file. private getDurableState(): Omit { const { githubCache: _memoryOnly, ...durable } = this.state return durable } // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. private buildStateToSave(): { payload: string stateHash: string protectedSecretUpdates: ProtectedSecretRetentionUpdate[] } { // Why sentinels (not a blob/key string match): the substitution must be // position-exact. A plain search for the ciphertext — or even for a // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an // agentDefaultEnv var named after a secret field, or a value equal to a // ciphertext), which would substitute the wrong site and let two DISTINCT // states normalize equal → a silently dropped write (data loss), reachable // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot // random UUID can't occur anywhere else in the serialized state (the user // sets their data before it is minted), so it appears exactly once. const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] let protectedStorageDegraded = false const encryptToSentinel = (slot: string, plaintext: string): string => { const encrypted = this.protectedSecrets.encrypt(slot, plaintext) if (encrypted.retentionUpdate) { protectedSecretUpdates.push(encrypted.retentionUpdate) } protectedStorageDegraded ||= encrypted.degraded const { blob, hashValue = plaintext } = encrypted // Values already identical in payload and hash need no sentinel substitution. if (blob === plaintext && hashValue === plaintext) { return blob } const sentinel = `orca-secret-slot-${randomUUID()}` secretSubs.push({ sentinel, blob, hashValue }) return sentinel } const encryptOptionalToSentinel = ( slot: string, plaintext: string | null | undefined ): string | null => { const encrypted = encryptToSentinel(slot, plaintext ?? '') return encrypted || null } // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ ...record, ownerLease: encryptToSentinel( sshPtyOwnerLeaseSecretSlot(record.targetId), record.ownerLease ) })), settings: { ...this.state.settings, opencodeSessionCookie: encryptToSentinel( PROTECTED_SECRET_SLOT.opencodeSessionCookie, this.state.settings.opencodeSessionCookie ), httpProxyUrl: encryptToSentinel( PROTECTED_SECRET_SLOT.httpProxyUrl, this.state.settings.httpProxyUrl ?? '' ) }, ui: { ...this.state.ui, browserKagiSessionLink: encryptOptionalToSentinel( PROTECTED_SECRET_SLOT.browserKagiSessionLink, this.state.ui.browserKagiSessionLink ) } } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) // Substitute each unique sentinel exactly once: ciphertext for the on-disk // payload, a stable normalized value for the guard hash. Function-form // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped // in `serialized`, so each replace is byte-for-byte position-exact. let payload = serialized let hashInput = serialized for (const { sentinel, blob, hashValue } of secretSubs) { const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) } const stateHash = createHash('sha1') .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') .update(hashInput) .digest('hex') return { payload, stateHash, protectedSecretUpdates } } // Why: async writes avoid blocking the main Electron thread on every debounced save. private async writeToDiskAsync(): Promise { if (this.writesFrozen) { return } const gen = this.writeGeneration const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. if (stateHash === this.lastWrittenStateHash) { this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) return } const dataFile = this.dataFile const dir = dirname(dataFile) await mkdir(dir, { recursive: true }).catch(() => {}) const tmpFile = durableWriteTempPath(dataFile) // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. let renamed = false try { // Why: fsync before rename, then fsync the directory; see writeFileDurable. const handle = await open(tmpFile, 'w') try { await handle.writeFile(payload, 'utf-8') await handle.sync() } finally { await handle.close() } // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. if (this.writeGeneration !== gen) { return } this.inFlightAsyncTmpFile = tmpFile try { await renameDurable(tmpFile, dataFile) renamed = true } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || this.writeGeneration === gen) { throw error } } finally { if (this.inFlightAsyncTmpFile === tmpFile) { this.inFlightAsyncTmpFile = null } } // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. if (renamed && this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) } else if (renamed) { this.lastWrittenStateHash = null } if (renamed) { this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) } } finally { if (!renamed) { await rm(tmpFile).catch(() => {}) } } if (!renamed) { return } // Why (#1158): rotate only after the primary rename while this write still owns its generation. if (this.writeGeneration !== gen) { return } await this.rotateBackupsAsync(dataFile) } // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { if (this.writesFrozen) { return } const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. if (!opts.force && stateHash === this.lastWrittenStateHash) { return } const dataFile = this.dataFile const dir = dirname(dataFile) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. let renamed = false try { // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty // content after power loss, losing projects/tabs back to the newest usable .bak slot. writeFileDurableSync(tmpFile, dataFile, payload) renamed = true this.lastWrittenStateHash = stateHash this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) this.lastDurableWriteGeneration = Math.max( this.lastDurableWriteGeneration, this.writeGeneration ) } finally { if (!renamed) { try { unlinkSync(tmpFile) } catch { // Best-effort cleanup; the write already failed, swallow secondary error. } } } const now = Date.now() if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { this.rotateBackupsSync(dataFile) } } flushOrThrow(): void { if (this.quitFlushStarted) { throw new Error('Cannot synchronously flush after final persistence has started') } if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null } this.firstPendingSaveAt = null const asyncWriteWasInFlight = this.pendingWrite !== null // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. this.writeGeneration++ if (this.inFlightAsyncTmpFile) { try { unlinkSync(this.inFlightAsyncTmpFile) this.inFlightAsyncTmpFile = null } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { void this.enqueueWrite().catch(() => {}) throw error } } } // Why: later async flushes must remain serialized behind the invalidated writer. this.writeToDiskSync({ force: asyncWriteWasInFlight, skipBackupRotation: this.backupRotationInFlight }) } flushActiveViewPreferenceOrThrow(): void { this.activeViewPreference.flushOrThrow() } getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger) } replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { if (this.writesFrozen) { throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') } const next = parseCodexResetCreditAttemptLedger(ledger) const previous = this.state.codexResetCreditAttemptLedger ? structuredClone(this.state.codexResetCreditAttemptLedger) : undefined this.state.codexResetCreditAttemptLedger = next try { this.flushOrThrow() } catch (error) { // Why: callers use a successful return as the durability barrier before // handing a scarce-credit mutation to the provider. this.state.codexResetCreditAttemptLedger = previous throw error } } // ── Repos ────────────────────────────────────────────────────────── getRepos(): Repo[] { return this.state.repos.map((repo) => this.hydrateRepo(repo)) } getProjects(): Project[] { return [...this.state.projects] } updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { const project = this.state.projects.find((entry) => entry.id === id) if (!project) { return null } if ('localWindowsRuntimePreference' in updates) { if (updates.localWindowsRuntimePreference === undefined) { delete project.localWindowsRuntimePreference } else { project.localWindowsRuntimePreference = normalizeProjectRuntimePreference( updates.localWindowsRuntimePreference ) } } project.updatedAt = Date.now() this.scheduleSave() return { ...project } } getProjectHostSetups(): ProjectHostSetup[] { return [...this.state.projectHostSetups] } createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { const project = this.state.projects.find((entry) => entry.id === args.projectId) if (!project) { return null } const hostId = normalizeExecutionHostId(args.hostId) if (!hostId) { throw new Error(`Invalid host ID: ${args.hostId}`) } const duplicateSetup = this.state.projectHostSetups.find( (entry) => entry.projectId === project.id && entry.hostId === hostId ) if (duplicateSetup) { throw new Error(`Project host setup already exists: ${duplicateSetup.id}`) } const now = Date.now() const existingIds = new Set(this.state.projectHostSetups.map((entry) => entry.id)) const setup: ProjectHostSetup = { id: makeProjectHostSetupId(project.id, hostId, existingIds, args.setupId), projectId: project.id, hostId, repoId: '', path: args.path?.trim() ?? '', displayName: args.displayName?.trim() || project.displayName, ...(args.kind ? { kind: args.kind } : {}), ...(args.worktreeBasePath?.trim() ? { worktreeBasePath: args.worktreeBasePath.trim() } : {}), ...(args.gitUsername?.trim() ? { gitUsername: args.gitUsername.trim() } : {}), setupState: args.setupState ?? 'not-set-up', setupMethod: args.setupMethod ?? 'provisioned', createdAt: now, updatedAt: now } // Why: persist independently so future repo projection sync doesn't erase this non-repo-backed setup. this.state.projectHostSetups.push(setup) this.scheduleSave() return { project, setup } } updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) if (!setup) { return null } const project = this.state.projects.find((entry) => entry.id === setup.projectId) if (!project) { return null } const repo = setup.repoId ? this.state.repos.find((entry) => entry.id === setup.repoId) : undefined if (repo) { const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates) const updatedProject = updated ? this.state.projects.find((entry) => entry.id === updated.setup.projectId) : undefined return updated && updatedProject ? { project: updatedProject, setup: updated.setup, repo: updated.repo } : null } const updatedSetup = this.updateIndependentProjectHostSetup(setup, args.updates) return { project, setup: updatedSetup } } deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) if (!setup) { return null } const project = this.state.projects.find((entry) => entry.id === setup.projectId) if (!project) { return null } // Why: the same repo id can exist on multiple execution hosts, so match this setup's own host // row and never fall back to a sibling host's row — a stale repoId/hostId would delete that host's // registration. With no exact match the setup is stale, and the path below drops just the setup. const repo = setup.repoId ? this.state.repos.find( (entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId ) : undefined if (repo) { this.removeProjectForHost(repo.id, setup.hostId) return { project, setup, repo: this.hydrateRepo(repo) } } this.state.projectHostSetups = this.state.projectHostSetups.filter( (entry) => entry.id !== setup.id ) this.scheduleSave() return { project, setup } } /** O(1) repo count; unlike `getRepos()` this skips per-repo hydration. */ getRepoCount(): number { return this.state.repos.length } getRepo(id: string): Repo | undefined { const repo = this.state.repos.find((r) => r.id === id) return repo ? this.hydrateRepo(repo) : undefined } /** * Record a background-resolved git username; kept out of updateRepo's whitelist so the renderer can't write it directly. * @returns true when the hydrated value changed. */ setResolvedRepoGitUsername(id: string, username: string): boolean { const repo = this.state.repos.find((r) => r.id === id) if (!repo) { return false } const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '' this.gitUsernameCache.set(repo.path, username) if (previous === username) { return false } if (username) { // Why: persist so the next launch hydrates repos with the right branch prefix before enrichment re-runs. repo.gitUsername = username } else { delete repo.gitUsername } this.scheduleSave() return true } getProjectGroups(): ProjectGroup[] { return [...(this.state.projectGroups ?? [])].sort( (left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name) ) } createProjectGroup(input: { name: string parentPath?: string | null connectionId?: string | null parentGroupId?: string | null createdFrom: ProjectGroup['createdFrom'] }): ProjectGroup { let maxOrder = -1 // Why: persisted group lists can be large enough to exceed spread limits. for (const existingGroup of this.state.projectGroups ?? []) { maxOrder = Math.max(maxOrder, existingGroup.tabOrder) } const group = createProjectGroup({ ...input, tabOrder: maxOrder + 1 }) this.state.projectGroups = [...(this.state.projectGroups ?? []), group] this.scheduleSave() return group } updateProjectGroup( groupId: string, updates: Partial>, ownerHostId?: ExecutionHostId ): ProjectGroup | null { const projectGroups = this.state.projectGroups ?? [] const group = resolveProjectGroupOwner( buildProjectGroupOwnerIndex(projectGroups), groupId, ownerHostId ) if (!group) { return null } if (updates.name !== undefined) { group.name = normalizeProjectGroupName(updates.name, group.name) } if (updates.isCollapsed !== undefined) { group.isCollapsed = updates.isCollapsed } if (updates.tabOrder !== undefined && Number.isFinite(updates.tabOrder)) { group.tabOrder = updates.tabOrder } if (updates.color !== undefined) { group.color = typeof updates.color === 'string' ? updates.color : null } group.updatedAt = Date.now() this.scheduleSave() return group } deleteProjectGroup(groupId: string, ownerHostId?: ExecutionHostId): boolean { const projectGroups = this.state.projectGroups ?? [] const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups) const rootGroup = resolveProjectGroupOwner(projectGroupIndex, groupId, ownerHostId) if (!rootGroup) { return false } const before = this.state.projectGroups?.length ?? 0 const deletedGroupIdentities = getProjectGroupOwnerSubtreeIdentities(projectGroups, rootGroup) this.state.projectGroups = projectGroups.filter( (group) => !deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group)) ) if ((this.state.projectGroups?.length ?? 0) === before) { return false } // Why: groups are sidebar organization only, so deleting one ungroups its repos rather than deleting them. this.state.repos = this.state.repos.map((repo) => { if (!repo.projectGroupId) { return repo } const group = resolveProjectGroupMembership( projectGroupIndex, repo.projectGroupId, getRepoExecutionHostId(repo) ) return group && deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group)) ? { ...repo, projectGroupId: null } : repo }) const folderWorkspaces = this.state.folderWorkspaces ?? [] const removedFolderWorkspaces = folderWorkspaces.filter((workspace) => { const group = resolveFolderWorkspaceProjectGroupWithLegacySsh(projectGroupIndex, workspace) return group && deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group)) }) this.state.folderWorkspaces = folderWorkspaces.filter( (workspace) => !removedFolderWorkspaces.includes(workspace) ) const removedFolderWorkspaceKeys = new Set() for (const workspace of removedFolderWorkspaces) { const hasSameIdSibling = this.state.folderWorkspaces.some( (candidate) => candidate.id === workspace.id ) if (!hasSameIdSibling) { removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id)) this.state.workspaceSession = removeWorkspaceSessionOwner( this.state.workspaceSession, folderWorkspaceKey(workspace.id) )! this.removeWorkspaceLineageForFolderParent(workspace.id) } } this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId)) this.scheduleSave() return true } getFolderWorkspaces(): FolderWorkspace[] { return [...(this.state.folderWorkspaces ?? [])].sort( (left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name) ) } getFolderWorkspace(id: string, ownerHostId?: ExecutionHostId): FolderWorkspace | undefined { const matches = (this.state.folderWorkspaces ?? []).filter( (workspace) => workspace.id === id && (!ownerHostId || resolveFolderWorkspaceCatalogOwnerHostId(workspace, this.state.projectGroups ?? []) === ownerHostId) ) return matches.length === 1 ? matches[0] : undefined } createFolderWorkspace(input: { projectGroupId: string name?: string folderPath?: string | null linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] connectionId?: string | null createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }): FolderWorkspace { const projectGroupIndex = buildProjectGroupOwnerIndex(this.state.projectGroups ?? []) const group = resolveProjectGroupOwner( projectGroupIndex, input.projectGroupId, input.connectionId !== undefined ? input.connectionId ? toSshExecutionHostId(input.connectionId) : LOCAL_EXECUTION_HOST_ID : undefined ) const folderPath = typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 ? input.folderPath : group?.parentPath if (!group || !folderPath) { throw new Error('Folder-backed project group not found.') } const now = Date.now() const linkedTask = normalizeWorkspaceLinkedItem(input.linkedTask) const sourceContext = normalizeStoredTaskSourceContext(input.linkedTaskSourceContext) const workspace: FolderWorkspace = { id: randomUUID(), projectGroupId: group.id, name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`), folderPath, connectionId: input.connectionId ?? group.connectionId ?? null, linkedTask, linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext) ? sourceContext : null, comment: '', isArchived: false, isUnread: false, isPinned: false, sortOrder: now, ...(input.createdWithAgent ? { createdWithAgent: input.createdWithAgent } : {}), ...(input.pendingFirstAgentMessageRename === true && input.createdWithAgent ? { pendingFirstAgentMessageRename: true } : {}), lastActivityAt: 0, createdAt: now, updatedAt: now } this.state.folderWorkspaces = [workspace, ...(this.state.folderWorkspaces ?? [])] this.scheduleSave() return workspace } updateFolderWorkspace( id: string, updates: Partial< Pick< FolderWorkspace, | 'name' | 'folderPath' | 'linkedTask' | 'linkedTaskSourceContext' | 'comment' | 'isArchived' | 'isUnread' | 'isPinned' | 'sortOrder' | 'manualOrder' | 'workspaceStatus' | 'createdWithAgent' | 'pendingFirstAgentMessageRename' | 'firstAgentMessageRenameError' | 'lastActivityAt' > >, ownerHostId?: ExecutionHostId ): FolderWorkspace | null { const workspace = this.getFolderWorkspace(id, ownerHostId) if (!workspace) { return null } if (updates.name !== undefined) { workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name) } if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { workspace.folderPath = updates.folderPath } if (updates.linkedTask !== undefined) { workspace.linkedTask = normalizeWorkspaceLinkedItem(updates.linkedTask) if ( workspace.linkedTaskSourceContext && !isWorkspaceLinkedItemSourceContextMatch( workspace.linkedTask, workspace.linkedTaskSourceContext ) ) { workspace.linkedTaskSourceContext = null } } if (updates.linkedTaskSourceContext !== undefined) { const linkedTaskSourceContext = normalizeStoredTaskSourceContext( updates.linkedTaskSourceContext ) workspace.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( workspace.linkedTask, linkedTaskSourceContext ) ? linkedTaskSourceContext : null } if (updates.comment !== undefined) { workspace.comment = updates.comment } if (updates.isArchived !== undefined) { workspace.isArchived = updates.isArchived } if (updates.isUnread !== undefined) { workspace.isUnread = updates.isUnread } if (updates.isPinned !== undefined) { workspace.isPinned = updates.isPinned } if (updates.sortOrder !== undefined && Number.isFinite(updates.sortOrder)) { workspace.sortOrder = updates.sortOrder } if (updates.manualOrder !== undefined) { if (Number.isFinite(updates.manualOrder)) { workspace.manualOrder = updates.manualOrder } else { delete workspace.manualOrder } } if (updates.workspaceStatus !== undefined) { workspace.workspaceStatus = updates.workspaceStatus } if (updates.createdWithAgent !== undefined) { workspace.createdWithAgent = updates.createdWithAgent } if (updates.pendingFirstAgentMessageRename !== undefined) { workspace.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename } if (updates.firstAgentMessageRenameError !== undefined) { workspace.firstAgentMessageRenameError = updates.firstAgentMessageRenameError } if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) { workspace.lastActivityAt = updates.lastActivityAt } workspace.updatedAt = Date.now() this.scheduleSave() return workspace } removeFolderWorkspace(id: string, ownerHostId?: ExecutionHostId): boolean { const workspace = this.getFolderWorkspace(id, ownerHostId) if (!workspace) { return false } this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( (candidate) => candidate !== workspace ) if (!this.state.folderWorkspaces.some((candidate) => candidate.id === id)) { this.state.workspaceSession = removeWorkspaceSessionOwner( this.state.workspaceSession, folderWorkspaceKey(id) )! this.removeWorkspaceLineageForFolderParent(id) this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id)) } this.scheduleSave() return true } moveProjectToGroup( repoId: string, groupId: string | null, order?: number, ownerHostId?: ExecutionHostId ): Repo | null { const hasExplicitOwner = ownerHostId !== undefined if (hasExplicitOwner && !parseExecutionHostId(ownerHostId)) { return null } const matchingRepos = this.state.repos.filter( (entry) => entry.id === repoId && (!hasExplicitOwner || getRepoExecutionHostId(entry) === ownerHostId) ) if (matchingRepos.length !== 1) { return null } const repo = matchingRepos[0] const repoOwnerHostId = getRepoExecutionHostId(repo) if ( groupId !== null && !resolveProjectGroupOwner( buildProjectGroupOwnerIndex(this.state.projectGroups ?? []), groupId, repoOwnerHostId ) ) { return null } const siblingRepos = this.state.repos.filter( (entry) => entry !== repo && getRepoExecutionHostId(entry) === repoOwnerHostId ) repo.projectGroupId = groupId repo.projectGroupOrder = typeof order === 'number' && Number.isFinite(order) ? order : getNextProjectGroupOrder(siblingRepos, groupId) this.scheduleSave() return this.hydrateRepo(repo) } addRepo(repo: Repo): void { this.state.repos.push(repo) this.syncProjectHostSetupCompatibilityState() this.scheduleSave() } // Why: return false on a stale permutation (concurrent add/remove) so the caller resyncs instead of persisting an order that drops/duplicates ids. reorderRepos(orderedIds: string[]): boolean { const current = this.state.repos if (orderedIds.length !== current.length) { return false } const seen = new Set() for (const id of orderedIds) { if (typeof id !== 'string' || seen.has(id)) { return false } seen.add(id) } const byId = new Map() for (const r of current) { byId.set(r.id, r) } const next: Repo[] = [] for (const id of orderedIds) { const repo = byId.get(id) if (!repo) { return false } next.push(repo) } this.state.repos = next this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return true } // Why: repo ids are unique only within an execution host; drags persist one permutation per host when local and SSH repos coexist. reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { const current = this.state.repos const hostRepos = current.filter((repo) => getRepoExecutionHostId(repo) === hostId) if (orderedIds.length !== hostRepos.length) { return false } const byId = new Map(hostRepos.map((repo) => [repo.id, repo])) if (byId.size !== hostRepos.length) { return false } const seen = new Set() const reorderedHostRepos: Repo[] = [] for (const id of orderedIds) { const repo = typeof id === 'string' && !seen.has(id) ? byId.get(id) : undefined if (!repo) { return false } seen.add(id) reorderedHostRepos.push(repo) } let nextHostIndex = 0 this.state.repos = current.map((repo) => getRepoExecutionHostId(repo) === hostId ? reorderedHostRepos[nextHostIndex++] : repo ) this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return true } removeProject(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) this.syncProjectHostSetupCompatibilityState() // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. delete this.state.sparsePresetsByRepo[id] this.pruneWorktreeStateForRepo(id, null) this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( this.state.workspaceSessionsByHostId, id ) this.scheduleSave() } // Why: the same repo id can exist on multiple execution hosts; remove only this host's row and metadata, never another host's. removeProjectForHost(id: string, hostId: ExecutionHostId): void { this.state.repos = this.state.repos.filter( (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) ) const idStillPresent = this.state.repos.some((r) => r.id === id) // Why: presets are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. if (!idStillPresent) { delete this.state.sparsePresetsByRepo[id] } this.syncProjectHostSetupCompatibilityState() // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) if (!idStillPresent) { this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( this.state.workspaceSessionsByHostId, id ) } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { const session = this.state.workspaceSessionsByHostId?.[hostId] if (session) { this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, [hostId]: removeRepoFromWorkspaceSession(session, id) } } } this.scheduleSave() } // Prune worktree meta/lineage for a repo id; hostId null prunes all entries, else only that host's (missing meta.hostId = local). private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { const prefix = `${id}::` // Why snapshot up front: the first loop deletes metas, so reading meta.hostId live later would misclassify an SSH worktree as local. const hostMembership = new Map() const belongsToHost = (key: string): boolean => { if (!key.startsWith(prefix)) { return false } if (hostId === null) { return true } const cached = hostMembership.get(key) if (cached !== undefined) { return cached } // Why default to local: metas without hostId predate host stamping, so a host-scoped prune skips them rather than risk deleting another host's live meta. const metaHostId = this.state.worktreeMeta[key]?.hostId ?? LOCAL_EXECUTION_HOST_ID const result = metaHostId === hostId hostMembership.set(key, result) return result } // Why: session state (legacy blob + per-host partitions) references worktrees // by the same `${repoId}::${path}` owner key; if it is not pruned here, a // deleted project's worktrees stay in lastVisitedAtByWorktreeId / // sleepingAgentSessionsByPaneKey and get re-materialized into worktreeMeta on // the next launch, surfacing as an orphaned "unknown" workspace. // worktreeMeta is host-classified via belongsToHost, but session partitions // are keyed by host directly. A session owner key carries no host, and the // same key can exist in multiple partitions (shared repo id/path across // hosts). So for session cleanup we collect every prefix-matching owner key // regardless of belongsToHost, and let the per-partition host gating below // decide which partition to touch. (belongsToHost still governs // worktreeMeta/lineage deletion. Collect before deleting worktreeMeta.) const ownerKeysToPrune = new Set() const collectPrefixedKeys = (keys: Iterable): void => { for (const key of keys) { if (key.startsWith(prefix)) { ownerKeysToPrune.add(key) } } } collectPrefixedKeys(Object.keys(this.state.worktreeMeta)) collectPrefixedKeys(Object.keys(this.state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) } for (const key of Object.keys(this.state.worktreeMeta)) { if (belongsToHost(key)) { delete this.state.worktreeMeta[key] } } // Why: owner keys are `${repoId}::${path}` and do not carry a host, so a // host-scoped prune (hostId != null) must only touch that host's session: // the legacy blob is the local host's session, and each // workspaceSessionsByHostId partition is one non-local host. Pruning every // partition here would wipe a surviving host's tabs, sleeping-agent state, // and active-worktree pointer for a shared repo id/path. A full removal // (hostId === null) still clears every host. const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID const pruneAllHostPartitions = hostId === null if (pruneLegacyLocalSession) { this.state.workspaceSession = removeWorkspaceSessionOwners( this.state.workspaceSession, ownerKeysToPrune )! } if (this.state.workspaceSessionsByHostId) { for (const [partitionHostId, session] of Object.entries( this.state.workspaceSessionsByHostId )) { if (!pruneAllHostPartitions && partitionHostId !== hostId) { continue } const pruned = removeWorkspaceSessionOwners(session, ownerKeysToPrune) if (pruned) { this.state.workspaceSessionsByHostId[partitionHostId] = pruned } } } for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) { if (belongsToHost(childId) || belongsToHost(lineage.parentWorktreeId)) { delete this.state.worktreeLineageById[childId] } } for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { const childScope = parseWorkspaceKey(childKey) const parentScope = parseWorkspaceKey(lineage.parentWorkspaceKey) if (childScope?.type === 'worktree' && belongsToHost(childScope.worktreeId)) { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] continue } if (parentScope?.type === 'worktree' && belongsToHost(parentScope.worktreeId)) { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] } } this.pruneMobileClientTabSelections(belongsToHost) } private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { for (const [clientNavigationId, selectionsByWorktree] of Object.entries( this.state.mobileClientTabSelectionsByDeviceId ?? {} )) { for (const worktreeId of Object.keys(selectionsByWorktree)) { if (matchesWorktreeId(worktreeId)) { delete selectionsByWorktree[worktreeId] } } if (Object.keys(selectionsByWorktree).length === 0) { delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] } } } updateRepo( id: string, updates: Partial< Pick< Repo, | 'displayName' | 'badgeColor' | 'repoIcon' | 'upstream' | 'gitRemoteIdentity' | 'hookSettings' | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' | 'executionHostId' | 'symlinkPaths' | 'issueSourcePreference' | 'forkSyncMode' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' | 'projectHostSetupMethod' > > & { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null }, hostId?: ExecutionHostId ): Repo | null { const repo = this.state.repos.find( (candidate) => candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId) ) if (!repo) { return null } const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates) if ('projectGroupId' in sanitizedUpdates) { const nextGroupId = sanitizedUpdates.projectGroupId if ( typeof nextGroupId !== 'string' || nextGroupId.trim().length === 0 || !this.state.projectGroups.some((group) => group.id === nextGroupId) ) { sanitizedUpdates.projectGroupId = null } } if ( 'projectGroupOrder' in sanitizedUpdates && (typeof sanitizedUpdates.projectGroupOrder !== 'number' || !Number.isFinite(sanitizedUpdates.projectGroupOrder)) ) { delete sanitizedUpdates.projectGroupOrder } const externalWorktreeVisibilityLegacy = 'externalWorktreeVisibility' in sanitizedUpdates && repo.externalWorktreeVisibilityLegacy === undefined ? isLegacyRepoForExternalWorktreeVisibility(repo) : undefined // Why: selected repo fields use `undefined` as an explicit clear signal, so delete them before assigning the patch. if ( 'issueSourcePreference' in sanitizedUpdates && sanitizedUpdates.issueSourcePreference === undefined ) { delete repo.issueSourcePreference delete sanitizedUpdates.issueSourcePreference } if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) { delete repo.worktreeBasePath delete sanitizedUpdates.worktreeBasePath } if ( 'externalWorktreeVisibility' in sanitizedUpdates && repo.externalWorktreeVisibilityLegacy === undefined ) { // Why: old persisted repos have no marker; stamp it on first visibility change so later hide/show keeps legacy safety. repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy } if ( 'externalWorktreeDiscoverySuppressedAt' in sanitizedUpdates && (sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === undefined || sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === null) ) { delete repo.externalWorktreeDiscoverySuppressedAt delete sanitizedUpdates.externalWorktreeDiscoverySuppressedAt } if ( 'sourceControlAi' in sanitizedUpdates && (sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null) ) { delete repo.sourceControlAi delete sanitizedUpdates.sourceControlAi } else if ('sourceControlAi' in sanitizedUpdates) { const normalizedSourceControlAi = normalizeRepoSourceControlAiOverrides( sanitizedUpdates.sourceControlAi ) if (normalizedSourceControlAi === undefined) { delete sanitizedUpdates.sourceControlAi } else { sanitizedUpdates.sourceControlAi = normalizedSourceControlAi } } Object.assign(repo, sanitizedUpdates) this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return this.hydrateRepo(repo) } private syncProjectHostSetupCompatibilityState(): void { const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) this.state.projects = compatibilityState.projects this.state.projectHostSetups = compatibilityState.projectHostSetups } private updateRepoBackedProjectHostSetup( setup: ProjectHostSetup, repo: Repo, updates: ProjectHostSetupUpdateArgs['updates'] ): { setup: ProjectHostSetup; repo: Repo } | null { if (updates.path !== undefined && updates.path !== repo.path) { throw new Error( 'Repo-backed project host setup paths must be changed by re-importing the project.' ) } if (updates.setupState !== undefined && updates.setupState !== 'ready') { throw new Error('Repo-backed project host setups cannot be marked unavailable.') } const repoUpdates: Parameters[1] = {} if (updates.displayName !== undefined) { repoUpdates.displayName = updates.displayName } if (updates.worktreeBasePath !== undefined) { repoUpdates.worktreeBasePath = updates.worktreeBasePath } if (updates.kind !== undefined) { repoUpdates.kind = updates.kind } if (updates.setupMethod === 'provisioned') { throw new Error('Repo-backed project host setups cannot be marked provisioned.') } if (updates.setupMethod !== undefined && updates.setupMethod !== 'legacy-repo') { repoUpdates.projectHostSetupMethod = updates.setupMethod } const updatedRepo = Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo if (!updatedRepo) { return null } return { setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup, repo: updatedRepo } } private updateIndependentProjectHostSetup( setup: ProjectHostSetup, updates: ProjectHostSetupUpdateArgs['updates'] ): ProjectHostSetup { if (updates.displayName !== undefined) { setup.displayName = updates.displayName.trim() || setup.displayName } if (updates.path !== undefined) { setup.path = updates.path.trim() || setup.path } if (updates.worktreeBasePath !== undefined) { const worktreeBasePath = updates.worktreeBasePath.trim() if (worktreeBasePath) { setup.worktreeBasePath = worktreeBasePath } else { delete setup.worktreeBasePath } } if (updates.kind !== undefined) { setup.kind = updates.kind } if (updates.gitUsername !== undefined) { const gitUsername = updates.gitUsername.trim() if (gitUsername) { setup.gitUsername = gitUsername } else { delete setup.gitUsername } } if (updates.setupState !== undefined) { setup.setupState = updates.setupState } if (updates.setupMethod !== undefined) { setup.setupMethod = updates.setupMethod } setup.updatedAt = Date.now() this.scheduleSave() return setup } private hydrateRepo(repo: Repo): Repo { const { repoIcon: rawRepoIcon, upstream: rawUpstream, gitRemoteIdentity: rawGitRemoteIdentity, sourceControlAi: rawSourceControlAi, projectHostSetupMethod: rawProjectHostSetupMethod, forkSyncMode: rawForkSyncMode, ...repoWithoutIcon } = repo const repoIcon = sanitizeRepoIcon(rawRepoIcon) const upstream = sanitizeRepoUpstream(rawUpstream) const gitRemoteIdentity = sanitizeGitRemoteIdentity(rawGitRemoteIdentity) const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi) const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod) const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode) // Why: never spawn git/gh username resolution in hydration — a stuck probe froze Windows startup for minutes (issue #7225); read only cache/persisted value. const gitUsername = isFolderRepo(repo) ? '' : (this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '') return { ...repoWithoutIcon, ...(repoIcon !== undefined ? { repoIcon } : {}), ...(upstream !== undefined ? { upstream } : {}), ...(gitRemoteIdentity !== undefined ? { gitRemoteIdentity } : {}), ...(sourceControlAi !== undefined ? { sourceControlAi } : {}), ...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}), ...(forkSyncMode !== undefined ? { forkSyncMode } : {}), kind: isFolderRepo(repo) ? 'folder' : 'git', gitUsername, hookSettings: { ...getDefaultRepoHookSettings(), ...repo.hookSettings, scripts: { ...getDefaultRepoHookSettings().scripts, ...repo.hookSettings?.scripts } } } } // ── Sparse Presets ───────────────────────────────────────────────── // ── Mobile client tab selections ────────────────────────────────── getMobileClientTabSelections(): PersistedMobileClientTabSelections { return this.state.mobileClientTabSelectionsByDeviceId ?? {} } setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { this.state.mobileClientTabSelectionsByDeviceId = next this.scheduleSave() } getSparsePresets(repoId: string): SparsePreset[] { return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => left.name.localeCompare(right.name) ) } saveSparsePreset(preset: SparsePreset): SparsePreset { const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? [] const index = existing.findIndex((entry) => entry.id === preset.id) this.state.sparsePresetsByRepo[preset.repoId] = index === -1 ? [...existing, preset] : existing.map((entry, i) => (i === index ? preset : entry)) this.scheduleSave() return preset } removeSparsePreset(repoId: string, presetId: string): void { const existing = this.state.sparsePresetsByRepo[repoId] ?? [] this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId) this.scheduleSave() } // ── Automations ─────────────────────────────────────────────────── listAutomations(): Automation[] { return (this.state.automations ?? []) .map((automation) => normalizeAutomationSessionReuse(automation)) .sort((left, right) => left.name.localeCompare(right.name)) } listAutomationRuns(automationId?: string): AutomationRun[] { const runs = this.state.automationRuns ?? [] return [...(automationId ? runs.filter((run) => run.automationId === automationId) : runs)] .map((run) => ({ ...run, precheckResult: normalizeAutomationPrecheckResult(run.precheckResult) })) .sort((left, right) => right.createdAt - left.createdAt) } createAutomation(input: AutomationCreateInput): Automation { const repo = this.state.repos.find((entry) => entry.id === input.projectId) const now = Date.now() const executionTargetType = repo?.connectionId ? 'ssh' : 'local' const schedulerOwner = getAutomationSchedulerOwner(repo) const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? []) const automation: Automation = { id: randomUUID(), name: input.name.trim() || 'Untitled automation', prompt: input.prompt, precheck: normalizeAutomationPrecheck(input.precheck), agentId: input.agentId, runContext: input.runContext ?? contexts.runContext, sourceContext: input.sourceContext ?? contexts.sourceContext, projectId: input.projectId, executionTargetType, executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', schedulerOwner, workspaceMode: input.workspaceMode, workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null, baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null, setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode( input.workspaceMode, input.setupDecision ), reuseSession: input.workspaceMode === 'existing' ? (input.reuseSession ?? false) : false, timezone: input.timezone, rrule: input.rrule, dtstart: input.dtstart, enabled: input.enabled ?? true, nextRunAt: nextAutomationOccurrenceAfter(input.rrule, input.dtstart, now), missedRunPolicy: 'run_once_within_grace', missedRunGraceMinutes: input.missedRunGraceMinutes ?? 720, createdAt: now, updatedAt: now } this.state.automations = [...(this.state.automations ?? []), automation] this.recordFeatureInteraction('automation-created') this.flush() return automation } updateAutomation(id: string, updates: AutomationUpdateInput): Automation { const index = (this.state.automations ?? []).findIndex((entry) => entry.id === id) if (index === -1) { throw new Error('Automation not found.') } const current = this.state.automations[index] const repoId = updates.projectId ?? current.projectId const repo = this.state.repos.find((entry) => entry.id === repoId) const executionTargetType = repo?.connectionId ? 'ssh' : 'local' const schedulerOwner = getAutomationSchedulerOwner(repo) const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? []) const rrule = updates.rrule ?? current.rrule const dtstart = updates.dtstart ?? current.dtstart const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined const workspaceMode = updates.workspaceMode ?? current.workspaceMode const updated: Automation = { ...current, ...updates, name: updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name, precheck: Object.hasOwn(updates, 'precheck') ? normalizeAutomationPrecheck(updates.precheck) : normalizeAutomationPrecheck(current.precheck), projectId: repoId, runContext: Object.hasOwn(updates, 'runContext') ? (updates.runContext ?? null) : updates.projectId !== undefined ? contexts.runContext : (current.runContext ?? contexts.runContext), sourceContext: Object.hasOwn(updates, 'sourceContext') ? (updates.sourceContext ?? null) : updates.projectId !== undefined ? contexts.sourceContext : (current.sourceContext ?? contexts.sourceContext), executionTargetType, executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', schedulerOwner, workspaceMode, workspaceId: workspaceMode === 'existing' ? Object.hasOwn(updates, 'workspaceId') ? (updates.workspaceId ?? null) : current.workspaceId : null, baseBranch: workspaceMode === 'new_per_run' ? Object.hasOwn(updates, 'baseBranch') ? (updates.baseBranch ?? null) : (current.baseBranch ?? null) : null, setupDecision: workspaceMode === 'new_per_run' ? Object.hasOwn(updates, 'setupDecision') ? normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, updates.setupDecision) : normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, current.setupDecision) : undefined, reuseSession: workspaceMode === 'existing' ? (updates.reuseSession ?? current.reuseSession ?? false) : false, rrule, dtstart, nextRunAt: scheduleChanged ? nextAutomationOccurrenceAfter(rrule, dtstart, Date.now()) : current.nextRunAt, updatedAt: Date.now() } this.state.automations[index] = updated this.flush() return updated } deleteAutomation(id: string): void { this.state.automations = (this.state.automations ?? []).filter((entry) => entry.id !== id) this.state.automationRuns = (this.state.automationRuns ?? []).filter( (entry) => entry.automationId !== id ) this.flush() } createAutomationRun( automation: Automation, scheduledFor: number, trigger: AutomationRunTrigger = 'scheduled' ): AutomationRun { const existing = (this.state.automationRuns ?? []).find( (run) => run.automationId === automation.id && run.scheduledFor === scheduledFor ) if (existing) { return existing } const now = Date.now() // Why: retention prunes old runs, so the retained count isn't the ordinal — carry the number forward from the newest survivor. const runNumber = nextAutomationRunNumber( (this.state.automationRuns ?? []).filter((run) => run.automationId === automation.id) ) const run: AutomationRun = { id: randomUUID(), automationId: automation.id, runNumber, runContext: automation.runContext ?? null, sourceContext: automation.sourceContext ?? null, title: `${automation.name} run ${runNumber}`, scheduledFor, status: 'pending', trigger, workspaceId: automation.workspaceId, workspaceDisplayName: this.getAutomationRunWorkspaceDisplayName(automation.workspaceId), sessionKind: 'terminal', chatSessionId: null, terminalSessionId: null, terminalPaneKey: null, terminalPtyId: null, outputSnapshot: null, precheckResult: null, usage: null, error: null, startedAt: null, dispatchedAt: null, createdAt: now } this.state.automationRuns = pruneAutomationRuns([...(this.state.automationRuns ?? []), run]) if (trigger === 'manual') { this.recordFeatureInteraction('automation-run') } this.flush() return run } updateAutomationRun(result: AutomationDispatchResult): AutomationRun { const index = (this.state.automationRuns ?? []).findIndex((entry) => entry.id === result.runId) if (index === -1) { throw new Error('Automation run not found.') } const now = Date.now() const current = this.state.automationRuns[index] const workspaceId = result.workspaceId ?? current.workspaceId const workspaceDisplayName = Object.hasOwn(result, 'workspaceDisplayName') ? normalizeAutomationRunWorkspaceDisplayName(result.workspaceDisplayName ?? null) : null const updated: AutomationRun = { ...current, status: result.status, workspaceId, workspaceDisplayName: workspaceDisplayName ?? normalizeAutomationRunWorkspaceDisplayName(current.workspaceDisplayName ?? null) ?? this.getAutomationRunWorkspaceDisplayName(workspaceId), terminalSessionId: Object.hasOwn(result, 'terminalSessionId') ? (result.terminalSessionId ?? null) : current.terminalSessionId, terminalPaneKey: Object.hasOwn(result, 'terminalPaneKey') ? normalizeAutomationRunTerminalPaneKey(result.terminalPaneKey) : normalizeAutomationRunTerminalPaneKey(current.terminalPaneKey), terminalPtyId: Object.hasOwn(result, 'terminalPtyId') ? normalizeAutomationRunTerminalPtyId(result.terminalPtyId) : normalizeAutomationRunTerminalPtyId(current.terminalPtyId), outputSnapshot: Object.hasOwn(result, 'outputSnapshot') ? normalizeAutomationRunOutputSnapshot(result.outputSnapshot) : normalizeAutomationRunOutputSnapshot(current.outputSnapshot), precheckResult: Object.hasOwn(result, 'precheckResult') ? normalizeAutomationPrecheckResult(result.precheckResult) : normalizeAutomationPrecheckResult(current.precheckResult), usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null), error: result.error ?? null, startedAt: current.startedAt ?? now, dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt } this.state.automationRuns[index] = updated const automation = this.state.automations.find((entry) => entry.id === updated.automationId) if (automation) { automation.lastRunAt = now automation.updatedAt = now } this.flush() return updated } snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { const normalizedDisplayName = normalizeAutomationRunWorkspaceDisplayName(displayName) if (!normalizedDisplayName) { return 0 } let updatedCount = 0 this.state.automationRuns = (this.state.automationRuns ?? []).map((run) => { if (run.workspaceId !== workspaceId || run.workspaceDisplayName === normalizedDisplayName) { return run } updatedCount += 1 return { ...run, workspaceDisplayName: normalizedDisplayName } }) if (updatedCount > 0) { this.flush() } return updatedCount } private getAutomationRunWorkspaceDisplayName( workspaceId: string | null | undefined ): string | null { if (!workspaceId) { return null } return normalizeAutomationRunWorkspaceDisplayName( this.state.worktreeMeta[workspaceId]?.displayName ?? getWorktreePathBasenameFromId(workspaceId) ) } advanceAutomationNextRun(id: string, now = Date.now()): Automation { const index = (this.state.automations ?? []).findIndex((entry) => entry.id === id) if (index === -1) { throw new Error('Automation not found.') } const current = this.state.automations[index] const nextRunAt = nextAutomationOccurrenceAfter(current.rrule, current.dtstart, now) const updated = { ...current, nextRunAt, updatedAt: Date.now() } this.state.automations[index] = updated this.flush() return updated } getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { return latestAutomationOccurrenceAtOrBefore(automation.rrule, automation.dtstart, now) } // ── Worktree Meta ────────────────────────────────────────────────── getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { return this.state.worktreeMeta[worktreeId] } getAllWorktreeMeta(): Record { return this.state.worktreeMeta } setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() const updated = { ...existing, ...meta } updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) const linkedTaskSourceContext = normalizeStoredTaskSourceContext( updated.linkedTaskSourceContext ) updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( updated.linkedWorkItem, linkedTaskSourceContext ) ? linkedTaskSourceContext : null if (!updated.instanceId) { updated.instanceId = randomUUID() } this.state.worktreeMeta[worktreeId] = updated this.scheduleSave() return updated } removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { // Persisted ownership beats stale live routing; hostId is only an ownerless fallback. const owner = this.state.worktreeMeta[worktreeId]?.hostId ?? hostId // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. const partitions = new Set( workspaceSessionPartitionIdsForHost(owner).filter((partition) => this.hasPersistedWorkspaceSession(partition) ) ) // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill // partition that never held this worktree has no claim on the repo at all. const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) const fencedPartitions = new Set( [...partitions].filter( (partition) => this.partitionOwnsWorktreeTabs(worktreeId, partition) || (partition === ownerPartition && !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) ) ) delete this.state.worktreeMeta[worktreeId] delete this.state.worktreeLineageById[worktreeId] delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] for (const partition of partitions) { this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { advanceTerminalTopologyRevision: fencedPartitions.has(partition) }) } this.scheduleSave() } getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { return this.state.worktreeLineageById[worktreeId] } getAllWorktreeLineage(): Record { return this.state.worktreeLineageById } setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { this.state.worktreeLineageById[worktreeId] = lineage this.scheduleSave() return lineage } removeWorktreeLineage(worktreeId: string): void { delete this.state.worktreeLineageById[worktreeId] this.scheduleSave() } /** * Re-key every worktreeId-keyed record from `oldWorktreeId` to `newWorktreeId` after the worktree folder (and its * `${repoId}::${path}` id) was renamed on disk, so a refresh re-binds state instead of orphaning it. Records the old id on * the new meta's `priorWorktreeIds` so session GC/hydration still recognizes PTY sessions minted under it. No-op when ids match. * Renderer counterpart: `buildWorktreeRenameState` in store/slices/worktrees.ts. */ migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { if (oldWorktreeId === newWorktreeId) { return } const oldWorkspaceKey = worktreeWorkspaceKey(oldWorktreeId) const newWorkspaceKey = worktreeWorkspaceKey(newWorktreeId) const moveKey = ( record: Record, mapValue: (value: T) => T = (value) => value ): boolean => { if (!(oldWorktreeId in record)) { return false } record[newWorktreeId] = mapValue(record[oldWorktreeId]) delete record[oldWorktreeId] return true } const withNewWorktreeId = (value: T): T => value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value const migrateSession = (session: WorkspaceSessionState | undefined): boolean => { if (!session) { return false } let sessionChanged = false const moveSessionKey = ( record: Record | undefined, mapValue: (value: T) => T = (value) => value ): boolean => { if (!record) { return false } let moved = false const pairs: [string, string][] = [ [oldWorktreeId, newWorktreeId], [oldWorkspaceKey, newWorkspaceKey] ] for (const [oldKey, newKey] of pairs) { if (!(oldKey in record)) { continue } record[newKey] = mapValue(record[oldKey]) delete record[oldKey] moved = true } return moved } sessionChanged = moveSessionKey(session.tabsByWorktree, (tabs) => tabs.map(withNewWorktreeId)) || sessionChanged sessionChanged = moveSessionKey(session.openFilesByWorktree, (files) => files.map(withNewWorktreeId)) || sessionChanged sessionChanged = moveSessionKey(session.activeFileIdByWorktree) || sessionChanged sessionChanged = moveSessionKey(session.browserTabsByWorktree, (workspaces) => workspaces.map(withNewWorktreeId) ) || sessionChanged if (session.browserPagesByWorkspace) { let pagesChanged = false const nextPagesByWorkspace = { ...session.browserPagesByWorkspace } for (const [workspaceId, pages] of Object.entries(nextPagesByWorkspace)) { if (!pages.some((page) => page.worktreeId === oldWorktreeId)) { continue } nextPagesByWorkspace[workspaceId] = pages.map(withNewWorktreeId) pagesChanged = true } if (pagesChanged) { session.browserPagesByWorkspace = nextPagesByWorkspace sessionChanged = true } } sessionChanged = moveSessionKey(session.activeBrowserTabIdByWorktree) || sessionChanged sessionChanged = moveSessionKey(session.activeTabTypeByWorktree) || sessionChanged sessionChanged = moveSessionKey(session.activeTabIdByWorktree) || sessionChanged sessionChanged = moveSessionKey(session.unifiedTabs, (tabs) => tabs.map(withNewWorktreeId)) || sessionChanged sessionChanged = moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged sessionChanged = moveSessionKey(session.lastVisitedAtByWorktreeId) || sessionChanged sessionChanged = moveSessionKey(session.defaultTerminalTabsAppliedByWorktreeId) || sessionChanged if (session.activeWorktreeIdsOnShutdown?.includes(oldWorktreeId)) { session.activeWorktreeIdsOnShutdown = session.activeWorktreeIdsOnShutdown.map((id) => id === oldWorktreeId ? newWorktreeId : id ) sessionChanged = true } if (session.activeWorktreeId === oldWorktreeId) { session.activeWorktreeId = newWorktreeId sessionChanged = true } if (session.activeWorkspaceKey === oldWorkspaceKey) { session.activeWorkspaceKey = newWorkspaceKey sessionChanged = true } if (session.sleepingAgentSessionsByPaneKey) { let sleepingChanged = false const nextSleeping = { ...session.sleepingAgentSessionsByPaneKey } for (const [paneKey, record] of Object.entries(nextSleeping)) { if (record.worktreeId !== oldWorktreeId) { continue } nextSleeping[paneKey] = { ...record, worktreeId: newWorktreeId } sleepingChanged = true } if (sleepingChanged) { session.sleepingAgentSessionsByPaneKey = nextSleeping sessionChanged = true } } if (session.terminalSurfaceTombstonesByPaneKey) { let tombstonesChanged = false const nextTombstones = { ...session.terminalSurfaceTombstonesByPaneKey } for (const [paneKey, tombstone] of Object.entries(nextTombstones)) { if (tombstone.worktreeId !== oldWorktreeId) { continue } nextTombstones[paneKey] = { ...tombstone, worktreeId: newWorktreeId } tombstonesChanged = true } if (tombstonesChanged) { session.terminalSurfaceTombstonesByPaneKey = nextTombstones sessionChanged = true } } return sessionChanged } let changed = moveKey(this.state.worktreeMeta) // Record the prior id so a session minted under it isn't reaped as an orphan. const newMeta = this.state.worktreeMeta[newWorktreeId] if (newMeta) { const prior = newMeta.priorWorktreeIds ?? [] if (!prior.includes(oldWorktreeId)) { newMeta.priorWorktreeIds = [...prior, oldWorktreeId] changed = true } } changed = moveKey(this.state.worktreeLineageById) || changed const movedLineage = this.state.worktreeLineageById[newWorktreeId] if (movedLineage && movedLineage.worktreeId === oldWorktreeId) { movedLineage.worktreeId = newWorktreeId } // Why: children carry this as parentWorktreeId; keep the denormalized path-derived id consistent (parentWorktreeInstanceId is stable). for (const lineage of Object.values(this.state.worktreeLineageById)) { if (lineage.parentWorktreeId === oldWorktreeId) { lineage.parentWorktreeId = newWorktreeId changed = true } } if (oldWorkspaceKey in this.state.workspaceLineageByChildKey) { const lineage = this.state.workspaceLineageByChildKey[oldWorkspaceKey] this.state.workspaceLineageByChildKey[newWorkspaceKey] = { ...lineage, childWorkspaceKey: newWorkspaceKey } delete this.state.workspaceLineageByChildKey[oldWorkspaceKey] changed = true } for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { if (lineage.parentWorkspaceKey === oldWorkspaceKey) { this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] = { ...lineage, parentWorkspaceKey: newWorkspaceKey } changed = true } } changed = migrateSession(this.state.workspaceSession) || changed for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { changed = migrateSession(session) || changed } for (const selectionsByWorktree of Object.values( this.state.mobileClientTabSelectionsByDeviceId ?? {} )) { changed = moveKey(selectionsByWorktree) || changed } const showDotfiles = this.state.ui?.showDotfilesByWorktree if (showDotfiles) { changed = moveKey(showDotfiles) || changed } if (changed) { this.scheduleSave() } } getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { return this.state.workspaceLineageByChildKey[childWorkspaceKey] } getAllWorkspaceLineage(): Record { return this.state.workspaceLineageByChildKey } setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { this.state.workspaceLineageByChildKey[lineage.childWorkspaceKey] = lineage this.scheduleSave() return lineage } removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { delete this.state.workspaceLineageByChildKey[childWorkspaceKey] this.scheduleSave() } private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { const parentKey = folderWorkspaceKey(folderWorkspaceId) for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { if (lineage.parentWorkspaceKey === parentKey) { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] } } } // ── Settings ─────────────────────────────────────────────────────── getSettings(): GlobalSettings { return this.state.settings } onSettingsChanged( listener: ( updates: Partial, settings: GlobalSettings, originWebContentsId?: number ) => void ): () => void { this.settingsChangeListeners.add(listener) return () => { this.settingsChangeListeners.delete(listener) } } private notifySettingsChanged( updates: Partial, originWebContentsId?: number ): void { for (const listener of this.settingsChangeListeners) { listener(updates, this.state.settings, originWebContentsId) } } // Why: UI view-state is written from both desktop and mobile (ui.set RPC), so notify to keep bi-directional sync (desktop hydrates UI only once). onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { this.uiChangeListeners.add(listener) return () => { this.uiChangeListeners.delete(listener) } } private notifyUIChanged(): void { if (this.uiChangeListeners.size === 0) { return } const ui = this.getUI() for (const listener of this.uiChangeListeners) { listener(ui) } } updateSettings( updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) } if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) } // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true } if ('showMenuBarIcon' in updates) { sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true } // Why: the artifact publish capability must be an exact boolean on disk; no truthy value grants it. if ('artifactSharingEnabled' in updates) { sanitizedUpdates.artifactSharingEnabled = updates.artifactSharingEnabled === true } if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } if ('agentDefaultArgs' in updates) { sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) sanitizedUpdates.agentYoloDefaultsMigrated = true } if ('agentDefaultEnv' in updates) { sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv) sanitizedUpdates.agentYoloDefaultsMigrated = true } if ('terminalQuickCommands' in updates) { sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands( updates.terminalQuickCommands ) } if ('terminalCustomThemes' in updates) { sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes( updates.terminalCustomThemes ) } if ('terminalScrollbackRows' in updates) { sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( updates.terminalScrollbackRows ) } if ( 'terminalTuiScrollSensitivity' in updates || 'terminalTuiScrollSensitivityDefaultedToOne' in updates ) { sanitizedUpdates.terminalTuiScrollSensitivityDefaultedToOne = true } if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { const taskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: 'visibleTaskProviders' in updates ? updates.visibleTaskProviders : this.state.settings.visibleTaskProviders, defaultTaskSource: 'defaultTaskSource' in updates ? updates.defaultTaskSource : this.state.settings.defaultTaskSource }) sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders if ('visibleTaskProviders' in updates) { sanitizedUpdates.visibleTaskProvidersDefaultedForJira = true } } if ('autoRenameBranchFromWork' in updates || 'autoRenameBranchFromWorkDefaultedOn' in updates) { sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true } if ('openInApplications' in updates) { sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications) } if ('terminalShortcutPolicy' in updates) { sanitizedUpdates.terminalShortcutPolicy = normalizeTerminalShortcutPolicy( updates.terminalShortcutPolicy ) } if ('sourceControlGroupOrder' in updates) { sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder( updates.sourceControlGroupOrder ) } if ('appIcon' in updates) { sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) } if ('uiLanguage' in updates) { sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) } if ('prBotAuthorOverrides' in updates) { // Why: every writer (desktop IPC, web RPC, migrations) hits this boundary, so the persisted list stays bounded and well-formed. sanitizedUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( updates.prBotAuthorOverrides ) } if ('mobilePairingCustomAddress' in updates) { sanitizedUpdates.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( updates.mobilePairingCustomAddress ) } if ('mobilePairingCustomAddresses' in updates) { sanitizedUpdates.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses( updates.mobilePairingCustomAddresses ) } if ( 'mobilePairingCustomAddress' in sanitizedUpdates || 'mobilePairingCustomAddresses' in sanitizedUpdates ) { const mobilePairingCustomAddress = 'mobilePairingCustomAddress' in sanitizedUpdates ? sanitizedUpdates.mobilePairingCustomAddress : this.state.settings.mobilePairingCustomAddress if (mobilePairingCustomAddress) { sanitizedUpdates.mobilePairingCustomAddresses = addMobilePairingCustomAddress( sanitizedUpdates.mobilePairingCustomAddresses ?? this.state.settings.mobilePairingCustomAddresses ?? [], mobilePairingCustomAddress ) } } const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate( this.state.settings, sanitizedUpdates ) if (historyWithPreviousLayout) { sanitizedUpdates.workspaceDirHistory = historyWithPreviousLayout } // Why deep-merge telemetry: a partial update (e.g. flipping only `optedIn`) must not clobber siblings like `installId`. const mergedTelemetry = sanitizedUpdates.telemetry !== undefined ? { ...this.state.settings.telemetry, ...sanitizedUpdates.telemetry } : this.state.settings.telemetry if ('sourceControlAi' in sanitizedUpdates) { sanitizedUpdates.sourceControlAi = retireLegacyInstructionsForClearedTextActionRecipes( sanitizedUpdates.sourceControlAi, this.state.settings ) const normalizedSourceControlAi = normalizeSourceControlAiSettings( sanitizedUpdates.sourceControlAi, this.state.settings.commitMessageAi ) sanitizedUpdates.sourceControlAi = normalizedSourceControlAi sanitizedUpdates.commitMessageAi = projectSourceControlAiToLegacyCommitMessageAi( normalizedSourceControlAi, this.state.settings.commitMessageAi ) } else if ('commitMessageAi' in sanitizedUpdates) { sanitizedUpdates.sourceControlAi = mergeLegacyCommitMessageAiIntoSourceControlAi( this.state.settings.sourceControlAi, sanitizedUpdates.commitMessageAi ) } const previousSettings = this.state.settings this.state.settings = { ...this.state.settings, ...sanitizedUpdates, notifications: normalizeNotificationSettings({ ...this.state.settings.notifications, ...sanitizedUpdates.notifications }), ...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {}) } this.scheduleSave() const changedUpdates = {} as Partial & Record for (const key of Object.keys(sanitizedUpdates) as (keyof GlobalSettings)[]) { if (!Object.is(previousSettings[key], this.state.settings[key])) { changedUpdates[String(key)] = this.state.settings[key] } } if (options.notifyListeners === true && Object.keys(changedUpdates).length > 0) { this.notifySettingsChanged(changedUpdates, options.originWebContentsId) } return this.state.settings } // ── UI State ─────────────────────────────────────────────────────── getUI(): PersistedState['ui'] { const uiState = stripMainOwnedTelemetryMarkerFromUI(this.state.ui) return { ...getDefaultUIState(), ...uiState, groupBy: normalizeGroupBy(this.state.ui?.groupBy), sortBy: normalizeSortBy(this.state.ui?.sortBy), projectOrderBy: normalizeProjectOrderBy(this.state.ui?.projectOrderBy), rightSidebarTab: normalizeRightSidebarTab(this.state.ui?.rightSidebarTab), rightSidebarExplorerView: normalizeRightSidebarExplorerView( this.state.ui?.rightSidebarExplorerView, this.state.ui?.rightSidebarTab ), worktreeCardProperties: normalizeWorktreeCardProperties( this.state.ui?.worktreeCardProperties ), agentActivityDisplayMode: normalizeAgentActivityDisplayMode( this.state.ui?.agentActivityDisplayMode ), workspaceStatuses: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity(this.state.ui?.workspaceBoardOpacity), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( this.state.ui?.workspaceBoardColumnWidth ), syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, usagePercentageDisplay: normalizeUsagePercentageDisplay( this.state.ui?.usagePercentageDisplay ), statusBarUsageMode: normalizeStatusBarUsageMode(this.state.ui?.statusBarUsageMode), // Why: strict boolean coercion so a missing/legacy value reads as false (first-run notice still fires). trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, osc52ClipboardDefaultOnNoticePending: this.state.ui?.osc52ClipboardDefaultOnNoticePending === true, markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( this.state.ui?.combinedDiffFileTreeWidth ), visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( this.state.ui?.visibleWorkspaceHostIds ), workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), manualRepoOrder: normalizeManualRepoOrder(this.state.ui?.manualRepoOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( this.state.ui?.browserDefaultZoomLevel ), showDotfilesByWorktree: normalizeShowDotfilesByWorktree( this.state.ui?.showDotfilesByWorktree ), featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), contextualToursSeenIds: normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds), featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions), activeView: this.activeViewPreference.get() } } updateUI(updates: Partial): void { if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) } const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) const { activeView, ...durableUpdates } = sanitizedUpdates const activeViewChanged = this.activeViewPreference.set(activeView) if (Object.keys(durableUpdates).length === 0) { if (activeViewChanged) { this.notifyUIChanged() } return } const currentUI = { ...getDefaultUIState(), ...stripMainOwnedTelemetryMarkerFromUI(this.state.ui) } const previousUI = { ...this.getUI(), // Why: the legacy field stays unchanged as a migration/downgrade // fallback; the profile sidecar is authoritative in current builds. activeView: currentUI.activeView } const nextRightSidebarTab = sanitizedUpdates.rightSidebarTab !== undefined ? normalizeRightSidebarTab(sanitizedUpdates.rightSidebarTab) : normalizeRightSidebarTab(this.state.ui?.rightSidebarTab) const nextRightSidebarExplorerView = sanitizedUpdates.rightSidebarExplorerView !== undefined ? normalizeRightSidebarExplorerView( sanitizedUpdates.rightSidebarExplorerView, nextRightSidebarTab ) : sanitizedUpdates.rightSidebarTab === 'search' ? 'search' : normalizeRightSidebarExplorerView( this.state.ui?.rightSidebarExplorerView, nextRightSidebarTab ) const nextUI = { ...currentUI, ...durableUpdates, groupBy: durableUpdates.groupBy ? normalizeGroupBy(durableUpdates.groupBy) : normalizeGroupBy(this.state.ui?.groupBy), sortBy: durableUpdates.sortBy ? normalizeSortBy(durableUpdates.sortBy) : normalizeSortBy(this.state.ui?.sortBy), projectOrderBy: updates.projectOrderBy ? normalizeProjectOrderBy(updates.projectOrderBy) : normalizeProjectOrderBy(this.state.ui?.projectOrderBy), activeView: currentUI.activeView, rightSidebarTab: nextRightSidebarTab, rightSidebarExplorerView: nextRightSidebarExplorerView, worktreeCardProperties: sanitizedUpdates.worktreeCardProperties !== undefined ? normalizeWorktreeCardProperties(sanitizedUpdates.worktreeCardProperties) : normalizeWorktreeCardProperties(this.state.ui?.worktreeCardProperties), agentActivityDisplayMode: updates.agentActivityDisplayMode !== undefined ? normalizeAgentActivityDisplayMode(updates.agentActivityDisplayMode) : normalizeAgentActivityDisplayMode(this.state.ui?.agentActivityDisplayMode), workspaceStatuses: sanitizedUpdates.workspaceStatuses !== undefined ? normalizeWorkspaceStatuses(sanitizedUpdates.workspaceStatuses) : normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity( sanitizedUpdates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity ), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ), syncTaskStatusFromWorkspaceBoard: sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true : this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, usagePercentageDisplay: normalizeUsagePercentageDisplay( sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay ), statusBarUsageMode: normalizeStatusBarUsageMode( sanitizedUpdates.statusBarUsageMode ?? this.state.ui?.statusBarUsageMode ), markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( sanitizedUpdates.combinedDiffFileTreeWidth ?? this.state.ui?.combinedDiffFileTreeWidth ), visibleWorkspaceHostIds: updates.visibleWorkspaceHostIds !== undefined ? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds) : normalizeVisibleExecutionHostIds(this.state.ui?.visibleWorkspaceHostIds), workspaceHostOrder: updates.workspaceHostOrder !== undefined ? normalizeExecutionHostOrder(updates.workspaceHostOrder) : normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), manualRepoOrder: updates.manualRepoOrder !== undefined ? normalizeManualRepoOrder(updates.manualRepoOrder) : normalizeManualRepoOrder(this.state.ui?.manualRepoOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel ), showDotfilesByWorktree: updates.showDotfilesByWorktree !== undefined ? normalizeShowDotfilesByWorktree(updates.showDotfilesByWorktree) : normalizeShowDotfilesByWorktree(this.state.ui?.showDotfilesByWorktree), featureTipsSeenIds: sanitizedUpdates.featureTipsSeenIds !== undefined ? normalizeFeatureTipIds(sanitizedUpdates.featureTipsSeenIds) : normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), // Why: renderer and paired clients can mark different tours seen from stale snapshots; union so completed tours stay suppressed. contextualToursSeenIds: updates.contextualToursSeenIds !== undefined ? mergeContextualTourSeenIds( this.state.ui?.contextualToursSeenIds, updates.contextualToursSeenIds ) : normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds), // Why: runtime RPCs and the renderer both record education state; merge so a stale renderer snapshot can't erase runtime-only interactions. featureInteractions: sanitizedUpdates.featureInteractions !== undefined ? mergeFeatureInteractions( this.state.ui?.featureInteractions, sanitizedUpdates.featureInteractions ) : normalizeFeatureInteractions(this.state.ui?.featureInteractions) } if (persistedUIValuesEqual(previousUI, nextUI)) { if (activeViewChanged) { this.notifyUIChanged() } return } this.state.ui = nextUI this.scheduleSave() this.notifyUIChanged() } recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { const featureInteractions = normalizeFeatureInteractions(this.state.ui?.featureInteractions) const telemetryBuckets = normalizeFeatureInteractionTelemetryBuckets( this.state.featureInteractionTelemetryBuckets ) const existing = featureInteractions[id] const previousCount = existing?.interactionCount ?? 0 const nextCount = previousCount + 1 const previousBucket = getFeatureInteractionUsageBucket(previousCount) const nextBucket = getFeatureInteractionUsageBucket(nextCount) const lastEmittedBucket = telemetryBuckets[id] ?? null const shouldEmit = nextBucket !== null && (lastEmittedBucket === null || compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0) this.updateUI({ featureInteractions: { ...featureInteractions, [id]: { firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), interactionCount: nextCount } } }) this.state.featureInteractionTelemetryBuckets = shouldEmit ? { ...telemetryBuckets, [id]: nextBucket } : telemetryBuckets this.scheduleSave() if (shouldEmit) { track('feature_interaction_usage_bucket_reached', { feature_id: id, feature_category: getFeatureInteractionCategory(id), count_bucket: nextBucket, bucket_source: lastEmittedBucket === null && previousBucket !== null && previousBucket === nextBucket ? 'observed_existing' : 'crossed_now', ...getCohortAtEmit() }) } return this.getUI() } // ── Onboarding ──────────────────────────────────────────────────── getOnboarding(): PersistedState['onboarding'] { const defaults = getDefaultOnboardingState() return { ...defaults, ...this.state.onboarding, checklist: { ...defaults.checklist, ...this.state.onboarding?.checklist } } } updateOnboarding( updates: Partial> & { checklist?: Partial } ): PersistedState['onboarding'] { const current = this.getOnboarding() this.state.onboarding = { ...current, ...updates, checklist: { ...current.checklist, ...updates.checklist } } this.scheduleSave() return this.getOnboarding() } // ── GitHub Cache ────────────────────────────────────────────────── getGitHubCache(): PersistedState['githubCache'] { return this.state.githubCache } setGitHubCache(cache: PersistedState['githubCache']): void { // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. this.state.githubCache = cache this.githubCacheDirty = true this.githubCacheGeneration += 1 } // ── Workspace Session ───────────────────────────────────────────── /** Resolve an execution host argument to a canonical id; unknown/empty falls back to 'local' for legacy callers. */ private resolveHostId(hostId?: string | null): ExecutionHostId { return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID } getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { const resolved = this.resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { return this.state.workspaceSession ?? getDefaultWorkspaceSession() } return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() } /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { return ( hostId === LOCAL_EXECUTION_HOST_ID || this.state.workspaceSessionsByHostId?.[hostId] !== undefined ) } getWorkspaceSessionHostIds(): ExecutionHostId[] { const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) { const hostId = normalizeExecutionHostId(key) if (hostId) { hostIds.add(hostId) } } return [...hostIds] } readTerminalScrollbackSnapshot(ref: string): string | null { return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) } /** Resolve the worktree a terminal tab belongs to; more reliable than agent-echoed hook fields. */ getWorktreeIdForTab(tabId: string): string | undefined { return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) } setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { const resolved = this.resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { this.setLocalWorkspaceSession(session) return } this.setHostWorkspaceSession(resolved, session) } removeWorkspaceSessionStateForWorktree( worktreeId: string, hostId?: ExecutionHostId | null, options: { advanceTerminalTopologyRevision?: boolean } = {} ): void { for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { this.removeWorkspaceSessionOwnerInPartition(worktreeId, resolved, options) } } private removeWorkspaceSessionOwnerInPartition( worktreeId: string, resolved: ExecutionHostId, options: { advanceTerminalTopologyRevision?: boolean } ): void { if (!this.hasPersistedWorkspaceSession(resolved)) { return } const current = this.getWorkspaceSession(resolved) const session = removeWorkspaceSessionOwner(current, worktreeId, { advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true }) if (!session) { return } if (resolved === LOCAL_EXECUTION_HOST_ID) { this.state.workspaceSession = session } else { // Host scoping matters because identical repo/path ids may exist on two servers. this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, [resolved]: session } } this.scheduleSave() } /** Whether a partition still holds terminal membership for `worktreeId`. */ private partitionOwnsWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { return this.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined } /** Whether fencing this partition would rebase a sibling worktree of the same repo. */ private partitionHasOtherRepoWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { const repoId = getRepoIdFromWorktreeId(worktreeId) const tabsByWorktree = this.getWorkspaceSession(hostId).tabsByWorktree ?? {} return Object.entries(tabsByWorktree).some( ([id, tabs]) => id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 ) } stageWorkspaceSessionBeforeUnload( session: PersistedState['workspaceSession'], hostId?: string | null ): void { const resolved = this.resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { this.setLocalWorkspaceSession(session, true) return } this.setHostWorkspaceSession(resolved, session) } /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. session = sanitizeWorkspaceSessionTerminalRetirements( session, this.state.workspaceSessionsByHostId?.[hostId] ) const pruned = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(session, this.state.repos) ) this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, [hostId]: pruned } this.scheduleSave() } private setLocalWorkspaceSession( session: PersistedState['workspaceSession'], deferSnapshotFiles = false ): void { const prior = this.state.workspaceSession session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) session = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(session, this.state.repos) ) // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. const normalized = normalizeWorkspaceSessionPaneIdentities( session, prior?.terminalLayoutsByTabId ) for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( this.state.ui?.acknowledgedAgentsByPaneKey, normalized.leafIdByInputLeafIdByTabId ) if (remappedAcknowledgements.changed) { this.state.ui = { ...this.state.ui, acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements } } for (const entry of normalized.legacyPaneKeyAliasEntries) { registerPersistedPaneKeyAlias(entry) } session = normalized.session const remappedLeases = remapSshRemotePtyLeaseLeafIds( this.state.sshRemotePtyLeases ?? [], normalized.leafIdByInputLeafIdByTabId, normalized.leafIdByPtyIdByTabId ) if (remappedLeases.changed) { this.state.sshRemotePtyLeases = remappedLeases.leases } if (session && prior) { const priorTabs = prior.tabsByWorktree ?? {} const nextTabs = session.tabsByWorktree ?? {} const worktreeIdByTabId = new Map() for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { for (const tab of tabs) { worktreeIdByTabId.set(tab.id, worktreeId) } } for (const [worktreeId, tabs] of Object.entries(nextTabs)) { const priorList = priorTabs[worktreeId] if (!priorList) { continue } for (const tab of tabs) { if (tab.ptyId) { continue } const priorTab = priorList.find((t) => t.id === tab.id) if ( priorTab?.ptyId && this.isRestorablePtyBinding({ ptyId: priorTab.ptyId, worktreeId, targetId: this.getConnectionIdForWorktree(worktreeId), tabId: tab.id }) ) { tab.ptyId = priorTab.ptyId } } } const priorLayouts = prior.terminalLayoutsByTabId ?? {} const nextLayouts = session.terminalLayoutsByTabId ?? {} for (const [tabId, layout] of Object.entries(nextLayouts)) { const priorLayout = priorLayouts[tabId] if (!priorLayout?.ptyIdsByLeafId) { continue } const incoming = layout.ptyIdsByLeafId ?? {} const incomingHasAnyBinding = Object.keys(incoming).length > 0 const liveLeafIds = this.getTerminalLayoutLeafIds(layout.root) const worktreeId = worktreeIdByTabId.get(tabId) const targetId = worktreeId ? this.getConnectionIdForWorktree(worktreeId) : null const restorableBindings = Object.fromEntries( Object.entries(priorLayout.ptyIdsByLeafId).filter( ([leafId, ptyId]) => liveLeafIds.has(leafId) && incoming[leafId] === undefined && // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. (incomingHasAnyBinding ? this.hasRestorableSshRemotePtyLease({ ptyId, targetId, worktreeId, tabId, leafId }) : this.isRestorablePtyBinding({ ptyId, targetId, worktreeId, tabId, leafId })) ) ) if (Object.keys(restorableBindings).length > 0) { layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. const buffersByLeafId = preserveMissingLeafRecordEntries( priorLayout.buffersByLeafId, layout.buffersByLeafId, liveLeafIds ) const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( priorLayout.scrollbackRefsByLeafId, layout.scrollbackRefsByLeafId, liveLeafIds ) const titlesByLeafId = preserveMissingLeafRecordEntries( priorLayout.titlesByLeafId, layout.titlesByLeafId, liveLeafIds ) if (buffersByLeafId) { layout.buffersByLeafId = buffersByLeafId } if (scrollbackRefsByLeafId) { layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId } if (titlesByLeafId) { layout.titlesByLeafId = titlesByLeafId } } } } session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) if (!deferSnapshotFiles) { const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( session, this.terminalScrollbackSnapshotStorage ) session = migratedScrollback.session deleteRemovedTerminalScrollbackSnapshots( prior, session, this.terminalScrollbackSnapshotStorage ) } this.state.workspaceSession = session if (deferSnapshotFiles) { this.enqueueTerminalScrollbackSnapshotWork(prior, session) } this.scheduleSave() } private enqueueTerminalScrollbackSnapshotWork( prior: WorkspaceSessionState | undefined, staged: WorkspaceSessionState ): void { const previous = this.pendingSnapshotFileWork ?? Promise.resolve() const work = previous .then(async () => { if (this.state.workspaceSession !== staged) { if (this.state.workspaceSession) { await deleteRemovedTerminalScrollbackSnapshotsAsync( prior, this.state.workspaceSession, this.terminalScrollbackSnapshotStorage ) } return } const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( staged, this.terminalScrollbackSnapshotStorage ) const current = this.state.workspaceSession === staged ? migrated : this.state.workspaceSession if (this.state.workspaceSession === staged) { this.state.workspaceSession = migrated } else if (current) { await deleteRemovedTerminalScrollbackSnapshotsAsync( migrated, current, this.terminalScrollbackSnapshotStorage ) } if (current) { await deleteRemovedTerminalScrollbackSnapshotsAsync( prior, current, this.terminalScrollbackSnapshotStorage ) } }) .catch((error) => { console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) }) .finally(() => { if (this.pendingSnapshotFileWork === work) { this.pendingSnapshotFileWork = null } }) this.pendingSnapshotFileWork = work } patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { const resolved = this.resolveHostId(hostId) // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. let next: WorkspaceSessionState = { ...this.getWorkspaceSession(resolved), ...patch } if (workspaceSessionPatchNeedsFullNormalization(patch)) { this.setWorkspaceSession(next, resolved) return } if (Object.hasOwn(patch, 'browserUrlHistory')) { next = pruneWorkspaceSessionBrowserHistory(next) } if (resolved === LOCAL_EXECUTION_HOST_ID) { this.state.workspaceSession = next } else { this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, [resolved]: next } } this.scheduleSave() } private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { const leafIds = new Set() const visit = (node: TerminalPaneLayoutNode | null): void => { if (!node) { return } if (node.type === 'leaf') { if (isTerminalLeafId(node.leafId)) { leafIds.add(node.leafId) } return } visit(node.first) visit(node.second) } visit(root) return leafIds } private isRestorablePtyBinding(binding: { ptyId: string targetId?: string | null worktreeId?: string tabId?: string leafId?: string }): boolean { const leases = this.state.sshRemotePtyLeases?.filter((entry) => this.sshRemotePtyLeaseMatchesBinding(entry, binding) ) return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') } private getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { try { return toRelaySshPtyId(targetId, ptyId) } catch { return ptyId } } private getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { return toRelaySshPtyId(targetId, ptyId) } private sshRemotePtyLeaseMatchesBinding( lease: SshRemotePtyLease, binding: { ptyId: string targetId?: string | null worktreeId?: string tabId?: string leafId?: string } ): boolean { const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) if (lease.ptyId !== bindingPtyId) { return false } // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. return ( (binding.targetId === undefined || binding.targetId === null || lease.targetId === binding.targetId) && (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && (binding.tabId === undefined || lease.tabId === binding.tabId) && (binding.leafId === undefined || lease.leafId === binding.leafId) ) } private hasRestorableSshRemotePtyLease(binding: { ptyId: string targetId?: string | null worktreeId?: string tabId?: string leafId?: string }): boolean { return ( this.state.sshRemotePtyLeases?.some( (lease) => this.sshRemotePtyLeaseMatchesBinding(lease, binding) && lease.state !== 'terminated' && lease.state !== 'expired' ) ?? false ) } private sshRemotePtyLeaseMayReferenceBinding( lease: SshRemotePtyLease, binding: { ptyId: string targetId: string worktreeId?: string tabId?: string leafId?: string } ): boolean { const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(binding.targetId, binding.ptyId) if (lease.targetId !== binding.targetId || lease.ptyId !== bindingPtyId) { return false } // Why: target removal is destructive; scrub matching bindings before deleting the lease, else removing the tombstone can revive stale PTY ids. return ( (binding.worktreeId === undefined || lease.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && (binding.tabId === undefined || lease.tabId === undefined || lease.tabId === binding.tabId) && (binding.leafId === undefined || lease.leafId === undefined || lease.leafId === binding.leafId) ) } private getConnectionIdForWorktree(worktreeId: string): string | null { const repoId = getRepoIdFromWorktreeId(worktreeId) return this.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null } // Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217). persistPtyBinding( args: { worktreeId: string tabId: string leafId: string ptyId: string incarnationId?: string startupCwd?: string expectedBinding?: { ptyId: string; incarnationId?: string } }, hostId?: string | null ): boolean { const resolvedHostId = this.resolveHostId(hostId) const session = this.getWorkspaceSession(resolvedHostId) const paneKey = `${args.tabId}:${args.leafId}` if (args.expectedBinding) { const tab = session.tabsByWorktree?.[args.worktreeId]?.find( (candidate) => candidate.id === args.tabId && candidate.worktreeId === args.worktreeId ) const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] if ( !tab || boundPtyId !== args.expectedBinding.ptyId || session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId ) { return false } } if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, [resolvedHostId]: session } } const sessionBeforeBinding = cloneWorkspaceSessionState(session) const reconciledIncarnation = args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false const advanceTopologyFence = (): void => { const repoId = getRepoIdFromWorktreeId(args.worktreeId) const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 if (!reconciledIncarnation && (!terminalMembershipChanged || currentRevision <= 0)) { return } // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. session.terminalTopologyRevisionByRepoId = { ...session.terminalTopologyRevisionByRepoId, [repoId]: currentRevision + 1 } } const restoreSession = (): void => { if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { this.state.workspaceSession = sessionBeforeBinding } else { this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, [resolvedHostId]: sessionBeforeBinding } } } if (args.incarnationId) { session.terminalPtyIncarnationsByPaneKey = { ...session.terminalPtyIncarnationsByPaneKey, [paneKey]: args.incarnationId } if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { session.terminalSurfaceTombstonesByPaneKey = { ...session.terminalSurfaceTombstonesByPaneKey } delete session.terminalSurfaceTombstonesByPaneKey[paneKey] } } const tabs = session.tabsByWorktree?.[args.worktreeId] const tab = tabs?.find((t) => t.id === args.tabId) if (tab) { tab.ptyId = args.ptyId } else { terminalMembershipChanged = true // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. const nextTabs = [ ...(tabs ?? []), createMinimalPersistedTerminalTab({ ...args, existingTabCount: tabs?.length ?? 0 }) ] session.tabsByWorktree = { ...session.tabsByWorktree, [args.worktreeId]: nextTabs } session.activeWorktreeId ??= args.worktreeId session.activeTabId ??= args.tabId session.activeTabIdByWorktree = { ...session.activeTabIdByWorktree, [args.worktreeId]: session.activeTabIdByWorktree?.[args.worktreeId] ?? args.tabId } } if (!isTerminalLeafId(args.leafId)) { // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. advanceTopologyFence() try { this.flushOrThrow() } catch (err) { restoreSession() throw err } return true } const layout = session.terminalLayoutsByTabId?.[args.tabId] if (layout) { if (!layout.root) { terminalMembershipChanged = true // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. layout.root = { type: 'leaf', leafId: args.leafId } layout.activeLeafId = args.leafId layout.expandedLeafId = null } else if (!layoutContainsLeafId(layout.root, args.leafId)) { terminalMembershipChanged = true // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. layout.root = { type: 'split', direction: 'vertical', first: cloneLayoutNode(layout.root), second: { type: 'leaf', leafId: args.leafId } } layout.activeLeafId = args.leafId if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { layout.expandedLeafId = null } } layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [args.leafId]: args.ptyId } } else { terminalMembershipChanged = true // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. session.terminalLayoutsByTabId = { ...session.terminalLayoutsByTabId, [args.tabId]: { root: { type: 'leaf', leafId: args.leafId }, activeLeafId: args.leafId, expandedLeafId: null, ptyIdsByLeafId: { [args.leafId]: args.ptyId } } } } advanceTopologyFence() try { this.flushOrThrow() } catch (err) { restoreSession() throw err } return true } // ── SSH Targets ──────────────────────────────────────────────────── getSshTargets(): SshTarget[] { return (this.state.sshTargets ?? []).map(normalizeSshTarget) } getSshTarget(id: string): SshTarget | undefined { const target = this.state.sshTargets?.find((t) => t.id === id) return target ? normalizeSshTarget(target) : undefined } addSshTarget(target: SshTarget): void { this.state.sshTargets ??= [] this.state.sshTargets.push(normalizeSshTarget(target)) this.scheduleSave() } updateSshTarget(id: string, updates: Partial>): SshTarget | null { const target = this.state.sshTargets?.find((t) => t.id === id) if (!target) { return null } const normalized = normalizeSshTarget({ ...target, ...updates }) Object.assign(target, updates, normalized) if (!Object.hasOwn(normalized, 'relayGracePeriodSeconds')) { delete target.relayGracePeriodSeconds } if (!Object.hasOwn(normalized, 'systemSshConnectionReuse')) { delete target.systemSshConnectionReuse } this.scheduleSave() return { ...target } } removeSshTarget(id: string): void { const targets = this.state.sshTargets ?? [] const recoveries = this.state.sshPtyConsumerRecoveries ?? [] const nextTargets = targets.filter((target) => target.id !== id) const nextRecoveries = recoveries.filter((record) => record.targetId !== id) if (nextTargets.length === targets.length && nextRecoveries.length === recoveries.length) { return } this.state.sshTargets = nextTargets this.state.sshPtyConsumerRecoveries = nextRecoveries this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(id)) this.scheduleSave() } // ── Live Claude PTY sessions ─────────────────────────────────────── getClaudeLivePtySessionIds(): string[] { return [...(this.state.claudeLivePtySessionIds ?? [])] } addClaudeLivePtySessionId(sessionId: string): void { if (sessionId.length === 0 || sessionId.length > 512) { return } const ids = this.state.claudeLivePtySessionIds ?? [] if (ids.includes(sessionId)) { return } // Why: drop oldest at the cap — stale ids get pruned against the daemon at startup, so only recency matters. this.state.claudeLivePtySessionIds = [...ids, sessionId].slice(-MAX_CLAUDE_LIVE_PTY_SESSION_IDS) // Why: flush sync so a force-quit right after a Claude spawn still seeds the live-PTY gate next launch. this.flush() } removeClaudeLivePtySessionId(sessionId: string): void { const ids = this.state.claudeLivePtySessionIds ?? [] if (!ids.includes(sessionId)) { return } this.state.claudeLivePtySessionIds = ids.filter((id) => id !== sessionId) this.scheduleSave() } getDeletedSshConfigAliases(): string[] { return [...(this.state.deletedSshConfigAliases ?? [])] } addDeletedSshConfigAlias(alias: string): void { this.state.deletedSshConfigAliases ??= [] if (!this.state.deletedSshConfigAliases.includes(alias)) { this.state.deletedSshConfigAliases.push(alias) this.scheduleSave() } } removeDeletedSshConfigAlias(alias: string): void { const current = this.state.deletedSshConfigAliases if (!current || !current.includes(alias)) { return } this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias) this.scheduleSave() } clearDeletedSshConfigAliases(): void { if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) { this.state.deletedSshConfigAliases = [] this.scheduleSave() } } getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { return [...(this.state.removedSshTargetTombstones ?? [])] } addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { const existing = this.state.removedSshTargetTombstones ?? [] // Why: dedupe by oldTargetId so re-removing the same id can't stack duplicate tombstones; newest wins. const filtered = existing.filter((t) => t.oldTargetId !== tombstone.oldTargetId) // Cap the history so pathological churn can't grow the state file unbounded. this.state.removedSshTargetTombstones = [...filtered, tombstone].slice( -MAX_REMOVED_SSH_TARGET_TOMBSTONES ) this.scheduleSave() } removeRemovedSshTargetTombstone(oldTargetId: string): void { const existing = this.state.removedSshTargetTombstones if (!existing?.some((t) => t.oldTargetId === oldTargetId)) { return } this.state.removedSshTargetTombstones = existing.filter((t) => t.oldTargetId !== oldTargetId) this.scheduleSave() } /** * Re-point every repo and worktree meta pinned to a removed SSH target id onto * a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids. */ reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { if (oldTargetId === newTargetId) { return [] } const oldHostId = toSshExecutionHostId(oldTargetId) const newHostId = toSshExecutionHostId(newTargetId) const repoIds = new Set() for (const repo of this.state.repos) { const matchesConnection = repo.connectionId === oldTargetId const matchesHost = repo.executionHostId === oldHostId if (!matchesConnection && !matchesHost) { continue } if (matchesConnection) { repo.connectionId = newTargetId } // Why: don't stamp executionHostId where it was unset — addRemoteRepoFromPath repos derive the host from connectionId. if (matchesHost) { repo.executionHostId = newHostId } repoIds.add(repo.id) } // Re-point worktree metas whose hostId pointed at the old SSH host. let metaChanged = false for (const meta of Object.values(this.state.worktreeMeta)) { if (meta.hostId === oldHostId) { meta.hostId = newHostId metaChanged = true } } // Why: any carrier still holding the old id later throws `SSH target not found` (STA-1468); migrate them all. let carrierChanged = migrateWorkspaceSessionSshTargetId( this.state.workspaceSession, oldTargetId, newTargetId ) for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) { carrierChanged = true } } // Why: partitions are read by host id; re-key from the removed id to the new one (keep new if it already exists). const partitions = this.state.workspaceSessionsByHostId const oldPartition = partitions?.[oldHostId] if (partitions && oldPartition) { delete partitions[oldHostId] partitions[newHostId] ??= oldPartition carrierChanged = true } if (migrateUiHostScopeSshTargetId(this.state.ui, oldTargetId, newTargetId)) { carrierChanged = true } for (const lease of this.state.sshRemotePtyLeases ?? []) { if (lease.targetId === oldTargetId) { lease.targetId = newTargetId carrierChanged = true } } const recoveries = this.state.sshPtyConsumerRecoveries ?? [] const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId) if (retainedRecoveries.length !== recoveries.length) { this.state.sshPtyConsumerRecoveries = retainedRecoveries this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(oldTargetId)) carrierChanged = true } let setupsChanged = false const keptSetups: ProjectHostSetup[] = [] for (const setup of this.state.projectHostSetups) { if (setup.hostId !== oldHostId) { keptSetups.push(setup) continue } const duplicate = this.state.projectHostSetups.some( (entry) => entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId ) // Why: drop the old ghost row that would violate (projectId, hostId) uniqueness with the re-added host's setup. if (duplicate) { setupsChanged = true continue } setup.hostId = newHostId setup.updatedAt = Date.now() keptSetups.push(setup) setupsChanged = true } if (setupsChanged) { this.state.projectHostSetups = keptSetups } // Why: repo-row and host-setup rewrites affect host-setup compatibility; meta-only rewrites don't, so gate the sync here. if (repoIds.size > 0 || setupsChanged) { this.syncProjectHostSetupCompatibilityState() } if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) { this.scheduleSave() } return [...repoIds] } // ── SSH PTY Consumer Recovery ────────────────────────────────────── getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { const record = (this.state.sshPtyConsumerRecoveries ?? []).find( (candidate) => candidate.targetId === targetId ) if ( record && this.protectedSecrets.isSealed(sshPtyOwnerLeaseSecretSlot(record.targetId), record.ownerLease) ) { return null } return record ? structuredClone(record) : null } async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { const normalized = normalizeSshPtyConsumerRecovery(record) if (!normalized) { throw new Error('Invalid SSH PTY consumer recovery record') } const recoveries = this.state.sshPtyConsumerRecoveries ?? [] this.state.sshPtyConsumerRecoveries = [ ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), normalized ] await this.flushSshPtyConsumerRecovery() } async removeSshPtyConsumerRecovery(targetId: string): Promise { const recoveries = this.state.sshPtyConsumerRecoveries ?? [] const next = recoveries.filter((record) => record.targetId !== targetId) if (next.length === recoveries.length) { return } this.state.sshPtyConsumerRecoveries = next this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) await this.flushSshPtyConsumerRecovery() } private async flushSshPtyConsumerRecovery(): Promise { // Why: ownership must be durable before relay setup continues, but this runs on the live // establish/reconnect path — a sync flush would park the main thread on a stalled profile mount. // Why not caught here: the failure must reach the awaiting caller. await this.flushDurableStateOrThrowAsync() } // ── SSH Remote PTY Leases ────────────────────────────────────────── getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { const leases = this.state.sshRemotePtyLeases ?? [] return leases.filter((lease) => targetId === undefined || lease.targetId === targetId) } upsertSshRemotePtyLease( lease: Omit & Partial> ): void { this.state.sshRemotePtyLeases ??= [] const normalizedLease = { ...lease } if (normalizedLease.leafId !== undefined && !isTerminalLeafId(normalizedLease.leafId)) { delete normalizedLease.leafId } // Why: store target-local pty ids in leases so reconnect can call relay pty.attach with raw ids (app ids are global). normalizedLease.ptyId = this.getRelayPtyIdForSshLeaseStorage( normalizedLease.targetId, normalizedLease.ptyId ) const now = Date.now() const existingIndex = this.state.sshRemotePtyLeases.findIndex( (entry) => entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId ) const existing = existingIndex >= 0 ? this.state.sshRemotePtyLeases[existingIndex] : undefined const next: SshRemotePtyLease = { ...existing, ...normalizedLease, createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, updatedAt: normalizedLease.updatedAt ?? now } if (existingIndex >= 0) { this.state.sshRemotePtyLeases[existingIndex] = next } else { this.state.sshRemotePtyLeases.push(next) } this.flush() } markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { if (this.updateSshRemotePtyLeaseStates(targetId, state)) { this.flush() } } // Why no write of its own: the committed quit path calls this immediately before the final store // flush, and that flush is what persists it. A durable write here would race the flush and be // rejected the moment it latches, which is exactly how an attached lease used to survive quit. markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { this.updateSshRemotePtyLeaseStates(targetId, state) } async markSshRemotePtyLeasesAsync( targetId: string, state: SshRemotePtyLease['state'] ): Promise { if (this.updateSshRemotePtyLeaseStates(targetId, state)) { await this.flushDurableStateOrThrowAsync() } } async markSshRemotePtyLeasesAttachedAsync( targetId: string, ptyIds: readonly string[] ): Promise { const relayPtyIds = new Set( ptyIds.map((ptyId) => this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId)) ) if (this.updateSshRemotePtyLeaseStates(targetId, 'attached', relayPtyIds)) { await this.flushDurableStateOrThrowAsync() } } private updateSshRemotePtyLeaseStates( targetId: string, state: SshRemotePtyLease['state'], ptyIds?: ReadonlySet ): boolean { const now = Date.now() let changed = false const shouldClearBindings = state === 'terminated' || state === 'expired' const leasesToClear: SshRemotePtyLease[] = [] this.state.sshRemotePtyLeases ??= [] for (const lease of this.state.sshRemotePtyLeases) { if (lease.targetId !== targetId || (ptyIds && !ptyIds.has(lease.ptyId))) { continue } if (state === 'attached' && (lease.state === 'terminated' || lease.state === 'expired')) { continue } if (state === 'detached' && lease.state !== 'attached') { continue } if (lease.state !== state) { lease.state = state lease.updatedAt = now if (state === 'attached') { lease.lastAttachedAt = now } else if (state === 'detached') { lease.lastDetachedAt = now } changed = true } if (shouldClearBindings) { leasesToClear.push(lease) } } const bindingsChanged = shouldClearBindings ? this.clearSshRemotePtyBindingsForLeases(targetId, leasesToClear) : false return changed || bindingsChanged } markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) const lease = this.state.sshRemotePtyLeases?.find( (entry) => entry.targetId === targetId && entry.ptyId === relayPtyId ) if (!lease) { return } const shouldClearBindings = state === 'terminated' || state === 'expired' if (lease.state === state) { if (shouldClearBindings && this.clearSshRemotePtyBindingsForLeases(targetId, [lease])) { this.flush() } return } const now = Date.now() lease.state = state lease.updatedAt = now if (state === 'attached') { lease.lastAttachedAt = now } else if (state === 'detached') { lease.lastDetachedAt = now } if (shouldClearBindings) { this.clearSshRemotePtyBindingsForLeases(targetId, [lease]) } this.flush() } removeSshRemotePtyLease(targetId: string, ptyId: string): void { const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) const leases = (this.state.sshRemotePtyLeases ?? []).filter( (lease) => lease.targetId === targetId && lease.ptyId === relayPtyId ) const before = this.state.sshRemotePtyLeases?.length ?? 0 this.clearSshRemotePtyBindingsForLeases(targetId, leases) this.state.sshRemotePtyLeases = (this.state.sshRemotePtyLeases ?? []).filter( (lease) => lease.targetId !== targetId || lease.ptyId !== relayPtyId ) if (this.state.sshRemotePtyLeases.length !== before) { this.flush() } } removeSshRemotePtyLeases(targetId: string): void { this.state.sshRemotePtyLeases ??= [] this.clearSshRemotePtyBindingsForTarget(targetId) const before = this.state.sshRemotePtyLeases.length this.state.sshRemotePtyLeases = this.state.sshRemotePtyLeases.filter( (lease) => lease.targetId !== targetId ) if (this.state.sshRemotePtyLeases.length !== before) { this.flush() } } private clearSshRemotePtyBindingsForTarget(targetId: string): void { const leases = this.state.sshRemotePtyLeases?.filter((lease) => lease.targetId === targetId) this.clearSshRemotePtyBindingsForLeases(targetId, leases ?? []) } private clearSshRemotePtyBindingsForLeases( targetId: string, leases: SshRemotePtyLease[] ): boolean { if (!leases?.length) { return false } let changed = false const sessions = new Set( [ this.state.workspaceSession, this.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)] ].filter((session): session is WorkspaceSessionState => Boolean(session)) ) for (const session of sessions) { for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { for (const tab of tabs) { if ( tab.ptyId && leases.some((lease) => this.sshRemotePtyLeaseMayReferenceBinding(lease, { ptyId: tab.ptyId!, worktreeId, targetId, tabId: tab.id }) ) ) { tab.ptyId = null changed = true } } } for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { const bindings = layout.ptyIdsByLeafId if (!bindings) { continue } const worktreeId = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) => tabs.some((tab) => tab.id === tabId) )?.[0] const nextBindings = Object.fromEntries( Object.entries(bindings).filter( ([leafId, ptyId]) => !leases.some((lease) => this.sshRemotePtyLeaseMayReferenceBinding(lease, { ptyId, targetId, worktreeId, tabId, leafId }) ) ) ) if (Object.keys(nextBindings).length !== Object.keys(bindings).length) { layout.ptyIdsByLeafId = nextBindings changed = true } } } if (changed) { this.scheduleSave() } return changed } // ── Flush (for shutdown) ─────────────────────────────────────────── flush(): void { if (this.quitFlushStarted) { return } try { this.flushOrThrow() } catch (err) { console.error('[persistence] Failed to flush state:', err) } try { this.flushActiveViewPreferenceOrThrow() } catch (err) { console.error('[active-view] Failed to flush preference:', err) } this.writeGithubCacheSnapshotSync() } /** * Async twin of flush() for the quit path. * * Why the quit path needs one: writeToDiskSync fsyncs a multi-MB file from the Electron * main thread. On a stalled network profile mount that syscall is uninterruptible, so the * app stops repainting and Force Quit stops working — and no main-thread deadline can * bound it, because the deadline's own timer is stuck behind the same block. * * Never throws — it joins the quit teardown barrier, where a rejection is noise. */ flushAsync(): Promise { if (this.quitFlushPromise) { return this.quitFlushPromise } this.quitFlushStarted = true this.quitFlushPromise = this.flushCurrentStateAsync(true).catch(() => {}) return this.quitFlushPromise } flushPendingAsync(): Promise { // Best-effort callers must not livelock while the live app keeps mutating state. return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) } flushPendingOrThrowAsync( options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} ): Promise { if (this.writesFrozen || this.quitFlushStarted) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) } return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) } // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are // quit/startup work and must not be snapshotted on the live SSH establish/reconnect path. private async flushDurableStateOrThrowAsync(): Promise { if (this.writesFrozen || this.quitFlushStarted) { throw new Error('Cannot flush while persistence is finalized') } for (;;) { if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null } this.firstPendingSaveAt = null const generation = this.writeGeneration await this.enqueueWrite() if (generation === this.writeGeneration) { break } } } private async flushCurrentStateAsync( final: boolean, signal?: AbortSignal, drainToStableGeneration = true, requireInitialGenerationDurable = false ): Promise { const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null for (;;) { if (signal?.aborted) { throw new Error('Persistence flush aborted') } if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null } this.firstPendingSaveAt = null const generation = this.writeGeneration try { await this.enqueueWrite() } catch (error) { await (final ? this.activeViewPreference.flushAsync() : this.activeViewPreference.flushPendingAsync(signal)) await this.writeGithubCacheSnapshotAsync(final, signal) throw error } await (final ? this.activeViewPreference.flushAsync() : this.activeViewPreference.flushPendingAsync(signal)) await this.writeGithubCacheSnapshotAsync(final, signal) if (signal?.aborted) { throw new Error('Persistence flush aborted') } if (!drainToStableGeneration) { if ( requiredDurableGeneration === null || this.lastDurableWriteGeneration >= requiredDurableGeneration ) { break } continue } if (generation === this.writeGeneration) { break } } } // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. private async writeGithubCacheSnapshotAsync( drainToStableGeneration = true, signal?: AbortSignal ): Promise { if (!this.githubCacheDirty) { return } const previousWrite = this.pendingGithubCacheWrite ?? this.staleGithubCacheTempCleanup const nextWrite = previousWrite .then(async () => { while (this.githubCacheDirty) { if (signal?.aborted) { throw new Error('GitHub cache flush aborted') } const generation = this.githubCacheGeneration const cacheFile = getGithubCacheFile(this.dataFile) const tmpFile = durableWriteTempPath(cacheFile) let renamed = false try { await writeFile(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') if (generation === this.githubCacheGeneration) { await rename(tmpFile, cacheFile) renamed = true if (generation === this.githubCacheGeneration) { this.githubCacheDirty = false } } } finally { if (!renamed) { await rm(tmpFile).catch(() => {}) } } if (signal?.aborted) { throw new Error('GitHub cache flush aborted') } if (!drainToStableGeneration) { break } } }) .catch((err) => { console.warn('[persistence] Failed to write github cache snapshot:', err) }) .finally(() => { if (this.pendingGithubCacheWrite === nextWrite) { this.pendingGithubCacheWrite = null } }) this.pendingGithubCacheWrite = nextWrite await nextWrite } // Why: a project move rewrote the data file directly; in-memory state is now stale and any write would undo the transfer. freezeWrites(): void { this.writesFrozen = true if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null } } // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. private writeGithubCacheSnapshotSync(): void { if (!this.githubCacheDirty) { return } if (this.pendingGithubCacheWrite) { void this.writeGithubCacheSnapshotAsync() return } const cacheFile = getGithubCacheFile(this.dataFile) const generation = this.githubCacheGeneration const tmpFile = durableWriteTempPath(cacheFile) try { writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') renameSync(tmpFile, cacheFile) if (generation === this.githubCacheGeneration) { this.githubCacheDirty = false } } catch (err) { try { unlinkSync(tmpFile) } catch { // Best-effort cleanup. } console.warn('[persistence] Failed to write github cache snapshot:', err) } } } function getDefaultWorktreeMeta(): WorktreeMeta { return { instanceId: randomUUID(), displayName: '', comment: '', linkedIssue: null, linkedPR: null, linkedLinearIssue: null, linkedGitLabMR: null, linkedGitLabIssue: null, linkedBitbucketPR: null, linkedAzureDevOpsPR: null, linkedGiteaPR: null, linkedWorkItem: null, linkedTaskSourceContext: null, isArchived: false, isUnread: false, isPinned: false, sortOrder: Date.now(), lastActivityAt: 0, workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID } }