Files
orca/src/main/persistence.ts
T
Brennan Benson c67791e4c1 fix(setup-prompt): isolate state by execution host (#11447)
Prevent setup prompt inspection, caching, dismissal, saves, telemetry, and settings navigation from leaking across local, direct SSH, and runtime-relayed hosts.
2026-07-29 19:56:19 -07:00

6810 lines
257 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, safeStorage } from 'electron'
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
renameSync,
unlinkSync,
copyFileSync,
statSync,
realpathSync
} from 'node:fs'
import { rename, mkdir, rm, copyFile, open } from 'node:fs/promises'
import { 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 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 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 { parseWorkspaceSession } from '../shared/workspace-session-schema'
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 { 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 {
clearMissingProjectGroupMemberships,
createProjectGroup,
getNextProjectGroupOrder,
getProjectGroupSubtreeIds,
normalizeProjectGroupName,
normalizeProjectGroups
} from '../shared/project-groups'
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 {
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 { track } from './telemetry/client'
import { getCohortAtEmit } from './telemetry/cohort-classifier'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
function encrypt(plaintext: string): string {
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
return plaintext
}
try {
return safeStorage.encryptString(plaintext).toString('base64')
} catch (err) {
console.error('[persistence] Encryption failed:', err)
return plaintext
}
}
function decrypt(ciphertext: string): string {
if (!ciphertext || !safeStorage.isEncryptionAvailable()) {
return ciphertext
}
try {
return safeStorage.decryptString(Buffer.from(ciphertext, 'base64'))
} catch {
// Why: decrypt failure usually means plaintext (pre-encryption) or a changed keychain; return raw so the cookie survives upgrade.
console.warn(
'[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.'
)
return ciphertext
}
}
function decryptOptionalSecret(value: string | null | undefined): string | null {
return value ? decrypt(value) : null
}
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
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:<uuid>) 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
for (const meta of Object.values(state.worktreeMeta ?? {})) {
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<string, unknown> =>
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<keyof WorkspaceSessionState>([
'tabsByWorktree',
'terminalLayoutsByTabId'
])
function logPersistenceStartupMilestone(
event: string,
details: Record<string, unknown> = {}
): 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)
)
}
/** 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
): Partial<Record<ExecutionHostId, WorkspaceSessionState>> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return {}
}
const partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
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 = parseWorkspaceSession(value)
if (!result.ok) {
console.error(
`[persistence] Corrupt workspace session for host ${hostId}, using defaults:`,
result.error
)
continue
}
partitions[hostId] = { ...defaults, ...result.value }
}
return partitions
}
function backupPath(dataFile: string, index: number): string {
return `${dataFile}.bak.${index}`
}
function buildWorkspaceDirHistoryForUpdate(
current: GlobalSettings,
updates: Partial<GlobalSettings>
): 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<GlobalSettings> | undefined
): Partial<GlobalSettings> {
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
{}) as Partial<GlobalSettings> & { 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<GlobalSettings, 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated'> {
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<string, boolean> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const out: Record<string, boolean> = {}
for (const [worktreeId, showDotfiles] of Object.entries(value as Record<string, unknown>)) {
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<PersistedState['ui']> | undefined
): Partial<PersistedState['ui']> {
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:<publisher>.<id>/<panel>` 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<WorkspaceKey, WorkspaceLineage> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const normalized: Record<WorkspaceKey, WorkspaceLineage> = {}
for (const [key, entry] of Object.entries(value)) {
if (!isWorkspaceKey(key) || !entry || typeof entry !== 'object') {
continue
}
const lineage = entry as Partial<WorkspaceLineage>
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<NotificationSettings>) : {}
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<Automation, 'runContext' | 'sourceContext'> {
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<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'>
): {
state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'>
changed: boolean
} {
let changed = false
const contextsByAutomationId = new Map<string, Pick<Automation, 'runContext' | 'sourceContext'>>()
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<string, unknown>
): 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<Omit<OnboardingState, 'checklist'>> & { checklist?: Partial<OnboardingChecklistState> } {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return {}
}
const raw = input as Record<string, unknown>
const out: Partial<Omit<OnboardingState, 'checklist'>> & {
checklist?: Partial<OnboardingChecklistState>
} = {}
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<string, unknown>
const checklist: Partial<OnboardingChecklistState> = {}
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 }
const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : ''
const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : ''
return owner && repo ? { owner, repo } : undefined
}
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<string>()
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<SshRemotePtyLease>
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 } : {})
}
}
type LayoutLeafNormalization = {
snapshot: TerminalLayoutSnapshot
changed: boolean
leafIdByInputLeafId: Map<string, string>
}
function collectLayoutLeafCounts(
node: TerminalPaneLayoutNode,
counts: Map<string, number> = new Map()
): Map<string, number> {
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<string, string>,
duplicatedInputLeafIds: Set<string>
): 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<string, string> | undefined,
leafIdByInputLeafId: Map<string, string>,
duplicatedInputLeafIds: Set<string>
): Record<string, string> | undefined {
if (!source) {
return undefined
}
const next: Record<string, string> = {}
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<string, string> | undefined,
right: Record<string, string> | 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<string, string> | undefined,
incomingRecord: Record<string, string> | undefined,
liveLeafIds: Set<string>
): Record<string, string> | 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<string, string>
}): 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<string>()
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<string, MigrationUnsupportedPtyEntry[]>()
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<string, string>()
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<string, TerminalLayoutSnapshot> = {}
): {
session: WorkspaceSessionState
changed: boolean
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
} {
let changed = false
const leafIdByInputLeafIdByTabId = new Map<string, Map<string, string>>()
const leafIdByPtyIdByTabId = new Map<string, Map<string, string>>()
const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
const terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
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<string, string>()
const duplicatePtyIds = new Set<string>()
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<string, Map<string, string>>,
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
): { 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<string, Map<string, string>>
): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
if (!acknowledgements || Object.keys(acknowledgements).length === 0) {
return { acknowledgements, changed: false }
}
let changed = false
const next: NonNullable<PersistedState['ui']['acknowledgedAgentsByPaneKey']> = {}
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<MigrationUnsupportedPtyEntry>
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<LegacyPaneKeyAliasEntry>
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<string, LegacyPaneKeyAliasEntry>()
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<PersistedState, 'projects' | 'projectHostSetups'>,
nextState: Pick<PersistedState, 'projects' | 'projectHostSetups'>
): boolean {
return (
JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) &&
JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups)
)
}
function isRepoBackedProjectHostSetup(
setup: ProjectHostSetup,
currentRepoIds: ReadonlySet<string>
): boolean {
const repoId = typeof setup.repoId === 'string' ? setup.repoId : ''
return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId)
}
function mergeProjectHostSetupCompatibilityState(
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
repos: readonly Repo[]
): Pick<PersistedState, 'projects' | 'projectHostSetups'> {
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<string>,
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<string>,
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<string>,
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)
)
}
function removeWorkspaceSessionOwner(
session: WorkspaceSessionState | undefined,
ownerKey: string,
options: { advanceTerminalTopologyRevision?: boolean } = {}
): WorkspaceSessionState | undefined {
if (!session) {
return session
}
const next = cloneWorkspaceSessionState(session)
const removedTabIds = new Set<string>()
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<string>
): WorkspaceSessionState | undefined {
if (!session || ownerKeys.size === 0) {
return session
}
const next = cloneWorkspaceSessionState(session)
const removedTabIds = new Set<string>()
for (const ownerKey of ownerKeys) {
deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds)
}
deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) =>
ownerKeys.has(worktreeId)
)
return next
}
function inferFolderScopeConnectionIdForMigration(args: {
folderPath: string
projectGroupId: string
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
}): string | null {
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
const groupRepos = args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
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<string>()
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 ?? []
let changed = false
const projectGroups = groups.map((group) => {
if (group.connectionId || !group.parentPath) {
return group
}
const connectionId = inferFolderScopeConnectionIdForMigration({
folderPath: group.parentPath,
projectGroupId: group.id,
projectGroups: groups,
repos
})
if (!connectionId) {
return group
}
changed = true
return { ...group, connectionId }
})
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => {
if (workspace.connectionId) {
return workspace
}
const groupConnectionId = groupsById.get(workspace.projectGroupId)?.connectionId ?? null
const connectionId =
groupConnectionId ??
inferFolderScopeConnectionIdForMigration({
folderPath: workspace.folderPath,
projectGroupId: workspace.projectGroupId,
projectGroups,
repos
})
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<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | null = null
private writeGeneration = 0
// 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
// 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 firstPendingSaveAt: number | null = null
private githubCacheDirty = false
private gitUsernameCache = new Map<string, string>()
private loadNeedsSave = false
private settingsChangeListeners = new Set<
(
updates: Partial<GlobalSettings>,
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()
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<string, number>()
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: 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<void> {
if (!existsSync(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)
if (existsSync(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)
})
}
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 = decrypt(parsed.settings.opencodeSessionCookie)
}
if (parsed.settings?.httpProxyUrl) {
parsed.settings.httpProxyUrl = decrypt(parsed.settings.httpProxyUrl)
}
if (parsed.ui?.browserKagiSessionLink) {
parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink)
}
// 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 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),
// 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 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
})()
const normalized = normalizeWorktreeCardProperties(expandedCandidate)
const changed =
normalized.length !== rawCardProps.length ||
normalized.some((property, index) => property !== rawCardProps[index])
return changed ? normalized : undefined
})()
if (
migratedCardProps !== undefined ||
!inlineAgentsMigrated ||
!expandedCardPropsMigrated
) {
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
}
})(),
// 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 = parseWorkspaceSession(parsed.workspaceSession)
if (!result.ok) {
console.error(
'[persistence] Corrupt workspace session, using defaults:',
result.error
)
return defaults.workspaceSession
}
return { ...defaults.workspaceSession, ...result.value }
})(),
// Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat.
workspaceSessionsByHostId: parseWorkspaceSessionsByHostId(
parsed.workspaceSessionsByHostId,
defaults.workspaceSession
),
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),
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
}
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,
automations: automationContextMigration.state.automations,
automationRuns: automationContextMigration.state.automationRuns
}
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
...result,
repos,
...projectHostSetupCompatibility,
workspaceSession: migratedScrollback.session
})
if (folderScopeConnectionMigration.changed) {
this.loadNeedsSave = true
}
result = folderScopeConnectionMigration.state
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 {
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
// Why (issue #1158): serialize async writes so backup rotation can't race two callers over the same paths.
const prev = this.pendingWrite ?? Promise.resolve()
const next = prev
.then(() => this.writeToDiskAsync())
.catch((err) => {
console.error('[persistence] Failed to write state:', err)
})
.finally(() => {
if (this.pendingWrite === next) {
this.pendingWrite = null
}
})
this.pendingWrite = next
}, delay)
}
/** Wait for any in-flight async disk write to complete. Used in tests. */
async waitForPendingWrite(): Promise<void> {
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<PersistedState, 'githubCache'> {
const { githubCache: _memoryOnly, ...durable } = this.state
return durable
}
// Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). One full-state stringify serves both the on-disk payload and the no-op-write guard hash: each secret slot is serialized as a fresh unguessable sentinel, then sentinels are substituted to ciphertext for the payload and to plaintext for the hash. The hash is thus a pure function of plaintext state (skips a byte-identical rewrite) without a second stringify.
private buildStateToSave(): { payload: string; stateHash: string } {
// 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; plaintext: string }[] = []
const encryptToSentinel = (plaintext: string): string => {
const blob = encrypt(plaintext)
// Deterministic already (empty secret / safeStorage unavailable / encrypt
// failure): blob === plaintext, so no normalization — and no sentinel,
// which also avoids substituting an empty or plaintext-shaped slot.
if (blob === plaintext) {
return blob
}
const sentinel = `orca-secret-slot-${randomUUID()}`
secretSubs.push({ sentinel, blob, plaintext })
return sentinel
}
// Why: clone before encrypting secrets so in-memory this.state stays plaintext.
const stateToSave = {
...this.getDurableState(),
settings: {
...this.state.settings,
opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie),
httpProxyUrl: encryptToSentinel(this.state.settings.httpProxyUrl ?? '')
},
ui: {
...this.state.ui,
browserKagiSessionLink: this.state.ui.browserKagiSessionLink
? encryptToSentinel(this.state.ui.browserKagiSessionLink)
: null
}
}
// 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, plaintext for the guard hash. Function-form replacement keeps
// `$` in blob/plaintext 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, plaintext } of secretSubs) {
const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
payload = payload.replace(escapedSentinel, () => blob)
hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(plaintext).slice(1, -1))
}
const stateHash = createHash('sha1').update(hashInput).digest('hex')
return { payload, stateHash }
}
// Why: async writes avoid blocking the main Electron thread on every debounced save.
private async writeToDiskAsync(): Promise<void> {
if (this.writesFrozen) {
return
}
const gen = this.writeGeneration
const { payload, stateHash } = this.buildStateToSave()
// Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted.
if (stateHash === this.lastWrittenStateHash) {
return
}
const dataFile = this.dataFile
const dir = dirname(dataFile)
await mkdir(dir, { recursive: true }).catch(() => {})
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 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
}
await renameDurable(tmpFile, dataFile)
renamed = true
// Why re-check gen: a sync flush during the rename await may have written fresher state; don't record a stale hash over it.
if (this.writeGeneration === gen) {
this.lastWrittenStateHash = stateHash
}
} finally {
if (!renamed) {
await rm(tmpFile).catch(() => {})
}
}
// Why (issue #1158): rotate backups only after rename succeeded, and let a concurrent flush own rotation.
if (this.writeGeneration !== gen) {
return
}
const now = Date.now()
if (this.shouldRotateBackups(now, dataFile)) {
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 } = {}): void {
if (this.writesFrozen) {
return
}
const { payload, stateHash } = 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
} finally {
if (!renamed) {
try {
unlinkSync(tmpFile)
} catch {
// Best-effort cleanup; the write already failed, swallow secondary error.
}
}
}
const now = Date.now()
if (this.shouldRotateBackups(now, dataFile)) {
this.rotateBackupsSync(dataFile)
}
}
flushOrThrow(): void {
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++
this.pendingWrite = null
this.writeToDiskSync({ force: asyncWriteWasInFlight })
}
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
}
const repo = setup.repoId
? this.state.repos.find((entry) => entry.id === setup.repoId)
: undefined
if (repo) {
this.removeProject(repo.id)
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<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
): ProjectGroup | null {
const group = (this.state.projectGroups ?? []).find((entry) => entry.id === groupId)
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): boolean {
const before = this.state.projectGroups?.length ?? 0
const deletedGroupIds = getProjectGroupSubtreeIds(this.state.projectGroups ?? [], groupId)
this.state.projectGroups = (this.state.projectGroups ?? []).filter(
(group) => !deletedGroupIds.has(group.id)
)
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) =>
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
const removedFolderWorkspaceKeys = new Set<string>()
for (const workspace of this.state.folderWorkspaces ?? []) {
if (deletedGroupIds.has(workspace.projectGroupId)) {
removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id))
this.state.workspaceSession = removeWorkspaceSessionOwner(
this.state.workspaceSession,
folderWorkspaceKey(workspace.id)
)!
this.removeWorkspaceLineageForFolderParent(workspace.id)
}
}
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
)
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): FolderWorkspace | undefined {
return (this.state.folderWorkspaces ?? []).find((workspace) => workspace.id === id)
}
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 group = (this.state.projectGroups ?? []).find(
(entry) => entry.id === input.projectGroupId
)
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'
>
>
): FolderWorkspace | null {
const workspace = this.getFolderWorkspace(id)
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): boolean {
const before = this.state.folderWorkspaces?.length ?? 0
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
(workspace) => workspace.id !== id
)
if ((this.state.folderWorkspaces?.length ?? 0) === before) {
return false
}
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): Repo | null {
const repo = this.state.repos.find((entry) => entry.id === repoId)
if (!repo) {
return null
}
const normalizedGroupId =
groupId && (this.state.projectGroups ?? []).some((group) => group.id === groupId)
? groupId
: null
const siblingRepos = this.state.repos.filter((entry) => entry.id !== repoId)
repo.projectGroupId = normalizedGroupId
repo.projectGroupOrder =
typeof order === 'number' && Number.isFinite(order)
? order
: getNextProjectGroupOrder(siblingRepos, normalizedGroupId)
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<string>()
for (const id of orderedIds) {
if (typeof id !== 'string' || seen.has(id)) {
return false
}
seen.add(id)
}
const byId = new Map<string, Repo>()
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<string>()
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<string, boolean>()
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<string>()
const collectPrefixedKeys = (keys: Iterable<string>): 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<Store['updateRepo']>[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<string, WorktreeMeta> {
return this.state.worktreeMeta
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): 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): void {
delete this.state.worktreeMeta[worktreeId]
delete this.state.worktreeLineageById[worktreeId]
delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)]
this.state.workspaceSession = removeWorkspaceSessionOwner(
this.state.workspaceSession,
worktreeId,
{ advanceTerminalTopologyRevision: true }
)!
this.scheduleSave()
}
getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined {
return this.state.worktreeLineageById[worktreeId]
}
getAllWorktreeLineage(): Record<string, WorktreeLineage> {
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 = <T>(
record: Record<string, T>,
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 = <T extends { worktreeId: string }>(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 = <T>(
record: Record<string, T> | 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<WorkspaceKey, WorkspaceLineage> {
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<GlobalSettings>,
settings: GlobalSettings,
originWebContentsId?: number
) => void
): () => void {
this.settingsChangeListeners.add(listener)
return () => {
this.settingsChangeListeners.delete(listener)
}
}
private notifySettingsChanged(
updates: Partial<GlobalSettings>,
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<GlobalSettings>,
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
): GlobalSettings {
const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates)
// 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
}
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
)
}
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<GlobalSettings> & Record<string, unknown>
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<PersistedState['ui']>): void {
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<Omit<PersistedState['onboarding'], 'checklist'>> & {
checklist?: Partial<OnboardingChecklistState>
}
): 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
}
// ── 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()
}
getWorkspaceSessionHostIds(): ExecutionHostId[] {
const hostIds = new Set<ExecutionHostId>([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)
}
/** 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']): 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<string, string>()
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)
const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(
session,
this.terminalScrollbackSnapshotStorage
)
session = migratedScrollback.session
deleteRemovedTerminalScrollbackSnapshots(prior, session, this.terminalScrollbackSnapshotStorage)
this.state.workspaceSession = session
this.scheduleSave()
}
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<string> {
const leafIds = new Set<string>()
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
},
hostId?: string | null
): void {
const resolvedHostId = this.resolveHostId(hostId)
const session = this.getWorkspaceSession(resolvedHostId)
if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) {
this.state.workspaceSessionsByHostId = {
...this.state.workspaceSessionsByHostId,
[resolvedHostId]: session
}
}
const sessionBeforeBinding = cloneWorkspaceSessionState(session)
const paneKey = `${args.tabId}:${args.leafId}`
let terminalMembershipChanged = false
const advanceTopologyAfterMembershipChange = (): void => {
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
if (!terminalMembershipChanged || currentRevision <= 0) {
return
}
// Why: a real host-admitted spawn after a retirement must be distinguishable from 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.
advanceTopologyAfterMembershipChange()
try {
this.flushOrThrow()
} catch (err) {
restoreSession()
throw err
}
return
}
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 }
}
}
}
advanceTopologyAfterMembershipChange()
try {
this.flushOrThrow()
} catch (err) {
restoreSession()
throw err
}
}
// ── 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<Omit<SshTarget, 'id'>>): 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 {
if (!this.state.sshTargets) {
return
}
this.state.sshTargets = this.state.sshTargets.filter((t) => t.id !== 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<string>()
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
}
}
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 Remote PTY Leases ──────────────────────────────────────────
getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] {
const leases = this.state.sshRemotePtyLeases ?? []
return leases.filter((lease) => targetId === undefined || lease.targetId === targetId)
}
upsertSshRemotePtyLease(
lease: Omit<SshRemotePtyLease, 'createdAt' | 'updatedAt'> &
Partial<Pick<SshRemotePtyLease, 'createdAt' | 'updatedAt'>>
): 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 {
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) {
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
if (changed || bindingsChanged) {
this.flush()
}
}
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 {
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()
}
// 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
}
const cacheFile = getGithubCacheFile(this.dataFile)
const tmpFile = `${cacheFile}.${process.pid}.tmp`
try {
writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8')
renameSync(tmpFile, cacheFile)
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
}
}