orca/src/main/persistence.ts

6478 lines
243 KiB
TypeScript

/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration,
load/save, and flush logic in one file so the full storage contract is reviewable
as a unit instead of being scattered across modules. */
import { app, safeStorage } from 'electron'
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
renameSync,
unlinkSync,
copyFileSync,
statSync,
realpathSync
} from 'node:fs'
import { writeFile, rename, mkdir, rm, copyFile } from 'node:fs/promises'
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,
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 type { GitRemoteIdentity } from '../shared/git-remote-identity'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext
} from '../shared/task-source-context'
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
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 } 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 {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostOrder,
normalizeExecutionHostId,
normalizeVisibleExecutionHostIds,
toSshExecutionHostId,
type ExecutionHostId
} from '../shared/execution-host'
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 { 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 {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardColumnWidth,
clampWorkspaceBoardOpacity,
normalizePersistedWorkspaceStatuses,
normalizeWorkspaceStatuses
} from '../shared/workspace-statuses'
import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-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 { normalizeUiLanguage } from '../shared/ui-language'
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
import { persistedUIValuesEqual } from '../shared/persisted-ui-equality'
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: if decryption fails, it likely means the value was stored as
// plaintext (pre-encryption build) or the OS keychain changed. Fall
// back to the raw string so users don't lose their cookie after upgrade.
console.warn(
'[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.'
)
return ciphertext
}
}
function encryptOptionalSecret(value: string | null | undefined): string | null {
return value ? encrypt(value) : null
}
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` during normalize/project.
instructionsByOperation = { ...instructionsByOperation, [actionId]: '' }
changed = true
}
return changed ? { ...sourceControlAi, instructionsByOperation } : sourceControlAi
}
// Why: the data-file path must not be a module-level constant. Module-level
// code runs at import time — before configureDevUserDataPath() redirects the
// userData path in index.ts — so a constant would capture the default (non-dev)
// path, causing dev and production instances to share the same file and silently
// overwrite each other.
//
// It also must not be resolved lazily on every call, because app.setName('Orca')
// runs before the Store constructor and would change the resolved path from
// lowercase 'orca' to uppercase 'Orca'. On case-sensitive filesystems (Linux)
// this would look in the wrong directory and lose existing user data.
//
// Solution: index.ts calls initDataPath() right after configureDevUserDataPath()
// but before app.setName(), capturing the correct path 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 is a refetchable 5-min-TTL poll cache whose
// fetchedAt stamps change on every refresh — keeping it inside orca-data.json
// made every poll cycle rewrite the whole multi-MB durable state (defeating
// the content-hash guard by design). It lives in memory during the session
// and is snapshotted here best-effort at quit so PR/issue badges still paint
// instantly on the next launch. Loss of this file costs nothing.
function getGithubCacheFile(dataFile = getDataFile()): string {
return join(dirname(dataFile), 'orca-github-cache.json')
}
// Why: worktrees deleted outside Orca (git CLI worktree remove, rm -rf,
// agent scripts) purge renderer session state but nothing removed their
// worktreeMeta, so the map grew monotonically (63% dead entries measured on
// a heavy install). GC is deliberately narrow: local-host entries only
// (SSH/runtime metas embed remote paths a local existsSync would falsely
// condemn; WSL UNC paths are skipped the same way), and only after a
// 30-day idle grace so pushTarget cleanup for recently-vanished worktrees
// and quick recreations keep their metadata.
const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000
function gcStaleWorktreeMeta(state: PersistedState): number {
// Why: a hand-corrupted file with `"worktreeMeta": null` overrides the
// defaults merge; normalize instead of throwing outside the parse guard.
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 are keyed
// `repoId::path::workspace:<uuid>` and their meta IS the workspace
// record — never a filesystem-checkout row. Skip them entirely.
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 project/host semantics on the entry itself;
// stay conservative and leave them to their own lifecycle.
continue
}
// Unowned entries (repo removed before removeProject pruned metas) 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 linked worktrees on Windows carry Linux-style paths from git
// porcelain; a Windows existsSync cannot probe those and would falsely
// condemn live worktrees.
if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) {
continue
}
// Why keep timestamp-less entries: without lastActivityAt/createdAt we
// cannot prove the 30-day idle grace elapsed; the measured dead entries
// all carry timestamps, so this costs almost nothing in reclaimed bytes.
// Grace runs before the stat so healthy profiles skip the existsSync
// fan-out (and its slow-NFS tail) for active entries entirely.
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 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 that must share storage with orca-data.json (mobile pairing's
* DeviceRegistry, E2EE keypair, runtime metadata) read this instead of
* resolving the path late, which on case-sensitive filesystems can land in a
* different directory and lose paired devices across restarts/updates.
*/
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.
*
* Existing installs may already have credentials in the late app.getPath('userData')
* directory. Before switching the runtime server to the canonical path, copy the
* registry and E2EE keypair forward as a pair so an update does not force one
* last 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: these are credential files (device tokens, E2EE secret key). copyFileSync
// does not carry Windows ACLs, so re-assert the current-user-only restriction on
// the copy instead of relying on the runtime's later lazy re-harden on read.
hardenExistingSecureFile(targetPath)
}
}
// Why (issue #1158): keep 5 rolling backups of orca-data.json so a corrupt or
// empty write leaves at least one earlier copy recoverable. Five snapshots at
// >=1-hour spacing cover recent work without churning disk on every debounce.
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 the persisted non-'local' host partitions. 'local' is intentionally
* dropped here — it is the legacy workspaceSession blob — so the two surfaces
* never diverge. Each partition is zod-validated independently: a corrupt host
* drops to defaults without taking out the others. Idempotent: re-running on an
* already-normalized map yields the same shape. */
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' belongs in workspaceSession; an invalid/local key here is
// legacy noise and must not shadow the canonical local 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 per-agent launch defaults via
// command overrides, so those agents are treated 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
}
function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] {
if (
tab === 'explorer' ||
tab === 'search' ||
tab === 'vault' ||
tab === 'workspaces' ||
tab === 'source-control' ||
tab === 'checks' ||
tab === 'ports'
) {
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
// explicit run target once so dispatch/precheck no longer infer it later.
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
}
// Why: old persisted targets predate configHost. Default to label-based lookup
// so imported SSH aliases keep resolving through ssh -G after upgrade.
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 workspace sync now follows the SSH relay lifecycle, so the
// retired per-target sync opt-out and grace-period fields stop at disk load.
delete target.remoteWorkspaceSyncEnabled
delete target.remoteWorkspaceSyncGracePeriodSeconds
delete target.relayGracePeriodSeconds
delete target.systemSshConnectionReuse
// Why: synced legacy targets ignored stale relayGracePeriodSeconds values.
// Prefer the synced grace 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: the old SSH form eagerly persisted 10800 even when the user had not
// chosen a timeout; 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: shared by load-time merge and the IPC update handler so the same
// strict whitelist guards every entry into onboarding state — arbitrary
// renderer/disk input cannot inject unknown keys or wrong-typed values.
// Returns only validated fields; unknown keys are dropped silently.
// Why: returns Partial<...> with a partial checklist so the IPC update path
// merges over current state without wiping previously-true keys. Invalid
// top-level fields are OMITTED (not coerced to fallbacks) so partial updates
// don't clobber valid persisted state; the load-path caller spreads defaults.
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 was the four-step flow before the Windows terminal preference
// page. Step 4 already meant notifications, so open progress should resume
// there rather than treating it as the newly inserted Windows step.
if (raw.flowVersion === 3) {
return Math.min(4, lastCompletedStep)
}
// Why: v2 was the five-step flow; missing/older versions were seven-step
// data where step 4 was 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: `typeof raw.closedAt === 'number'` would let NaN/Infinity through;
// JSON.stringify writes those as `null` on save, which silently reverts
// closedAt and re-opens the wizard on next load. Require a finite,
// non-negative timestamp so live state matches what disk can persist.
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 numeric meanings. Migrate raw
// legacy disk values before the new final-step bound can drop 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 (e.g.
// `{ addedRepo: true }`) 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: if we successfully parsed an existing orca-data.json that lacks an
// onboarding block, this is an upgrade-cohort user — backfill as completed
// (not dismissed) so they don't get dropped into the wizard regardless of
// whether they currently have repos, SSH targets, or just non-default
// settings. Analytics still distinguish this from users who explicitly
// bailed mid-funnel.
if (!input) {
return {
...defaults,
closedAt: Date.now(),
outcome: 'completed',
lastCompletedStep: ONBOARDING_FINAL_STEP
}
}
// Why: validate every persisted onboarding key explicitly via the shared
// sanitizer instead of spreading raw values. A type-flipped field on disk
// (string where number expected, unknown checklist key) is dropped or
// coerced to the default rather than poisoning in-memory state.
const sanitized = sanitizeOnboardingUpdate(input, {
migrateLegacyProgress: true
})
// Why: a persisted completed/dismissed outcome means the user left
// onboarding. Recover from a bad/missing/null closedAt instead of reopening
// the new-user sidebar 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: the sidebar checklist is a new-user prompt. Once onboarding is
// closed, persisted false is just the old default value, not a user opt-in.
return onboarding.closedAt !== null || persistedDismissed === true
}
// Why: read a settings field that was removed from GlobalSettings but can
// still exist on disk. One-shot use 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 | undefined {
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" 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: persisted PaneManager ids are 1-based. A zero-based alias in split
// layouts would make tab:1 ambiguous and can route the first pane to the second.
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 instead of
// persisted as restart-required rows. Existing saved rows are pruned during
// normalizePersistedPaneIdentityState.
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 dev/RC migration rows did not store the old numeric
// key. Only synthesize the single-pane aliases when the row is unambiguous
// for its tab; split rows need layout-derived aliases instead of a guess.
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 persistPtyBinding has already sync-flushed the UUID root.
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 persisted split layouts can generate enough alias rows to
// exceed V8's argument limit if the arrays are 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; do not
// 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: UI acks are keyed by paneKey just like hook rows. When a legacy
// numeric/pane:* leaf is promoted to a UUID, carry the read marker over
// so already-seen Activity/sidebar rows do not 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 ever needs the
// handful of Claude sessions a daemon can realistically keep alive.
const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200
// Why: bound the removed-SSH-target history so remove/re-add churn can't grow
// the state file without limit. Re-adoption only needs recent removals.
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 while bounding the work done
// on an oversized/corrupt list.
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 stable = parsePaneKey(candidate.stablePaneKey)
return Boolean(legacy && stable && legacy.tabId === stable.tabId)
})
}
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 use the repo id as the setup id. Keep
// only independent setup rows here so repo deletion does not leave 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)
}
function removeWorkspaceSessionOwner(
session: WorkspaceSessionState | undefined,
ownerKey: string
): WorkspaceSessionState | undefined {
if (!session) {
return session
}
const next = cloneWorkspaceSessionState(session)
const removedTerminalTabs = next.tabsByWorktree?.[ownerKey] ?? []
if (next.tabsByWorktree) {
delete next.tabsByWorktree[ownerKey]
}
for (const tab of removedTerminalTabs) {
delete next.terminalLayoutsByTabId[tab.id]
if (next.activeTabId === tab.id) {
next.activeTabId = null
}
}
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.sleepingAgentSessionsByPaneKey) {
for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey)) {
if (record.worktreeId === ownerKey) {
delete next.sleepingAgentSessionsByPaneKey[paneKey]
}
}
}
if (next.activeWorkspaceKey === ownerKey) {
next.activeWorkspaceKey = null
}
if (next.activeWorktreeId === ownerKey) {
next.activeWorktreeId = null
}
next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter(
(worktreeId) => worktreeId !== ownerKey
)
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 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 store's file on disk behind
// its back, the stale in-memory state must never be persisted again — a
// late sync flush before the relaunch would resurrect the moved project.
private writesFrozen = false
// Why: hash of the plaintext state as of the last successful write. Saves
// triggered by mutations that net out to identical state skip the full
// 1.6MB pretty-print + tmp write + rename. Hashing plaintext (not the
// written payload) because encrypt() uses a random IV per call, so the
// on-disk bytes differ even for identical state.
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 creates more than one possible state path. Capture
// the path per Store instance so late async writes cannot 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
const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups()
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
}
for (const entry of normalized.legacyPaneKeyAliasEntries) {
agentHookServer.registerPaneKeyAlias(
entry.legacyPaneKey,
entry.stablePaneKey,
entry.ptyId,
entry.updatedAt,
{ overwriteExisting: false }
)
}
setMigrationUnsupportedPtyPersistenceListener((entries) => {
this.state.migrationUnsupportedPtyEntries = entries
this.scheduleSave()
})
agentHookServer.setPaneKeyAliasPersistenceListener((entries) => {
this.state.legacyPaneKeyAliasEntries = entries
this.scheduleSave()
})
if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) {
// Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at
// the main persistence boundary so older renderer writes cannot revive them.
// Other one-shot load migrations also set loadNeedsSave to persist their
// guard flags before the next restart.
this.scheduleSave()
}
}
private adaptFlatFolderScanProjectGroups(): boolean {
// Why: older folder imports persisted a real parent path but kept all repos
// flat. Upgrade that shape into v1 sparse folder scopes on load.
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 (issue #1158): debounced writes fire as often as every 300ms during
// active use. The backup ring should capture meaningfully different moments,
// not five near-identical snapshots from one burst of store updates.
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 oldest to discarded and shift .bak.i to .bak.i+1 by rename;
// then copy the current data file to .bak.0 so load() has a JSON recovery
// source even if a later primary write is truncated or corrupted.
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 once, at the top: this is the unambiguous "has the user run
// Orca before?" signal used by the telemetry cohort migration below.
// Field-based inference (e.g., `settings.telemetry` presence) does not
// work on the telemetry release itself — `telemetry` is new here, so it
// would be absent on every pre-telemetry install and misclassify existing
// users as fresh, flipping them to default-on in violation of the
// social contract we installed them under.
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: secret settings are stored encrypted on disk via safeStorage.
// Decrypt at the load boundary so the rest of 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: before the layout-aware 'auto' mode shipped (issue #903),
// terminalMacOptionAsAlt defaulted to 'true' globally. That silently
// broke Option-layer characters (@ on Turkish via Option+Q, @ on
// German via Option+L, € on French via Option+E) for non-US users.
// We can't distinguish a persisted 'true' that the user chose
// explicitly from one they inherited from the old default — so on
// first launch after upgrade, flip 'true' back to 'auto' and let
// the renderer's keyboard-layout probe pick the right value per
// layout. US users land on 'true' via detection (no change); non-US
// users land on 'false' (correct). 'false'/'left'/'right' are
// definitionally explicit choices (they never matched the old
// default) so we carry those forward unchanged. The migrated flag
// guards against re-running this on subsequent launches.
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 floating-terminal builds persisted the old off-by-default
// value into user profiles. Flip only unmigrated profiles so a later
// deliberate opt-out still survives reload.
const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers
? (parsed.settings?.floatingTerminalEnabled ?? true)
: true
const floatingTerminalCwdMigrated =
parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true
// Why: an earlier migration wrote '' for the default app-owned notes
// directory. Floating terminals should still open at home by default;
// markdown notes resolve their app-owned directory through 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 represented user 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 every
// pre-migration profile off once, then preserve future user opt-ins.
const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers
? (parsed.settings?.experimentalActivity ?? false)
: false
const autoRenameBranchFromWorkDefaultedOn =
parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true
// Why: default-on rollout should activate old profiles once, but a
// later Settings opt-out must survive reloads.
const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn(
parsed.settings
)
const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings)
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
}
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 ?? {},
workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey(
parsed.workspaceLineageByChildKey
),
settings: {
...defaults.settings,
...stripLegacyTerminalScrollbackBytes(parsed.settings),
// Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry
// the old persisted flag forward once so enabled users don't lose it.
experimentalPet:
parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false,
// Why: early primary-selection builds saved the disabled default.
// Flip Linux/macOS profiles once so terminal-style defaults match
// platform convention; the guards preserve future opt-outs.
primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault
? true
: (parsed.settings?.primarySelectionMiddleClickPaste ??
defaults.settings.primarySelectionMiddleClickPaste),
primarySelectionMiddleClickPasteDefaultedForLinux:
primarySelectionDefaultedForLinux ||
(process.platform === 'linux' && migratePrimarySelectionPlatformDefault),
primarySelectionMiddleClickPasteDefaultedForTerminalDefaults:
primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults,
...migratedAutoRenameBranchFromWork,
...migratedTerminalCursorStyle,
...migratedTerminalTuiScrollSensitivity.settings,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
// Why: compact worktree cards graduated from Experimental; preserve
// the old opt-in for profiles written during the rollout.
compactWorktreeCards: loadedCompactWorktreeCards,
experimentalCompactWorktreeCards: undefined,
terminalMacOptionAsAlt: migratedOptionAsAlt,
terminalMacOptionAsAltMigrated: true,
localWindowsRuntimeDefault: migratedWindowsRuntimeDefault,
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 can be user-edited or written by older
// builds; keep tray-minimize false unless the stored value is true.
minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true,
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: new builds read sourceControlAi, but rollback builds still
// write commitMessageAi; after merging those writes, refresh the
// legacy projection for continued rollback compatibility.
commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi(
migratedSourceControlAi,
parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi
),
voice: {
...getDefaultVoiceSettings(),
...parsed.settings?.voice
}
},
// Why: 'recent' used to mean the weighted smart sort. One-shot
// migration moves it to 'smart'; the flag prevents re-firing after
// a user intentionally selects the new last-activity 'recent' sort.
// Gate on the *raw* persisted value, not the normalized one: the
// default sortBy is now 'recent', so a fresh install with no
// persisted sortBy would otherwise be mis-migrated to 'smart'.
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; user-authored reorders then survive.
const workspaceStatusesReorderedDefaultRepaired =
parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true
// Why: only exact legacy default payloads are migrated; users who
// customized status labels, colors, icons, or order keep theirs.
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 by runtime writes.
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 used to be controlled by the generic issue
// property and Ports were always visible. Add the split-out
// properties once so existing cards keep their prior surface.
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
}
return {
...defaults.ui,
// Why: missing card properties should follow the persisted card
// layout mode; explicit property choices are preserved below.
worktreeCardProperties: getWorktreeCardModeProperties(
loadedCompactWorktreeCards ? 'Compact' : 'Default'
),
...stripMainOwnedTelemetryMarkerFromUI(parsed.ui),
// Why: migrate once from the retired Appearance setting only
// when no explicit persisted chrome preference exists yet.
rightSidebarOpen,
rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab),
setupGuideSidebarDismissed,
setupGuideBrowserMilestoneMigrated:
typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean'
? parsed.ui.setupGuideBrowserMilestoneMigrated
: false,
setupGuideBrowserMilestoneLegacyComplete:
parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true,
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 forward-compat with
// a rollback to a pre-default-on build that still reads it.
// The new flag is the one that actually gates the migration.
_inlineAgentsDefaultedForExperiment: true,
_inlineAgentsDefaultedForAllUsers: true,
_expandedWorktreeCardPropertiesDefaulted: true
}
})(),
// Why: the workspace session is the most volatile persisted surface
// (schema evolves per release, daemon session IDs embedded in it).
// Zod-validate at the read boundary so a field-type flip from an
// older build — or a truncated write from a crash — gets rejected
// cleanly instead of poisoning Zustand state and crashing the
// renderer on mount. On validation failure, fall back to defaults
// and log; a corrupt session file shouldn't trap the user out.
workspaceSession: (() => {
if (parsed.workspaceSession === undefined) {
return defaults.workspaceSession
}
const result = parseWorkspaceSession(parsed.workspaceSession)
if (!result.ok) {
console.error(
'[persistence] Corrupt workspace session, using defaults:',
result.error
)
return defaults.workspaceSession
}
return { ...defaults.workspaceSession, ...result.value }
})(),
// Why: per-host session partitions for non-'local' hosts. 'local'
// stays in workspaceSession (legacy field) so a downgrade still
// reads the user's workspace. Each entry is zod-validated the same
// way as the legacy blob — a corrupt partition drops to that host's
// defaults without poisoning the others.
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: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [],
onboarding: normalizedOnboarding
}
}
} catch (err) {
console.error('[persistence] Failed to load primary state, trying backups:', err)
}
// Corrupt-file catch path and "no file on disk" path converge here. The
// telemetry migration below runs on whichever branch produced `result`,
// because a user whose `orca-data.json` got corrupted is not a fresh
// install of the telemetry release — they still count as existing and
// must see the opt-in banner, not the default-on toast.
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 (gcStaleWorktreeMeta(result) > 0) {
this.loadNeedsSave = true
}
const migrated = this.migrateTelemetry(result, fileExistedOnLoad)
// githubCache lives in a sidecar file now (see getGithubCacheFile). A
// legacy in-file cache (pre-sidecar build, or a downgrade round-trip) is
// kept as this session's seed and stripped from the durable file by the
// save scheduled below; otherwise seed from the sidecar snapshot.
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 if no
// poll refresh happens this session — the seed survives the migration.
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. Runs on every `load()` but is a
// no-op once `existedBeforeTelemetryRelease` is set, so subsequent launches
// pay only the property lookup. Populates:
// - `existedBeforeTelemetryRelease` — cohort discriminator (drives
// whether the existing-user opt-in banner is shown in PR 3;
// new users get no first-launch surface).
// - `optedIn` — new users start opted in; existing users are `null` until
// the banner resolves (the consent resolver returns `pending_banner`
// until then, so nothing transmits).
// - `installId` — anonymous UUID v4. Stable across launches; not surfaced in the UI.
private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState {
const existing = state.settings?.telemetry
// Why: the one-shot is complete only when all three invariants hold.
// Keying on `existedBeforeTelemetryRelease` alone would let a partially-
// written telemetry block (crash mid-save, hand-edit, future bug) short-
// circuit migration and leave `installId` undefined or `optedIn` wiped.
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: cohort is the authoritative discriminator per invariant #8, so
// resolve it once and reuse it below — the `optedIn` fallback must not
// re-infer cohort from `fileExistedOnLoad` or field presence, or a
// partially-written telemetry block could land a new user in the
// existing-user `pending_banner` state.
const resolvedExistedBefore =
typeof existing?.existedBeforeTelemetryRelease === 'boolean'
? existing.existedBeforeTelemetryRelease
: fileExistedOnLoad
return {
...state,
settings: {
...state.settings,
telemetry: {
...existing,
existedBeforeTelemetryRelease: resolvedExistedBefore,
// Why: preserve an explicit opt-in/out if the user has ever resolved
// it. Only fall back to the cohort default (new users: on; existing
// users: undecided until the first-launch banner resolves) when
// optedIn is truly unset (undefined), never when it is `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 (previously 300ms trailing, unbounded):
// sustained sub-interval mutation bursts used to either rewrite the full
// multi-MB state ~3x/sec or postpone the write indefinitely by resetting
// the timer. The max-wait bounds crash staleness at 5s while bursts
// coalesce; the content-hash guard in the writers skips no-op payloads.
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 never has
// two callers racing over the same dataFile/tmp/.bak 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> {
if (this.pendingWrite) {
await this.pendingWrite
}
}
// Why githubCache is omitted: it is memory-only during the session (see
// getGithubCacheFile) — excluding it from both the payload and the hash
// keeps cache refreshes from ever touching the durable file.
private getDurableState(): Omit<PersistedState, 'githubCache'> {
const { githubCache: _memoryOnly, ...durable } = this.state
return durable
}
private computeStateHash(): string {
return createHash('sha1').update(JSON.stringify(this.getDurableState())).digest('hex')
}
// Why: builds the on-disk payload synchronously so the hash and the
// serialized bytes reflect the same state tick (no mutation can interleave
// before an await).
private buildStateToSave(): string {
// Why: secrets must be encrypted on disk. Clone state so the in-memory
// this.state stays plaintext for the rest of the app.
const stateToSave = {
...this.getDurableState(),
settings: {
...this.state.settings,
opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie),
httpProxyUrl: encrypt(this.state.settings.httpProxyUrl ?? '')
},
ui: {
...this.state.ui,
browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink)
}
}
return JSON.stringify(stateToSave, null, 2)
}
// Why: async writes avoid blocking the main Electron thread on every
// debounced save during active use.
private async writeToDiskAsync(): Promise<void> {
if (this.writesFrozen) {
return
}
const gen = this.writeGeneration
const stateHash = this.computeStateHash()
// Why: a mutation burst that nets out to already-persisted state (or a
// flush that raced ahead) must not rewrite a byte-identical multi-MB file.
if (stateHash === this.lastWrittenStateHash) {
return
}
const payload = this.buildStateToSave()
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: wrap write+rename in try/finally-on-error so any failure (ENOSPC,
// ENFILE, EIO, permission) removes the tmp file rather than leaving a
// multi-megabyte orphan behind. Successful rename consumes the tmp file.
let renamed = false
try {
await writeFile(tmpFile, payload, 'utf-8')
// Why: if flush() ran while this async write was in-flight, it bumped
// writeGeneration and already wrote the latest state synchronously.
// Renaming this stale tmp file would overwrite the fresh data.
if (this.writeGeneration !== gen) {
return
}
await rename(tmpFile, dataFile)
renamed = true
// Why the gen re-check: a sync flush can interleave during the rename
// await, write fresher state, and record its own hash. Recording this
// stale hash over it would make later saves skip against content that
// is not what the file holds.
if (this.writeGeneration === gen) {
this.lastWrittenStateHash = stateHash
}
} finally {
if (!renamed) {
await rm(tmpFile).catch(() => {})
}
}
// Why (issue #1158): rotate only after the atomic rename succeeded; then
// re-check the generation so a concurrent flush owns any backup rotation.
if (this.writeGeneration !== gen) {
return
}
const now = Date.now()
if (this.shouldRotateBackups(now, dataFile)) {
await this.rotateBackupsAsync(dataFile)
}
}
// Why: synchronous variant kept 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 stateHash = this.computeStateHash()
// Why: skipping is safe under flushOrThrow's durability contract — a
// matching hash means this exact state is already the file's content.
// Except when an async write was in flight at flush entry (force): its
// rename may already be dispatched past the generation check, and only
// an unconditional sync write afterwards reliably out-orders it.
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`
const payload = this.buildStateToSave()
// Why: mirror the async path — on any failure between writeFileSync and
// renameSync, remove the tmp file so crashes during shutdown don't leak
// orphans into userData.
let renamed = false
try {
writeFileSync(tmpFile, payload, 'utf-8')
renameSync(tmpFile, dataFile)
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)
}
}
private flushOrThrow(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
this.firstPendingSaveAt = null
const asyncWriteWasInFlight = this.pendingWrite !== null
// Why: bump writeGeneration so any in-flight async writeToDiskAsync skips
// its rename, preventing a stale snapshot from overwriting this sync write.
this.writeGeneration++
this.pendingWrite = null
this.writeToDiskSync({ force: asyncWriteWasInFlight })
}
// ── 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: this is the first non-repo-backed setup creation path; it must
// persist independently so future repo projection sync does not erase it.
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) read of the persisted repo count. Use this when you only need the
* count (e.g. cohort-classifier) — `getRepos()` hydrates each repo, which
* is wasteful when the caller only reads `.length`.
*/
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 (repo-git-username-enrichment).
* Kept out of updateRepo's whitelist so the renderer-facing update surface
* cannot 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: persisting the resolved value lets the next launch hydrate repos
// with the right branch prefix before enrichment has re-run.
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. Deleting one must not delete
// repos or worktrees, so contained repos from the full subtree are ungrouped.
this.state.repos = this.state.repos.map((repo) =>
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
for (const workspace of this.state.folderWorkspaces ?? []) {
if (deletedGroupIds.has(workspace.projectGroupId)) {
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.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']
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 workspace: FolderWorkspace = {
id: randomUUID(),
projectGroupId: group.id,
name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`),
folderPath,
connectionId: input.connectionId ?? group.connectionId ?? null,
linkedTask: input.linkedTask ?? 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'
| '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 = updates.linkedTask
}
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.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: returns false on a stale permutation (concurrent add/remove races
// the renderer's drag) so the caller can tell the renderer to resync rather
// than persist an order that drops or 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
}
removeProject(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
this.syncProjectHostSetupCompatibilityState()
// Why: presets are repo-scoped, so removing the repo means the presets
// can never be referenced again — drop them with the parent.
delete this.state.sparsePresetsByRepo[id]
this.pruneWorktreeStateForRepo(id, null)
this.scheduleSave()
}
// Why: the same repo id can exist on more than one execution host (local, an
// SSH target, a re-added SSH target). Forgetting one host's copy must remove
// only that host's repo row and worktree metadata — never the local or
// another host's records that happen to share the id.
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); only drop them once the
// last host's copy of this repo is gone, or a surviving host loses its presets.
if (!idStillPresent) {
delete this.state.sparsePresetsByRepo[id]
}
this.syncProjectHostSetupCompatibilityState()
// Why: if the id survives on another host, prune only this host's worktree
// metas; otherwise prune everything for the id (matches removeProject).
this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null)
this.scheduleSave()
}
// Clean up worktree meta, lineage, and workspace lineage for a repo id.
// When hostId is null, prune all of the repo's entries; when a hostId is
// given, prune only entries whose meta.hostId resolves to that host (a
// missing hostId is treated as local).
private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void {
const prefix = `${id}::`
// Why: snapshot host membership up front. Lineage pruning below checks the
// meta.hostId of worktree keys that may already have been deleted from
// worktreeMeta in the first loop, so reading hostId live would misclassify
// an SSH worktree as local once its meta is gone.
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: worktree metas created on/after host-ownership
// stamping carry hostId. A metas without it predates that and is treated as
// local, so a host-scoped (non-local) prune conservatively leaves it — it
// may leak a stale entry for a legacy SSH worktree sharing a repo id with a
// local repo, but it never deletes the wrong 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
}
for (const key of Object.keys(this.state.worktreeMeta)) {
if (belongsToHost(key)) {
delete this.state.worktreeMeta[key]
}
}
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]
}
}
}
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
}
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
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 rest of 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 explicit marker. Stamp it the first
// time visibility changes so later hide/show choices keep 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: username resolution spawns git/gh subprocesses, so it must never
// run inside hydration — the first getRepos() of a launch executes on the
// Electron main thread and a stuck probe froze startup for minutes on
// Windows (issue #7225). Hydration only reads the enrichment cache or the
// value persisted by a previous launch; repo-git-username-enrichment.ts
// refreshes both in the background.
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 ─────────────────────────────────────────────────
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()
const runNumber =
(this.state.automationRuns ?? []).filter((run) => run.automationId === automation.id).length +
1
const run: AutomationRun = {
id: randomUUID(),
automationId: automation.id,
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 = [...(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 }
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.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()
}
/**
* Move every worktreeId-keyed record from `oldWorktreeId` to `newWorktreeId`
* after the worktree's folder (and thus its `${repoId}::${path}` id) was
* renamed on disk, so a post-move refresh re-binds the worktree's state under
* the new id instead of orphaning it. Records the old id on the new meta's
* `priorWorktreeIds` so the session GC/hydration can still recognize PTY
* sessions minted under the old (path-derived) id. No-op when the ids match.
*
* Renderer counterpart: `buildWorktreeRenameState` in store/slices/worktrees.ts
* re-keys the renderer's own worktree-scoped maps for the same id change.
*/
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
}
}
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: other worktrees created from this one carry it as parentWorktreeId;
// the stable parentWorktreeInstanceId is unaffected, but keep the denormalized
// path-derived id consistent too.
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
}
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 (group/sort/filters etc.) is written from both the
// desktop renderer and mobile (via the ui.set RPC) into one shared store.
// Without this, a mobile change persisted but the desktop renderer — which
// hydrates UI state once — never learned of it, breaking bi-directional sync.
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 strictly to boolean here (not at the IPC edge) so every write
// path is covered and a non-bool renderer payload can never persist a
// truthy non-bool that later reads as "tray-minimize on".
if ('minimizeToTrayOnClose' in updates) {
sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === 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)
}
const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate(
this.state.settings,
sanitizedUpdates
)
if (historyWithPreviousLayout) {
sanitizedUpdates.workspaceDirHistory = historyWithPreviousLayout
}
// Why: `telemetry` is deep-merged for the same reason `notifications` is —
// partial updates from the Privacy pane / consent flow (e.g., flipping
// only `optedIn`) must not clobber sibling fields like `installId` or
// `existedBeforeTelemetryRelease`. The field is optional, so we only
// synthesize a `telemetry` key on the result when at least one side has
// one.
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,
// Why: strict boolean coercion so a missing/legacy value reads as false
// (first-run notice still fires) rather than leaking a non-bool through.
trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true,
markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth),
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(
this.state.ui?.visibleWorkspaceHostIds
),
workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder),
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)
}
}
updateUI(updates: Partial<PersistedState['ui']>): void {
const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates)
const previousUI = this.getUI()
const currentUI = {
...getDefaultUIState(),
...stripMainOwnedTelemetryMarkerFromUI(this.state.ui)
}
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,
...sanitizedUpdates,
groupBy: sanitizedUpdates.groupBy
? normalizeGroupBy(sanitizedUpdates.groupBy)
: normalizeGroupBy(this.state.ui?.groupBy),
sortBy: sanitizedUpdates.sortBy
? normalizeSortBy(sanitizedUpdates.sortBy)
: normalizeSortBy(this.state.ui?.sortBy),
projectOrderBy: updates.projectOrderBy
? normalizeProjectOrderBy(updates.projectOrderBy)
: normalizeProjectOrderBy(this.state.ui?.projectOrderBy),
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,
markdownTocPanelWidth: clampMarkdownTocPanelWidth(
sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth
),
visibleWorkspaceHostIds:
updates.visibleWorkspaceHostIds !== undefined
? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds)
: normalizeVisibleExecutionHostIds(this.state.ui?.visibleWorkspaceHostIds),
workspaceHostOrder:
updates.workspaceHostOrder !== undefined
? normalizeExecutionHostOrder(updates.workspaceHostOrder)
: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder),
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 UI snapshots; union them 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 can both record education state.
// Merge instead of replacing so a stale renderer snapshot cannot erase
// runtime-only feature interactions.
featureInteractions:
sanitizedUpdates.featureInteractions !== undefined
? mergeFeatureInteractions(
this.state.ui?.featureInteractions,
sanitizedUpdates.featureInteractions
)
: normalizeFeatureInteractions(this.state.ui?.featureInteractions)
}
if (persistedUIValuesEqual(previousUI, nextUI)) {
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: the cache is memory-only during the session and
// snapshotted to its sidecar file at flush (quit/reload) time. Every poll
// refresh restamps fetchedAt, so persisting here rewrote the whole
// durable state file once per poll cycle for refetchable data.
this.state.githubCache = cache
this.githubCacheDirty = true
}
// ── Workspace Session ─────────────────────────────────────────────
/** Resolve an execution host argument to a canonical id. Unknown/empty
* values fall back to 'local' so legacy callers without a hostId keep
* reading and writing the local partition exactly as before. */
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()
}
readTerminalScrollbackSnapshot(ref: string): string | null {
return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage)
}
/** Resolve the worktree a terminal tab belongs to, from the session's
* tab→worktree map. 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. The PTY-binding race protections in
* setLocalWorkspaceSession only apply to the local daemon, so remote hosts
* take the lighter prune-and-store path. */
private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void {
const pruned = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
)
this.state.workspaceSessionsByHostId = {
...this.state.workspaceSessionsByHostId,
[hostId]: pruned
}
this.scheduleSave()
}
private setLocalWorkspaceSession(session: PersistedState['workspaceSession']): void {
session = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
)
// Why: closes the second half of the SIGKILL race (Issue #217). The
// renderer's debounced session writer captures its state BEFORE pty:spawn
// returns, so the snapshot it later flushes via session:set has no
// tab.ptyId / ptyIdsByLeafId for the just-spawned PTY. If that stale
// snapshot lands AFTER persistPtyBinding's sync flush, it would overwrite
// the durable binding and re-open the orphan window. Merge in any
// existing bindings whenever the incoming snapshot's binding is empty.
const prior = this.state.workspaceSession
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) {
agentHookServer.registerPaneKeyAlias(
entry.legacyPaneKey,
entry.stablePaneKey,
entry.ptyId,
entry.updatedAt,
{ overwriteExisting: false }
)
}
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 can 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 session write that drops ptyIdsByLeafId can
// also be from an older renderer that lacks 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 renderer's debounced hot path sends only changed top-level
// session slices. Scalar/UI patches avoid the terminal normalization path;
// terminal topology/layout patches still reuse the 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. Workspace PTY bindings
// only store the id, so derive target/context when possible and require
// stored lease context to match instead of treating missing fields as
// wildcards that can 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. Legacy/contextless leases should
// scrub matching workspace bindings before the lease record is deleted,
// otherwise removing the tombstone can let stale PTY ids revive later.
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: closes the SIGKILL-between-spawn-and-persist race (Issue #217). The
// renderer's debounced session writer (~450 ms total) is normally the only
// path that writes tab.ptyId / ptyIdsByLeafId; a force-quit inside that
// window orphans the daemon's history dir. Patching + sync flushing here
// before pty:spawn returns guarantees the renderer cannot observe a
// spawn-success without the binding already being durable on disk.
persistPtyBinding(args: {
worktreeId: string
tabId: string
leafId: string
ptyId: string
startupCwd?: string
}): void {
const session = this.state.workspaceSession
if (!session) {
return
}
const sessionBeforeBinding = cloneWorkspaceSessionState(session)
const tabs = session.tabsByWorktree?.[args.worktreeId]
const tab = tabs?.find((t) => t.id === args.tabId)
if (tab) {
tab.ptyId = args.ptyId
} else {
// Why: pty:spawn can beat the debounced session writer for a newly
// created tab. Persist a minimal tab so hydration does not prune the
// crash-safe layout binding below as an orphaned tab id.
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: legacy renderer-local pane ids may arrive from older callers; keep
// them out of durable leaf-keyed layout state after the UUID migration.
try {
this.flushOrThrow()
} catch (err) {
this.state.workspaceSession = sessionBeforeBinding
throw err
}
return
}
const layout = session.terminalLayoutsByTabId?.[args.tabId]
if (layout) {
if (!layout.root) {
// Why: createTab can persist an empty layout before TerminalPane mounts.
// The sync spawn binding must still leave a durable UUID root behind.
layout.root = { type: 'leaf', leafId: args.leafId }
layout.activeLeafId = args.leafId
layout.expandedLeafId = null
} else if (!layoutContainsLeafId(layout.root, args.leafId)) {
// Why: splitPane publishes the new pane and starts pty:spawn before the
// debounced full layout snapshot reaches main. Add a minimal leaf so a
// crash in that window cannot make the new pane's binding unreachable.
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 {
// Why: first-spawn-ever for a new tab — the renderer's debounced writer
// creates the layout entry on PaneManager init, but the binding has to
// be on disk before pty:spawn returns or a SIGKILL inside the same
// window would lose ptyIdsByLeafId for split-pane cold restore. The
// renderer will overwrite this minimal layout once persistLayoutSnapshot
// fires.
session.terminalLayoutsByTabId = {
...session.terminalLayoutsByTabId,
[args.tabId]: {
root: { type: 'leaf', leafId: args.leafId },
activeLeafId: args.leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [args.leafId]: args.ptyId }
}
}
}
try {
this.flushOrThrow()
} catch (err) {
this.state.workspaceSession = sessionBeforeBinding
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 the oldest entry at the cap — stale ids are pruned against the
// daemon at startup anyway, so recency is the only thing worth keeping.
this.state.claudeLivePtySessionIds = [...ids, sessionId].slice(-MAX_CLAUDE_LIVE_PTY_SESSION_IDS)
// Why: flush synchronously — a force-quit right after a Claude spawn must
// still seed the live-PTY gate on the 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 a remove/re-remove of 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 to the live
* host instead of remaining un-removable ghosts. Returns the number of repos
* re-pointed (0 when nothing referenced the old id).
*/
reassignSshTargetId(oldTargetId: string, newTargetId: string): number {
if (oldTargetId === newTargetId) {
return 0
}
const oldHostId = toSshExecutionHostId(oldTargetId)
const newHostId = toSshExecutionHostId(newTargetId)
let repoCount = 0
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: only rewrite executionHostId when it was actually set to the old
// SSH host. SSH repos created via addRemoteRepoFromPath leave it unset and
// derive the host from connectionId, so we must not stamp a value where
// there wasn't one.
if (matchesHost) {
repo.executionHostId = newHostId
}
repoCount++
}
// 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: the old id also survives in session pty ids, the startup reconnect
// list, sleeping-agent records, host setups, host-scope UI, and pty leases;
// any un-migrated carrier later throws `SSH target not found` (STA-1468).
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, so one stored under the removed id
// would be orphaned. No writer keys partitions by ssh host today, but the
// schema tolerates it — re-key rather than strand it. If the new key
// already has a partition, that one is live; drop the dead old one.
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: a setup already exists for the re-added host — the old row is a
// stale ghost that would violate the (projectId, hostId) uniqueness.
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 can affect host-setup compatibility,
// but meta-only rewrites cannot — keep that sync under this gate. Persist
// whenever anything changed, so partial re-points aren't lost on quit.
if (repoCount > 0 || setupsChanged) {
this.syncProjectHostSetupCompatibilityState()
}
if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) {
this.scheduleSave()
}
return repoCount
}
// ── 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: app-facing SSH PTY ids are globally scoped; durable relay leases
// stay target-local so reconnect can call relay pty.attach with raw ids.
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 {
const session = this.state.workspaceSession
if (!leases?.length || !session) {
return false
}
let changed = false
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)
}
this.writeGithubCacheSnapshotSync()
}
// Why: called after a project move rewrote this store's data file directly.
// From that point until relaunch, the in-memory state is stale and any
// write (debounced, sync, or shutdown flush) 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 on 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,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: Date.now(),
lastActivityAt: 0,
workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID
}
}